Theme 2: cross-division delivery pipeline (change requests)

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>
This commit is contained in:
soroush.asadi
2026-06-17 07:47:57 +03:30
parent 5c2b697b66
commit c12935ad74
12 changed files with 1816 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import { LoginPage } from '@/pages/LoginPage'
import { MembersPage } from '@/pages/MembersPage'
import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage'
import { PipelinePage } from '@/pages/PipelinePage'
import { ProductProfilesPage } from '@/pages/ProductProfilesPage'
import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
@@ -33,6 +34,7 @@ export default function App() {
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="/delivery" element={token ? <DeliveryPage /> : <Navigate to="/login" replace />} />
<Route path="/pipeline" element={token ? <PipelinePage /> : <Navigate to="/login" replace />} />
<Route path="/cartable" element={token ? <CartablePage /> : <Navigate to="/login" replace />} />
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
+2
View File
@@ -19,6 +19,7 @@ import {
Sparkles,
TrendingUp,
Users,
Workflow,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
@@ -100,6 +101,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={LayoutDashboard} label="Board" to="/" color="#38bdf8" />
<NavItem icon={Sparkles} label="Team" to="/team" color="#38bdf8" />
<NavItem icon={Inbox} label="Cartable" to="/cartable" color="#38bdf8" />
<NavItem icon={Workflow} label="Delivery pipeline" to="/pipeline" color="#38bdf8" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" color="#38bdf8" badge={reviewCount} />
<NavSection label="Organization" />
+483
View File
@@ -0,0 +1,483 @@
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>
)
}