feat(hq-web): owner company profile editor and client 360 aws usage trend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 2acca18fc9
commit dbccb8f954

@ -1,9 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router-dom' import { Navigate, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { Badge, ThemeSwitcher } from '@sims/ui' import { Badge, ThemeSwitcher } from '@sims/ui'
import { clearSession, displayName, getEmailStatus, hasSession } from './api' import { clearSession, displayName, getEmailStatus, hasSession, role } from './api'
const NAV = [ const BASE_NAV = [
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
{ to: '/clients', label: 'Clients' }, { to: '/clients', label: 'Clients' },
{ to: '/modules', label: 'Modules' }, { to: '/modules', label: 'Modules' },
@ -25,6 +25,9 @@ export function Layout() {
if (!hasSession()) return <Navigate to="/login" replace /> if (!hasSession()) return <Navigate to="/login" replace />
// Company (the company.* settings editor) is owner-only — staff never see the item.
const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/company', label: 'Company' }] : BASE_NAV
return ( return (
<div className="hq-shell"> <div className="hq-shell">
<nav className="hq-side"> <nav className="hq-side">
@ -32,7 +35,7 @@ export function Layout() {
SiMS HQ SiMS HQ
<small>Ops console</small> <small>Ops console</small>
</div> </div>
{NAV.map((n) => ( {nav_items.map((n) => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/'}> <NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/'}>
{n.label} {n.label}
</NavLink> </NavLink>

@ -12,6 +12,7 @@ import { Clients } from './pages/Clients'
import { ClientDetail } from './pages/ClientDetail' import { ClientDetail } from './pages/ClientDetail'
import { Modules } from './pages/Modules' import { Modules } from './pages/Modules'
import { Reports } from './pages/Reports' import { Reports } from './pages/Reports'
import { CompanyProfile } from './pages/CompanyProfile'
import { NewDocument } from './pages/NewDocument' import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView' import { DocumentView } from './pages/DocumentView'
@ -26,6 +27,7 @@ function App() {
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} /> <Route path="/modules" element={<Modules />} />
<Route path="/reports" element={<Reports />} /> <Route path="/reports" element={<Reports />} />
<Route path="/settings/company" element={<CompanyProfile />} />
<Route path="/documents/new" element={<NewDocument />} /> <Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} /> <Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

@ -8,6 +8,7 @@ import {
role, getRecurringPlans, createRecurringPlan, deactivateRecurringPlan, role, getRecurringPlans, createRecurringPlan, deactivateRecurringPlan,
getAmc, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, createAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, createInteraction, updateInteraction, getInteractions, getInteractionTypes, createInteraction, updateInteraction,
getClientAwsUsage,
CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES, CLIENT_MODULE_STATUSES, CLIENT_STATUSES, KIND_LABEL, PAYMENT_MODES,
type AmcPaidStatus, type ClientModule, type ClientModuleStatus, type ClientStatus, type AmcPaidStatus, type ClientModule, type ClientModuleStatus, type ClientStatus,
type InteractionType, type Kind, type Module, type Outcome, type PaymentMode, type InteractionType, type Kind, type Module, type Outcome, type PaymentMode,
@ -32,6 +33,7 @@ export function ClientDetail() {
const amc = useData(() => getAmc(id), [id]) const amc = useData(() => getAmc(id), [id])
const interactions = useData(() => getInteractions(id), [id]) const interactions = useData(() => getInteractions(id), [id])
const types = useData(getInteractionTypes, []) const types = useData(getInteractionTypes, [])
const awsUsage = useData(() => getClientAwsUsage(id), [id])
const isOwner = role() === 'owner' const isOwner = role() === 'owner'
const [actionErr, setActionErr] = useState<string | undefined>() const [actionErr, setActionErr] = useState<string | undefined>()
@ -278,6 +280,23 @@ export function ClientDetail() {
}))} }))}
/> />
)} )}
<h3 style={{ marginTop: 20 }}>AWS usage</h3>
{awsUsage.error !== undefined && <Notice tone="err">{awsUsage.error}</Notice>}
{awsUsage.data === undefined || awsUsage.data.length === 0
? <EmptyState>No AWS usage recorded for this client.</EmptyState>
: (
<DataTable
columns={[
{ key: 'month', label: 'Month' }, { key: 'storage', label: 'Storage GB', numeric: true },
{ key: 'transfer', label: 'Transfer GB', numeric: true }, { key: 'cost', label: 'Cost', numeric: true },
{ key: 'source', label: 'Source' },
]}
rows={awsUsage.data.map((u) => ({
month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source,
}))}
/>
)}
</div> </div>
) )
} }

@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { Button, Field, Notice, PageHeader } from '@sims/ui'
import { getCompanyProfile, putCompanyProfile, role } from '../api'
const FIELDS: { key: string; setting: string; label: string }[] = [
{ key: 'name', setting: 'company.name', label: 'Company name' },
{ key: 'address', setting: 'company.address', label: 'Address' },
{ key: 'gstin', setting: 'company.gstin', label: 'GSTIN' },
{ key: 'stateCode', setting: 'company.state_code', label: 'State code' },
{ key: 'phone', setting: 'company.phone', label: 'Phone' },
{ key: 'email', setting: 'company.email', label: 'Email' },
{ key: 'bank', setting: 'company.bank', label: 'Bank details' },
]
/** Owner-only editor for the company.* settings the letterhead PDFs read. */
export function CompanyProfile() {
const [f, setF] = useState<Record<string, string>>({})
const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
getCompanyProfile()
.then((c) => setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))))
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
}, [])
if (role() !== 'owner') return <Navigate to="/" replace />
const save = () => {
setSaving(true); setMsg(undefined)
putCompanyProfile(f)
.then((c) => { setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))); setMsg({ tone: 'ok', text: 'Saved. PDFs use the new details immediately.' }) })
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
.finally(() => setSaving(false))
}
return (
<div className="wf-page">
<PageHeader title="Company profile" desc="These details appear on every quotation, invoice, and receipt." />
{msg !== undefined && <Notice tone={msg.tone}>{msg.text}</Notice>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 520, marginTop: 10 }}>
{FIELDS.map((x) => (
<Field key={x.key} label={x.label}>
{x.key === 'address' || x.key === 'bank'
? <textarea className="wf" rows={2} style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />
: <input className="wf" style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />}
</Field>
))}
<div><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save company profile'}</Button></div>
</div>
</div>
)
}
Loading…
Cancel
Save