This commit is contained in:
@@ -8,30 +8,18 @@
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PermissionGrant } from '@pig/core';
|
||||
import { Pencil, Plus } from 'lucide-react';
|
||||
import { get, money, relativeTime } from '@/lib/api';
|
||||
import { Badge, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { Badge, Button, Card, EmptyState, Skeleton } from '@/components/ui';
|
||||
import { usePageTitle } from '@/lib/title';
|
||||
|
||||
interface DemandDeal {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
productLine: string;
|
||||
acvCents: number | null;
|
||||
msaExecuted: boolean;
|
||||
dpaExecuted: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SupplyDeal {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: string;
|
||||
gpuType: string | null;
|
||||
gpuCount: number | null;
|
||||
targetCostPerGpuHourCents: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
import { can } from '@/lib/permissions';
|
||||
import {
|
||||
DemandDealSheet,
|
||||
SupplyDealSheet,
|
||||
type DemandDealRecord,
|
||||
type SupplyDealRecord,
|
||||
} from '@/components/RecordSheets';
|
||||
|
||||
interface Board<T> {
|
||||
stages: string[];
|
||||
@@ -64,10 +52,12 @@ const STAGE_LABELS: Record<string, string> = {
|
||||
|
||||
export function DemandPipeline() {
|
||||
return (
|
||||
<PipelineBoard<DemandDeal>
|
||||
<PipelineBoard<DemandDealRecord>
|
||||
title="Demand"
|
||||
subtitle="Selling compute and post-training. Note that legal sits early — paper gates the deal rather than closing it."
|
||||
endpoint="/api/deals/demand"
|
||||
team="demand"
|
||||
renderSheet={({ open, onOpenChange, record }) => <DemandDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, accountName) => (
|
||||
<>
|
||||
<p className="truncate font-medium">{deal.name}</p>
|
||||
@@ -90,10 +80,12 @@ export function DemandPipeline() {
|
||||
|
||||
export function SupplyPipeline() {
|
||||
return (
|
||||
<PipelineBoard<SupplyDeal>
|
||||
<PipelineBoard<SupplyDealRecord>
|
||||
title="Supply"
|
||||
subtitle="Sourcing GPU capacity. Technical and financial diligence are separate gates — accepting capacity is a two-key decision."
|
||||
endpoint="/api/deals/supply"
|
||||
team="supply"
|
||||
renderSheet={({ open, onOpenChange, record }) => <SupplyDealSheet open={open} onOpenChange={onOpenChange} record={record} />}
|
||||
renderCard={(deal, accountName) => (
|
||||
<>
|
||||
<p className="truncate font-medium">{deal.name}</p>
|
||||
@@ -120,12 +112,16 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
title,
|
||||
subtitle,
|
||||
endpoint,
|
||||
team,
|
||||
renderCard,
|
||||
renderSheet,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
endpoint: string;
|
||||
team: 'supply' | 'demand';
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
renderSheet: (props: { open: boolean; onOpenChange(open: boolean): void; record?: T }) => React.ReactNode;
|
||||
}) {
|
||||
usePageTitle(title);
|
||||
|
||||
@@ -133,6 +129,12 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
queryKey: [endpoint],
|
||||
queryFn: () => get<Board<T>>(endpoint),
|
||||
});
|
||||
const { data: me } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => get<{ permissions: PermissionGrant[] }>('/api/me'),
|
||||
});
|
||||
const writable = can(me, 'deal:write', team);
|
||||
const [sheet, setSheet] = useState<{ open: boolean; record?: T }>({ open: false });
|
||||
|
||||
const [activeStage, setActiveStage] = useState<string | null>(null);
|
||||
|
||||
@@ -150,13 +152,15 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
if (!data || data.deals.length === 0) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<Header title={title} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
|
||||
<Card>
|
||||
<EmptyState
|
||||
title={`No ${title.toLowerCase()} deals yet`}
|
||||
description="Deals appear here once created. Stages follow how this market actually operates rather than a generic sales funnel."
|
||||
action={<Button variant="primary" disabled={!writable} onClick={() => setSheet({ open: true })}><Plus aria-hidden />New {title.toLowerCase()} deal</Button>}
|
||||
/>
|
||||
</Card>
|
||||
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +170,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<Header title={title} subtitle={subtitle} writable={writable} onCreate={() => setSheet({ open: true })} />
|
||||
|
||||
{/* Phone: pick one stage. The chips scroll; the board does not. */}
|
||||
<div className="lg:hidden">
|
||||
@@ -192,7 +196,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(byStage.get(currentStage) ?? []).map((row) => (
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
|
||||
))}
|
||||
{(byStage.get(currentStage) ?? []).length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted">
|
||||
@@ -216,7 +220,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} />
|
||||
<DealCard key={row.deal.id} row={row} renderCard={renderCard} writable={writable} onEdit={() => setSheet({ open: true, record: row.deal })} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -224,6 +228,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{renderSheet({ open: sheet.open, onOpenChange: (open) => setSheet((state) => ({ ...state, open })), record: sheet.record })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -231,23 +236,28 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
||||
function DealCard<T extends { id: string; updatedAt: string }>({
|
||||
row,
|
||||
renderCard,
|
||||
writable,
|
||||
onEdit,
|
||||
}: {
|
||||
row: { deal: T; accountName: string | null };
|
||||
renderCard: (deal: T, accountName: string | null) => React.ReactNode;
|
||||
writable: boolean;
|
||||
onEdit(): void;
|
||||
}) {
|
||||
return (
|
||||
<article className="card p-3">
|
||||
<article className="card relative p-3 pr-12">
|
||||
<Button size="icon" variant="ghost" className="absolute right-1 top-1" disabled={!writable} onClick={onEdit} title="Edit deal"><Pencil aria-hidden /><span className="sr-only">Edit deal</span></Button>
|
||||
{renderCard(row.deal, row.accountName)}
|
||||
<p className="mt-2 text-[11px] text-muted">{relativeTime(row.deal.updatedAt)}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
function Header({ title, subtitle, writable, onCreate }: { title: string; subtitle: string; writable: boolean; onCreate(): void }) {
|
||||
return (
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p>
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div><h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1><p className="mt-1 max-w-2xl text-sm text-muted">{subtitle}</p></div>
|
||||
<Button variant="primary" disabled={!writable} onClick={onCreate}><Plus aria-hidden />New deal</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user