|
|
|
@ -1,22 +1,27 @@
|
|
|
|
import { useState } from 'react'
|
|
|
|
import { Fragment, useState } from 'react'
|
|
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|
|
|
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
|
|
|
|
Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, Field, RecordHeader, Skeleton,
|
|
|
|
StatCard, Stats, Tabs, Toolbar, useToast,
|
|
|
|
StatCard, Stats, Tabs, Toolbar, useToast,
|
|
|
|
} from '@sims/ui'
|
|
|
|
} from '@sims/ui'
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
assignClientModule, getClient, getClientModules, getLedger, getModules,
|
|
|
|
assignClientModule, getClient, getClientModules, getLedger, getModules,
|
|
|
|
patchClient, revealDbPassword, role, isManagerial, getEmployees, setClientOwner,
|
|
|
|
patchClient, patchClientModule, revealDbPassword, revealModulePassword,
|
|
|
|
|
|
|
|
role, isManagerial, getEmployees, setClientOwner,
|
|
|
|
getRecurringPlans, deactivateRecurringPlan,
|
|
|
|
getRecurringPlans, deactivateRecurringPlan,
|
|
|
|
getAmc, deactivateAmc, generateAmcRenewalInvoice,
|
|
|
|
getAmc, deactivateAmc, generateAmcRenewalInvoice,
|
|
|
|
getInteractions, getInteractionTypes, updateInteraction,
|
|
|
|
getInteractions, getInteractionTypes, updateInteraction,
|
|
|
|
getClientAwsUsage,
|
|
|
|
getClientAwsUsage,
|
|
|
|
|
|
|
|
getBranches, createBranch, patchBranch, getTickets,
|
|
|
|
CLIENT_STATUSES, KIND_LABEL,
|
|
|
|
CLIENT_STATUSES, KIND_LABEL,
|
|
|
|
type AmcContract, type AmcPaidStatus, type Client, type ClientStatus, type Interaction,
|
|
|
|
type AmcContract, type AmcPaidStatus, type Branch, type Client, type ClientModule,
|
|
|
|
type Outcome, type RecurringPlan,
|
|
|
|
type ClientStatus, type Interaction, type Outcome, type RecurringPlan, type ServiceDetail,
|
|
|
|
} from '../api'
|
|
|
|
} from '../api'
|
|
|
|
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
|
|
|
|
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
|
|
NewTicketDialog, TicketActions, TicketDescription, TICKET_STATUS_LABEL, TICKET_TONE,
|
|
|
|
|
|
|
|
} from './Tickets'
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
|
|
|
|
AccountOwnerSelect, AmcDialog, AssignModuleForm, ClientModuleStatusSelect,
|
|
|
|
InteractionDialog, PaymentForm, PlanDialog, RowDate,
|
|
|
|
InteractionDialog, PaymentForm, PlanDialog, RowDate,
|
|
|
|
@ -30,7 +35,7 @@ const inr = (p: number) => formatINR(p, { symbol: false })
|
|
|
|
const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
|
|
|
|
const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
|
|
|
|
const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', neutral: 'warn', negative: 'err' }
|
|
|
|
const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', neutral: 'warn', negative: 'err' }
|
|
|
|
|
|
|
|
|
|
|
|
const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
|
|
|
|
const TAB_KEYS = ['overview', 'modules', 'tickets', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
|
|
|
|
type TabKey = (typeof TAB_KEYS)[number]
|
|
|
|
type TabKey = (typeof TAB_KEYS)[number]
|
|
|
|
|
|
|
|
|
|
|
|
/** One pending confirmation — replaces the browser confirm() popup with the styled dialog
|
|
|
|
/** One pending confirmation — replaces the browser confirm() popup with the styled dialog
|
|
|
|
@ -52,6 +57,10 @@ export function ClientDetail() {
|
|
|
|
const types = useData(getInteractionTypes, [])
|
|
|
|
const types = useData(getInteractionTypes, [])
|
|
|
|
const awsUsage = useData(() => getClientAwsUsage(id), [id])
|
|
|
|
const awsUsage = useData(() => getClientAwsUsage(id), [id])
|
|
|
|
const employees = useData(() => getEmployees(), [])
|
|
|
|
const employees = useData(() => getEmployees(), [])
|
|
|
|
|
|
|
|
const branches = useData(() => getBranches(id), [id])
|
|
|
|
|
|
|
|
const [ticketPage, setTicketPage] = useState(1)
|
|
|
|
|
|
|
|
const tickets = useData(() => getTickets({ clientId: id, page: ticketPage, pageSize: 50 }), [id, ticketPage])
|
|
|
|
|
|
|
|
const [ticketDialog, setTicketDialog] = useState(false)
|
|
|
|
const isOwner = role() === 'owner'
|
|
|
|
const isOwner = role() === 'owner'
|
|
|
|
const canRoute = isManagerial()
|
|
|
|
const canRoute = isManagerial()
|
|
|
|
const toast = useToast()
|
|
|
|
const toast = useToast()
|
|
|
|
@ -178,6 +187,7 @@ export function ClientDetail() {
|
|
|
|
tabs={[
|
|
|
|
tabs={[
|
|
|
|
{ key: 'overview', label: 'Overview' },
|
|
|
|
{ key: 'overview', label: 'Overview' },
|
|
|
|
{ key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
|
|
|
|
{ key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
|
|
|
|
|
|
|
|
{ key: 'tickets', label: 'Tickets', count: tickets.data?.total ?? 0 },
|
|
|
|
{ key: 'documents', label: 'Documents', count: docs.length },
|
|
|
|
{ key: 'documents', label: 'Documents', count: docs.length },
|
|
|
|
{ key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
|
|
|
|
{ key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
|
|
|
|
{ key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
|
|
|
|
{ key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
|
|
|
|
@ -214,6 +224,12 @@ export function ClientDetail() {
|
|
|
|
{docs.length > 5 && (
|
|
|
|
{docs.length > 5 && (
|
|
|
|
<Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents →</Button></Toolbar>
|
|
|
|
<Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents →</Button></Toolbar>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
<h3 style={{ marginTop: 18 }}>Branches</h3>
|
|
|
|
|
|
|
|
{branches.error !== undefined && <ErrorState message={branches.error} onRetry={branches.reload} />}
|
|
|
|
|
|
|
|
{branches.data === undefined && branches.error === undefined ? <Skeleton />
|
|
|
|
|
|
|
|
: branches.data !== undefined && (
|
|
|
|
|
|
|
|
<BranchList clientId={id} branches={branches.data} onChanged={branches.reload} />
|
|
|
|
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
@ -257,6 +273,61 @@ export function ClientDetail() {
|
|
|
|
})}
|
|
|
|
})}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
{(cms.data ?? []).length > 0 && <h3 style={{ marginTop: 20 }}>Service data</h3>}
|
|
|
|
|
|
|
|
{(cms.data ?? []).map((cm) => (
|
|
|
|
|
|
|
|
<ServiceDataCard key={cm.id} cm={cm} name={moduleName(cm.moduleId)} onChanged={() => cms.reload()} />
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{tab === 'tickets' && (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<Toolbar>
|
|
|
|
|
|
|
|
<Button tone="primary" onClick={() => setTicketDialog(true)}>New ticket</Button>
|
|
|
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
|
|
|
{tickets.data !== undefined && <Badge>{tickets.data.total} total</Badge>}
|
|
|
|
|
|
|
|
</Toolbar>
|
|
|
|
|
|
|
|
{tickets.error !== undefined && <ErrorState message={tickets.error} onRetry={tickets.reload} />}
|
|
|
|
|
|
|
|
{tickets.data === undefined && tickets.error === undefined ? <Skeleton />
|
|
|
|
|
|
|
|
: tickets.data !== undefined && tickets.data.tickets.length === 0
|
|
|
|
|
|
|
|
? <EmptyState>No tickets for this client.</EmptyState>
|
|
|
|
|
|
|
|
: tickets.data !== undefined && (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
|
|
{ key: 'opened', label: 'Opened' }, { key: 'branch', label: 'Branch' },
|
|
|
|
|
|
|
|
{ key: 'module', label: 'Module', mono: true }, { key: 'kind', label: 'Kind' },
|
|
|
|
|
|
|
|
{ key: 'desc', label: 'Description' }, { key: 'assigned', label: 'Assigned' },
|
|
|
|
|
|
|
|
{ key: 'status', label: 'Status' }, { key: 'act', label: '' },
|
|
|
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
rows={tickets.data.tickets.map((t) => ({
|
|
|
|
|
|
|
|
opened: t.openedOn,
|
|
|
|
|
|
|
|
branch: t.branchName ?? '—',
|
|
|
|
|
|
|
|
module: t.moduleCode ?? '—',
|
|
|
|
|
|
|
|
kind: t.kind !== '' ? t.kind : '—',
|
|
|
|
|
|
|
|
desc: <TicketDescription text={t.description} />,
|
|
|
|
|
|
|
|
assigned: t.assignedName ?? '—',
|
|
|
|
|
|
|
|
status: <Badge tone={TICKET_TONE[t.status]}>{TICKET_STATUS_LABEL[t.status]}</Badge>,
|
|
|
|
|
|
|
|
act: <TicketActions t={t} onDone={tickets.reload} onError={setActionErr} />,
|
|
|
|
|
|
|
|
}))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
{Math.ceil(tickets.data.total / tickets.data.pageSize) > 1 && (
|
|
|
|
|
|
|
|
<div className="wf-pager">
|
|
|
|
|
|
|
|
<span>{tickets.data.total} ticket(s)</span>
|
|
|
|
|
|
|
|
<Button onClick={() => { if (ticketPage > 1) setTicketPage((p) => p - 1) }}>‹ Prev</Button>
|
|
|
|
|
|
|
|
<span>Page {tickets.data.page}/{Math.ceil(tickets.data.total / tickets.data.pageSize)}</span>
|
|
|
|
|
|
|
|
<Button onClick={() => { if (ticketPage < Math.ceil(tickets.data!.total / tickets.data!.pageSize)) setTicketPage((p) => p + 1) }}>Next ›</Button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
<NewTicketDialog
|
|
|
|
|
|
|
|
open={ticketDialog}
|
|
|
|
|
|
|
|
onClose={() => setTicketDialog(false)}
|
|
|
|
|
|
|
|
onCreated={() => tickets.reload()}
|
|
|
|
|
|
|
|
kinds={tickets.data?.kinds ?? []}
|
|
|
|
|
|
|
|
initialClient={{ id: c.id, code: c.code, name: c.name }}
|
|
|
|
|
|
|
|
/>
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
@ -582,3 +653,219 @@ function SupportCard(props: { client: Client; onChanged: () => void }) {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Branch book (D20 — APEX ho_branch_list parity): list + add + inline rename /
|
|
|
|
|
|
|
|
* deactivate. Branches are never deleted (tickets reference them) — deactivating
|
|
|
|
|
|
|
|
* hides them from pickers while history keeps its name.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function BranchList(props: { clientId: string; branches: Branch[]; onChanged: () => void }) {
|
|
|
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
|
|
|
const [name, setName] = useState('')
|
|
|
|
|
|
|
|
const [code, setCode] = useState('')
|
|
|
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
|
|
|
const [edit, setEdit] = useState<{ id: string; name: string; code: string } | undefined>()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const add = () => {
|
|
|
|
|
|
|
|
if (busy || name.trim() === '') return
|
|
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
|
|
createBranch(props.clientId, { name, ...(code.trim() !== '' ? { code } : {}) })
|
|
|
|
|
|
|
|
.then(() => { toast.ok('Branch added'); setName(''); setCode(''); props.onChanged() })
|
|
|
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const saveEdit = () => {
|
|
|
|
|
|
|
|
if (edit === undefined || busy) return
|
|
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
|
|
patchBranch(edit.id, { name: edit.name, code: edit.code })
|
|
|
|
|
|
|
|
.then(() => { toast.ok('Branch saved'); setEdit(undefined); props.onChanged() })
|
|
|
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const setActive = (id: string, active: boolean) => {
|
|
|
|
|
|
|
|
if (busy) return
|
|
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
|
|
patchBranch(id, { active })
|
|
|
|
|
|
|
|
.then(() => { toast.ok(active ? 'Branch reactivated' : 'Branch deactivated'); props.onChanged() })
|
|
|
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<Toolbar>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
className="wf" style={{ maxWidth: 220 }} placeholder="Branch name" aria-label="Branch name"
|
|
|
|
|
|
|
|
value={name} onChange={(e) => setName(e.target.value)}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
className="wf" style={{ maxWidth: 120 }} placeholder="Code (optional)" aria-label="Branch code"
|
|
|
|
|
|
|
|
value={code} onChange={(e) => setCode(e.target.value)}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
<Button tone="primary" disabled={busy || name.trim() === ''} onClick={add}>Add branch</Button>
|
|
|
|
|
|
|
|
</Toolbar>
|
|
|
|
|
|
|
|
{props.branches.length === 0 ? <EmptyState>No branches — head office only.</EmptyState> : (
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
|
|
{ key: 'name', label: 'Name' }, { key: 'code', label: 'Code', mono: true },
|
|
|
|
|
|
|
|
{ key: 'active', label: 'Active' }, { key: 'act', label: '' },
|
|
|
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
rows={props.branches.map((b) => {
|
|
|
|
|
|
|
|
const editing = edit !== undefined && edit.id === b.id
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
name: editing
|
|
|
|
|
|
|
|
? <input className="wf" aria-label="Name" value={edit.name} onChange={(e) => setEdit((p) => p && ({ ...p, name: e.target.value }))} />
|
|
|
|
|
|
|
|
: b.name,
|
|
|
|
|
|
|
|
code: editing
|
|
|
|
|
|
|
|
? <input className="wf" aria-label="Code" style={{ width: 100 }} value={edit.code} onChange={(e) => setEdit((p) => p && ({ ...p, code: e.target.value }))} />
|
|
|
|
|
|
|
|
: (b.code ?? '—'),
|
|
|
|
|
|
|
|
active: b.active ? <Badge tone="ok">active</Badge> : <Badge>inactive</Badge>,
|
|
|
|
|
|
|
|
act: editing
|
|
|
|
|
|
|
|
? (
|
|
|
|
|
|
|
|
<span style={{ display: 'flex', gap: 6 }}>
|
|
|
|
|
|
|
|
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
|
|
|
|
|
|
|
|
<Button onClick={() => setEdit(undefined)}>Cancel</Button>
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
: (
|
|
|
|
|
|
|
|
<span style={{ display: 'flex', gap: 6 }}>
|
|
|
|
|
|
|
|
<Button onClick={() => setEdit({ id: b.id, name: b.name, code: b.code ?? '' })}>Rename</Button>
|
|
|
|
|
|
|
|
{b.active
|
|
|
|
|
|
|
|
? <Button tone="danger" disabled={busy} onClick={() => setActive(b.id, false)}>Deactivate</Button>
|
|
|
|
|
|
|
|
: <Button disabled={busy} onClick={() => setActive(b.id, true)}>Reactivate</Button>}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Per-service operational data (D20 — APEX sms_clients_list parity), following
|
|
|
|
|
|
|
|
* SupportCard's exact visual pattern: label/value cells, the portal password as
|
|
|
|
|
|
|
|
* •••• with managerial audited reveal (gated like the DB password), and Edit
|
|
|
|
|
|
|
|
* switching provider/username/remark + the labeled details into inputs — one
|
|
|
|
|
|
|
|
* audited PATCH saves the lot.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function ServiceDataCard(props: { cm: ClientModule; name: string; onChanged: () => void }) {
|
|
|
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
|
|
|
const managerial = isManagerial()
|
|
|
|
|
|
|
|
const cm = props.cm
|
|
|
|
|
|
|
|
const [revealed, setRevealed] = useState<string | undefined>()
|
|
|
|
|
|
|
|
const [settingPw, setSettingPw] = useState(false)
|
|
|
|
|
|
|
|
const [pw, setPw] = useState('')
|
|
|
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
|
|
|
const [editing, setEditing] = useState(false)
|
|
|
|
|
|
|
|
const [f, setF] = useState({ provider: '', username: '', remark: '' })
|
|
|
|
|
|
|
|
const [details, setDetails] = useState<ServiceDetail[]>([])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const copy = (text: string, what: string) => {
|
|
|
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => toast.ok(`${what} copied`)).catch(() => toast.err('Copy failed'))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const reveal = () => {
|
|
|
|
|
|
|
|
if (busy) return
|
|
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
|
|
revealModulePassword(cm.id)
|
|
|
|
|
|
|
|
.then((p) => setRevealed(p))
|
|
|
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const savePw = () => {
|
|
|
|
|
|
|
|
if (busy || pw === '') return
|
|
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
|
|
patchClientModule(cm.id, { password: pw })
|
|
|
|
|
|
|
|
.then(() => { toast.ok('Portal password stored (encrypted)'); setSettingPw(false); setPw(''); setRevealed(undefined); props.onChanged() })
|
|
|
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const startEdit = () => {
|
|
|
|
|
|
|
|
setF({ provider: cm.provider ?? '', username: cm.username ?? '', remark: cm.remark ?? '' })
|
|
|
|
|
|
|
|
setDetails(cm.details.map((d) => ({ ...d })))
|
|
|
|
|
|
|
|
setEditing(true)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const saveEdit = () => {
|
|
|
|
|
|
|
|
if (busy) return
|
|
|
|
|
|
|
|
const cleaned = details.filter((d) => d.label.trim() !== '' || d.value.trim() !== '')
|
|
|
|
|
|
|
|
if (cleaned.some((d) => d.label.trim() === '')) { toast.err('Every detail needs a label'); return }
|
|
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
|
|
patchClientModule(cm.id, {
|
|
|
|
|
|
|
|
provider: f.provider, username: f.username, remark: f.remark,
|
|
|
|
|
|
|
|
details: cleaned.map((d) => ({ label: d.label.trim(), value: d.value })),
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
.then(() => { toast.ok('Service data saved'); setEditing(false); props.onChanged() })
|
|
|
|
|
|
|
|
.catch((e: Error) => toast.err(e.message))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cell = (label: string, value: string | undefined, mono = false, copyable = false) => (
|
|
|
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
|
|
|
|
|
|
|
|
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>{label}</span>
|
|
|
|
|
|
|
|
<span className={mono ? 'mono' : undefined}>{value ?? '—'}</span>
|
|
|
|
|
|
|
|
{copyable && value !== undefined && <Button onClick={() => copy(value, label)}>Copy</Button>}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (editing) {
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<div className="wf-card" style={{ padding: '10px 14px' }}>
|
|
|
|
|
|
|
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>{props.name} — service data</div>
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
|
|
|
|
|
|
|
|
<Field label="Provider"><input className="wf" style={{ width: 140 }} value={f.provider} onChange={(e) => setF((p) => ({ ...p, provider: e.target.value }))} /></Field>
|
|
|
|
|
|
|
|
<Field label="Username"><input className="wf mono" style={{ width: 160 }} value={f.username} onChange={(e) => setF((p) => ({ ...p, username: e.target.value }))} /></Field>
|
|
|
|
|
|
|
|
<Field label="Remark"><input className="wf" style={{ width: 220 }} value={f.remark} onChange={(e) => setF((p) => ({ ...p, remark: e.target.value }))} /></Field>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{details.map((d, i) => (
|
|
|
|
|
|
|
|
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
className="wf" placeholder="Label" style={{ maxWidth: 180 }} value={d.label}
|
|
|
|
|
|
|
|
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
className="wf" placeholder="Value" value={d.value}
|
|
|
|
|
|
|
|
onChange={(e) => setDetails((prev) => prev.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
<Button onClick={() => setDetails((prev) => prev.filter((_x, j) => j !== i))}>✕</Button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
|
|
|
|
|
|
<Button onClick={() => setDetails((prev) => [...prev, { label: '', value: '' }])}>+ Add detail</Button>
|
|
|
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
|
|
|
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
|
|
|
|
|
|
|
|
<Button onClick={() => setEditing(false)}>Cancel</Button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<div className="wf-card" style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, padding: '10px 14px' }}>
|
|
|
|
|
|
|
|
<span style={{ fontWeight: 600, marginRight: 10 }}>{props.name}</span>
|
|
|
|
|
|
|
|
{cell('Provider', cm.provider ?? undefined)}
|
|
|
|
|
|
|
|
{cell('Username', cm.username ?? undefined, true, true)}
|
|
|
|
|
|
|
|
{cm.details.map((d, i) => <Fragment key={i}>{cell(d.label, d.value)}</Fragment>)}
|
|
|
|
|
|
|
|
{cell('Remark', cm.remark ?? undefined)}
|
|
|
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
|
|
|
|
|
|
|
|
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>Portal password</span>
|
|
|
|
|
|
|
|
{revealed !== undefined
|
|
|
|
|
|
|
|
? <><span className="mono">{revealed}</span><Button onClick={() => copy(revealed, 'Portal password')}>Copy</Button><Button onClick={() => setRevealed(undefined)}>Hide</Button></>
|
|
|
|
|
|
|
|
: cm.hasPassword
|
|
|
|
|
|
|
|
? <><span className="mono">••••••••</span>{managerial && <Button onClick={reveal}>{busy ? '…' : 'Reveal'}</Button>}</>
|
|
|
|
|
|
|
|
: <span style={{ opacity: 0.6 }}>not set</span>}
|
|
|
|
|
|
|
|
{managerial && !settingPw && <Button onClick={() => setSettingPw(true)}>{cm.hasPassword ? 'Update' : 'Set…'}</Button>}
|
|
|
|
|
|
|
|
{managerial && settingPw && (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<input className="wf mono" type="password" style={{ width: 160 }} placeholder="Portal password"
|
|
|
|
|
|
|
|
value={pw} onChange={(e) => setPw(e.target.value)} />
|
|
|
|
|
|
|
|
<Button tone="primary" onClick={savePw}>{busy ? 'Saving…' : 'Save'}</Button>
|
|
|
|
|
|
|
|
<Button onClick={() => { setSettingPw(false); setPw('') }}>Cancel</Button>
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
<span style={{ flex: 1 }} />
|
|
|
|
|
|
|
|
<Button onClick={startEdit}>Edit</Button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|
|
|
|
|