diff --git a/client/src/App.tsx b/client/src/App.tsx index 0aa57ff..c527ec2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { : } /> : } /> : } /> + : } /> : } /> : } /> : } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index ab209cc..04f76f3 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -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 }) { + diff --git a/client/src/pages/PipelinePage.tsx b/client/src/pages/PipelinePage.tsx new file mode 100644 index 0000000..7c03ecc --- /dev/null +++ b/client/src/pages/PipelinePage.tsx @@ -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 = { + 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 = { + 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([]) + const [divisions, setDivisions] = useState([]) + const [openId, setOpenId] = useState(null) + const [creating, setCreating] = useState(false) + + const load = useCallback(async () => { + if (!organizationId) return + try { + const [list, divs] = await Promise.all([ + api.get(`/api/orgboard/change-requests?organizationId=${organizationId}`), + api.get(`/api/orgboard/divisions?organizationId=${organizationId}`).catch(() => []), + ]) + setRequests(list) + setDivisions(divs) + } catch (err) { + toast.error((err as Error).message) + } + }, [organizationId]) + + useEffect(() => { + void load() + }, [load]) + + return ( + +
+
+
+

+ Delivery pipeline +

+

+ Customer change requests across divisions: estimate → approve → pay → go-live. +

+
+ +
+ + {creating && organizationId && ( + { + setCreating(false) + void load() + }} + /> + )} + + {/* Pipeline legend */} +
+ {STAGES.map((s, i) => ( + + {s} + {i < STAGES.length - 1 && } + + ))} +
+ +
+ {requests.map((cr) => { + const pct = cr.stepCount > 0 ? Math.round((cr.doneStepCount / cr.stepCount) * 100) : 0 + return ( + + ) + })} + {requests.length === 0 && ( + + + No change requests yet. Log a customer ask to start the pipeline. + + + )} +
+
+ + {openId && ( + setOpenId(null)} + onChanged={load} + /> + )} +
+ ) +} + +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 ( +
+
+
+ + setCustomer(e.target.value)} placeholder="Acme Corp" /> +
+
+ + setTitle(e.target.value)} placeholder="Add SSO to the portal" /> +
+
+
+ +