c12935ad74
A customer change request now flows through a guarded commercial pipeline: Requested -> Estimated -> Approved -> Paid -> Live. The cross-division work and its dependencies live on the request's steps (a division's slice + hours + an optional depends-on link), and estimating sums the steps into a total. Each transition is guarded on the ChangeRequest aggregate, so it can only move forward in order; guard violations surface as 400s. - Domain: ChangeRequest + ChangeRequestStep aggregates with stage guards - Persistence: two tables + EF migration (applied) - Endpoints under /api/orgboard/change-requests: create/list/detail, add/advance steps, and estimate/approve/pay/go-live/reject (reads need board-view, commercial actions are owner-level) - New Delivery pipeline page: request list with stage + step progress, a detail drawer with a stage stepper, the next commercial action, quote entry, and a per-division step breakdown with dependencies Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { ArrowRight, Check, GitBranch, Plus, Workflow } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { AppShell } from '@/components/AppShell'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { api } from '@/lib/api'
|
|
import { useAuth } from '@/store/auth'
|
|
|
|
interface Division {
|
|
id: string
|
|
name: string
|
|
}
|
|
|
|
interface ChangeSummary {
|
|
id: string
|
|
customerName: string
|
|
title: string
|
|
status: string
|
|
estimateHours: number | null
|
|
amount: number | null
|
|
currency: string
|
|
stepCount: number
|
|
doneStepCount: number
|
|
}
|
|
|
|
interface ChangeStep {
|
|
id: string
|
|
divisionId: string | null
|
|
divisionName: string | null
|
|
title: string
|
|
estimateHours: number
|
|
status: string
|
|
dependsOnStepId: string | null
|
|
order: number
|
|
}
|
|
|
|
interface ChangeDetail {
|
|
summary: ChangeSummary
|
|
description: string | null
|
|
totalStepHours: number
|
|
steps: ChangeStep[]
|
|
}
|
|
|
|
const STAGES = ['Requested', 'Estimated', 'Approved', 'Paid', 'Live'] as const
|
|
const STEP_STATUSES = ['Pending', 'InProgress', 'Done', 'Blocked'] as const
|
|
|
|
// The single commercial action available at each pipeline stage.
|
|
const ACTION: Record<string, { label: string; path: string } | null> = {
|
|
Requested: { label: 'Send estimate', path: 'estimate' },
|
|
Estimated: { label: 'Mark approved', path: 'approve' },
|
|
Approved: { label: 'Record payment', path: 'pay' },
|
|
Paid: { label: 'Go live', path: 'go-live' },
|
|
Live: null,
|
|
Rejected: null,
|
|
}
|
|
|
|
const STATUS_TONE: Record<string, string> = {
|
|
Requested: 'bg-muted text-muted-foreground',
|
|
Estimated: 'bg-sky-500/15 text-sky-700 dark:text-sky-400',
|
|
Approved: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
|
|
Paid: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
|
|
Live: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
|
|
Rejected: 'bg-destructive/15 text-destructive',
|
|
}
|
|
|
|
export function PipelinePage() {
|
|
const organizationId = useAuth((s) => s.organizationId)
|
|
const [requests, setRequests] = useState<ChangeSummary[]>([])
|
|
const [divisions, setDivisions] = useState<Division[]>([])
|
|
const [openId, setOpenId] = useState<string | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
if (!organizationId) return
|
|
try {
|
|
const [list, divs] = await Promise.all([
|
|
api.get<ChangeSummary[]>(`/api/orgboard/change-requests?organizationId=${organizationId}`),
|
|
api.get<Division[]>(`/api/orgboard/divisions?organizationId=${organizationId}`).catch(() => []),
|
|
])
|
|
setRequests(list)
|
|
setDivisions(divs)
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
}
|
|
}, [organizationId])
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
}, [load])
|
|
|
|
return (
|
|
<AppShell>
|
|
<div className="mx-auto max-w-4xl p-6">
|
|
<header className="mb-5 flex items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
|
<Workflow className="size-6" /> Delivery pipeline
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Customer change requests across divisions: estimate → approve → pay → go-live.
|
|
</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setCreating((v) => !v)}>
|
|
<Plus data-icon="inline-start" /> New request
|
|
</Button>
|
|
</header>
|
|
|
|
{creating && organizationId && (
|
|
<NewRequestForm
|
|
organizationId={organizationId}
|
|
onCreated={() => {
|
|
setCreating(false)
|
|
void load()
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Pipeline legend */}
|
|
<div className="mb-5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
|
{STAGES.map((s, i) => (
|
|
<span key={s} className="flex items-center gap-1.5">
|
|
<span className="rounded-full bg-muted px-2 py-0.5 font-medium">{s}</span>
|
|
{i < STAGES.length - 1 && <ArrowRight className="size-3" />}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{requests.map((cr) => {
|
|
const pct = cr.stepCount > 0 ? Math.round((cr.doneStepCount / cr.stepCount) * 100) : 0
|
|
return (
|
|
<button
|
|
key={cr.id}
|
|
type="button"
|
|
onClick={() => setOpenId(cr.id)}
|
|
className="flex items-center gap-3 rounded-xl border bg-card/60 px-4 py-3 text-left backdrop-blur-sm transition-colors hover:border-ring/60"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-medium">{cr.title}</span>
|
|
<span className={`shrink-0 rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[cr.status] ?? 'bg-muted'}`}>
|
|
{cr.status}
|
|
</span>
|
|
</div>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{cr.customerName}
|
|
{cr.estimateHours != null && ` · ${cr.estimateHours}h`}
|
|
{cr.amount != null && ` · ${cr.amount.toLocaleString()} ${cr.currency}`}
|
|
{cr.stepCount > 0 && ` · ${cr.doneStepCount}/${cr.stepCount} steps`}
|
|
</p>
|
|
</div>
|
|
{cr.stepCount > 0 && (
|
|
<div className="hidden h-2 w-24 shrink-0 overflow-hidden rounded-full bg-muted/60 sm:block">
|
|
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
{requests.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
|
No change requests yet. Log a customer ask to start the pipeline.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{openId && (
|
|
<RequestDrawer
|
|
id={openId}
|
|
divisions={divisions}
|
|
onClose={() => setOpenId(null)}
|
|
onChanged={load}
|
|
/>
|
|
)}
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
function NewRequestForm({ organizationId, onCreated }: { organizationId: string; onCreated: () => void }) {
|
|
const [customer, setCustomer] = useState('')
|
|
const [title, setTitle] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
async function submit() {
|
|
if (!customer.trim() || !title.trim()) {
|
|
toast.error('Customer and title are required.')
|
|
return
|
|
}
|
|
setBusy(true)
|
|
try {
|
|
await api.post('/api/orgboard/change-requests', { organizationId, customerName: customer, title, description })
|
|
toast.success('Change request logged.')
|
|
onCreated()
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mb-5 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>Customer</Label>
|
|
<Input value={customer} onChange={(e) => setCustomer(e.target.value)} placeholder="Acme Corp" />
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>Title</Label>
|
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Add SSO to the portal" />
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>What they're asking for</Label>
|
|
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" disabled={busy} onClick={submit}>Log request</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RequestDrawer({
|
|
id,
|
|
divisions,
|
|
onClose,
|
|
onChanged,
|
|
}: {
|
|
id: string
|
|
divisions: Division[]
|
|
onClose: () => void
|
|
onChanged: () => void
|
|
}) {
|
|
const [detail, setDetail] = useState<ChangeDetail | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const [amount, setAmount] = useState('')
|
|
const [currency, setCurrency] = useState('USD')
|
|
|
|
const reload = useCallback(async () => {
|
|
try {
|
|
setDetail(await api.get<ChangeDetail>(`/api/orgboard/change-requests/${id}`))
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
}
|
|
}, [id])
|
|
|
|
useEffect(() => {
|
|
void reload()
|
|
}, [reload])
|
|
|
|
async function act(fn: () => Promise<ChangeDetail>, success?: string) {
|
|
setBusy(true)
|
|
try {
|
|
setDetail(await fn())
|
|
if (success) toast.success(success)
|
|
onChanged()
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const status = detail?.summary.status ?? 'Requested'
|
|
const action = ACTION[status]
|
|
const stageIndex = STAGES.indexOf(status as (typeof STAGES)[number])
|
|
|
|
return (
|
|
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
|
<SheetContent className="overflow-y-auto sm:max-w-xl">
|
|
{detail && (
|
|
<>
|
|
<SheetHeader>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`rounded-md px-2 py-0.5 text-xs font-medium ${STATUS_TONE[status] ?? 'bg-muted'}`}>{status}</span>
|
|
<Badge variant="outline">{detail.summary.customerName}</Badge>
|
|
</div>
|
|
<SheetTitle>{detail.summary.title}</SheetTitle>
|
|
{detail.description && <SheetDescription>{detail.description}</SheetDescription>}
|
|
</SheetHeader>
|
|
|
|
{/* Stage stepper */}
|
|
<div className="flex items-center gap-1 px-4">
|
|
{STAGES.map((s, i) => {
|
|
const done = stageIndex >= 0 && i <= stageIndex
|
|
return (
|
|
<div key={s} className="flex flex-1 flex-col items-center gap-1">
|
|
<div className="flex w-full items-center">
|
|
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[10px] ${done ? 'border-emerald-500 bg-emerald-500/15 text-emerald-600' : 'border-border bg-muted/40 text-muted-foreground'}`}>
|
|
{done ? <Check className="size-3" /> : i + 1}
|
|
</span>
|
|
{i < STAGES.length - 1 && <span className={`h-0.5 flex-1 ${stageIndex > i ? 'bg-emerald-500' : 'bg-border'}`} />}
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground">{s}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Totals + commercial action */}
|
|
<div className="mx-4 flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">Total estimate</span>
|
|
<span className="font-semibold">{detail.totalStepHours}h{detail.summary.amount != null && ` · ${detail.summary.amount.toLocaleString()} ${detail.summary.currency}`}</span>
|
|
</div>
|
|
|
|
{status === 'Requested' && (
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<Label className="text-xs">Quote amount (optional)</Label>
|
|
<Input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="12000" />
|
|
</div>
|
|
<div className="flex w-24 flex-col gap-1">
|
|
<Label className="text-xs">Currency</Label>
|
|
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{action && (
|
|
<Button
|
|
disabled={busy}
|
|
onClick={() =>
|
|
act(
|
|
() =>
|
|
api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/${action.path}`,
|
|
action.path === 'estimate'
|
|
? { amount: amount ? Number(amount) : null, currency }
|
|
: {}),
|
|
`${action.label} done.`,
|
|
)
|
|
}
|
|
>
|
|
{action.label} <ArrowRight data-icon="inline-end" />
|
|
</Button>
|
|
)}
|
|
{status !== 'Live' && status !== 'Rejected' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={busy}
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => act(() => api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/reject`, {}), 'Rejected.')}
|
|
>
|
|
Reject request
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Steps */}
|
|
<div className="flex flex-col gap-2 px-4 pb-6">
|
|
<Label className="flex items-center gap-1.5"><GitBranch className="size-4" /> Cross-division steps</Label>
|
|
{detail.steps.map((step) => (
|
|
<div key={step.id} className="flex items-center gap-2 rounded-lg border bg-card/50 px-3 py-2 text-sm backdrop-blur-sm">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-medium">{step.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{step.divisionName ?? 'Unassigned'} · {step.estimateHours}h
|
|
{step.dependsOnStepId && ' · depends on a prior step'}
|
|
</p>
|
|
</div>
|
|
<Select value={step.status} onValueChange={(v) => act(() => api.patch<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps/${step.id}`, { status: v }))}>
|
|
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{STEP_STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
))}
|
|
{detail.steps.length === 0 && <p className="text-xs text-muted-foreground">No steps yet — break the work down by division below.</p>}
|
|
|
|
<AddStepForm id={id} divisions={divisions} steps={detail.steps} onAdded={(d) => setDetail(d)} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
function AddStepForm({
|
|
id,
|
|
divisions,
|
|
steps,
|
|
onAdded,
|
|
}: {
|
|
id: string
|
|
divisions: Division[]
|
|
steps: ChangeStep[]
|
|
onAdded: (detail: ChangeDetail) => void
|
|
}) {
|
|
const [title, setTitle] = useState('')
|
|
const [hours, setHours] = useState('')
|
|
const [divisionId, setDivisionId] = useState<string>('')
|
|
const [dependsOn, setDependsOn] = useState<string>('')
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
async function submit() {
|
|
if (!title.trim() || !hours) {
|
|
toast.error('Step title and hours are required.')
|
|
return
|
|
}
|
|
setBusy(true)
|
|
try {
|
|
const detail = await api.post<ChangeDetail>(`/api/orgboard/change-requests/${id}/steps`, {
|
|
title,
|
|
estimateHours: Number(hours),
|
|
divisionId: divisionId || null,
|
|
dependsOnStepId: dependsOn || null,
|
|
})
|
|
onAdded(detail)
|
|
setTitle('')
|
|
setHours('')
|
|
setDivisionId('')
|
|
setDependsOn('')
|
|
} catch (err) {
|
|
toast.error((err as Error).message)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
|
|
<div className="flex gap-2">
|
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Step (e.g. Build SSO connector)" className="flex-1" />
|
|
<Input value={hours} onChange={(e) => setHours(e.target.value)} inputMode="decimal" placeholder="Hours" className="w-24" />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Select value={divisionId} onValueChange={setDivisionId}>
|
|
<SelectTrigger className="flex-1"><SelectValue placeholder="Division (optional)" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{divisions.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
{steps.length > 0 && (
|
|
<Select value={dependsOn} onValueChange={setDependsOn}>
|
|
<SelectTrigger className="flex-1"><SelectValue placeholder="Depends on (optional)" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
{steps.map((s) => <SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>)}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" variant="outline" disabled={busy} onClick={submit}>
|
|
<Plus data-icon="inline-start" /> Add step
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|