Compare commits

...

4 Commits

Author SHA1 Message Date
Bhanu Prakash Sai Potteri
fe6c257ace refactor: restructure the AI assessment and activity blocks
Assessment now reads verdict -> checks -> evidence -> reasoning. AI Reasoning
moved below the PO/GR/contract records it narrates, so it lands as the
conclusion rather than the preamble. Each part is a SubBlock: small-caps title,
optional badge, hairline above, no nested cards. Evidence rows get an aligned
label column and a red wash on the discrepancy row.

Activity gains a run summary (decisions, wall time, tokens, cost, model),
numbered decisions on a connected timeline, and honest confidence: the figure
the model acted on, colour-banded, annotated when the self-check revised it,
plus a chip when reflexion asked to escalate. Plan/reasoning/self-check sit
behind accent rules, knowledge sources render as chips, and the trace
waterfall carries per-step tokens, indents child spans and reddens failures.

Total run time sums only top-level spans; nested ones would double-count.
2026-07-29 20:13:55 +05:30
Bhanu Prakash Sai Potteri
a52ff32eb4 feat: surface the AI's work, the audit trail and the invoice PDF in our UI
Ports the data that arrived with the merge into the rebuilt detail view, so
nothing the API returns is left unrendered and there is one visual language.

- SourceDocument: invoice_pdf was write-only — uploaded, OCR'd, then shown
  nowhere. Fetched as a Blob (the endpoint forces an attachment) and rendered
  inline via an object URL, revoked on unmount
- AuditTrail: the history as a timeline — actor AI-vs-human, resulting state,
  per-activity headline and how long each hop waited
- AiActivity: decisions with status/confidence/model, the model's plan,
  reasoning and self-check, escalations, and a per-run trace waterfall with
  durations, tokens and cost
- adopts lib/matchSummary for both free-text AI fields: it parses entity
  labels our inline version dropped ("PO-3001:", "GR:"), treats
  PARTIAL/MISSING as warnings and "Payments: NONE" as neutral, and turns
  analysis_so_far into evidence rows instead of a paragraph
- drops the five pre-migration components these replace, plus the TONE_PILL /
  TONE_DOT class maps they were the only users of — the last hardcoded
  slate/zinc palette in the app. CSS 115.8 -> 98.1 kB

STATE_META stays: keying display metadata off state UUIDs beats matching
served names, and folding it into lib/workflow.ts is the next step.
2026-07-29 20:04:58 +05:30
Bhanu Prakash Sai Potteri
e32bc729c5 Merge branch 'main': keep the rebuilt UI, land the new data layer
AppShell resolved in favour of the rebuilt shell. The incoming hunk routed
workflow instances to InvoiceDetail, whose components predate the Tailwind v4
token migration and are written in hardcoded slate/zinc with a second dark
palette, so adopting them wholesale would have put two visual languages on
one page.

Their data layer lands intact and unrendered for now: the monitor/audit/file
endpoints in viewService, useInstanceDetail, lib/matchSummary and
lib/workflowMeta. Next step is to port that logic into the rebuilt UI —
source-document viewer, audit trail, AI decision/trace panels and the
structured analysis_so_far evidence are all data we do not surface yet.
2026-07-29 19:53:11 +05:30
Bhanu Prakash Sai Potteri
f5bc1fa119 feat: rebuild the UI on Tailwind v4 + shadcn/ui
Migrates Tailwind v3 -> v4 (CSS-first, theme tokens for brand, workflow
states and charts) and adds 23 shadcn/ui components on Radix.

- lib/workflow.ts is now the single source of truth for state name, colour,
  icon and available actions; "Financial Review" is the canonical served
  name, with "Finance Review" kept as an alias so those rows stop falling
  through to the unstyled fallback and dropping out of every count
- record view: TanStack Table with server-side sort/paginate, company column
  with initials avatar, page totals, density and a URL-synced query state
- dashboard: one cached analytics fetch shared by tiles and charts, donut +
  aging + vendor spend, click-to-filter
- detail view: rebuilt against the live detailview payload — restores the PO
  number and payment terms that the old key heuristics silently dropped,
  parses match_summary into a 3-way-match checklist, and separates the
  department's and reviewer's own words from AI-generated content
- form modal on Radix Dialog (focus trap, scroll lock, escape handling)
2026-07-29 19:47:59 +05:30
68 changed files with 13544 additions and 3492 deletions

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

6584
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,19 +10,32 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"next-themes": "^0.4.6",
"nuqs": "^2.9.2",
"radix-ui": "^1.6.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.26.0" "react-router-dom": "^6.26.0",
"recharts": "^3.10.1",
"shadcn": "^4.16.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.2",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "tailwindcss": "^4.3.3",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "~5.5.4", "typescript": "~5.5.4",
"vite": "^5.4.2" "vite": "^5.4.2"
} }

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,343 +0,0 @@
// AIActivityPanel — the AI employee's reasoning, brought into the app.
//
// Port of the studio builder's "AI Employee → Logs" tab
// (studio-frontend/src/components/monitoring/AIEmployeeLogsPanel.js +
// AIEmployeeTrace.jsx), narrowed to a single instance and restyled to match
// this app. Same data, same event vocabulary and confidence thresholds — a
// client shouldn't have to be shown the builder to see what the AI did.
//
// Two sources, both instance-scoped:
// /monitor/instances/{id}/log → decisions (+ escalations)
// /monitor/instances/{id}/trace → per-run step traces
import { useMemo, useState } from "react";
import {
Sparkles, ChevronDown, ChevronRight, AlertTriangle, Coins, Hash, Timer, Layers,
} from "lucide-react";
import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "../api/viewService";
import { activityLabel, fmtDuration, fmtRelative, fmtDateTime } from "../lib/workflowMeta";
// ---------------------------------------------------------------------------
// Event styling — mirrors studio's TYPE_META so the two read the same
// ---------------------------------------------------------------------------
const TYPE_META: Record<string, { label: string; bar: string; chip: string }> = {
context: { label: "Context", bar: "bg-slate-400", chip: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300" },
plan: { label: "Plan", bar: "bg-violet-500", chip: "bg-violet-100 text-violet-700 dark:bg-violet-950/50 dark:text-violet-300" },
llm: { label: "LLM", bar: "bg-blue-500", chip: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300" },
tool: { label: "Tool", bar: "bg-cyan-500", chip: "bg-cyan-100 text-cyan-700 dark:bg-cyan-950/50 dark:text-cyan-300" },
api: { label: "API", bar: "bg-amber-500", chip: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" },
knowledge: { label: "Knowledge", bar: "bg-emerald-500", chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" },
reflexion: { label: "Reflexion", bar: "bg-fuchsia-500", chip: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-950/50 dark:text-fuchsia-300" },
decision: { label: "Decision", bar: "bg-emerald-600", chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" },
escalation: { label: "Escalation", bar: "bg-amber-600", chip: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" },
};
const FALLBACK = { label: "Step", bar: "bg-slate-400", chip: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300" };
const metaFor = (t: string) => TYPE_META[t] ?? FALLBACK;
const STATUS_CHIP: Record<string, string> = {
submitted: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300",
submitting: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
failed: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
error: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
escalated: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300",
};
// Same thresholds studio uses: >=90 good, >=70 caution, below that a warning.
function ConfidencePill({ value }: { value?: number }) {
if (value == null) return null;
const pct = Math.round(value * 100);
const cls = pct >= 90
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300"
: pct >= 70
? "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300"
: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300";
return <span className={`px-1.5 py-0.5 rounded text-[10.5px] font-bold ${cls}`}>{pct}%</span>;
}
const fmtCost = (c?: number): string | null => {
if (!c) return null;
return c < 0.0001 ? "<$0.0001" : `$${c.toFixed(4)}`;
};
const asText = (v: unknown): string =>
v == null ? "" : typeof v === "string" ? v : JSON.stringify(v, null, 2);
// ---------------------------------------------------------------------------
// Trace waterfall
// ---------------------------------------------------------------------------
function Waterfall({ trace }: { trace: AITrace }) {
const [openSeq, setOpenSeq] = useState<number | null>(null);
const { t0, span, totals } = useMemo(() => {
const starts = trace.events.map((e) => new Date(e.started_at).getTime()).filter((n) => !Number.isNaN(n));
const ends = trace.events.map((e) =>
new Date(e.ended_at ?? e.started_at).getTime()).filter((n) => !Number.isNaN(n));
const start = starts.length ? Math.min(...starts) : 0;
const end = ends.length ? Math.max(...ends) : start + 1;
return {
t0: start,
span: Math.max(end - start, 1),
totals: trace.events.reduce(
(acc, e) => ({
tokens: acc.tokens + (e.tokens_in ?? 0) + (e.tokens_out ?? 0),
cost: acc.cost + (e.cost ?? 0),
}),
{ tokens: 0, cost: 0 },
),
};
}, [trace]);
return (
<div className="mt-2">
<div className="flex items-center gap-4 mb-2 text-[10.5px] text-slate-400 dark:text-zinc-500">
<span className="inline-flex items-center gap-1"><Layers size={10} />{trace.events.length} steps</span>
<span className="inline-flex items-center gap-1"><Timer size={10} />{fmtDuration(span)}</span>
{totals.tokens > 0 && (
<span className="inline-flex items-center gap-1"><Hash size={10} />{totals.tokens.toLocaleString()} tokens</span>
)}
{fmtCost(totals.cost) && (
<span className="inline-flex items-center gap-1"><Coins size={10} />{fmtCost(totals.cost)}</span>
)}
</div>
<ol className="space-y-1">
{trace.events.map((ev: AITraceEvent) => {
const m = metaFor(ev.type);
const s = new Date(ev.started_at).getTime();
const dur = ev.duration_ms ?? Math.max(new Date(ev.ended_at ?? ev.started_at).getTime() - s, 0);
const left = Number.isNaN(s) ? 0 : ((s - t0) / span) * 100;
const width = Math.max((dur / span) * 100, 1.2);
const failed = ev.status && ev.status !== "ok" && ev.status !== "success";
const isOpen = openSeq === ev.seq;
return (
<li key={ev.seq}>
<button
type="button"
onClick={() => setOpenSeq(isOpen ? null : ev.seq)}
className="w-full text-left group"
>
<div className="flex items-center gap-2">
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9.5px] font-bold uppercase tracking-wide ${m.chip}`}>
{m.label}
</span>
<span className="text-[11.5px] text-slate-600 dark:text-zinc-300 truncate max-w-[190px] group-hover:text-violet-600 dark:group-hover:text-violet-400">
{ev.name}
</span>
{/* Waterfall lane */}
<span className="flex-1 h-[7px] bg-slate-100 dark:bg-zinc-800 rounded-full relative overflow-hidden min-w-[60px]">
<span
className={`absolute top-0 bottom-0 rounded-full ${failed ? "bg-red-500" : m.bar}`}
style={{ left: `${left}%`, width: `${width}%` }}
/>
</span>
<span className="shrink-0 text-[10.5px] tabular-nums text-slate-400 dark:text-zinc-500 w-14 text-right">
{fmtDuration(dur)}
</span>
</div>
</button>
{isOpen && (
<div className="ml-2 mt-1.5 mb-2 pl-3 border-l-2 border-slate-100 dark:border-zinc-800 space-y-2">
{(ev.tokens_in || ev.tokens_out) && (
<p className="text-[10.5px] text-slate-400 dark:text-zinc-500">
{(ev.tokens_in ?? 0).toLocaleString()} in · {(ev.tokens_out ?? 0).toLocaleString()} out
{fmtCost(ev.cost) ? ` · ${fmtCost(ev.cost)}` : ""}
</p>
)}
{ev.error && (
<p className="text-[11.5px] text-red-600 dark:text-red-400">{ev.error}</p>
)}
{ev.input != null && (
<Snippet label="Input" text={asText(ev.input)} />
)}
{ev.output != null && (
<Snippet label={ev.type === "reflexion" ? "Self-critique" : ev.type === "plan" ? "Plan" : "Output"} text={asText(ev.output)} />
)}
</div>
)}
</li>
);
})}
</ol>
</div>
);
}
function Snippet({ label, text }: { label: string; text: string }) {
if (!text.trim()) return null;
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600 mb-0.5">{label}</div>
<pre className="text-[11px] leading-relaxed text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800/60 rounded-lg px-2.5 py-2 max-h-52 overflow-auto whitespace-pre-wrap break-words">
{text}
</pre>
</div>
);
}
// ---------------------------------------------------------------------------
// Decision card
// ---------------------------------------------------------------------------
function DecisionCard({ d, trace }: { d: AIDecision; trace?: AITrace }) {
const [open, setOpen] = useState(false);
const escalated = d.reflexion?.should_escalate;
return (
<div className="rounded-xl border border-slate-200/80 dark:border-zinc-700/60 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">
{activityLabel(d.activity_id)}
</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${STATUS_CHIP[d.status] ?? "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-400"}`}>
{d.status}
</span>
<ConfidencePill value={d.confidence} />
</div>
<div className="text-[10.5px] text-slate-400 dark:text-zinc-500 mt-0.5">
{d.instance_state ? `saw "${d.instance_state}"` : null}
{d.model ? ` · ${d.model}` : ""}
</div>
</div>
<span className="text-[11px] text-slate-400 dark:text-zinc-500 shrink-0" title={fmtDateTime(d.created_at)}>
{fmtRelative(d.created_at)}
</span>
</div>
{d.reasoning && (
<p className="text-[12.5px] leading-relaxed text-slate-600 dark:text-zinc-300 mt-2">
{d.reasoning}
</p>
)}
{d.error_message && (
<div className="mt-2 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400">
<AlertTriangle size={12} className="shrink-0 mt-0.5" />
<span className="break-words">{d.error_message}</span>
</div>
)}
{escalated && (
<div className="mt-2 flex items-center gap-1.5 text-[11.5px] text-amber-700 dark:text-amber-400">
<AlertTriangle size={12} />Flagged for human escalation
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="mt-2 text-[11px] text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 inline-flex items-center gap-1 transition-colors"
>
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
{open ? "Hide reasoning trail" : "Reasoning trail"}
</button>
{open && (
<div className="mt-2 space-y-2.5">
{d.plan && <Snippet label="Plan" text={d.plan} />}
{d.reflexion?.concerns ? <Snippet label="Concerns" text={d.reflexion.concerns} /> : null}
{d.reflexion?.adjusted_confidence != null && (
<p className="text-[11px] text-slate-400 dark:text-zinc-500">
Adjusted confidence after self-check: {Math.round(d.reflexion.adjusted_confidence * 100)}%
</p>
)}
{trace && <Waterfall trace={trace} />}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
interface Props {
decisions: AIDecision[];
escalations: AIEscalation[];
traces: AITrace[];
unavailable?: boolean;
}
export default function AIActivityPanel({ decisions, escalations, traces, unavailable }: Props) {
// Each trace carries the decision_id it belongs to on its events, so the
// waterfall can be attached to the decision it explains.
const traceByDecision = useMemo(() => {
const m = new Map<number, AITrace>();
for (const t of traces) {
const id = t.events.find((e) => e.decision_id != null)?.decision_id;
if (id != null && !m.has(id)) m.set(id, t);
}
return m;
}, [traces]);
const ordered = useMemo(
() => [...decisions].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
[decisions],
);
const totals = useMemo(() => {
let tokens = 0, cost = 0;
for (const t of traces) for (const e of t.events) {
tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
cost += e.cost ?? 0;
}
return { tokens, cost };
}, [traces]);
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
<Sparkles size={14} className="text-violet-500" />
AI Review
<span className="font-normal text-slate-400 dark:text-zinc-500">· Priya · AI Finance</span>
</h3>
<div className="flex items-center gap-3 text-[10.5px] text-slate-400 dark:text-zinc-500">
{ordered.length > 0 && <span>{ordered.length} {ordered.length === 1 ? "pass" : "passes"}</span>}
{totals.tokens > 0 && <span>{totals.tokens.toLocaleString()} tokens</span>}
{fmtCost(totals.cost) && <span>{fmtCost(totals.cost)}</span>}
</div>
</div>
{unavailable ? (
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
AI activity is unavailable right now the monitoring service couldn't be reached.
</p>
) : ordered.length === 0 ? (
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
No AI review recorded for this invoice yet.
</p>
) : (
<div className="space-y-2.5">
{ordered.map((d) => (
<DecisionCard key={d.id} d={d} trace={traceByDecision.get(d.id)} />
))}
</div>
)}
{escalations.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-zinc-800">
<h4 className="text-[11px] uppercase tracking-wide text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1.5">
<AlertTriangle size={11} />Escalations
</h4>
<div className="space-y-2">
{escalations.map((e) => (
<div key={e.id} className="text-[12px] text-slate-600 dark:text-zinc-300">
<span className="font-medium">{e.recommended_action || "Escalated"}</span>
{e.reason ? <> {e.reason}</> : null}
<span className="text-slate-400 dark:text-zinc-500"> · {fmtRelative(e.created_at)}</span>
</div>
))}
</div>
</div>
)}
</section>
);
}

View File

@ -0,0 +1,367 @@
// What the AI employee actually did on this instance.
//
// Sourced from /monitor/instances/{id}/log (decisions + escalations) and
// /trace (per-run step waterfall). Both are best-effort: an instance the
// employee never touched has no rows, and ai-employee-service is a separate
// deployment that can be down without this page breaking — hence `unavailable`
// rendering as a note rather than an error.
//
// Presentation is on our theme tokens; the decision↔trace linking, ordering and
// token/cost roll-up are the logic that came with the endpoints.
import { useMemo, useState } from "react";
import {
AlertTriangle,
ChevronDown,
Clock,
Coins,
Cpu,
Hash,
Sparkles,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "@/api/viewService";
import { activityLabel, fmtDuration } from "@/lib/workflowMeta";
import { formatDate, formatTime } from "@/lib/format";
const STATUS_TONE: Record<string, string> = {
submitted: "bg-state-approved/12 text-state-approved",
escalated: "bg-state-clarification/12 text-state-clarification",
submitting: "bg-state-review/12 text-state-review",
failed: "bg-state-rejected/12 text-state-rejected",
error: "bg-state-rejected/12 text-state-rejected",
};
/** Same banding as the assessment block: 90 auto-accept, 60 re-check. */
const confidenceTone = (pct: number) =>
pct >= 90 ? "text-state-approved" : pct >= 60 ? "text-state-clarification" : "text-state-rejected";
interface Props {
decisions: AIDecision[];
escalations: AIEscalation[];
traces: AITrace[];
unavailable?: boolean;
}
export default function AiActivity({ decisions, escalations, traces, unavailable }: Props) {
// Each trace carries the decision_id it belongs to on its events, so the
// waterfall can be attached to the decision it explains.
const traceByDecision = useMemo(() => {
const m = new Map<number, AITrace>();
for (const t of traces) {
const id = t.events.find((e) => e.decision_id != null)?.decision_id;
if (id != null && !m.has(id)) m.set(id, t);
}
return m;
}, [traces]);
const ordered = useMemo(
() =>
[...decisions].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
),
[decisions],
);
const totals = useMemo(() => {
let tokens = 0;
let cost = 0;
let ms = 0;
for (const t of traces)
for (const e of t.events) {
tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
cost += e.cost ?? 0;
// Only top-level steps, or nested spans would be counted twice.
if (e.parent_seq == null) ms += e.duration_ms ?? 0;
}
return { tokens, cost, ms };
}, [traces]);
if (unavailable) {
return (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
The AI monitoring service isnt reachable, so decisions and traces cant be shown.
</p>
);
}
if (ordered.length === 0 && escalations.length === 0) {
return (
<p className="text-sm text-muted-foreground">The AI employee hasnt acted on this invoice.</p>
);
}
const models = [...new Set(ordered.map((d) => d.model).filter(Boolean))];
return (
<div>
{/* Run summary — the cost of the automation, before the detail of it. */}
{(ordered.length > 0 || totals.tokens > 0) && (
<dl className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-2xs text-muted-foreground">
<Stat icon={Sparkles}>
{ordered.length} {ordered.length === 1 ? "decision" : "decisions"}
</Stat>
{totals.ms > 0 && <Stat icon={Clock}>{fmtDuration(totals.ms)}</Stat>}
{totals.tokens > 0 && (
<Stat icon={Hash}>{totals.tokens.toLocaleString("en-IN")} tokens</Stat>
)}
{totals.cost > 0 && <Stat icon={Coins}>${totals.cost.toFixed(4)}</Stat>}
{models.length > 0 && <Stat icon={Cpu}>{models.join(", ")}</Stat>}
</dl>
)}
<ol>
{ordered.map((d, i) => (
<Decision
key={d.id}
decision={d}
trace={traceByDecision.get(d.id)}
index={i + 1}
last={i === ordered.length - 1 && escalations.length === 0}
/>
))}
</ol>
{escalations.length > 0 && (
<div className="space-y-2.5">
{escalations.map((e) => (
<div
key={e.id}
className="border-l-2 border-state-clarification/50 pl-3"
>
<p className="flex flex-wrap items-center gap-1.5 text-base font-semibold text-state-clarification">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
Escalated to a human
{e.recommended_action && (
<span className="text-2xs font-bold uppercase tracking-wider">
· {e.recommended_action}
</span>
)}
</p>
{(e.reasoning || e.reason) && (
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{e.reasoning || e.reason}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}
function Stat({ icon: Icon, children }: { icon: typeof Hash; children: React.ReactNode }) {
return (
<dd className="flex items-center gap-1 tabular-nums">
<Icon className="size-2.5 shrink-0 text-primary" aria-hidden />
{children}
</dd>
);
}
function Decision({
decision: d,
trace,
index,
last,
}: {
decision: AIDecision;
trace?: AITrace;
index: number;
last: boolean;
}) {
const [open, setOpen] = useState(false);
// The self-check can revise the model's own confidence down; show the number
// it ended up acting on, and say so when it changed.
const raw = d.confidence != null ? d.confidence * 100 : null;
const adjusted =
d.reflexion?.adjusted_confidence != null ? d.reflexion.adjusted_confidence * 100 : null;
const pct = adjusted ?? raw;
const time = formatTime(d.created_at);
const hasDetail = !!(d.plan || d.reasoning || d.reflexion?.concerns || trace);
return (
<li className={cn("relative flex gap-3", last ? "pb-0" : "pb-4")}>
{/* Connector — makes a multi-run sequence read as one process. */}
{!last && (
<span aria-hidden className="absolute bottom-0 left-3.5 top-8 w-px bg-primary/15" />
)}
<span className="relative flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/12 text-2xs font-bold tabular-nums text-primary">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-base font-semibold text-foreground">
{activityLabel(d.activity_id)}
</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider",
STATUS_TONE[d.status] ?? "bg-muted text-muted-foreground",
)}
>
{d.status}
</span>
{pct != null && (
<span className={cn("text-sm font-bold tabular-nums", confidenceTone(pct))}>
{Math.round(pct)}%
{adjusted != null && raw != null && Math.round(adjusted) !== Math.round(raw) && (
<span className="ml-1 font-normal text-muted-foreground">
(self-checked from {Math.round(raw)}%)
</span>
)}
</span>
)}
{d.reflexion?.should_escalate && (
<span className="rounded-full bg-state-clarification/12 px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-clarification">
flagged for escalation
</span>
)}
</div>
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
{formatDate(d.created_at)}
{time && ` · ${time}`}
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
</p>
{d.error_message && (
<p className="mt-1.5 border-l-2 border-destructive/50 pl-2.5 text-sm text-destructive">
{d.error_message}
</p>
)}
{hasDetail && (
<>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="mt-1.5 flex items-center gap-1 rounded text-xs font-semibold text-primary hover:underline"
>
<ChevronDown
className={cn("size-3 transition-transform", open && "rotate-180")}
aria-hidden
/>
{open ? "Hide" : "Show"} the models work
</button>
{open && (
<div className="mt-2.5 space-y-3">
{d.plan && <Passage label="Plan" text={d.plan} />}
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
{d.reflexion?.concerns && (
<Passage
label="Self-check"
text={d.reflexion.concerns}
tone={d.reflexion.should_escalate ? "warn" : undefined}
/>
)}
{d.knowledge_sources && d.knowledge_sources.length > 0 && (
<div>
<Label>Knowledge used</Label>
<ul className="mt-1 flex flex-wrap gap-1.5">
{d.knowledge_sources.map((s) => (
<li
key={s}
className="rounded-md bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground"
>
{s}
</li>
))}
</ul>
</div>
)}
{trace && <Waterfall trace={trace} />}
</div>
)}
</>
)}
</div>
</li>
);
}
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</p>
);
}
function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) {
return (
<div
className={cn(
"border-l-2 pl-2.5",
tone === "warn" ? "border-state-clarification/50" : "border-primary/20",
)}
>
<p
className={cn(
"text-2xs font-semibold uppercase tracking-wider",
tone === "warn" ? "text-state-clarification" : "text-muted-foreground",
)}
>
{label}
</p>
<p className="mt-0.5 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
{text.replace(/[ \t]{2,}/g, " ")}
</p>
</div>
);
}
/** Step timings for one run, as bars proportional to the slowest step. */
function Waterfall({ trace }: { trace: AITrace }) {
const events = [...trace.events].sort((a, b) => a.seq - b.seq);
const max = Math.max(...events.map((e) => e.duration_ms ?? 0), 1);
return (
<div>
<Label>Run trace</Label>
<ul className="mt-1.5 space-y-1">
{events.map((e) => (
<TraceRow key={e.seq} event={e} max={max} />
))}
</ul>
</div>
);
}
function TraceRow({ event: e, max }: { event: AITraceEvent; max: number }) {
const failed = !!e.error || e.status === "error" || e.status === "failed";
const tokens = (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
return (
<li className="flex items-center gap-2 text-2xs">
<span
className={cn("min-w-0 flex-1 truncate", failed ? "text-destructive" : "text-muted-foreground")}
style={{ paddingLeft: e.parent_seq != null ? 12 : 0 }}
title={e.error || e.name || e.type}
>
{e.name || e.type}
</span>
{tokens > 0 && (
<span className="w-20 shrink-0 text-right tabular-nums text-muted-foreground/60">
{tokens.toLocaleString("en-IN")} tok
</span>
)}
<span className="h-1.5 w-20 shrink-0 overflow-hidden rounded-full bg-muted">
<span
className={cn("block h-full rounded-full", failed ? "bg-state-rejected" : "bg-primary/60")}
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
/>
</span>
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">
{fmtDuration(e.duration_ms)}
</span>
</li>
);
}

View File

@ -1,250 +1,344 @@
import { useEffect, useState, useCallback } from "react"; import { Suspense, lazy, useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { Loader2, CheckCircle2, XCircle, PauseCircle, MessageSquare } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Loader2, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import DynamicHeader from "./DynamicHeader"; import DynamicHeader from "./DynamicHeader";
import DashboardStats from "./DashboardStats"; import DashboardStats from "./DashboardStats";
import ScreenRenderer from "./ScreenRenderer"; import ScreenRenderer from "./ScreenRenderer";
import { TableSkeleton } from "./Skeleton";
import DetailViewPanel from "./DetailViewPanel"; import DetailViewPanel from "./DetailViewPanel";
import InvoiceDetail from "./InvoiceDetail";
import FormModal from "./FormModal";
import { getHeaderConfig, getScreen, getInstance, type LayoutElement, type NavItem } from "../api/viewService";
import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, SCREEN_UUIDS, RDBMS_RV_SCREEN_UUID, RDBMS_DV_SCREEN_UUID } from "../config";
import RecordViewTable from "./RecordViewTable"; import RecordViewTable from "./RecordViewTable";
import FormModal from "./FormModal";
import CommandPalette from "./CommandPalette";
import StatusBadge from "./StatusBadge";
import { TableSkeleton } from "./Skeleton";
import { getScreen, getInstance, type LayoutElement } from "@/api/viewService";
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
import { useSidebar } from "@/hooks/useSidebar";
import { actionsForStateId, stateById } from "@/lib/workflow";
import {
RDBMS_DV_SCREEN_UUID,
RDBMS_RV_SCREEN_UUID,
SCREEN_UUIDS,
STATE_IDS,
WORKFLOW_ID,
} from "@/config";
// Keys + values are UUIDs (source_uuid from tbl_appconfig_rv_screens / dv_screens / screens). // recharts is heavy — keep it out of the initial bundle so the queue paints
const RDBMS_SCREENS: Record<string, string> = { // first, then swap the charts in over a same-size placeholder.
[RDBMS_RV_SCREEN_UUID]: RDBMS_RV_SCREEN_UUID, // Purchase Orders (Vendor Data) rv_screen const DashboardCharts = lazy(() => import("./DashboardCharts"));
};
/** rv_screens rendered directly rather than through a screen layout. */
const RDBMS_SCREEN_LABELS: Record<string, string> = { const RDBMS_SCREEN_LABELS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: "Purchase Orders", [RDBMS_RV_SCREEN_UUID]: "Purchase Orders",
}; };
// RDBMS list screen → DV screen (both are dv/rv screen source_uuids).
const RDBMS_DETAIL_SCREENS: Record<string, string> = { const RDBMS_DETAIL_SCREENS: Record<string, string> = {
[RDBMS_RV_SCREEN_UUID]: RDBMS_DV_SCREEN_UUID, [RDBMS_RV_SCREEN_UUID]: RDBMS_DV_SCREEN_UUID,
}; };
// List screen → detail screen (tbl_appconfig_screens source_uuid). /** List screen → detail screen. */
const SCREEN_TO_DETAIL_SCREEN: Record<string, string> = { const SCREEN_TO_DETAIL: Record<string, string> = {
[SCREEN_UUIDS.MY_INVOICES]: SCREEN_UUIDS.INVOICE_DETAIL, [SCREEN_UUIDS.MY_INVOICES]: SCREEN_UUIDS.INVOICE_DETAIL,
[SCREEN_UUIDS.PENDING_CLARIFICATIONS]: SCREEN_UUIDS.CLARIFICATION_DETAIL, [SCREEN_UUIDS.PENDING_CLARIFICATIONS]: SCREEN_UUIDS.CLARIFICATION_DETAIL,
[SCREEN_UUIDS.FINANCE_REVIEW]: SCREEN_UUIDS.FINANCE_REVIEW_DETAIL, [SCREEN_UUIDS.FINANCE_REVIEW]: SCREEN_UUIDS.FINANCE_REVIEW_DETAIL,
[SCREEN_UUIDS.ALL_INVOICES]: SCREEN_UUIDS.INVOICE_DETAIL, [SCREEN_UUIDS.ALL_INVOICES]: SCREEN_UUIDS.INVOICE_DETAIL,
}; };
const ACTIVITY_META: Record<string, { label: string; icon: React.ReactNode; style: "primary" | "danger" | "ghost" }> = { const detailScreenFor = (screenId: string | null) =>
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: { label: "Provide Clarification", icon: <MessageSquare size={14} />, style: "primary" }, (screenId ? SCREEN_TO_DETAIL[screenId] ?? RDBMS_DETAIL_SCREENS[screenId] : null) ??
[ACTIVITY_IDS.APPROVE_PAYMENT]: { label: "Approve", icon: <CheckCircle2 size={14} />, style: "primary" }, SCREEN_UUIDS.INVOICE_DETAIL;
[ACTIVITY_IDS.REJECT_PAYMENT]: { label: "Reject", icon: <XCircle size={14} />, style: "danger" },
[ACTIVITY_IDS.HOLD_PAYMENT]: { label: "Hold", icon: <PauseCircle size={14} />, style: "ghost" },
};
const BTN_STYLES: Record<string, string> = {
primary: "text-white shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 hover:scale-[1.02]",
danger: "text-red-600 dark:text-red-400 bg-white dark:bg-zinc-900 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-950/30",
ghost: "text-slate-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-slate-200 dark:border-zinc-700 hover:bg-slate-50 dark:hover:bg-zinc-800",
};
export default function AppShell() { export default function AppShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const params = useParams(); const params = useParams();
const screenIdParam = params.screenId ?? null; const screenIdParam = params.screenId ?? null;
const instanceIdParam = params.instanceId; const instanceIdParam = params.instanceId ?? null;
const sidebar = useSidebar();
const { navItems, isSuccess: navReady } = useHeaderConfig();
const [activeScreenId, setActiveScreenId] = useState<string | null>(screenIdParam); const [activeScreenId, setActiveScreenId] = useState<string | null>(screenIdParam);
const [layout, setLayout] = useState<LayoutElement[]>([]); const [detailTitle, setDetailTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [commandOpen, setCommandOpen] = useState(false);
const [ready, setReady] = useState(false); const [formModal, setFormModal] = useState<{
activityId: string;
instanceId: string;
title: string;
} | null>(null);
const [detailInstanceId, setDetailInstanceId] = useState<string | null>(instanceIdParam || null); const isRdbms = !!activeScreenId && activeScreenId === RDBMS_RV_SCREEN_UUID;
const [detailViewId, setDetailViewId] = useState<string | null>(null); const detailViewId = instanceIdParam ? detailScreenFor(screenIdParam ?? activeScreenId) : null;
const [detailScreenLayout, setDetailScreenLayout] = useState<LayoutElement[] | null>(null);
const [refreshKey, setRefreshKey] = useState(0); // Land on the first nav item when no screen is in the URL.
const [formModal, setFormModal] = useState<{ activityId: string; instanceId: string; title: string } | null>(null);
const [instanceState, setInstanceState] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [navItems, setNavItems] = useState<NavItem[]>([]);
// Init
useEffect(() => { useEffect(() => {
if (!screenIdParam && !instanceIdParam) { if (!screenIdParam && !instanceIdParam && navReady && navItems.length > 0) {
getHeaderConfig("desktop").then((cfg) => { handleNavigate(navItems[0].screen_uuid);
const items = cfg.components?.nav?.items ?? [];
setNavItems(items);
if (items.length > 0) handleNavigate(items[0].screen_uuid);
}).catch(console.error).finally(() => setReady(true));
} else {
setReady(true);
if (instanceIdParam && screenIdParam) {
setDetailInstanceId(instanceIdParam);
setDetailViewId(SCREEN_TO_DETAIL_SCREEN[screenIdParam] ?? RDBMS_DETAIL_SCREENS[screenIdParam] ?? SCREEN_UUIDS.INVOICE_DETAIL);
}
} }
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navReady, navItems.length, screenIdParam, instanceIdParam]);
// Sync detail state with URL params on browser back/forward // Keep local screen state in step with browser back/forward.
useEffect(() => { useEffect(() => {
if (!params.instanceId && detailInstanceId) { if (screenIdParam && screenIdParam !== activeScreenId) setActiveScreenId(screenIdParam);
setDetailInstanceId(null); if (!instanceIdParam) setDetailTitle(null);
setDetailViewId(null); }, [screenIdParam, instanceIdParam, activeScreenId]);
setDetailScreenLayout(null);
setInstanceState(null);
} else if (params.instanceId && params.instanceId !== detailInstanceId) {
setDetailInstanceId(params.instanceId);
const sid = params.screenId ?? activeScreenId;
if (sid) setDetailViewId(SCREEN_TO_DETAIL_SCREEN[sid] ?? RDBMS_DETAIL_SCREENS[sid] ?? SCREEN_UUIDS.INVOICE_DETAIL);
}
}, [params.instanceId]);
// Load screen layout (skip for RDBMS screens — rendered directly) // ── Screen layout ─────────────────────────────────────────────────────────
useEffect(() => { const screenQuery = useQuery({
if (!activeScreenId || detailInstanceId) return; queryKey: ["screen", activeScreenId],
if (RDBMS_SCREENS[activeScreenId]) { setLoading(false); return; } enabled: !!activeScreenId && !isRdbms && !instanceIdParam,
setLoading(true); staleTime: 5 * 60_000,
getScreen(activeScreenId) queryFn: async () => (await getScreen(activeScreenId!)).layout ?? ([] as LayoutElement[]),
.then((s) => setLayout(s.layout ?? [])) });
.catch(console.error)
.finally(() => setLoading(false));
}, [activeScreenId, detailInstanceId]);
// Load detail screen const detailScreenQuery = useQuery({
useEffect(() => { queryKey: ["screen", detailViewId],
if (!detailViewId) return; enabled: !!detailViewId,
getScreen(detailViewId) staleTime: 5 * 60_000,
.then((s) => setDetailScreenLayout(s.layout ?? [])) queryFn: async () => (await getScreen(detailViewId!)).layout ?? ([] as LayoutElement[]),
.catch(() => setDetailScreenLayout(null)); });
}, [detailViewId]);
// Load instance state // ── Instance state ────────────────────────────────────────────────────────
useEffect(() => { // The old code faked "wait for the AI" with a setTimeout(2500) that bumped a
if (!detailInstanceId) return; // refresh key twice. This polls while the instance is mid-analysis and stops
getInstance(WORKFLOW_ID, detailInstanceId) // as soon as it settles.
.then((inst) => setInstanceState(inst.current_state_id)) const instanceQuery = useQuery({
.catch(() => {}); queryKey: ["instance", instanceIdParam],
}, [detailInstanceId]); enabled: !!instanceIdParam,
queryFn: () => getInstance(WORKFLOW_ID, instanceIdParam!),
refetchInterval: (q) => {
const stateId = (q.state.data as any)?.current_state_id;
return stateId === STATE_IDS.AI_ANALYSIS ? 3000 : false;
},
});
const handleNavigate = useCallback((id: string) => { const instanceStateId: string | null =
setActiveScreenId(id); setDetailInstanceId(null); setDetailViewId(null); (instanceQuery.data as any)?.current_state_id ?? null;
setDetailScreenLayout(null); setInstanceState(null); const instanceState = stateById(instanceStateId);
if (RDBMS_SCREENS[id]) setLayout([]); const analysing = instanceStateId === STATE_IDS.AI_ANALYSIS;
navigate(`/screen/${id}`); const actions = actionsForStateId(instanceStateId);
}, [navigate]);
const handleRowClick = useCallback((instanceId: string) => { const handleNavigate = useCallback(
const dvScreenId = activeScreenId ? (SCREEN_TO_DETAIL_SCREEN[activeScreenId] ?? SCREEN_UUIDS.INVOICE_DETAIL) : SCREEN_UUIDS.INVOICE_DETAIL; (id: string) => {
setDetailInstanceId(instanceId); setActiveScreenId(id);
setDetailViewId(dvScreenId); setDetailTitle(null);
navigate(`/screen/${activeScreenId}/detail/${instanceId}`); navigate(`/screen/${id}`);
}, [activeScreenId, navigate]); },
[navigate],
const handleBack = () => {
setDetailInstanceId(null); setDetailViewId(null); setDetailScreenLayout(null);
setInstanceState(null);
navigate(`/screen/${activeScreenId}`);
};
const handleFormSuccess = () => {
setFormModal(null);
if (detailInstanceId) {
const v = detailViewId; setDetailViewId(null); setTimeout(() => setDetailViewId(v), 50);
}
};
const actions = (() => {
if (!instanceState) return [];
if (instanceState === STATE_IDS.AWAITING_CLARIFICATION) return [ACTIVITY_IDS.PROVIDE_CLARIFICATION];
if (instanceState === STATE_IDS.FINANCE_REVIEW) return [ACTIVITY_IDS.APPROVE_PAYMENT, ACTIVITY_IDS.REJECT_PAYMENT, ACTIVITY_IDS.HOLD_PAYMENT];
return [];
})();
if (!ready) return (
<div className="min-h-screen bg-slate-50 dark:bg-zinc-950 flex justify-center items-center">
<div className="flex flex-col items-center gap-3">
<Loader2 size={22} className="animate-spin text-violet-500" />
<span className="text-[12px] text-slate-400 dark:text-zinc-600">Loading</span>
</div>
</div>
); );
return ( const handleRowClick = useCallback(
<div className="min-h-screen bg-slate-50/60 dark:bg-zinc-950"> (instanceId: string) => navigate(`/screen/${activeScreenId}/detail/${instanceId}`),
<DynamicHeader activeScreenId={activeScreenId} onNavigate={handleNavigate} onSidebarChange={setSidebarOpen} /> [activeScreenId, navigate],
);
<main className={`transition-all duration-200 py-6 px-7 ${sidebarOpen ? "ml-60" : "ml-0"}`}> const handleBack = () => navigate(`/screen/${activeScreenId}`);
<div className="w-full max-w-7xl mx-auto">
{detailInstanceId && detailViewId ? ( const screenLabel =
navItems.find((n) => n.screen_uuid === activeScreenId)?.label ??
(activeScreenId ? RDBMS_SCREEN_LABELS[activeScreenId] : undefined) ??
"Invoices";
if (!navReady && !screenIdParam && !instanceIdParam) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-5 animate-spin text-primary" aria-hidden />
<span className="text-sm text-muted-foreground">Loading</span>
</div>
</div>
);
}
const showDetail = !!instanceIdParam && !!detailViewId;
return (
<div className="min-h-screen bg-background">
{/* Must be the first focusable element, and must move focus (not just
scroll) to satisfy WCAG 2.4.1. */}
<a href="#main" className="skip-link">
Skip to content
</a>
<DynamicHeader
activeScreenId={activeScreenId}
onNavigate={handleNavigate}
sidebar={sidebar}
/>
<main
id="main"
tabIndex={-1}
className={cn(
"px-4 py-5 transition-[margin] duration-200 sm:px-6",
// No margin on mobile — the sidebar is an overlay there.
sidebar.isMobile ? "ml-0" : sidebar.open ? "ml-60" : "ml-14",
)}
>
<div className="mx-auto w-full max-w-[1400px]">
{showDetail ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> {/* Breadcrumb shows the invoice number once the detail view
{/* Breadcrumb */} reports it, instead of the raw instance UUID. */}
<nav className="flex items-center gap-1.5 text-[13px]"> <div className="flex flex-wrap items-center justify-between gap-3">
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-base">
<button <button
onClick={handleBack} onClick={handleBack}
className="text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 transition-colors font-medium" className="rounded font-medium text-muted-foreground transition-colors hover:text-primary"
> >
{navItems.find(n => n.screen_uuid === activeScreenId)?.label ?? (activeScreenId ? RDBMS_SCREEN_LABELS[activeScreenId] : undefined) ?? "Invoices"} {screenLabel}
</button> </button>
<span className="text-slate-300 dark:text-zinc-700">/</span> <ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" aria-hidden />
<span className="text-slate-700 dark:text-zinc-200 font-medium truncate max-w-xs"> <span className="truncate font-medium text-foreground" aria-current="page">
{detailInstanceId} {detailTitle ?? (
<span className="font-mono text-sm text-muted-foreground">
{instanceIdParam.slice(0, 8)}
</span>
)}
</span> </span>
</nav> </nav>
{actions.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{actions.map((id) => { {analysing && (
const m = ACTIVITY_META[id]; <span className="flex items-center gap-1.5 rounded-full border border-state-analysis/25 bg-state-analysis/10 px-2.5 py-1 text-xs font-semibold text-state-analysis">
if (!m) return null; <Sparkles className="size-3 animate-pulse" aria-hidden />
return ( AI analysing
<button </span>
key={id} )}
onClick={() => setFormModal({ activityId: id, instanceId: detailInstanceId, title: m.label })} {instanceState && !analysing && actions.length === 0 && (
className={`h-8 px-3.5 text-[12px] font-semibold rounded-xl transition-all flex items-center gap-1.5 ${BTN_STYLES[m.style]}`} <StatusBadge value={instanceState.name} />
style={m.style === "primary" ? { background: "linear-gradient(135deg, #7c3aed, #a855f7)" } : {}} )}
> {actions.map((a) => (
{m.icon}{m.label} <Button
</button> key={a.activityId}
); size="sm"
})} variant={
</div> a.style === "danger"
)} ? "destructive"
: a.style === "ghost"
? "outline"
: "default"
}
className="h-8 gap-1.5"
onClick={() =>
setFormModal({
activityId: a.activityId,
instanceId: instanceIdParam,
title: a.label,
})
}
>
<a.icon className="size-3.5" aria-hidden />
{a.label}
</Button>
))}
</div>
</div> </div>
{/* Workflow instances get the composed invoice page hierarchy, {detailScreenQuery.data && detailScreenQuery.data.length > 0 ? (
match evidence, source document, AI trail, history. An <ScreenRenderer
RDBMS-backed detail screen is not a workflow instance (it has layout={detailScreenQuery.data}
no audit trail and no AI decisions), so it keeps the generic instanceId={instanceIdParam}
screen/field renderer. */} onTitle={setDetailTitle}
{activeScreenId && RDBMS_SCREENS[activeScreenId] />
? (detailScreenLayout ) : (
? <ScreenRenderer layout={detailScreenLayout} instanceId={detailInstanceId} /> <DetailViewPanel
: <DetailViewPanel viewId={detailViewId} instanceId={detailInstanceId} />) viewId={detailViewId}
: <InvoiceDetail instanceId={detailInstanceId} viewId={detailViewId} stateId={instanceState} />} instanceId={instanceIdParam}
onTitle={setDetailTitle}
/>
)}
</div> </div>
) : loading ? ( ) : isRdbms ? (
<><DashboardStats /><TableSkeleton rows={6} cols={6} /></>
) : activeScreenId && RDBMS_SCREENS[activeScreenId] ? (
<RecordViewTable <RecordViewTable
key={`rdbms-${activeScreenId}`} key={`rdbms-${activeScreenId}`}
viewId={RDBMS_SCREENS[activeScreenId]} viewId={activeScreenId!}
onRowClick={RDBMS_DETAIL_SCREENS[activeScreenId] ? (pkValue) => { onRowClick={handleRowClick}
setDetailInstanceId(pkValue);
setDetailViewId(RDBMS_DETAIL_SCREENS[activeScreenId]);
navigate(`/screen/${activeScreenId}/detail/${pkValue}`);
} : undefined}
/> />
) : layout.length > 0 ? ( ) : screenQuery.isPending ? (
<><DashboardStats key={`stats-${activeScreenId}-${refreshKey}`} /><ScreenRenderer key={`screen-${activeScreenId}-${refreshKey}`} layout={layout} onRowClick={handleRowClick} onRefresh={() => { setRefreshKey((k) => k + 1); setTimeout(() => setRefreshKey((k) => k + 1), 2500); }} /></> <>
<StatsPlaceholder />
<TableSkeleton rows={6} cols={6} />
</>
) : screenQuery.data && screenQuery.data.length > 0 ? (
<>
<DashboardStats />
<Suspense fallback={<ChartsPlaceholder />}>
<DashboardCharts />
</Suspense>
<ScreenRenderer
key={`screen-${activeScreenId}`}
layout={screenQuery.data}
onRowClick={handleRowClick}
/>
</>
) : ( ) : (
<div className="text-center py-20 text-sm text-slate-400 dark:text-zinc-600">Select a screen from the navigation</div> <div className="py-20 text-center text-base text-muted-foreground">
Select a screen from the navigation.
</div>
)} )}
</div> </div>
</main> </main>
{formModal && <FormModal activityId={formModal.activityId} instanceId={formModal.instanceId} title={formModal.title} onClose={() => setFormModal(null)} onSuccess={handleFormSuccess} />} <CommandPalette
open={commandOpen}
onOpenChange={setCommandOpen}
onNavigate={handleNavigate}
/>
{formModal && (
<FormModal
activityId={formModal.activityId}
instanceId={formModal.instanceId}
title={formModal.title}
onClose={() => setFormModal(null)}
onSuccess={() => {
setFormModal(null);
instanceQuery.refetch();
}}
/>
)}
</div> </div>
); );
} }
function fmtAct(id: string): string { return id.replace(/^p2p-act-/, "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } function StatsPlaceholder() {
return (
<div className="mb-3 grid grid-cols-2 gap-2.5 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="flex items-start gap-2.5 rounded-xl border border-border bg-card px-3.5 py-3.5"
>
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="mt-1.5 h-5 w-12" />
<Skeleton className="mt-1.5 h-2.5 w-16" />
</div>
</div>
))}
</div>
);
}
/**
* Same height as the real charts row, so the lazy chunk arriving doesn't shove
* the table down the page. Kept local rather than imported from
* DashboardCharts, which would pull recharts back into the main bundle.
*/
function ChartsPlaceholder() {
return (
<div className="mb-3 grid gap-3 lg:grid-cols-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-4">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="mt-1.5 h-2.5 w-40" />
<Skeleton className="mt-4 h-[190px] w-full rounded-lg" />
</div>
))}
</div>
);
}

View File

@ -1,199 +0,0 @@
// AuditTimeline — the invoice's history as a vertical timeline.
//
// Built from GET /app/{id}/view/audit, which returns one row per performed
// activity with the actor (user_id + roles), the resulting state, the timestamp
// and the submitted payload. A single status pill can't explain how an invoice
// reached its state; this can — including which steps the AI took versus a
// person, and how long each hop waited.
//
// The helper for this endpoint existed in viewService.ts and was never called
// from anywhere in the UI.
import { useState } from "react";
import {
Sparkles, User, ChevronRight, FileUp, HelpCircle, MessageSquareReply,
Gavel, CheckCircle2, XCircle, PauseCircle, Clock,
} from "lucide-react";
import type { AuditEntry } from "../api/viewService";
import { ACTIVITY_IDS } from "../config";
import {
activityLabel, stateMeta, resolveActor, fmtDateTime, fmtRelative, fmtGap,
TONE_DOT, TONE_PILL, fmtAmount,
} from "../lib/workflowMeta";
const ACTIVITY_ICON: Record<string, React.ReactNode> = {
[ACTIVITY_IDS.SUBMIT_INVOICE]: <FileUp size={13} />,
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: <HelpCircle size={13} />,
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: <MessageSquareReply size={13} />,
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: <Gavel size={13} />,
[ACTIVITY_IDS.APPROVE_PAYMENT]: <CheckCircle2 size={13} />,
[ACTIVITY_IDS.REJECT_PAYMENT]: <XCircle size={13} />,
[ACTIVITY_IDS.HOLD_PAYMENT]: <PauseCircle size={13} />,
};
// Per-activity headline: the one or two values that matter at a glance, so the
// timeline reads as a story instead of a list of generic event names.
function summarise(e: AuditEntry): string | null {
const d = (e.data ?? {}) as Record<string, any>;
switch (e.activity_id) {
case ACTIVITY_IDS.SUBMIT_INVOICE:
return [d.invoice_number, d.po_number, d.total_amount != null ? fmtAmount(d.total_amount, d.field_5 || "INR") : null]
.filter(Boolean).join(" · ") || null;
case ACTIVITY_IDS.REQUEST_CLARIFICATION:
return d.target_department ? `Asked ${String(d.target_department).replace(/_/g, " ")}` : null;
case ACTIVITY_IDS.PROVIDE_CLARIFICATION:
return d.response_text ? String(d.response_text) : null;
case ACTIVITY_IDS.SUBMIT_RECOMMENDATION:
return [
d.recommendation ? String(d.recommendation).toUpperCase() : null,
d.recommended_amount != null ? fmtAmount(d.recommended_amount) : null,
d.ai_confidence != null ? `${Math.round(Number(d.ai_confidence) * 100)}% confidence` : null,
].filter(Boolean).join(" · ") || null;
case ACTIVITY_IDS.APPROVE_PAYMENT:
return d.approved_amount != null ? fmtAmount(d.approved_amount) : null;
case ACTIVITY_IDS.REJECT_PAYMENT:
return d.rejection_reason ? String(d.rejection_reason) : null;
case ACTIVITY_IDS.HOLD_PAYMENT:
return d.hold_reason ? String(d.hold_reason) : null;
default:
return null;
}
}
// Fields already shown in the headline, or internal — don't repeat them in the
// expanded payload.
const HIDE_KEYS = new Set([
"invoice_pdf", "analysis_so_far", "_nodes", "_http", "_responsePayload",
]);
function PayloadGrid({ data }: { data: Record<string, unknown> }) {
const rows = Object.entries(data).filter(
([k, v]) => !HIDE_KEYS.has(k) && v !== null && v !== "" && typeof v !== "object",
);
if (rows.length === 0) return null;
return (
<dl className="mt-2.5 grid grid-cols-2 gap-x-5 gap-y-1.5">
{rows.map(([k, v]) => (
<div key={k} className="min-w-0">
<dt className="text-[10.5px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">
{k.replace(/_/g, " ")}
</dt>
<dd className="text-[12.5px] text-slate-700 dark:text-zinc-200 break-words">{String(v)}</dd>
</div>
))}
</dl>
);
}
export default function AuditTimeline({ entries }: { entries: AuditEntry[] }) {
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const toggle = (id: number) =>
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
if (entries.length === 0) {
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-6">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 mb-1">History</h3>
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">No activity recorded yet.</p>
</section>
);
}
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
<Clock size={14} className="text-slate-400" />History
</h3>
<span className="text-[11px] text-slate-400 dark:text-zinc-500">
{entries.length} {entries.length === 1 ? "step" : "steps"}
</span>
</div>
<ol className="relative">
{entries.map((e, i) => {
const actor = resolveActor(e.user_roles, e.user_id);
const st = stateMeta(e.execution_state);
const isLast = i === entries.length - 1;
const gap = i > 0 ? fmtGap(entries[i - 1].created_at, e.created_at) : "";
const headline = summarise(e);
const isOpen = expanded.has(e.id);
return (
<li key={e.id} className="relative pl-9 pb-5 last:pb-0">
{/* Rail */}
{!isLast && (
<span className="absolute left-[11px] top-6 bottom-0 w-px bg-slate-200 dark:bg-zinc-700" />
)}
{/* Node */}
<span
className={`absolute left-0 top-0.5 w-[23px] h-[23px] rounded-full flex items-center justify-center ring-4 ring-white dark:ring-zinc-900 ${
actor.isAI
? "bg-violet-100 text-violet-600 dark:bg-violet-950/60 dark:text-violet-300"
: "bg-slate-100 text-slate-500 dark:bg-zinc-800 dark:text-zinc-400"
}`}
>
{ACTIVITY_ICON[e.activity_id] ?? <ChevronRight size={13} />}
</span>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">
{activityLabel(e.activity_id)}
</span>
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10.5px] font-medium ${
actor.isAI
? "text-violet-700 bg-violet-50 dark:text-violet-300 dark:bg-violet-950/50"
: "text-slate-500 bg-slate-100 dark:text-zinc-400 dark:bg-zinc-800"
}`}>
{actor.isAI ? <Sparkles size={9} /> : <User size={9} />}{actor.name}
</span>
</div>
{headline && (
<p className="text-[12.5px] text-slate-600 dark:text-zinc-300 mt-1 line-clamp-2">
{headline}
</p>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10.5px] font-medium ${TONE_PILL[st.tone]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${TONE_DOT[st.tone]}`} />
{st.label}
</span>
<button
type="button"
onClick={() => toggle(e.id)}
className="text-[11px] text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
>
{isOpen ? "Hide details" : "Details"}
</button>
</div>
{isOpen && <PayloadGrid data={(e.data ?? {}) as Record<string, unknown>} />}
</div>
<div className="text-right shrink-0">
<div className="text-[11px] text-slate-400 dark:text-zinc-500 whitespace-nowrap" title={fmtDateTime(e.created_at)}>
{fmtRelative(e.created_at)}
</div>
{gap && (
<div className="text-[10.5px] text-slate-300 dark:text-zinc-600 whitespace-nowrap mt-0.5">
+{gap}
</div>
)}
</div>
</div>
</li>
);
})}
</ol>
</section>
);
}

View File

@ -0,0 +1,137 @@
// The invoice's history as a timeline.
//
// Built from GET /app/{id}/view/audit, which returns one row per performed
// activity with the actor (user_id + roles), the resulting state, the timestamp
// and the submitted payload. A status pill can't explain how an invoice reached
// its state; this can — including which steps the AI took versus a person, and
// how long each hop waited.
//
// Presentation is on our theme tokens; the actor resolution, per-activity
// headline and gap arithmetic are the logic that came with the endpoint.
import {
CheckCircle2,
FileUp,
Gavel,
HelpCircle,
MessageSquareReply,
PauseCircle,
Sparkles,
User,
XCircle,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { AuditEntry } from "@/api/viewService";
import { ACTIVITY_IDS } from "@/config";
import { activityLabel, fmtGap, resolveActor } from "@/lib/workflowMeta";
import { formatCurrency, formatDate, formatTime } from "@/lib/format";
import { stateById } from "@/lib/workflow";
import StatusBadge from "./StatusBadge";
const ACTIVITY_ICON: Record<string, LucideIcon> = {
[ACTIVITY_IDS.SUBMIT_INVOICE]: FileUp,
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: HelpCircle,
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: MessageSquareReply,
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: Gavel,
[ACTIVITY_IDS.APPROVE_PAYMENT]: CheckCircle2,
[ACTIVITY_IDS.REJECT_PAYMENT]: XCircle,
[ACTIVITY_IDS.HOLD_PAYMENT]: PauseCircle,
};
/**
* Per-activity headline: the one or two values that matter at a glance, so the
* timeline reads as a story instead of a list of generic event names.
*/
function summarise(e: AuditEntry): string | null {
const d = (e.data ?? {}) as Record<string, any>;
const money = (v: unknown) => (v != null ? formatCurrency(Number(v)) : null);
switch (e.activity_id) {
case ACTIVITY_IDS.SUBMIT_INVOICE:
return (
[d.invoice_number, d.po_number, money(d.total_amount)].filter(Boolean).join(" · ") || null
);
case ACTIVITY_IDS.REQUEST_CLARIFICATION:
return d.target_department
? `Asked ${String(d.target_department).replace(/_/g, " ")}`
: null;
case ACTIVITY_IDS.PROVIDE_CLARIFICATION:
return d.response_text ? String(d.response_text).replace(/[ \t]{2,}/g, " ") : null;
case ACTIVITY_IDS.SUBMIT_RECOMMENDATION:
return (
[
d.recommendation ? String(d.recommendation).toUpperCase() : null,
money(d.recommended_amount),
d.ai_confidence != null ? `${Math.round(Number(d.ai_confidence) * 100)}% confidence` : null,
]
.filter(Boolean)
.join(" · ") || null
);
case ACTIVITY_IDS.APPROVE_PAYMENT:
return money(d.approved_amount);
case ACTIVITY_IDS.REJECT_PAYMENT:
return d.rejection_reason ? String(d.rejection_reason) : null;
case ACTIVITY_IDS.HOLD_PAYMENT:
return d.hold_reason ? String(d.hold_reason) : null;
default:
return null;
}
}
export default function AuditTrail({ audit }: { audit: AuditEntry[] }) {
if (audit.length === 0) {
return <p className="text-sm text-muted-foreground">No activity recorded yet.</p>;
}
return (
<ol className="space-y-4">
{audit.map((e, i) => {
const actor = resolveActor(e.user_roles, e.user_id);
const Icon = ACTIVITY_ICON[e.activity_id] ?? Gavel;
const state = stateById(e.execution_state);
const summary = summarise(e);
const gap = i > 0 ? fmtGap(audit[i - 1].created_at, e.created_at) : "";
const time = formatTime(e.created_at);
return (
<li key={e.id} className="flex gap-3">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full",
actor.isAI ? "bg-primary/12 text-primary" : "bg-muted text-muted-foreground",
)}
>
<Icon className="size-3.5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="text-base font-semibold text-foreground">
{activityLabel(e.activity_id)}
</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{actor.isAI && <Sparkles className="size-2.5 text-primary" aria-hidden />}
{!actor.isAI && <User className="size-2.5" aria-hidden />}
{actor.name}
</span>
{state && <StatusBadge value={state.name} />}
</div>
{summary && (
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{summary}</p>
)}
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
{formatDate(e.created_at)}
{time && ` · ${time}`}
{/* How long this step waited on the one before it. */}
{gap && ` · +${gap}`}
</p>
</div>
</li>
);
})}
</ol>
);
}

View File

@ -0,0 +1,153 @@
import { useEffect } from "react";
import { useQueryStates } from "nuqs";
import { FileText, ListFilter, Moon, Search, Sun, X } from "lucide-react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import { useTheme } from "@/hooks/ThemeContext";
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
import { WORKFLOW_STATES } from "@/lib/workflow";
import { cn } from "@/lib/utils";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onNavigate: (screenUuid: string) => void;
}
/**
* K palette jump between screens and apply status filters without leaving
* the keyboard. This is a queue-triage app; reaching for the mouse to change a
* filter is the slow path.
*/
export default function CommandPalette({ open, onOpenChange, onNavigate }: Props) {
const { theme, toggleTheme } = useTheme();
const { navItems } = useHeaderConfig();
const [params, setParams] = useQueryStates(tableParams, tableParamsOptions);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onOpenChange(!open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
const run = (fn: () => void) => {
fn();
onOpenChange(false);
};
const filtersActive = params.status.length > 0 || !!params.q;
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Command palette"
description="Jump to a screen or filter the queue"
>
<CommandInput placeholder="Jump to a screen, or filter by status…" />
<CommandList>
<CommandEmpty>No matches.</CommandEmpty>
{navItems.length > 0 && (
<CommandGroup heading="Screens">
{navItems.map((item) => (
<CommandItem
key={item.id}
value={`screen ${item.label}`}
onSelect={() => run(() => onNavigate(item.screen_uuid))}
>
<FileText className="size-3.5" aria-hidden />
{item.label}
</CommandItem>
))}
</CommandGroup>
)}
<CommandSeparator />
<CommandGroup heading="Filter by status">
{WORKFLOW_STATES.map((s) => {
const selected = params.status.includes(s.name);
return (
<CommandItem
key={s.key}
value={`filter ${s.label}`}
onSelect={() =>
run(() =>
setParams({
status: selected
? params.status.filter((x) => x !== s.name)
: [...params.status, s.name],
page: 1,
}),
)
}
>
<span className={cn("size-2 shrink-0 rounded-full", s.tone.solid)} aria-hidden />
{s.label}
{selected && <CommandShortcut>active</CommandShortcut>}
</CommandItem>
);
})}
{filtersActive && (
<CommandItem
value="clear filters"
onSelect={() => run(() => setParams({ status: [], q: "", page: 1 }))}
>
<X className="size-3.5" aria-hidden />
Clear all filters
</CommandItem>
)}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem
value="focus search"
onSelect={() =>
run(() => {
// Defer so the dialog has released focus first.
requestAnimationFrame(() => {
document
.querySelector<HTMLInputElement>('input[aria-label="Search records"]')
?.focus();
});
})
}
>
<Search className="size-3.5" aria-hidden />
Search records
<CommandShortcut>/</CommandShortcut>
</CommandItem>
<CommandItem value="toggle theme" onSelect={() => run(toggleTheme)}>
{theme === "dark" ? (
<Sun className="size-3.5" aria-hidden />
) : (
<Moon className="size-3.5" aria-hidden />
)}
Switch to {theme === "dark" ? "light" : "dark"} mode
</CommandItem>
<CommandItem value="filters help" disabled>
<ListFilter className="size-3.5" aria-hidden />
Tip: tiles on the dashboard also filter the queue
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@ -0,0 +1,314 @@
import {
Bar,
BarChart,
Cell,
LabelList,
Pie,
PieChart,
ResponsiveContainer,
Tooltip as RTooltip,
XAxis,
YAxis,
} from "recharts";
import { useQueryStates } from "nuqs";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
import { useInvoiceAnalytics, formatCurrency } from "@/hooks/useInvoiceAnalytics";
import { CATEGORY_COLORS } from "@/lib/palette";
const CHART_H = 190;
/**
* Aging ramp: fresh buckets in the calm end of the palette, overdue ones in
* warning hues the chart says "chase these" without a legend.
*/
const AGING_COLORS = [
"var(--chart-5)",
"var(--chart-4)",
"var(--chart-1)",
"var(--state-clarification)",
"var(--state-rejected)",
];
/**
* Tooltip styled off the theme tokens rather than recharts' defaults, which are
* hard-coded light and unreadable in dark mode.
*/
function ChartTooltip({ active, payload, label, valueFormat }: any) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-lg">
{label && <div className="mb-1 font-semibold text-popover-foreground">{label}</div>}
{payload.map((p: any) => (
<div key={p.name} className="flex items-center gap-2 text-muted-foreground">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: p.payload?.color ?? p.color ?? p.fill }}
/>
<span className="text-popover-foreground">
{valueFormat ? valueFormat(p.value, p.payload) : p.value}
</span>
{p.payload?.label && <span>· {p.payload.label}</span>}
</div>
))}
</div>
);
}
function Panel({
title,
subtitle,
badge,
children,
className,
}: {
title: string;
subtitle?: string;
/** Headline figure for the panel, right-aligned against the title. */
badge?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"rounded-xl border border-border bg-card p-4 transition-colors hover:border-border/80",
className,
)}
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle && <p className="mt-0.5 text-2xs text-muted-foreground">{subtitle}</p>}
</div>
{badge && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-2xs font-semibold tabular-nums text-muted-foreground">
{badge}
</span>
)}
</div>
{children}
</div>
);
}
const AXIS_TICK = { fontSize: 10, fill: "var(--muted-foreground)" } as const;
export default function DashboardCharts() {
const { data, isPending, isError } = useInvoiceAnalytics();
const [, setParams] = useQueryStates(tableParams, tableParamsOptions);
if (isError) return null;
if (isPending) return <ChartsSkeleton />;
if (!data) return null;
const hasAging = data.aging.some((b) => b.count > 0);
const hasMix = data.byState.length > 0;
const hasVendors = data.topVendors.length > 0;
if (!hasAging && !hasMix && !hasVendors) return null;
const openTotal = data.aging.reduce((n, b) => n + b.count, 0);
const overdue = data.aging.filter((b) => b.overdue).reduce((n, b) => n + b.count, 0);
const agingData = data.aging.map((b, i) => ({ ...b, color: AGING_COLORS[i] }));
return (
<div className="mb-3 grid animate-fade-in-up gap-3 lg:grid-cols-2">
{/* ── Status mix ──────────────────────────────────────────────────── */}
{hasMix && (
<Panel
title="Invoice Pipeline"
subtitle="Click a slice or a row to filter the queue"
badge={data.capped ? `${data.sampled} sampled` : `${data.total} total`}
>
<div className="flex items-center gap-5">
<div className="relative size-[190px] shrink-0">
<ResponsiveContainer width="100%" height={CHART_H}>
<PieChart>
<Pie
data={data.byState}
dataKey="count"
nameKey="label"
innerRadius={58}
outerRadius={86}
paddingAngle={3}
cornerRadius={4}
strokeWidth={0}
onClick={(slice: any) =>
setParams({ status: [slice?.payload?.name], page: 1 })
}
>
{data.byState.map((s) => (
<Cell key={s.key} fill={s.color} className="cursor-pointer outline-hidden" />
))}
</Pie>
<RTooltip
content={
<ChartTooltip
valueFormat={(v: number, row: any) =>
`${v} ${v === 1 ? "invoice" : "invoices"}${
row?.amount ? ` · ${formatCurrency(row.amount, true)}` : ""
}`
}
/>
}
/>
</PieChart>
</ResponsiveContainer>
{/* Centre label a donut with a hole and nothing in it wastes
the most legible spot in the chart. */}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold tabular-nums leading-none text-foreground">
{data.total}
</span>
<span className="mt-1 text-2xs uppercase tracking-wider text-muted-foreground">
invoices
</span>
</div>
</div>
{/* Legend doubles as a per-state breakdown with counts and money. */}
<ul className="min-w-0 flex-1 space-y-0.5">
{data.byState.map((s) => (
<li key={s.key}>
<button
onClick={() => setParams({ status: [s.name], page: 1 })}
className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left transition-colors hover:bg-muted/60"
>
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: s.color }} />
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{s.label}
</span>
<span className="text-xs font-semibold tabular-nums text-foreground">
{s.count}
</span>
{s.amount > 0 && (
<span className="w-14 shrink-0 text-right text-2xs tabular-nums text-muted-foreground">
{formatCurrency(s.amount, true)}
</span>
)}
</button>
</li>
))}
</ul>
</div>
</Panel>
)}
{/* ── Aging ───────────────────────────────────────────────────────── */}
{hasAging && (
<Panel
title="Open Invoice Aging"
subtitle={
overdue > 0
? `${overdue} of ${openTotal} open past 14 days`
: "Open invoices by days since submission"
}
badge={`${openTotal} open`}
>
<ResponsiveContainer width="100%" height={CHART_H}>
<BarChart data={agingData} margin={{ top: 16, right: 4, bottom: 0, left: -22 }}>
<XAxis dataKey="label" tickLine={false} axisLine={false} tick={AXIS_TICK} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} tick={AXIS_TICK} />
<RTooltip
cursor={{ fill: "var(--accent)", opacity: 0.4 }}
content={
<ChartTooltip
valueFormat={(v: number, row: any) =>
`${v} open${row?.amount ? ` · ${formatCurrency(row.amount, true)}` : ""}`
}
/>
}
/>
<Bar dataKey="count" radius={[6, 6, 0, 0]} maxBarSize={48}>
<LabelList
dataKey="count"
position="top"
fontSize={10}
fill="var(--muted-foreground)"
formatter={(v: number) => (v > 0 ? v : "")}
/>
{agingData.map((b) => (
<Cell key={b.label} fill={b.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Panel>
)}
{/* ── Top vendors ─────────────────────────────────────────────────── */}
{hasVendors && (
<Panel
title="Vendor Spend"
subtitle="Highest billed value across the sampled invoices"
badge={`top ${data.topVendors.length}`}
>
<ResponsiveContainer width="100%" height={CHART_H}>
<BarChart
data={data.topVendors}
margin={{ top: 16, right: 4, bottom: 0, left: -14 }}
barCategoryGap="22%"
>
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tick={AXIS_TICK}
interval={0}
// Vendor names are long and the panel is half-width — clip to
// the first word or two and leave the full name to the tooltip.
tickFormatter={(v: string) => (v.length > 12 ? `${v.slice(0, 11)}` : v)}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={AXIS_TICK}
tickFormatter={(v: number) => formatCurrency(v, true)}
/>
<RTooltip
cursor={{ fill: "var(--accent)", opacity: 0.4 }}
content={
<ChartTooltip
valueFormat={(v: number, row: any) =>
`${formatCurrency(v, true)} · ${row?.count} ${
row?.count === 1 ? "invoice" : "invoices"
}`
}
/>
}
/>
<Bar dataKey="amount" radius={[6, 6, 0, 0]} maxBarSize={40}>
<LabelList
dataKey="amount"
position="top"
fontSize={10}
fill="var(--muted-foreground)"
formatter={(v: number) => formatCurrency(v, true)}
/>
{data.topVendors.map((v, i) => (
<Cell key={v.name} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Panel>
)}
</div>
);
}
function ChartsSkeleton() {
return (
<div className="mb-3 grid gap-3 lg:grid-cols-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-4">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="mt-1.5 h-2.5 w-40" />
<Skeleton className="mt-4 h-[190px] w-full rounded-lg" />
</div>
))}
</div>
);
}

View File

@ -1,84 +1,218 @@
import { useEffect, useState } from "react"; import { useQueryStates } from "nuqs";
import { FileText, AlertCircle, Clock, CheckCircle2 } from "lucide-react"; import {
import { getRecordView, getRVScreen } from "../api/viewService"; AlertCircle,
import { StatsSkeleton } from "./Skeleton"; CheckCircle2,
import { ALL_INVOICES_RV_SCREEN_UUID, RECORD_VIEW_IDS } from "../config"; Clock,
FileText,
TrendingDown,
TrendingUp,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { tableParams, tableParamsOptions } from "@/lib/tableParams";
import { useInvoiceAnalytics, formatCurrency, type InvoiceAnalytics } from "@/hooks/useInvoiceAnalytics";
import { WORKFLOW_STATES } from "@/lib/workflow";
const ALL_INVOICES_VIEW = ALL_INVOICES_RV_SCREEN_UUID; interface Tile {
key: string;
label: string;
value: string;
/** Secondary line — the money or ratio behind the count. */
sub?: string;
icon: LucideIcon;
/** Tailwind classes for the icon chip. */
tone: string;
/** State names this tile filters the table down to. Empty = clears filters. */
filter: string[];
hint: string;
/** Fraction of all invoices this tile represents. */
share?: number;
/** Week-over-week change, as a fraction. */
delta?: number | null;
}
interface Stats { function buildTiles(a: InvoiceAnalytics): Tile[] {
total: number; const state = (key: string) => WORKFLOW_STATES.find((s) => s.key === key)!;
pendingClarification: number; const pct = a.approvalRate === null ? null : Math.round(a.approvalRate * 100);
financeReview: number; const share = (n: number) => (a.total > 0 ? n / a.total : 0);
approved: number;
return [
{
key: "total",
label: "Total Invoices",
value: String(a.total),
sub: a.totalAmount > 0 ? formatCurrency(a.totalAmount, true) + " in value" : undefined,
icon: FileText,
tone: "bg-primary/10 text-primary",
filter: [],
hint: "All invoices. Click to clear status filters.",
delta: a.momentum.deltaPct,
},
{
key: "clarification",
label: "Pending Clarification",
value: String(a.counts.clarification),
sub: "with departments",
icon: AlertCircle,
tone: "bg-state-clarification/10 text-state-clarification",
filter: [state("clarification").name],
hint: "Waiting on a department to answer the AI's question.",
share: share(a.counts.clarification),
},
{
key: "review",
label: "Finance Review",
value: String(a.counts.review),
sub: "awaiting decision",
icon: Clock,
tone: "bg-state-review/10 text-state-review",
filter: [state("review").name],
hint: "Queued for a finance reviewer to approve, reject or hold.",
share: share(a.counts.review),
},
{
key: "approved",
label: "Approved",
value: String(a.counts.approved),
sub:
pct !== null
? `${pct}% approval rate`
: a.approvedAmount > 0
? formatCurrency(a.approvedAmount, true)
: undefined,
icon: CheckCircle2,
tone: "bg-state-approved/10 text-state-approved",
filter: [state("approved").name],
hint: "Approved for payment.",
share: share(a.counts.approved),
},
];
} }
export default function DashboardStats() { export default function DashboardStats() {
const [stats, setStats] = useState<Stats | null>(null); const { data, isPending, isError } = useInvoiceAnalytics();
const [loading, setLoading] = useState(true); const [params, setParams] = useQueryStates(tableParams, tableParamsOptions);
useEffect(() => { if (isPending) return <StatsSkeletonRow />;
async function fetchStats() { // A failed dashboard shouldn't take the table down with it.
try { if (isError || !data) return null;
const rvScreen = await getRVScreen(ALL_INVOICES_VIEW);
const id = rvScreen.rv_template_uid;
const rvUid = RECORD_VIEW_IDS.ALL_INVOICES;
// Step 1: get exact total from pagination const tiles = buildTiles(data);
const countRes = await getRecordView(id, rvUid, { page: 1, limit: 1 });
const total = countRes.pagination?.total_count ?? 0;
let pendingClarification = 0, financeReview = 0, approved = 0; const apply = (filter: string[]) => {
const same =
if (total > 0) { filter.length === params.status.length && filter.every((f) => params.status.includes(f));
// Step 2: fetch all rows in one call using exact total as limit // Clicking the active tile again clears it, so tiles behave like toggles.
const allRes = await getRecordView(id, rvUid, { page: 1, limit: total }); setParams({ status: same ? [] : filter, page: 1 });
for (const row of allRes.data ?? []) { };
const state = String(row.current_state_name ?? "");
if (state === "Awaiting Clarification") pendingClarification++;
else if (state === "Finance Review") financeReview++;
else if (state === "Approved") approved++;
}
}
setStats({ total, pendingClarification, financeReview, approved });
} catch {
setStats({ total: 0, pendingClarification: 0, financeReview: 0, approved: 0 });
} finally {
setLoading(false);
}
}
fetchStats();
}, []);
if (loading) return <StatsSkeleton />;
if (!stats) return null;
const cards = [
{ label: "Total Invoices", value: stats.total, icon: FileText, color: "text-slate-500 dark:text-zinc-400", bg: "bg-slate-100 dark:bg-zinc-800" },
{ label: "Pending Clarification", value: stats.pendingClarification, icon: AlertCircle, color: "text-amber-600 dark:text-amber-400", bg: "bg-amber-50 dark:bg-amber-950/40" },
{ label: "Finance Review", value: stats.financeReview, icon: Clock, color: "text-indigo-600 dark:text-indigo-400", bg: "bg-indigo-50 dark:bg-indigo-950/40" },
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-50 dark:bg-emerald-950/40" },
];
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5"> <div className="mb-3 grid grid-cols-2 gap-2.5 lg:grid-cols-4">
{cards.map((card) => { {tiles.map((tile, i) => {
const Icon = card.icon; const Icon = tile.icon;
const active =
tile.filter.length > 0 &&
tile.filter.length === params.status.length &&
tile.filter.every((f) => params.status.includes(f));
const isClear = tile.filter.length === 0 && params.status.length === 0;
const on = active || isClear;
return ( return (
<div key={card.label} className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4 flex items-center gap-4"> <Tooltip key={tile.key}>
<div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${card.bg}`}> <TooltipTrigger asChild>
<Icon size={16} className={card.color} /> <button
</div> type="button"
<div className="min-w-0"> onClick={() => apply(tile.filter)}
<div className="text-[22px] font-bold tabular-nums text-slate-900 dark:text-zinc-50 leading-none mb-0.5"> aria-pressed={active}
{card.value} style={{ animationDelay: `${i * 40}ms` }}
</div> className={cn(
<div className="text-[11px] text-slate-400 dark:text-zinc-500 truncate">{card.label}</div> "group flex animate-fade-in-up items-start gap-2.5 rounded-xl border",
</div> "bg-card px-3.5 py-3.5 text-left",
</div> "transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md",
on
? "border-primary/50 ring-1 ring-primary/20"
: "border-border hover:border-primary/30",
)}
>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
tile.tone,
)}
>
<Icon className="size-4" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
{tile.label}
</div>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className="text-xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{tile.value}
</span>
{tile.delta != null ? (
<DeltaChip delta={tile.delta} />
) : tile.share !== undefined ? (
<span className="text-2xs font-medium tabular-nums text-muted-foreground">
{Math.round(tile.share * 100)}%
</span>
) : null}
</div>
{tile.sub && (
<div className="mt-1 truncate text-2xs text-muted-foreground/80">
{tile.sub}
</div>
)}
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{tile.hint}</p>
</TooltipContent>
</Tooltip>
); );
})} })}
</div> </div>
); );
} }
function DeltaChip({ delta }: { delta: number }) {
const up = delta >= 0;
const Icon = up ? TrendingUp : TrendingDown;
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-px text-2xs font-semibold tabular-nums",
up ? "bg-state-approved/10 text-state-approved" : "bg-state-clarification/10 text-state-clarification",
)}
title="Submissions this week vs last week"
>
<Icon className="size-2.5" aria-hidden />
{up ? "+" : ""}
{Math.round(delta * 100)}%
</span>
);
}
function StatsSkeletonRow() {
return (
<div className="mb-3 grid grid-cols-2 gap-2.5 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="flex items-start gap-2.5 rounded-xl border border-border bg-card px-3.5 py-3.5"
>
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-2.5 w-20" />
<Skeleton className="mt-1.5 h-5 w-12" />
<Skeleton className="mt-1.5 h-2.5 w-16" />
</div>
</div>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,251 +1,374 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
LogOut, FileText, HelpCircle, Landmark, List, FileText,
Settings, PanelLeftClose, PanelLeftOpen, Sun, Moon, ShoppingCart, HelpCircle,
Landmark,
List,
LogOut,
Moon,
PanelLeftClose,
PanelLeftOpen,
Settings,
ShoppingCart,
Sun,
} from "lucide-react"; } from "lucide-react";
import { getHeaderConfig, type HeaderConfig, type NavItem } from "../api/viewService"; import { cn } from "@/lib/utils";
import { useAuthContext } from "../hooks/AuthContext"; import { Button } from "@/components/ui/button";
import { useTheme } from "../hooks/ThemeContext"; import { Separator } from "@/components/ui/separator";
import { RDBMS_RV_SCREEN_UUID } from "../config"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthContext } from "@/hooks/AuthContext";
import { useTheme } from "@/hooks/ThemeContext";
import { useHeaderConfig } from "@/hooks/useHeaderConfig";
import { RDBMS_RV_SCREEN_UUID } from "@/config";
import type { NavItem } from "@/api/viewService";
const ICON_MAP: Record<string, React.ReactNode> = { const ICON_MAP: Record<string, typeof FileText> = {
receipt_long: <FileText size={16} />, receipt_long: FileText,
help_outline: <HelpCircle size={16} />, help_outline: HelpCircle,
account_balance: <Landmark size={16} />, account_balance: Landmark,
list_alt: <List size={16} />, list_alt: List,
inventory_2: <FileText size={16} />, inventory_2: FileText,
question_mark: <HelpCircle size={16} />, question_mark: HelpCircle,
list: <List size={16} />, list: List,
}; };
const EXTRA_NAV_ITEMS = [ /** Screens not present in the served nav config. */
{ id: "vendor-data", screen_uuid: RDBMS_RV_SCREEN_UUID, label: "Purchase Orders", icon: <ShoppingCart size={16} /> }, const EXTRA_NAV: Array<{ id: string; screen_uuid: string; label: string; icon: typeof FileText }> = [
{
id: "vendor-data",
screen_uuid: RDBMS_RV_SCREEN_UUID,
label: "Purchase Orders",
icon: ShoppingCart,
},
]; ];
interface Props { interface Props {
activeScreenId: string | null; activeScreenId: string | null;
onNavigate: (screenUuid: string) => void; onNavigate: (screenUuid: string) => void;
onSidebarChange?: (open: boolean) => void; sidebar: {
open: boolean;
toggle: () => void;
isMobile: boolean;
mobileOpen: boolean;
setMobileOpen: (v: boolean) => void;
};
} }
export default function DynamicHeader({ activeScreenId, onNavigate, onSidebarChange }: Props) { export default function DynamicHeader({
activeScreenId,
onNavigate,
sidebar,
}: Props) {
const { user, logout } = useAuthContext(); const { user, logout } = useAuthContext();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [config, setConfig] = useState<HeaderConfig | null>(null); const { navItems, isPending } = useHeaderConfig();
const [profileOpen, setProfileOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => { const handleLogout = () => {
getHeaderConfig("desktop").then(setConfig).catch(console.error); logout();
}, []); navigate("/login");
};
const navItems = config?.components?.nav?.items ?? [];
const handleLogout = () => { logout(); navigate("/login"); };
const initials = (user?.name || "U") const initials = (user?.name || "U")
.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase(); .split(" ")
const roleName = user?.roles?.[0] .map((n) => n[0])
?.replace(/^p2p_/, "").replace(/_/g, " ") ?? ""; .join("")
.slice(0, 2)
.toUpperCase();
const toggleSidebar = () => { const roleName = user?.roles?.[0]?.replace(/^p2p_/, "").replace(/_/g, " ") ?? "";
const next = !sidebarOpen;
setSidebarOpen(next); // Collapsed on desktop = icon rail. Previously the sidebar animated to w-0,
onSidebarChange?.(next); // which took the entire navigation away instead of condensing it.
}; const railed = !sidebar.open && !sidebar.isMobile;
const items = [
...navItems.map((item: NavItem) => ({
id: item.id,
screen_uuid: item.screen_uuid,
label: item.label,
Icon: ICON_MAP[item.icon] ?? FileText,
})),
...EXTRA_NAV.map((e) => ({
id: e.id,
screen_uuid: e.screen_uuid,
label: e.label,
Icon: e.icon,
})),
];
const nav = (
<SidebarBody
items={items}
isPending={isPending}
railed={railed}
activeScreenId={activeScreenId}
onNavigate={(id) => {
onNavigate(id);
sidebar.setMobileOpen(false);
}}
user={{ name: user?.name, roleName, initials }}
onLogout={handleLogout}
/>
);
return ( return (
<> <>
{/* ── Top bar ─────────────────────────────────────────────────────── */} {/* ── Top bar ───────────────────────────────────────────────────────── */}
<header className="bg-white/90 dark:bg-zinc-950/90 backdrop-blur-md border-b border-slate-200/70 dark:border-zinc-800/70 sticky top-0 z-50 h-14 flex items-center px-4 justify-between"> {/* Offset past the sidebar so the two read as one chrome: the sidebar
{/* Left */} runs full height and owns the brand; the bar starts where it ends. */}
<div className="flex items-center gap-3"> <header
<button className={cn(
onClick={toggleSidebar} "sticky top-0 z-50 flex h-14 items-center justify-between gap-3 border-b border-border bg-background/85 px-3 backdrop-blur-md transition-[margin] duration-200",
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-700 dark:hover:text-zinc-200 hover:bg-slate-100 dark:hover:bg-zinc-800 transition-colors" sidebar.isMobile ? "ml-0" : sidebar.open ? "ml-60" : "ml-14",
> )}
{sidebarOpen ? <PanelLeftClose size={17} /> : <PanelLeftOpen size={17} />} >
</button> <div className="flex min-w-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" onClick={sidebar.toggle}>
{sidebar.open || sidebar.isMobile ? (
<PanelLeftClose className="size-4" aria-hidden />
) : (
<PanelLeftOpen className="size-4" aria-hidden />
)}
<span className="sr-only">Toggle navigation</span>
</Button>
</TooltipTrigger>
<TooltipContent>Toggle navigation</TooltipContent>
</Tooltip>
{/* Logo — unchanged from original */} {/* On desktop the sidebar carries the identity; repeating the logo
<img here would double it up. */}
src={import.meta.env.BASE_URL + "zino.svg"} {sidebar.isMobile && (
alt="Zino" <img
className="h-5 w-auto" src={import.meta.env.BASE_URL + "zino.svg"}
/> alt="Zino"
className="h-5 w-auto dark:brightness-0 dark:invert"
/>
)}
</div> </div>
{/* Right */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* Theme toggle */} <Tooltip>
<button <TooltipTrigger asChild>
onClick={toggleTheme} <Button variant="ghost" size="icon" className="size-8" onClick={toggleTheme}>
title={theme === "dark" ? "Switch to light" : "Switch to dark"} {theme === "dark" ? (
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-950/40 transition-colors" <Sun className="size-4" aria-hidden />
> ) : (
{theme === "dark" ? <Sun size={16} /> : <Moon size={16} />} <Moon className="size-4" aria-hidden />
</button> )}
<span className="sr-only">
Switch to {theme === "dark" ? "light" : "dark"} theme
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
</Tooltip>
{/* Avatar */} <DropdownMenu>
<div className="relative"> <DropdownMenuTrigger asChild>
<button <button className="gradient-brand flex size-8 items-center justify-center rounded-full ring-2 ring-background transition-shadow hover:ring-primary/30">
onClick={() => setProfileOpen(!profileOpen)} <span className="text-xs font-bold leading-none text-white">{initials}</span>
className="w-8 h-8 rounded-full flex items-center justify-center ring-2 ring-white dark:ring-zinc-900 shadow-sm hover:ring-violet-200 dark:hover:ring-violet-800 transition-all" <span className="sr-only">Account menu</span>
style={{ background: "linear-gradient(135deg, #7c3aed, #d946ef)" }} </button>
> </DropdownMenuTrigger>
<span className="text-[11px] font-bold text-white leading-none">{initials}</span> <DropdownMenuContent align="end" className="w-60">
</button> <div className="px-2 py-2">
<div className="flex items-center gap-2.5">
{profileOpen && ( <div className="gradient-brand flex size-9 shrink-0 items-center justify-center rounded-full">
<> <span className="text-sm font-bold text-white">{initials}</span>
<div className="fixed inset-0 z-40" onClick={() => setProfileOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-60 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-slate-900/10 dark:shadow-black/40 border border-slate-200 dark:border-zinc-700/70 overflow-hidden z-50">
<div className="px-4 py-3.5 border-b border-slate-100 dark:border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
style={{ background: "linear-gradient(135deg, #7c3aed, #d946ef)" }}>
<span className="text-[13px] font-bold text-white leading-none">{initials}</span>
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-slate-900 dark:text-zinc-100 truncate">{user?.name}</div>
<div className="text-[11px] text-slate-400 dark:text-zinc-500 truncate">{user?.email}</div>
</div>
</div>
{roleName && (
<div className="mt-2.5">
<span className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full capitalize"
style={{ background: "rgba(124,58,237,0.1)", color: "#7c3aed", border: "1px solid rgba(124,58,237,0.2)" }}>
{roleName}
</span>
</div>
)}
</div> </div>
<div className="py-1"> <div className="min-w-0">
<button className="w-full text-left px-4 py-2.5 text-[13px] text-slate-600 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-800 flex items-center gap-2.5 transition-colors"> <p className="truncate text-base font-semibold text-foreground">{user?.name}</p>
<Settings size={14} className="text-slate-400 dark:text-zinc-500" /> Settings <p className="truncate text-xs text-muted-foreground">{user?.email}</p>
</button>
<div className="h-px bg-slate-100 dark:bg-zinc-800 mx-3 my-0.5" />
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2.5 text-[13px] text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 flex items-center gap-2.5 transition-colors"
>
<LogOut size={14} /> Sign out
</button>
</div> </div>
</div> </div>
</> {roleName && (
)} <span className="mt-2 inline-block rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-2xs font-semibold uppercase tracking-wider text-primary">
</div> {roleName}
</span>
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<Settings className="size-3.5" aria-hidden />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="size-3.5" aria-hidden />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</header> </header>
{/* ── Sidebar ─────────────────────────────────────────────────────── */} {/* ── Sidebar ───────────────────────────────────────────────────────── */}
<aside {sidebar.isMobile ? (
className={`fixed top-14 left-0 bottom-0 z-40 flex flex-col transition-all duration-200 ${sidebarOpen ? "w-60" : "w-0 overflow-hidden"}`} <Sheet open={sidebar.mobileOpen} onOpenChange={sidebar.setMobileOpen}>
style={{ <SheetContent side="left" className="w-64 bg-sidebar p-0">
background: "linear-gradient(180deg, #faf9ff 0%, #f5f3ff 100%)", <SheetTitle className="sr-only">Navigation</SheetTitle>
}} {nav}
> </SheetContent>
{/* Dark mode override via class */} </Sheet>
<div className="flex flex-col h-full dark:bg-zinc-950 border-r border-violet-100 dark:border-zinc-800/80"> ) : (
<aside
{/* App identity strip */} className={cn(
<div className="px-5 pt-5 pb-4 border-b border-violet-100/60 dark:border-zinc-800/60"> "fixed inset-y-0 left-0 z-40 flex flex-col border-r border-sidebar-border bg-sidebar transition-[width] duration-200",
<div className="flex items-center gap-2.5"> railed ? "w-14" : "w-60",
<div )}
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" >
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }} {nav}
> </aside>
<FileText size={13} className="text-white" /> )}
</div>
<div>
<div className="text-[13px] font-bold text-slate-800 dark:text-zinc-100 leading-none">Invoice Portal</div>
<div className="text-[10px] text-slate-400 dark:text-zinc-600 mt-0.5">P2P · Procure-to-Pay</div>
</div>
</div>
</div>
{/* Nav section */}
<div className="flex-1 overflow-y-auto px-3 py-3">
<div className="text-[10px] font-bold text-slate-400 dark:text-zinc-600 uppercase tracking-widest px-2 mb-2">
Navigation
</div>
<nav className="space-y-0.5">
{navItems.map((item: NavItem) => {
const active = activeScreenId === item.screen_uuid;
return (
<button
key={item.id}
onClick={() => onNavigate(item.screen_uuid)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[13px] font-medium transition-all group ${
active
? "bg-violet-100 dark:bg-violet-950/50 text-violet-700 dark:text-violet-300"
: "text-slate-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800/70 hover:text-slate-900 dark:hover:text-zinc-100"
}`}
>
<span
className={`flex items-center justify-center w-7 h-7 rounded-lg shrink-0 transition-colors ${
active
? "bg-violet-200/70 dark:bg-violet-900/60 text-violet-600 dark:text-violet-400"
: "bg-white dark:bg-zinc-800 text-slate-400 dark:text-zinc-500 shadow-sm group-hover:text-violet-600 dark:group-hover:text-violet-400"
}`}
>
{ICON_MAP[item.icon] ?? <FileText size={16} />}
</span>
<span className="truncate">{item.label}</span>
</button>
);
})}
{EXTRA_NAV_ITEMS.map((item) => {
const active = activeScreenId === item.screen_uuid;
return (
<button
key={item.id}
onClick={() => onNavigate(item.screen_uuid)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-[13px] font-medium transition-all group ${
active
? "bg-violet-100 dark:bg-violet-950/50 text-violet-700 dark:text-violet-300"
: "text-slate-500 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800/70 hover:text-slate-900 dark:hover:text-zinc-100"
}`}
>
<span
className={`flex items-center justify-center w-7 h-7 rounded-lg shrink-0 transition-colors ${
active
? "bg-violet-200/70 dark:bg-violet-900/60 text-violet-600 dark:text-violet-400"
: "bg-white dark:bg-zinc-800 text-slate-400 dark:text-zinc-500 shadow-sm group-hover:text-violet-600 dark:group-hover:text-violet-400"
}`}
>
{item.icon}
</span>
<span className="truncate">{item.label}</span>
</button>
);
})}
</nav>
</div>
{/* Bottom — user card */}
<div className="px-3 py-3 border-t border-violet-100/60 dark:border-zinc-800/60">
<div className="flex items-center gap-2.5 px-2 py-2 rounded-xl hover:bg-white dark:hover:bg-zinc-800/70 transition-colors cursor-pointer group"
onClick={handleLogout}
title="Sign out"
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-[11px] font-bold text-white"
style={{ background: "linear-gradient(135deg, #7c3aed, #d946ef)" }}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-slate-700 dark:text-zinc-200 truncate leading-none">{user?.name}</div>
<div className="text-[10px] text-slate-400 dark:text-zinc-500 truncate mt-0.5 capitalize">{roleName}</div>
</div>
<LogOut size={13} className="text-slate-300 dark:text-zinc-600 group-hover:text-red-400 transition-colors shrink-0" />
</div>
</div>
</div>
</aside>
</> </>
); );
} }
// ---------------------------------------------------------------------------
interface SidebarBodyProps {
items: Array<{ id: string; screen_uuid: string; label: string; Icon: typeof FileText }>;
isPending: boolean;
railed: boolean;
activeScreenId: string | null;
onNavigate: (screenUuid: string) => void;
user: { name?: string; roleName: string; initials: string };
onLogout: () => void;
}
function SidebarBody({
items,
isPending,
railed,
activeScreenId,
onNavigate,
user,
onLogout,
}: SidebarBodyProps) {
return (
<div className="flex h-full min-h-0 flex-col">
{/* App identity h-14 with a bottom border so it lines up exactly with
the top bar and the two read as one continuous frame. */}
<div
className={cn(
"flex h-14 shrink-0 flex-col items-center justify-center gap-1 border-b border-sidebar-border px-3",
railed && "px-0",
)}
>
{/* 43×20 wordmark fits the 56px rail as-is, so it needs no separate
collapsed mark. Inverted in dark mode like the top-bar copy. */}
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="h-5 w-auto shrink-0 dark:brightness-0 dark:invert"
/>
{!railed && (
<span className="truncate text-2xs font-medium tracking-wide text-muted-foreground">
Procure to Pay
</span>
)}
</div>
{/* Nav */}
<nav
aria-label="Screens"
className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-2.5 py-4"
>
{!railed && (
<p className="mb-3 px-2 text-2xs font-bold uppercase tracking-widest text-muted-foreground">
Navigation
</p>
)}
{isPending ? (
<div className="space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className={cn("h-10", railed ? "mx-auto w-10" : "w-full")} />
))}
</div>
) : (
<ul className="space-y-2">
{items.map(({ id, screen_uuid, label, Icon }) => {
const active = activeScreenId === screen_uuid;
const button = (
<button
onClick={() => onNavigate(screen_uuid)}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-3 text-base font-medium transition-colors",
railed && "justify-center px-0",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" aria-hidden />
{!railed && <span className="truncate">{label}</span>}
{railed && <span className="sr-only">{label}</span>}
</button>
);
return (
<li key={id}>
{railed ? (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
) : (
button
)}
</li>
);
})}
</ul>
)}
</nav>
{/* User card */}
<Separator className="bg-sidebar-border" />
<div className="p-2">
<button
onClick={onLogout}
className={cn(
"group flex w-full items-center gap-2.5 rounded-lg px-2 py-2 transition-colors hover:bg-sidebar-accent/50",
railed && "justify-center px-0",
)}
>
<div className="gradient-brand flex size-7 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white">
{user.initials}
</div>
{!railed && (
<>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-sm font-semibold leading-none text-sidebar-foreground">
{user.name}
</p>
<p className="mt-0.5 truncate text-2xs capitalize text-muted-foreground">
{user.roleName}
</p>
</div>
<LogOut
className="size-3.5 shrink-0 text-muted-foreground transition-colors group-hover:text-destructive"
aria-hidden
/>
</>
)}
<span className="sr-only">Sign out</span>
</button>
</div>
</div>
);
}

View File

@ -1,20 +1,54 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useMemo, useState } from "react";
import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import { getFormScreen, startWorkflow, performActivity, uploadFieldFile } from "../api/viewService"; import { AlertCircle, ArrowRight, CheckCircle2, Loader2 } from "lucide-react";
import type { FormScreenField, FieldFileContext } from "../api/viewService"; import { toast } from "sonner";
import { WORKFLOW_ID } from "../config"; import { cn } from "@/lib/utils";
import { Skeleton } from "./Skeleton"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getFormScreen,
performActivity,
startWorkflow,
uploadFieldFile,
type FieldFileContext,
type FormScreenField,
} from "@/api/viewService";
import { WORKFLOW_ID } from "@/config";
import OcrUploadField, { resolveOcrConfig } from "./OcrUploadField"; import OcrUploadField, { resolveOcrConfig } from "./OcrUploadField";
type FormField = FormScreenField; type FormField = FormScreenField;
interface GridItem { i: string; x: number; y: number; w: number; h: number; } interface GridItem {
i: string;
x: number;
y: number;
w: number;
h: number;
}
// Field data types whose value is a file, not a scalar. They skip the // Field types whose value is a File rather than a scalar. They skip string
// string-based validation and number coercion paths, and their File is uploaded // validation and number coercion, and their File is uploaded via /upload before
// via /upload before submit so instance data references the blob by metadata. // submit so instance data references the blob by metadata.
const MEDIA_TYPES = new Set(["ocr", "file", "image"]); const MEDIA_TYPES = new Set(["ocr", "file", "image"]);
const isEmpty = (v: any) => const isEmpty = (v: unknown) =>
v == null || v == null ||
(typeof v === "string" && v.trim() === "") || (typeof v === "string" && v.trim() === "") ||
(Array.isArray(v) && v.length === 0); (Array.isArray(v) && v.length === 0);
@ -28,71 +62,79 @@ interface Props {
} }
export default function FormModal({ activityId, instanceId, title, onClose, onSuccess }: Props) { export default function FormModal({ activityId, instanceId, title, onClose, onSuccess }: Props) {
const [fields, setFields] = useState<FormField[]>([]);
const [gridConfig, setGridConfig] = useState<GridItem[]>([]);
const [formTitle, setFormTitle] = useState(title || "");
// Values are strings for scalar fields and a File for media fields.
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
// Identifiers /upload + /ocr-extract need to resolve a field's deployed
// config and RBAC server-side. The form screen echoes them; WORKFLOW_ID is
// the fallback for older view-service builds that don't.
const [wfUuid, setWfUuid] = useState<string>(WORKFLOW_ID);
const [versionUuid, setVersionUuid] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState<Set<string>>(new Set()); const [touched, setTouched] = useState<Set<string>>(new Set());
const [success, setSuccess] = useState(false); const [submitting, setSubmitting] = useState(false);
const [visible, setVisible] = useState(false); const [succeeded, setSucceeded] = useState(false);
const modalRef = useRef<HTMLDivElement>(null); const [error, setError] = useState<string | null>(null);
// Entrance animation const { data, isPending } = useQuery({
useEffect(() => { requestAnimationFrame(() => setVisible(true)); }, []); queryKey: ["form-screen", activityId, instanceId],
queryFn: () => getFormScreen(activityId, instanceId) as Promise<any>,
});
useEffect(() => { const fields: FormField[] = data?.fields ?? data?.form_json?.fields ?? [];
getFormScreen(activityId, instanceId) const gridConfig: GridItem[] = data?.grid_config ?? [];
.then((res: any) => { const formTitle = data?.activity_name || title || "Form";
const f: FormField[] = res.fields ?? res.form_json?.fields ?? [];
setFields(f);
setGridConfig(res.grid_config ?? []);
setFormTitle(res.activity_name || title || "Form");
if (res.workflow_uuid) setWfUuid(res.workflow_uuid);
setVersionUuid(res.version_uuid || undefined);
const pre: Record<string, any> = {};
f.forEach((field) => {
// Media prefills are already-uploaded metadata arrays, not strings.
if (field.value == null) return;
pre[field.id] = MEDIA_TYPES.has(field.data_type) ? field.value : String(field.value);
});
setFormData(pre);
})
.catch((err: any) => setError(err.message))
.finally(() => setLoading(false));
}, [activityId, instanceId, title]);
const animateClose = (callback: () => void) => { // /upload and /ocr-extract resolve the field's deployed config and RBAC from
setVisible(false); // these identifiers. The form screen echoes them; WORKFLOW_ID is the fallback
setTimeout(callback, 200); // for older view-service builds that don't.
};
// Base context for the field-scoped file endpoints; each call adds its fieldId.
const fileCtx: Omit<FieldFileContext, "fieldId"> = { const fileCtx: Omit<FieldFileContext, "fieldId"> = {
workflowUuid: wfUuid, workflowUuid: data?.workflow_uuid || WORKFLOW_ID,
activityId, activityId,
versionUuid, versionUuid: data?.version_uuid || undefined,
instanceId, instanceId,
}; };
// Write an OCR result into the fields ocr_config.field_mappings points at. // Seed prefills once the schema arrives.
// Returns the number of fields that actually received a value, which the OCR useEffect(() => {
// field reports back to the user ("Filled 9 fields…"). if (!fields.length) return;
const pre: Record<string, any> = {};
for (const f of fields) {
if (f.value == null) continue;
// Media prefills are already-uploaded metadata arrays, not strings.
pre[f.id] = MEDIA_TYPES.has(f.data_type) ? f.value : String(f.value);
}
setFormData(pre);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
/** Rows derived from the studio grid layout, so field order matches design. */
const rows = useMemo(() => {
const sorted = [...fields].sort((a, b) => {
const ga = gridConfig.find((g) => g.i === a.uid);
const gb = gridConfig.find((g) => g.i === b.uid);
if (!ga || !gb) return 0;
return ga.y !== gb.y ? ga.y - gb.y : ga.x - gb.x;
});
const out: FormField[][] = [];
let currentY = -1;
for (const f of sorted) {
const y = gridConfig.find((g) => g.i === f.uid)?.y ?? out.length;
if (y !== currentY) {
out.push([]);
currentY = y;
}
out[out.length - 1].push(f);
}
return out;
}, [fields, gridConfig]);
const setValue = (id: string, v: unknown) => {
setFormData((p) => ({ ...p, [id]: v }));
setTouched((t) => new Set(t).add(id));
if (error) setError(null);
};
/** Write an OCR result into the fields ocr_config.field_mappings points at. */
const applyExtraction = (ocrField: FormField, extracted: Record<string, unknown>): number => { const applyExtraction = (ocrField: FormField, extracted: Record<string, unknown>): number => {
const mappings = resolveOcrConfig(ocrField).field_mappings ?? []; const mappings = resolveOcrConfig(ocrField).field_mappings ?? [];
const next: Record<string, any> = {}; const next: Record<string, any> = {};
for (const m of mappings) { for (const m of mappings) {
if (!m?.extraction_key || !m?.target_field) continue; if (!m?.extraction_key || !m?.target_field) continue;
const raw = extracted[m.extraction_key]; const raw = extracted[m.extraction_key];
if (raw == null || raw === "") continue; // key absent, or the model found nothing if (raw == null || raw === "") continue; // key absent, or model found nothing
const target = fields.find((f) => f.id === m.target_field); const target = fields.find((f) => f.id === m.target_field);
const coerced = coerceExtracted(raw, target?.data_type); const coerced = coerceExtracted(raw, target?.data_type);
if (coerced === "") continue; if (coerced === "") continue;
@ -102,12 +144,18 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
if (filled.length > 0) { if (filled.length > 0) {
setFormData((p) => ({ ...p, ...next })); setFormData((p) => ({ ...p, ...next }));
// Mark them touched so a value the model got wrong still shows validation. // Mark them touched so a value the model got wrong still shows validation.
setTouched((t) => { const s = new Set(t); filled.forEach((k) => s.add(k)); return s; }); setTouched((t) => {
const s = new Set(t);
filled.forEach((k) => s.add(k));
return s;
});
setError(null); setError(null);
} }
return filled.length; return filled.length;
}; };
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && isEmpty(formData[f.id]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setTouched(new Set(fields.map((f) => f.id))); setTouched(new Set(fields.map((f) => f.id)));
@ -118,176 +166,242 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
return; return;
} }
setSubmitting(true); setError(null); setSubmitting(true);
setError(null);
try { try {
const data: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
for (const f of fields) { for (const f of fields) {
const v = formData[f.id]; const v = formData[f.id];
if (MEDIA_TYPES.has(f.data_type)) { if (MEDIA_TYPES.has(f.data_type)) {
// Freshly picked Files are uploaded now and submitted as metadata; // A freshly picked File is uploaded now and submitted as metadata; an
// an array is already-uploaded metadata from a prefill. Instance data // array is already-uploaded metadata from a prefill.
// holds an array either way, matching the platform's file fields.
if (v instanceof File) { if (v instanceof File) {
data[f.id] = [await uploadFieldFile(v, { ...fileCtx, fieldId: f.id })]; payload[f.id] = [await uploadFieldFile(v, { ...fileCtx, fieldId: f.id })];
} else if (Array.isArray(v)) { } else if (Array.isArray(v)) {
data[f.id] = v; payload[f.id] = v;
} }
continue; continue;
} }
const s = v ?? ""; const s = v ?? "";
data[f.id] = f.data_type === "number" ? (s !== "" ? Number(s) : 0) : String(s); payload[f.id] = f.data_type === "number" ? (s !== "" ? Number(s) : 0) : String(s);
} }
instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data);
setSuccess(true); if (instanceId) await performActivity(WORKFLOW_ID, instanceId, activityId, payload);
setTimeout(() => animateClose(onSuccess), 800); else await startWorkflow(WORKFLOW_ID, activityId, payload);
} catch (err: any) { setError(err.message || "Failed"); }
finally { setSubmitting(false); } setSucceeded(true);
// The toast outlives the dialog. The old 800ms in-modal overlay vanished
// with the modal, leaving no confirmation behind at all.
toast.success(`${formTitle} submitted`, {
description: instanceId ? "The invoice has been updated." : "A new invoice was created.",
});
setTimeout(onSuccess, 600);
} catch (err: any) {
const message = err?.message || "Submission failed";
setError(message);
toast.error(`${formTitle} failed`, { description: message });
} finally {
setSubmitting(false);
}
}; };
const sorted = [...fields].sort((a, b) => {
const ga = gridConfig.find((g) => g.i === a.uid), gb = gridConfig.find((g) => g.i === b.uid);
if (!ga || !gb) return 0;
return ga.y !== gb.y ? ga.y - gb.y : ga.x - gb.x;
});
const rows: FormField[][] = []; let cy = -1;
for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); }
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && isEmpty(formData[f.id]);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}> <Dialog open onOpenChange={(o) => !o && !submitting && onClose()}>
{/* Backdrop */} {/* Radix supplies the focus trap, Escape handling, aria-modal, scroll lock
<div className={`absolute inset-0 bg-slate-900/50 dark:bg-black/60 backdrop-blur-[2px] transition-opacity duration-200 ${visible ? "opacity-100" : "opacity-0"}`} /> and focus restore that the hand-rolled overlay never had. */}
<DialogContent
{/* Modal */} className="max-h-[90vh] gap-0 overflow-hidden p-0 sm:max-w-xl"
<div onInteractOutside={(e) => submitting && e.preventDefault()}
ref={modalRef}
className={`relative bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl shadow-slate-900/10 dark:shadow-black/50 border border-slate-200/0 dark:border-zinc-700/60 w-full max-w-xl mx-4 max-h-[90vh] flex flex-col transition-all duration-200 ${visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-2"}`}
onClick={(e) => e.stopPropagation()}
> >
{/* ── Success overlay ──────────────────────────────────────────── */} <DialogHeader className="border-b border-border px-5 py-4 text-left">
{success && ( <DialogTitle className="text-lg">{formTitle}</DialogTitle>
<div className="absolute inset-0 z-10 bg-white/95 dark:bg-zinc-900/95 rounded-2xl flex flex-col items-center justify-center gap-3"> <DialogDescription className="text-sm">
<div className="w-12 h-12 rounded-full bg-emerald-50 dark:bg-emerald-950/40 flex items-center justify-center"> {instanceId ? "Update the details below." : "Fill in the details to submit."}
<CheckCircle2 size={24} className="text-emerald-500" /> </DialogDescription>
</div> </DialogHeader>
<div className="text-[14px] font-semibold text-slate-800 dark:text-zinc-100">Submitted successfully</div>
</div>
)}
{/* ── Header ───────────────────────────────────────────────────── */} <div className="scrollbar-thin flex-1 overflow-y-auto px-5 py-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 dark:border-zinc-800"> {isPending ? (
<div> <div className="space-y-4">
<h2 className="text-[15px] font-semibold text-slate-900 dark:text-zinc-100">{formTitle}</h2> {Array.from({ length: 4 }).map((_, r) => (
<p className="text-[12px] text-slate-400 dark:text-zinc-500 mt-0.5">
{instanceId ? "Update the details below" : "Fill in the details to submit"}
</p>
</div>
<button
onClick={() => animateClose(onClose)}
className="w-8 h-8 flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-600 dark:hover:text-zinc-200 hover:bg-slate-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
>
<X size={16} />
</button>
</div>
{/* ── Body ─────────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading ? (
<div className="space-y-5">
{[0, 1, 2, 3].map((r) => (
<div key={r} className="grid grid-cols-2 gap-4"> <div key={r} className="grid grid-cols-2 gap-4">
<div><Skeleton className="h-3 w-20 mb-2" /><Skeleton className="h-10 w-full rounded-lg" /></div> {Array.from({ length: 2 }).map((__, c) => (
<div><Skeleton className="h-3 w-24 mb-2" /><Skeleton className="h-10 w-full rounded-lg" /></div> <div key={c}>
<Skeleton className="mb-2 h-3 w-20" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
))}
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<form id="act-form" onSubmit={handleSubmit} className="space-y-5"> <form id="activity-form" onSubmit={handleSubmit} className="space-y-4">
{rows.map((row, ri) => ( {rows.map((row, ri) => (
<div key={ri} className={`grid gap-4 ${row.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}> <div
{row.map((f) => ( key={ri}
<div key={f.id}> className={cn("grid gap-4", row.length > 1 ? "sm:grid-cols-2" : "grid-cols-1")}
<label className="block text-[12px] font-medium text-slate-500 dark:text-zinc-400 mb-1.5"> >
{f.name} {row.map((f) => {
{f.mandatory && <span className="text-red-400 ml-0.5">*</span>} const invalid = isMissing(f);
</label> return (
{MEDIA_TYPES.has(f.data_type) ? ( <div key={f.id}>
<OcrUploadField <Label htmlFor={f.id} className="mb-1.5 text-sm text-muted-foreground">
field={f} {f.name}
ctx={fileCtx} {f.mandatory && (
file={formData[f.id] instanceof File ? formData[f.id] : null} <span className="text-destructive" aria-hidden>
onFileChange={(file) => { *
setFormData((p) => ({ ...p, [f.id]: file })); </span>
setTouched((t) => new Set(t).add(f.id)); )}
if (error) setError(null); </Label>
}}
onExtracted={(extracted) => applyExtraction(f, extracted)} {MEDIA_TYPES.has(f.data_type) ? (
hasError={isMissing(f)} <OcrUploadField
/> field={f}
) : ( ctx={fileCtx}
renderInput(f, formData[f.id] ?? "", (v) => { file={formData[f.id] instanceof File ? formData[f.id] : null}
setFormData((p) => ({ ...p, [f.id]: v })); onFileChange={(file) => setValue(f.id, file)}
setTouched((t) => new Set(t).add(f.id)); onExtracted={(extracted) => applyExtraction(f, extracted)}
if (error) setError(null); hasError={invalid}
}, isMissing(f)) />
)} ) : (
{isMissing(f) && ( <FieldInput
<p className="text-[11px] text-red-500 mt-1 flex items-center gap-1"> field={f}
<AlertCircle size={11} /> Required value={formData[f.id] ?? ""}
</p> onChange={(v) => setValue(f.id, v)}
)} invalid={invalid}
</div> />
))} )}
{invalid && (
<p
id={`${f.id}-error`}
className="mt-1 flex items-center gap-1 text-xs text-destructive"
>
<AlertCircle className="size-3" aria-hidden />
Required
</p>
)}
</div>
);
})}
</div> </div>
))} ))}
</form> </form>
)} )}
{error && ( {error && (
<div className="mt-4 flex items-start gap-2.5 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-xl px-4 py-3 border border-red-100 dark:border-red-900"> <div
<AlertCircle size={15} className="shrink-0 mt-0.5" /> role="alert"
className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-base text-destructive"
>
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>{error}</span> <span>{error}</span>
</div> </div>
)} )}
</div> </div>
{/* ── Footer ───────────────────────────────────────────────────── */} {/* mx-0/mb-0 cancel DialogFooter's default negative margins, which
{!loading && ( assume the content keeps its own padding this dialog is p-0. */}
<div className="px-6 py-4 border-t border-slate-100 dark:border-zinc-800 flex items-center justify-between"> {!isPending && (
<div className="text-[11px] text-slate-400 dark:text-zinc-500"> <DialogFooter className="mx-0 mb-0 items-center border-t border-border px-5 py-3 sm:justify-between">
<span className="text-red-400">*</span> Required <p className="text-xs text-muted-foreground">
</div> <span className="text-destructive">*</span> Required
<div className="flex gap-2.5"> </p>
<button <div className="flex gap-2">
type="button" <Button type="button" variant="ghost" onClick={onClose} disabled={submitting}>
onClick={() => animateClose(onClose)}
className="h-9 px-4 text-[13px] font-medium text-slate-500 dark:text-zinc-400 hover:text-slate-700 dark:hover:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-800 rounded-xl transition-colors"
>
Cancel Cancel
</button> </Button>
<button <Button type="submit" form="activity-form" disabled={submitting || succeeded}>
type="submit"
form="act-form"
disabled={submitting}
className="h-9 px-5 text-[13px] font-semibold text-white disabled:opacity-50 rounded-xl transition-all flex items-center gap-1.5 shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 hover:scale-[1.02] active:scale-[0.98]"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
>
{submitting ? ( {submitting ? (
<><div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Submitting</> <>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
Submitting
</>
) : succeeded ? (
<>
<CheckCircle2 className="size-3.5" aria-hidden />
Submitted
</>
) : ( ) : (
<>{instanceId ? "Update" : "Submit"} <ArrowRight size={14} /></> <>
{instanceId ? "Update" : "Submit"}
<ArrowRight className="size-3.5" aria-hidden />
</>
)} )}
</button> </Button>
</div> </div>
</div> </DialogFooter>
)} )}
</div> </DialogContent>
</div> </Dialog>
); );
} }
// ---------------------------------------------------------------------------
// Inputs
// ---------------------------------------------------------------------------
function FieldInput({
field,
value,
onChange,
invalid,
}: {
field: FormField;
value: string;
onChange: (v: string) => void;
invalid: boolean;
}) {
const options = field.properties?.options;
const describedBy = invalid ? `${field.id}-error` : undefined;
if (options && options.length > 0) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
id={field.id}
aria-invalid={invalid}
aria-describedby={describedBy}
className="w-full"
>
<SelectValue placeholder={`Select ${field.name.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
const common = {
id: field.id,
value,
"aria-invalid": invalid,
"aria-describedby": describedBy,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(e.target.value),
};
switch (field.data_type) {
case "longtext":
return <Textarea {...common} rows={3} placeholder={`Enter ${field.name.toLowerCase()}`} />;
case "number":
return <Input {...common} type="number" step="any" placeholder="0" />;
case "date":
return <Input {...common} type="date" />;
case "email":
return <Input {...common} type="email" placeholder="name@company.com" />;
case "phone":
return <Input {...common} type="tel" placeholder={`Enter ${field.name.toLowerCase()}`} />;
default:
return <Input {...common} type="text" placeholder={`Enter ${field.name.toLowerCase()}`} />;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OCR value coercion // OCR value coercion
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -313,58 +427,3 @@ function coerceExtracted(raw: unknown, dataType?: string): string {
} }
return s; return s;
} }
// ---------------------------------------------------------------------------
// Input renderer
// ---------------------------------------------------------------------------
function renderInput(f: FormField, value: string, onChange: (v: string) => void, hasError: boolean) {
const base = `w-full h-10 border rounded-xl px-3.5 text-[13px] bg-slate-50/50 dark:bg-zinc-800/70 focus:bg-white dark:focus:bg-zinc-800 text-slate-800 dark:text-zinc-100 focus:outline-none focus:ring-2 transition placeholder:text-slate-400 dark:placeholder:text-zinc-600 ${
hasError
? "border-red-300 dark:border-red-700 focus:ring-red-500/20 focus:border-red-400"
: "border-slate-200 dark:border-zinc-700 focus:ring-violet-500/20 focus:border-violet-400 dark:focus:border-violet-500"
}`;
// Select / radio with options
const opts = f.properties?.options;
if (opts && opts.length > 0) {
return (
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${base} appearance-none pr-9 cursor-pointer`}
>
<option value="">Select {f.name.toLowerCase()}</option>
{opts.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
);
}
switch (f.data_type) {
case "longtext":
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={3}
className={`${base} h-auto py-2.5 rounded-xl`}
placeholder={`Enter ${f.name.toLowerCase()}`}
/>
);
case "number":
return <input type="number" step="any" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder="0" />;
case "date":
return <input type="date" value={value} onChange={(e) => onChange(e.target.value)} className={base} />;
case "email":
return <input type="email" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
case "phone":
return <input type="tel" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
default:
return <input type="text" value={value} onChange={(e) => onChange(e.target.value)} className={base} placeholder={`Enter ${f.name.toLowerCase()}`} />;
}
}

View File

@ -1,280 +0,0 @@
// InvoiceDetail — the invoice page, given a hierarchy.
//
// It used to render a flat grid of every field the detail view returned: 29 for
// Invoice Detail, 21 for Finance Review Detail. Grouped, but with no ranking, so
// the reviewer's actual decision inputs — the recommendation, its confidence,
// the match result, the amount — sat among twenty peers of equal weight.
//
// Order here follows the decision the viewer has to make:
//
// 1. Identity + status who/what/where in the flow
// 2. AI recommendation the answer, up front
// 3. 3-way match evidence why that answer is defensible
// 4. Clarification Q&A the human exchange, if there was one
// 5. Source document the PDF being reasoned about
// 6. AI review the full reasoning trail, on demand
// 7. History how it got here
// 8. All fields the original grid, collapsed
//
// Everything derives from one fetch pass (useInstanceDetail).
import { useState } from "react";
import {
ChevronDown, ChevronRight, Sparkles, MessageSquare, CheckCircle2,
XCircle, PauseCircle, HelpCircle, Loader2, AlertCircle, ListTree,
} from "lucide-react";
import DetailViewPanel from "./DetailViewPanel";
import ThreeWayMatchCard from "./ThreeWayMatchCard";
import InvoiceDocumentCard from "./InvoiceDocumentCard";
import AIActivityPanel from "./AIActivityPanel";
import AuditTimeline from "./AuditTimeline";
import { useInstanceDetail } from "../hooks/useInstanceDetail";
import { stateMeta, TONE_PILL, TONE_DOT, fmtAmount, fmtDateTime } from "../lib/workflowMeta";
interface Props {
instanceId: string;
/** dv/screen uuid used for the collapsed full-field grid. */
viewId: number | string;
/** Current state UUID, from the instance. */
stateId?: string | null;
}
const REC_STYLE: Record<string, { tone: string; icon: React.ReactNode; label: string }> = {
approve: { tone: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/40 dark:ring-emerald-800/60", icon: <CheckCircle2 size={13} />, label: "Approve" },
partial_approve: { tone: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:ring-amber-800/60", icon: <PauseCircle size={13} />, label: "Partial approve" },
reject: { tone: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/40 dark:ring-red-800/60", icon: <XCircle size={13} />, label: "Reject" },
hold: { tone: "text-slate-600 bg-slate-100 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", icon: <PauseCircle size={13} />, label: "Hold" },
};
const str = (v: unknown): string | null => {
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
};
export default function InvoiceDetail({ instanceId, viewId, stateId }: Props) {
const d = useInstanceDetail(instanceId);
const [showAll, setShowAll] = useState(false);
if (d.loading) {
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 py-16 flex flex-col items-center gap-2">
<Loader2 size={20} className="animate-spin text-violet-500" />
<span className="text-[12px] text-slate-400 dark:text-zinc-500">Loading invoice</span>
</div>
);
}
if (d.error) {
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-red-200 dark:border-red-900 px-5 py-4 flex items-start gap-2 text-[13px] text-red-600 dark:text-red-400">
<AlertCircle size={15} className="shrink-0 mt-0.5" />{d.error}
</div>
);
}
const s = d.submitted;
const cur = String(s.field_5 || "INR");
const st = stateMeta(stateId);
const recommendation = str(s.recommendation)?.toLowerCase();
const rec = recommendation ? REC_STYLE[recommendation] : undefined;
const confidence = s.ai_confidence != null ? Number(s.ai_confidence) : undefined;
const question = str(s.clarification_question);
const answer = str(s.response_text);
return (
<div className="space-y-4">
{/* ── 1. Identity + status ─────────────────────────────────────────── */}
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-[18px] font-bold text-slate-900 dark:text-zinc-50 tracking-tight">
{str(s.invoice_number) ?? `Invoice #${instanceId}`}
</h2>
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold ${TONE_PILL[st.tone]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${TONE_DOT[st.tone]}`} />{st.label}
</span>
</div>
<p className="text-[13px] text-slate-500 dark:text-zinc-400 mt-0.5">
{str(s.vendor_name) ?? "Unknown vendor"}
{str(s.vendor_id) ? ` · ${str(s.vendor_id)}` : ""}
</p>
</div>
<div className="text-right">
<div className="text-[22px] font-bold tabular-nums text-slate-900 dark:text-zinc-50 leading-none">
{fmtAmount(s.total_amount, cur)}
</div>
{s.tax_amount != null && (
<div className="text-[11px] text-slate-400 dark:text-zinc-500 mt-1">
incl. tax {fmtAmount(s.tax_amount, cur)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-6 flex-wrap mt-3.5 pt-3.5 border-t border-slate-100 dark:border-zinc-800">
<Meta label="Purchase order" value={str(s.po_number)} />
<Meta label="Invoice date" value={str(s.invoice_date)} />
<Meta label="Payment terms" value={str(s.payment_terms)} />
<Meta label="Currency" value={cur} />
<Meta label="Record" value={`#${instanceId}`} />
</div>
{str(s.notes) && (
<p className="mt-3 text-[12.5px] text-slate-500 dark:text-zinc-400">
<span className="text-slate-400 dark:text-zinc-600">Vendor notes: </span>{str(s.notes)}
</p>
)}
</section>
{/* ── 2. AI recommendation ─────────────────────────────────────────── */}
{rec && (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-center gap-2 mb-2.5">
<Sparkles size={14} className="text-violet-500" />
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">AI recommendation</h3>
</div>
<div className="flex items-center gap-3 flex-wrap">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[12.5px] font-bold ring-1 ${rec.tone}`}>
{rec.icon}{rec.label}
</span>
{s.recommended_amount != null && (
<div>
<span className="text-[11px] text-slate-400 dark:text-zinc-500">Recommended </span>
<span className="text-[14px] font-bold tabular-nums text-slate-800 dark:text-zinc-100">
{fmtAmount(s.recommended_amount, cur)}
</span>
</div>
)}
{confidence != null && Number.isFinite(confidence) && (
<span className="text-[11px] text-slate-400 dark:text-zinc-500">
{Math.round(confidence * 100)}% confidence
</span>
)}
</div>
{str(s.ai_reasoning) && (
<p className="text-[12.5px] leading-relaxed text-slate-600 dark:text-zinc-300 mt-2.5">
{str(s.ai_reasoning)}
</p>
)}
</section>
)}
{/* ── 3. Match evidence ────────────────────────────────────────────── */}
<ThreeWayMatchCard
matchSummary={str(s.match_summary) ?? undefined}
analysis={str(s.analysis_so_far) ?? undefined}
invoice={{
invoice_number: s.invoice_number,
po_number: s.po_number,
total_amount: s.total_amount,
tax_amount: s.tax_amount,
currency: cur,
}}
/>
{/* ── 4. Clarification exchange ────────────────────────────────────── */}
{(question || answer) && (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2 mb-3">
<MessageSquare size={14} className="text-slate-400" />Clarification
{str(s.target_department) && (
<span className="font-normal text-slate-400 dark:text-zinc-500">
· {String(s.target_department).replace(/_/g, " ")}
</span>
)}
</h3>
<div className="space-y-3">
{question && (
<Bubble
who="Priya · AI Finance"
ai
text={question}
context={str(s.clarification_context)}
/>
)}
{answer && (
<Bubble
who={str(s.target_department)?.replace(/_/g, " ") ?? "Department"}
text={answer}
context={str(s.response_notes)}
/>
)}
{question && !answer && (
<div className="flex items-center gap-1.5 text-[12px] text-amber-700 dark:text-amber-400">
<HelpCircle size={12} />Awaiting a response.
</div>
)}
</div>
</section>
)}
{/* ── 5. Source document ───────────────────────────────────────────── */}
<InvoiceDocumentCard file={d.invoicePdf} />
{/* ── 6. AI reasoning trail ────────────────────────────────────────── */}
<AIActivityPanel
decisions={d.decisions}
escalations={d.escalations}
traces={d.traces}
unavailable={d.aiUnavailable}
/>
{/* ── 7. History ───────────────────────────────────────────────────── */}
<AuditTimeline entries={d.audit} />
{/* ── 8. Everything else, collapsed ────────────────────────────────── */}
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60">
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="w-full px-5 py-3.5 flex items-center gap-2 text-[13px] font-semibold text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-800/60 rounded-2xl transition-colors"
>
{showAll ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<ListTree size={14} className="text-slate-400" />
All invoice fields
</button>
{showAll && (
<div className="px-5 pb-5 pt-1 border-t border-slate-100 dark:border-zinc-800">
<DetailViewPanel viewId={viewId} instanceId={instanceId} />
</div>
)}
</section>
</div>
);
}
function Meta({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">{label}</div>
<div className="text-[12.5px] font-medium text-slate-700 dark:text-zinc-200">{value}</div>
</div>
);
}
function Bubble({ who, text, context, ai }: { who: string; text: string; context?: string | null; ai?: boolean }) {
return (
<div className={`rounded-xl px-3.5 py-2.5 ${
ai
? "bg-violet-50/70 dark:bg-violet-950/25 border border-violet-100 dark:border-violet-900/40"
: "bg-slate-50 dark:bg-zinc-800/50 border border-slate-100 dark:border-zinc-700/60"
}`}>
<div className={`text-[10.5px] font-semibold uppercase tracking-wide mb-1 ${
ai ? "text-violet-600 dark:text-violet-400" : "text-slate-500 dark:text-zinc-400"
}`}>
{who}
</div>
<p className="text-[12.5px] leading-relaxed text-slate-700 dark:text-zinc-200 whitespace-pre-wrap">{text}</p>
{context && (
<p className="text-[11.5px] leading-relaxed text-slate-500 dark:text-zinc-400 mt-1.5 pt-1.5 border-t border-slate-200/70 dark:border-zinc-700/50">
{context}
</p>
)}
</div>
);
}

View File

@ -1,147 +0,0 @@
// InvoiceDocumentCard — the source invoice PDF, viewable by whoever is looking
// at the record.
//
// Before this, `invoice_pdf` was write-only: the vendor uploaded it, OCR read
// it, and then it appeared in no record view and no detail view — so the
// department answering a clarification and the reviewer approving payment
// couldn't open the document they were reasoning about.
//
// The /download endpoint sets Content-Disposition: attachment, so a plain <a>
// would always download. Fetching a Blob and building an object URL instead
// lets the same bytes be shown inline AND opened/downloaded on demand.
import { useEffect, useRef, useState } from "react";
import { FileText, Download, ExternalLink, AlertCircle, Loader2, Eye } from "lucide-react";
import { downloadFieldFile, type FileMeta } from "../api/viewService";
import { fmtBytes } from "../lib/workflowMeta";
interface Props {
file: FileMeta | null;
/** Render the preview open on mount (reviewers want it; list-side callers may not). */
defaultOpen?: boolean;
}
export default function InvoiceDocumentCard({ file, defaultOpen = false }: Props) {
const [url, setUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(defaultOpen);
const urlRef = useRef<string | null>(null);
// Fetch once, on first expand. Object URLs must be revoked or the blob leaks
// for the lifetime of the document.
useEffect(() => {
if (!file || !open || urlRef.current) return;
let cancelled = false;
setLoading(true);
setError(null);
downloadFieldFile(file.blob_path, file.original_name)
.then((blob) => {
if (cancelled) return;
const objUrl = URL.createObjectURL(blob);
urlRef.current = objUrl;
setUrl(objUrl);
})
.catch((e: any) => { if (!cancelled) setError(e?.message || "Could not load the document"); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [file, open]);
useEffect(() => () => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
}, []);
const save = () => {
if (!url || !file) return;
const a = document.createElement("a");
a.href = url;
a.download = file.original_name || "invoice.pdf";
a.click();
};
if (!file) {
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-center gap-2.5 text-[13px] text-slate-400 dark:text-zinc-500">
<FileText size={15} />
No invoice document was attached to this submission.
</div>
</section>
);
}
const isPdf = (file.mime_type || "").includes("pdf");
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-4 flex items-center gap-3">
<div className="w-9 h-9 shrink-0 rounded-xl bg-violet-50 dark:bg-violet-950/40 flex items-center justify-center">
<FileText size={16} className="text-violet-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 truncate">
{file.original_name || "Invoice document"}
</div>
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
Source document · {fmtBytes(file.size_bytes)}
{isPdf ? " · PDF" : file.mime_type ? ` · ${file.mime_type}` : ""}
</div>
</div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
>
<Eye size={13} />{open ? "Hide" : "View"}
</button>
{url && (
<>
<a
href={url}
target="_blank"
rel="noreferrer"
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
>
<ExternalLink size={13} />Open
</a>
<button
type="button"
onClick={save}
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
>
<Download size={13} />Save
</button>
</>
)}
</div>
{open && (
<div className="border-t border-slate-100 dark:border-zinc-800">
{loading && (
<div className="h-[420px] flex flex-col items-center justify-center gap-2 text-slate-400 dark:text-zinc-500">
<Loader2 size={18} className="animate-spin text-violet-500" />
<span className="text-[12px]">Loading document</span>
</div>
)}
{error && (
<div className="px-5 py-4 flex items-start gap-2 text-[12.5px] text-red-600 dark:text-red-400">
<AlertCircle size={14} className="shrink-0 mt-0.5" />{error}
</div>
)}
{url && !loading && !error && (
isPdf ? (
<iframe
src={url}
title={file.original_name || "Invoice"}
className="w-full h-[560px] bg-slate-50 dark:bg-zinc-950"
/>
) : (
<img src={url} alt={file.original_name || "Invoice"} className="w-full max-h-[560px] object-contain bg-slate-50 dark:bg-zinc-950" />
)
)}
</div>
)}
</section>
);
}

View File

@ -16,10 +16,22 @@
// out of the local config is only picker UX (allowed_types / max_size_mb / // out of the local config is only picker UX (allowed_types / max_size_mb /
// placeholder); mirrors renderer-desktop's FFOcrField. // placeholder); mirrors renderer-desktop's FFOcrField.
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { FileText, UploadCloud, X, ScanText, CheckCircle2, AlertCircle } from "lucide-react"; import {
import { extractOcr } from "../api/viewService"; AlertCircle,
import type { FieldFileContext, FormScreenField, OcrConfig } from "../api/viewService"; CheckCircle2,
ChevronDown,
Eye,
FileText,
Loader2,
ScanText,
UploadCloud,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { extractOcr } from "@/api/viewService";
import type { FieldFileContext, FormScreenField, OcrConfig } from "@/api/viewService";
// Studio writes the OCR keys either nested under properties.ocr_config or flat // Studio writes the OCR keys either nested under properties.ocr_config or flat
// on properties. The nested shape wins when it actually carries a schema. // on properties. The nested shape wins when it actually carries a schema.
@ -32,16 +44,12 @@ export function resolveOcrConfig(field: FormScreenField): OcrConfig {
// allowed_types arrives as a CSV string or an array, holding any of ".pdf", // allowed_types arrives as a CSV string or an array, holding any of ".pdf",
// "pdf", "application/pdf" or "image/*". Normalise to what <input accept> wants. // "pdf", "application/pdf" or "image/*". Normalise to what <input accept> wants.
function toAcceptAttr(raw: OcrConfig["allowed_types"]): string { function toAcceptAttr(raw: OcrConfig["allowed_types"]): string {
const entries = Array.isArray(raw) const entries = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
? raw return entries
: typeof raw === "string"
? raw.split(",")
: [];
const cleaned = entries
.map((e) => e.trim().toLowerCase()) .map((e) => e.trim().toLowerCase())
.filter(Boolean) .filter(Boolean)
.map((e) => (e.includes("/") || e.startsWith(".") ? e : `.${e}`)); .map((e) => (e.includes("/") || e.startsWith(".") ? e : `.${e}`))
return cleaned.join(","); .join(",");
} }
function formatBytes(n: number): string { function formatBytes(n: number): string {
@ -69,11 +77,33 @@ interface Props {
} }
export default function OcrUploadField({ export default function OcrUploadField({
field, ctx, file, onFileChange, onExtracted, disabled, hasError, field,
ctx,
file,
onFileChange,
onExtracted,
disabled,
hasError,
}: Props) { }: Props) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [state, setState] = useState<ExtractState>({ status: "idle" }); const [state, setState] = useState<ExtractState>({ status: "idle" });
const [showPreview, setShowPreview] = useState(false);
// A blob URL lets the reviewer see the document they just picked, side by side
// with the fields OCR filled in. This only works for the in-flight File —
// once uploaded there is no endpoint that serves it back.
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (!file) {
setPreviewUrl(null);
setShowPreview(false);
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const config = resolveOcrConfig(field); const config = resolveOcrConfig(field);
const accept = toAcceptAttr(config.allowed_types); const accept = toAcceptAttr(config.allowed_types);
@ -88,6 +118,10 @@ export default function OcrUploadField({
? accept.replace(/\./g, "").replace(/,/g, ", ").toUpperCase() ? accept.replace(/\./g, "").replace(/,/g, ", ").toUpperCase()
: "PDF or image"; : "PDF or image";
const isPdf = file?.type === "application/pdf";
const isImage = file?.type.startsWith("image/") ?? false;
const canPreview = !!previewUrl && (isPdf || isImage);
const pick = (next: File | null) => { const pick = (next: File | null) => {
setState({ status: "idle" }); setState({ status: "idle" });
if (next && maxSizeMb && next.size > maxSizeMb * 1024 * 1024) { if (next && maxSizeMb && next.size > maxSizeMb * 1024 * 1024) {
@ -114,6 +148,8 @@ export default function OcrUploadField({
return; return;
} }
setState({ status: "done", applied }); setState({ status: "done", applied });
// Opening the document next to the filled fields is the verification step.
if (canPreview) setShowPreview(true);
} catch (err: any) { } catch (err: any) {
setState({ status: "error", message: err?.message || "Extraction failed" }); setState({ status: "error", message: err?.message || "Extraction failed" });
} }
@ -140,101 +176,156 @@ export default function OcrUploadField({
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)} onDragLeave={() => setDragging(false)}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setDragging(false); setDragging(false);
if (!disabled) pick(e.dataTransfer.files?.[0] ?? null); if (!disabled) pick(e.dataTransfer.files?.[0] ?? null);
}} }}
className={`w-full rounded-xl border border-dashed px-5 py-7 flex flex-col items-center gap-2 transition-colors disabled:opacity-50 ${ className={cn(
"flex w-full flex-col items-center gap-2 rounded-lg border border-dashed px-5 py-7 transition-colors disabled:opacity-50",
dragging dragging
? "border-violet-400 bg-violet-50/70 dark:border-violet-500 dark:bg-violet-950/20" ? "border-primary bg-accent"
: hasError : hasError
? "border-red-300 bg-red-50/40 dark:border-red-700 dark:bg-red-950/20" ? "border-destructive/50 bg-destructive/5"
: "border-slate-200 bg-slate-50/50 hover:border-violet-300 hover:bg-violet-50/40 dark:border-zinc-700 dark:bg-zinc-800/40 dark:hover:border-violet-600 dark:hover:bg-violet-950/10" : "border-border bg-muted/40 hover:border-primary/50 hover:bg-accent/50",
}`} )}
> >
<div className="w-10 h-10 rounded-full bg-white dark:bg-zinc-800 shadow-sm flex items-center justify-center"> <span className="flex size-10 items-center justify-center rounded-full bg-card shadow-sm">
<UploadCloud size={18} className="text-violet-500" /> <UploadCloud className="size-4 text-primary" aria-hidden />
</div> </span>
<div className="text-[13px] font-medium text-slate-700 dark:text-zinc-200"> <span className="text-base font-medium text-foreground">
{config.placeholder || field.properties?.placeholder || `Drop ${field.name} here`} {config.placeholder || field.properties?.placeholder || `Drop ${field.name} here`}
</div> </span>
<div className="text-[11px] text-slate-400 dark:text-zinc-500"> <span className="text-xs text-muted-foreground">
or click to browse · {acceptHint} or click to browse · {acceptHint}
{maxSizeMb ? ` up to ${maxSizeMb} MB` : ""} {maxSizeMb ? ` up to ${maxSizeMb} MB` : ""}
</div> </span>
</button> </button>
) : ( ) : (
/* ── Picked file + extract action ────────────────────────────────── */ /* ── Picked file + extract action ────────────────────────────────── */
<div className="rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/60 px-4 py-3"> <div className="rounded-lg border border-border bg-card">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 p-2.5">
<div className="w-9 h-9 shrink-0 rounded-lg bg-violet-50 dark:bg-violet-950/40 flex items-center justify-center"> <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent">
<FileText size={16} className="text-violet-500" /> <FileText className="size-4 text-primary" aria-hidden />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-slate-800 dark:text-zinc-100 truncate"> <p className="truncate text-base font-medium text-foreground">{file.name}</p>
{file.name} <p className="text-xs text-muted-foreground">{formatBytes(file.size)}</p>
</div>
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
{formatBytes(file.size)}
</div>
</div> </div>
{canExtract && ( {canPreview && (
<button <Button
type="button" type="button"
variant="ghost"
size="sm"
className="h-8 gap-1.5"
aria-expanded={showPreview}
onClick={() => setShowPreview((s) => !s)}
>
<Eye className="size-3.5" aria-hidden />
{showPreview ? "Hide" : "View"}
<ChevronDown
className={cn("size-3 transition-transform", showPreview && "rotate-180")}
aria-hidden
/>
</Button>
)}
{canExtract && (
<Button
type="button"
size="sm"
className="h-8 shrink-0 gap-1.5"
disabled={disabled || state.status === "loading"} disabled={disabled || state.status === "loading"}
onClick={runExtract} onClick={runExtract}
className="h-8 px-3 shrink-0 text-[12px] font-semibold text-white rounded-lg flex items-center gap-1.5 disabled:opacity-60 transition-all hover:scale-[1.02] active:scale-[0.98]"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
> >
{state.status === "loading" ? ( {state.status === "loading" ? (
<> <>
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <Loader2 className="size-3.5 animate-spin" aria-hidden />
Reading Reading
</> </>
) : ( ) : (
<> <>
<ScanText size={13} /> <ScanText className="size-3.5" aria-hidden />
{state.status === "done" ? "Re-read" : "Read invoice"} {state.status === "done" ? "Re-read" : "Read invoice"}
</> </>
)} )}
</button> </Button>
)} )}
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
disabled={disabled || state.status === "loading"} disabled={disabled || state.status === "loading"}
onClick={clear} onClick={clear}
className="w-7 h-7 shrink-0 flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-600 dark:hover:text-zinc-200 hover:bg-slate-100 dark:hover:bg-zinc-700 rounded-lg transition-colors disabled:opacity-40"
title="Remove"
> >
<X size={14} /> <X className="size-3.5" aria-hidden />
</button> <span className="sr-only">Remove file</span>
</Button>
</div> </div>
{state.status === "done" && ( {state.status === "done" && (
<div className="mt-2.5 flex items-center gap-1.5 text-[11.5px] text-emerald-600 dark:text-emerald-400"> <p className="flex items-center gap-1.5 px-3 pb-2.5 text-xs text-state-approved">
<CheckCircle2 size={12} /> <CheckCircle2 className="size-3" aria-hidden />
Filled {state.applied} field{state.applied === 1 ? "" : "s"} from the document review before submitting. Filled {state.applied} field{state.applied === 1 ? "" : "s"} from the document check
</div> them against the original below.
</p>
)} )}
{state.status === "error" && ( {state.status === "error" && (
<div className="mt-2.5 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400"> <p className="flex items-start gap-1.5 px-3 pb-2.5 text-xs text-destructive">
<AlertCircle size={12} className="shrink-0 mt-0.5" /> <AlertCircle className="mt-px size-3 shrink-0" aria-hidden />
{state.message} {state.message}
</p>
)}
{/* Inline document preview the browser renders both PDFs and images
from a blob URL, so no PDF library is needed for this. */}
{showPreview && canPreview && (
<div className="border-t border-border p-2.5">
{isImage ? (
<img
src={previewUrl!}
alt={`Preview of ${file.name}`}
className="max-h-80 w-full rounded-md object-contain"
/>
) : (
<object
data={previewUrl!}
type="application/pdf"
className="h-80 w-full rounded-md"
aria-label={`Preview of ${file.name}`}
>
<p className="p-4 text-sm text-muted-foreground">
Your browser can't display this PDF inline.{" "}
<a
href={previewUrl!}
target="_blank"
rel="noreferrer"
className="font-semibold text-primary hover:underline"
>
Open it in a new tab
</a>
.
</p>
</object>
)}
</div> </div>
)} )}
</div> </div>
)} )}
{state.status === "error" && !file && ( {state.status === "error" && !file && (
<div className="mt-2 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400"> <p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<AlertCircle size={12} className="shrink-0 mt-0.5" /> <AlertCircle className="mt-px size-3 shrink-0" aria-hidden />
{state.message} {state.message}
</div> </p>
)} )}
</div> </div>
); );

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,22 @@
import { useState, ReactNode } from "react"; import { useState, ReactNode } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { LayoutElement } from "../api/viewService"; import type { LayoutElement } from "../api/viewService";
import { Button } from "@/components/ui/button";
import RecordViewTable from "./RecordViewTable"; import RecordViewTable from "./RecordViewTable";
import DetailViewPanel from "./DetailViewPanel"; import DetailViewPanel from "./DetailViewPanel";
import FormModal from "./FormModal"; import FormModal from "./FormModal";
import { collectStartButtons } from "../lib/startButtons"; import { collectStartButtons } from "../lib/startButtons";
interface Props { layout: LayoutElement[]; instanceId?: string; onRefresh?: () => void; onRowClick?: (instanceId: string) => void; } interface Props {
layout: LayoutElement[];
instanceId?: string;
onRefresh?: () => void;
onRowClick?: (instanceId: string) => void;
/** Forwarded to any detail view so the shell can title its breadcrumb. */
onTitle?: (title: string) => void;
}
export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick }: Props) { export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowClick, onTitle }: Props) {
const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null); const [formModal, setFormModal] = useState<{ activityId: string; instanceId?: string; title?: string } | null>(null);
// Collect button elements to inject into the next record view's toolbar // Collect button elements to inject into the next record view's toolbar
@ -33,14 +41,14 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
if (!aid) return; // a button with no activity to run is not renderable if (!aid) return; // a button with no activity to run is not renderable
const label = (cfg.name || "").replace(/^\+\s*/, ""); const label = (cfg.name || "").replace(/^\+\s*/, "");
buttons.push( buttons.push(
<button <Button
key={aid} key={aid}
size="sm"
className="h-8 gap-1.5"
onClick={() => setFormModal({ activityId: aid, instanceId: cfg.job_template === "perform_activity" ? instanceId : undefined, title: label })} onClick={() => setFormModal({ activityId: aid, instanceId: cfg.job_template === "perform_activity" ? instanceId : undefined, title: label })}
className="h-8 px-3.5 text-[12px] font-semibold text-white rounded-xl inline-flex items-center gap-1.5 transition-all hover:scale-[1.02] active:scale-[0.98] shadow-md shadow-violet-500/20"
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
> >
<Plus size={13} strokeWidth={2.5} />{label} <Plus className="size-3.5" strokeWidth={2.5} aria-hidden />{label}
</button> </Button>
); );
} else if (cfg.type === "recordsview") { } else if (cfg.type === "recordsview") {
const vid = cfg.view_uuid ?? cfg.view_id; const vid = cfg.view_uuid ?? cfg.view_id;
@ -76,7 +84,7 @@ export default function ScreenRenderer({ layout, instanceId, onRefresh, onRowCli
); );
} }
if (el.type === "dv") { if (el.type === "dv") {
return <div key={i} style={flowStyle(el.style)}><DetailViewPanel viewId={el.viewId} instanceId={instanceId || ""} /></div>; return <div key={i} style={flowStyle(el.style)}><DetailViewPanel viewId={el.viewId} instanceId={instanceId || ""} onTitle={onTitle} /></div>;
} }
return null; return null;
})} })}

View File

@ -1,26 +1,35 @@
export function Skeleton({ className = "" }: { className?: string }) { // Composite loading states. The primitive lives in components/ui/skeleton.
return <div className={`animate-pulse bg-slate-200/70 dark:bg-zinc-800 rounded ${className}`} />; //
} // StatsSkeleton was dropped along with the old DashboardStats — the rebuilt
// tiles carry their own skeleton so the two can't drift out of shape.
import { Skeleton } from "@/components/ui/skeleton";
export function TableSkeleton({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) { export function TableSkeleton({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
return ( return (
<div className="rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 bg-white dark:bg-zinc-900 overflow-hidden shadow-sm"> <div className="rounded-xl border border-border bg-card">
{/* Toolbar skeleton */} {/* Toolbar */}
<div className="px-5 py-3.5 border-b border-slate-100 dark:border-zinc-800 flex items-center justify-between"> <div className="flex items-center justify-between px-4 py-2.5">
<Skeleton className="h-5 w-48" /> <Skeleton className="h-5 w-32" />
<Skeleton className="h-8 w-8 rounded-lg" /> <div className="flex gap-1.5">
<Skeleton className="size-8 rounded-md" />
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-8 w-44 rounded-md" />
</div>
</div> </div>
{/* Header */} {/* Header */}
<div className="px-4 py-2.5 border-b border-slate-100 dark:border-zinc-800 bg-slate-50/50 dark:bg-zinc-800/30 flex gap-4"> <div className="flex gap-4 border-y border-border bg-muted/50 px-4 py-2.5">
{Array.from({ length: cols }).map((_, i) => ( {Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="h-3 flex-1" /> <Skeleton key={i} className="h-3 flex-1" />
))} ))}
</div> </div>
{/* Rows */} {/* Rows */}
{Array.from({ length: rows }).map((_, ri) => ( {Array.from({ length: rows }).map((_, ri) => (
<div key={ri} className="px-4 py-4 border-b border-slate-50 dark:border-zinc-800/60 flex gap-4"> <div key={ri} className="flex gap-4 border-b border-border/60 px-4 py-3.5">
{Array.from({ length: cols }).map((_, ci) => ( {Array.from({ length: cols }).map((_, ci) => (
<Skeleton key={ci} className={`h-4 flex-1 ${ci === 0 ? "max-w-[80px]" : ""}`} /> <Skeleton key={ci} className={ci === 0 ? "h-4 max-w-20 flex-1" : "h-4 flex-1"} />
))} ))}
</div> </div>
))} ))}
@ -30,44 +39,51 @@ export function TableSkeleton({ rows = 5, cols = 6 }: { rows?: number; cols?: nu
export function DetailSkeleton() { export function DetailSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-3">
{/* Status bar */} {/* Stepper */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-3 flex gap-4"> <div className="flex items-center gap-2">
<Skeleton className="h-6 w-32 rounded-full" /> {Array.from({ length: 5 }).map((_, i) => (
<Skeleton className="h-4 w-20 mt-1" /> <Skeleton key={i} className="h-8 flex-1 rounded-lg" />
<Skeleton className="h-4 w-24 mt-1" /> ))}
</div> </div>
{/* Detail card */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-3 border-b border-slate-100 dark:border-zinc-800">
<Skeleton className="h-4 w-32" />
</div>
<div className="grid grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="px-5 py-4 border-b border-slate-50 dark:border-zinc-800/60">
<Skeleton className="h-3 w-20 mb-2" />
<Skeleton className="h-4 w-32" />
</div>
))}
</div>
</div>
</div>
);
}
export function StatsSkeleton() { <div className="grid gap-3 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
return ( <div className="space-y-3">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-5"> <div className="rounded-xl border border-border bg-card px-5 py-4">
{Array.from({ length: 4 }).map((_, i) => ( <div className="flex items-start justify-between">
<div key={i} className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 p-4"> <div className="space-y-2">
<div className="flex items-start justify-between mb-3.5"> <Skeleton className="h-6 w-40" />
<Skeleton className="h-10 w-10 rounded-xl" /> <Skeleton className="h-3 w-56" />
<Skeleton className="h-8 w-12" /> </div>
<Skeleton className="h-8 w-28" />
</div>
</div>
<div className="rounded-xl border border-border bg-card px-5 py-4">
<Skeleton className="mb-4 h-2.5 w-24" />
<div className="grid gap-x-8 gap-y-4 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<Skeleton className="mb-1.5 h-2.5 w-20" />
<Skeleton className="h-4 w-32" />
</div>
))}
</div>
</div> </div>
<Skeleton className="h-3 w-24 mb-3" />
<Skeleton className="h-1 w-full rounded-full" />
</div> </div>
))}
<div className="space-y-3">
<div className="rounded-xl border border-border bg-card px-5 py-4">
<Skeleton className="mb-4 h-2.5 w-20" />
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between border-b border-border/50 py-2">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3.5 w-16" />
</div>
))}
</div>
</div>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,148 @@
// The source invoice PDF, viewable by whoever is looking at the record.
//
// `invoice_pdf` is write-only everywhere else: the vendor uploads it, OCR reads
// it, and it appears in no record view and no detail view — so the department
// answering a clarification and the reviewer approving payment can't open the
// document they're reasoning about.
//
// The /download endpoint sets Content-Disposition: attachment, so a plain <a>
// would always download. Fetching a Blob and building an object URL instead
// lets the same bytes render inline AND be opened or saved on demand.
import { useEffect, useRef, useState } from "react";
import { AlertCircle, Download, ExternalLink, Eye, FileText, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { downloadFieldFile, type FileMeta } from "@/api/viewService";
import { formatBytes } from "@/lib/format";
export default function SourceDocument({ file }: { file: FileMeta | null }) {
const [url, setUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const urlRef = useRef<string | null>(null);
// Fetch once, on first expand. Object URLs must be revoked or the blob leaks
// for the lifetime of the document.
useEffect(() => {
if (!file || !open || urlRef.current) return;
let cancelled = false;
setLoading(true);
setError(null);
downloadFieldFile(file.blob_path, file.original_name)
.then((blob) => {
if (cancelled) return;
const objUrl = URL.createObjectURL(blob);
urlRef.current = objUrl;
setUrl(objUrl);
})
.catch((e: any) => {
if (!cancelled) setError(e?.message || "Could not load the document");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [file, open]);
useEffect(
() => () => {
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
},
[],
);
if (!file) {
return (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="size-3.5 shrink-0" aria-hidden />
No document was attached to this submission.
</p>
);
}
const isPdf = (file.mime_type || "").includes("pdf");
const save = () => {
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = file.original_name || "invoice.pdf";
a.click();
};
return (
<div>
<div className="flex flex-wrap items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileText className="size-4 text-primary" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-md font-semibold text-foreground">
{file.original_name || "Invoice document"}
</p>
<p className="text-2xs text-muted-foreground">
{formatBytes(file.size_bytes)}
{isPdf ? " · PDF" : file.mime_type ? ` · ${file.mime_type}` : ""}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={() => setOpen((o) => !o)}>
<Eye className="size-3.5" aria-hidden />
{open ? "Hide" : "View"}
</Button>
{url && (
<>
<Button asChild variant="ghost" size="sm" className="h-8 gap-1.5">
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="size-3.5" aria-hidden />
Open
</a>
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5" onClick={save}>
<Download className="size-3.5" aria-hidden />
Save
</Button>
</>
)}
</div>
</div>
{open && (
<div className="mt-3 overflow-hidden rounded-lg border border-border/60">
{loading && (
<div className="flex h-64 flex-col items-center justify-center gap-2 bg-muted/30">
<Loader2 className="size-4 animate-spin text-primary" aria-hidden />
<span className="text-xs text-muted-foreground">Loading document</span>
</div>
)}
{error && (
<p className="flex items-start gap-2 px-3 py-2.5 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{error}
</p>
)}
{url &&
!loading &&
!error &&
(isPdf ? (
<iframe
src={url}
title={file.original_name || "Invoice"}
className="h-[560px] w-full bg-muted/30"
/>
) : (
<img
src={url}
alt={file.original_name || "Invoice"}
className="max-h-[560px] w-full bg-muted/30 object-contain"
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { resolveStatus } from "@/lib/workflow";
interface Props {
value: string | null | undefined;
/** `sm` for table cells, `md` for the detail header. */
size?: "sm" | "md";
/** Show the state icon alongside the dot. Off in dense tables. */
withIcon?: boolean;
className?: string;
}
/**
* The one status pill. Colour, label and icon all come from lib/workflow, so a
* state looks identical in a table cell, a detail header and a chart legend.
*/
export default function StatusBadge({ value, size = "sm", withIcon = false, className }: Props) {
const status = resolveStatus(value);
if (!status) return null;
const { label, tone, pulse, icon: Icon } = status;
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full border font-semibold",
tone.bg,
tone.border,
tone.text,
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
className,
)}
>
{withIcon ? (
<Icon className={size === "sm" ? "size-3" : "size-3.5"} aria-hidden />
) : (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
tone.solid,
// Only the in-progress state pulses, so motion means "working".
pulse && "animate-pulse",
)}
aria-hidden
/>
)}
{label}
</span>
);
}

View File

@ -1,154 +0,0 @@
// ThreeWayMatchCard — makes the AI's recommendation auditable.
//
// The employee reconciles invoice vs purchase order vs goods receipt vs payment
// history through four agentic tools, then writes its findings as prose into two
// fields. Both were previously invisible in the UI: `match_summary` was buried
// among ~29 flat fields, and `analysis_so_far` was explicitly dropped by
// DetailViewPanel's SKIP_KEYS because it looked like raw JSON.
//
// They're actually semi-structured, straight from the deployed data:
//
// match_summary "Price: MATCH (₹550/unit per contract amendment …).
// Quantity: MATCH (200 units invoiced = 200 units received
// per GR-3001). Goods Receipt: VALID (…). Duplicate Check:
// CLEAN (₹0 prior payments). Contract: ACTIVE with …"
//
// analysis_so_far "PO-3001: 200 units @ ₹500/unit = ₹100,000. GR-3001: 200
// units received, QC passed. Contract CNT-300: active,
// ₹500/unit, 2% tolerance. Invoice: 200 units @ ₹550/unit =
// ₹110,000. Payment history: clean (₹0 paid). Issue: 10%
// price variance exceeds 2% tolerance."
//
// So each parses into label → value pairs, with match_summary additionally
// carrying a verdict keyword the reviewer can scan in one pass.
import { CheckCircle2, XCircle, AlertTriangle, MinusCircle, Scale } from "lucide-react";
import { fmtAmount } from "../lib/workflowMeta";
import { parseMatchSummary, parseEvidence, overallTone, type Tone } from "../lib/matchSummary";
const TONE_STYLE: Record<Tone, { chip: string; icon: React.ReactNode }> = {
good: { chip: "text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/40 dark:ring-emerald-800/60", icon: <CheckCircle2 size={12} /> },
bad: { chip: "text-red-700 bg-red-50 ring-1 ring-red-200 dark:text-red-300 dark:bg-red-950/40 dark:ring-red-800/60", icon: <XCircle size={12} /> },
warn: { chip: "text-amber-700 bg-amber-50 ring-1 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:ring-amber-800/60", icon: <AlertTriangle size={12} /> },
neutral: { chip: "text-slate-600 bg-slate-100 ring-1 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", icon: <MinusCircle size={12} /> },
};
interface Props {
matchSummary?: string;
analysis?: string;
/** The invoice's own submitted figures, for the reference row. */
invoice?: {
invoice_number?: unknown;
po_number?: unknown;
total_amount?: unknown;
tax_amount?: unknown;
currency?: unknown;
};
}
export default function ThreeWayMatchCard({ matchSummary, analysis, invoice }: Props) {
const verdicts = parseMatchSummary(matchSummary);
const evidence = parseEvidence(analysis);
if (verdicts.length === 0 && evidence.length === 0) {
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2 mb-1">
<Scale size={14} className="text-slate-400" />3-Way Match
</h3>
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
The AI hasn't reconciled this invoice against its PO and goods receipt yet.
</p>
</section>
);
}
const worst = overallTone(verdicts);
const headline =
worst === "bad" ? "Discrepancy found" : worst === "warn" ? "Needs attention" : "All checks passed";
const cur = String(invoice?.currency || "INR");
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
<Scale size={14} className="text-slate-400" />3-Way Match
</h3>
{verdicts.length > 0 && (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold ${TONE_STYLE[worst].chip}`}>
{TONE_STYLE[worst].icon}{headline}
</span>
)}
</div>
{/* Reference figures from the invoice itself */}
{invoice && (invoice.invoice_number || invoice.po_number || invoice.total_amount != null) && (
<div className="flex items-center gap-5 flex-wrap mb-3 pb-3 border-b border-slate-100 dark:border-zinc-800">
{invoice.invoice_number ? (
<Ref label="Invoice" value={String(invoice.invoice_number)} />
) : null}
{invoice.po_number ? <Ref label="Purchase order" value={String(invoice.po_number)} /> : null}
{invoice.total_amount != null ? (
<Ref label="Invoiced" value={fmtAmount(invoice.total_amount, cur)} strong />
) : null}
{invoice.tax_amount != null ? <Ref label="Tax" value={fmtAmount(invoice.tax_amount, cur)} /> : null}
</div>
)}
{/* Per-dimension verdicts */}
{verdicts.length > 0 && (
<ul className="space-y-2">
{verdicts.map((v, i) => (
<li key={`${v.label}-${i}`} className="flex items-start gap-2.5">
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wide mt-0.5 ${TONE_STYLE[v.tone].chip}`}>
{TONE_STYLE[v.tone].icon}{v.verdict || "—"}
</span>
<div className="min-w-0">
<div className="text-[12.5px] font-medium text-slate-700 dark:text-zinc-200">{v.label}</div>
{v.detail && (
<div className="text-[12px] text-slate-500 dark:text-zinc-400 leading-relaxed">{v.detail}</div>
)}
</div>
</li>
))}
</ul>
)}
{/* Supporting evidence the AI gathered from PO / GR / contract / payments */}
{evidence.length > 0 && (
<div className={verdicts.length > 0 ? "mt-3 pt-3 border-t border-slate-100 dark:border-zinc-800" : ""}>
<div className="text-[10.5px] uppercase tracking-wide text-slate-400 dark:text-zinc-600 mb-2">
Evidence gathered
</div>
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2">
{evidence.map((e, i) => (
<div
key={`${e.label}-${i}`}
className={e.isIssue ? "sm:col-span-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 px-2.5 py-1.5" : ""}
>
<dt className={`text-[10.5px] uppercase tracking-wide ${e.isIssue ? "text-amber-700 dark:text-amber-400" : "text-slate-400 dark:text-zinc-600"}`}>
{e.label}
</dt>
<dd className={`text-[12.5px] ${e.isIssue ? "text-amber-800 dark:text-amber-200 font-medium" : "text-slate-700 dark:text-zinc-200"}`}>
{e.value}
</dd>
</div>
))}
</dl>
</div>
)}
</section>
);
}
function Ref({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">{label}</div>
<div className={`${strong ? "text-[14px] font-bold" : "text-[12.5px] font-medium"} text-slate-800 dark:text-zinc-100 tabular-nums`}>
{value}
</div>
</div>
);
}

View File

@ -0,0 +1,115 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { HAPPY_PATH, resolveStatus, stateByName } from "@/lib/workflow";
interface Props {
/** `current_state_name` for the instance. */
state: string | null | undefined;
className?: string;
}
/**
* Renders the invoice lifecycle as a stepper.
*
* The workflow states existed in config.ts all along but were never shown a
* reviewer could see "Finance Review" as a pill without any sense of what came
* before it or what is left. Off-path outcomes (Rejected, On Hold) aren't steps,
* so when the instance is in one of those the strip renders as a single banner
* instead of a misleading progress line.
*/
export default function WorkflowStepper({ state, className }: Props) {
const current = stateByName(state);
const status = resolveStatus(state);
if (!status) return null;
// Rejected / On Hold have no position on the happy path.
if (!current || current.step === null) {
const Icon = status.icon;
return (
<div
className={cn(
"flex items-center gap-2 rounded-xl border px-4 py-3",
status.tone.bg,
status.tone.border,
className,
)}
>
<Icon className={cn("size-4 shrink-0", status.tone.text)} aria-hidden />
<span className={cn("text-sm font-semibold", status.tone.text)}>{status.label}</span>
<span className="text-xs text-muted-foreground">
{current?.key === "hold"
? "Paused — no further action until it is resumed."
: "This invoice is closed."}
</span>
</div>
);
}
const activeStep = current.step;
const doneCount = HAPPY_PATH.filter((s) => (s.step as number) < activeStep).length;
return (
<div className={cn("rounded-xl border border-border bg-card px-4 py-3", className)}>
<div className="mb-2 flex items-center justify-between">
<p className="text-2xs font-bold uppercase tracking-widest text-muted-foreground">
Workflow Progress
</p>
<span className="text-2xs tabular-nums text-muted-foreground">
Step {doneCount + 1} of {HAPPY_PATH.length}
</span>
</div>
<ol className="flex items-center gap-1 overflow-x-auto" aria-label="Invoice progress">
{HAPPY_PATH.map((s, i) => {
const done = (s.step as number) < activeStep;
const active = s.key === current.key;
const Icon = s.icon;
return (
<li key={s.key} className="flex min-w-0 flex-1 items-center gap-1">
{/* No pill on the active step the solid dot and the coloured
label already mark it, and the ring read as a stray box. */}
<div
className="flex min-w-0 items-center gap-2 px-1.5 py-1.5"
aria-current={active ? "step" : undefined}
>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full text-2xs font-bold",
done && "bg-state-approved/15 text-state-approved",
active && cn(s.tone.solid, "text-white shadow-sm"),
!done && !active && "bg-muted text-muted-foreground",
)}
>
{done ? (
<Check className="size-3.5" strokeWidth={3} aria-hidden />
) : (
<Icon className="size-3.5" aria-hidden />
)}
</span>
<span
className={cn(
"truncate text-xs font-medium",
active ? cn(s.tone.text, "font-semibold") : done ? "text-foreground/70" : "text-muted-foreground",
)}
>
{s.label}
</span>
</div>
{i < HAPPY_PATH.length - 1 && (
<span
aria-hidden
className={cn(
"h-0.5 min-w-2 flex-1 shrink rounded-full",
done ? "bg-state-approved/50" : "bg-border",
)}
/>
)}
</li>
);
})}
</ol>
</div>
);
}

View File

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
src/components/ui/card.tsx Normal file
View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,31 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,267 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

145
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,145 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,51 @@
"use client"
// Upstream shadcn wires this to next-themes; this project has its own
// ThemeContext (class strategy on <html>), so it reads from that instead.
import { useTheme } from "@/hooks/ThemeContext"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,87 @@
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Toggle as TogglePrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@ -21,15 +21,22 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
if (theme === "dark") { root.classList.toggle("dark", theme === "dark");
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("p2p_theme", theme); localStorage.setItem("p2p_theme", theme);
}, [theme]); }, [theme]);
const toggleTheme = () => setTheme((t) => (t === "light" ? "dark" : "light")); // The colour-transition is opt-in per switch rather than a standing global
// rule, so ordinary hovers and mounts don't animate. Skipped entirely when
// the user prefers reduced motion.
const toggleTheme = () => {
const root = document.documentElement;
const animate = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (animate) {
root.classList.add("theming");
window.setTimeout(() => root.classList.remove("theming"), 220);
}
setTheme((t) => (t === "light" ? "dark" : "light"));
};
return ( return (
<ThemeContext.Provider value={{ theme, toggleTheme }}> <ThemeContext.Provider value={{ theme, toggleTheme }}>

View File

@ -0,0 +1,19 @@
// Header/nav config.
//
// AppShell and DynamicHeader each called getHeaderConfig() independently on
// mount, so every page load fetched the same nav payload twice. One cached
// query now serves both.
import { useQuery } from "@tanstack/react-query";
import { getHeaderConfig, type NavItem } from "../api/viewService";
export function useHeaderConfig() {
const query = useQuery({
queryKey: ["header-config", "desktop"],
staleTime: 5 * 60_000,
queryFn: () => getHeaderConfig("desktop"),
});
const navItems: NavItem[] = query.data?.components?.nav?.items ?? [];
return { ...query, navItems };
}

View File

@ -0,0 +1,284 @@
// Dashboard analytics — one cached fetch shared by the tiles and the charts.
//
// What this replaces: DashboardStats made two sequential requests, the second
// with `limit: <exact total>`, pulling every invoice row down just to count
// three states client-side. It also refetched on every screen change because
// nothing cached it. This does a single bounded request through react-query
// (already provided by ZinoProvider but previously unused by app code), and
// derives every tile and chart from that one payload.
import { useQuery } from "@tanstack/react-query";
import { getRVScreen, getRecordView } from "../api/viewService";
import { ALL_INVOICES_RV_SCREEN_UUID, RECORD_VIEW_IDS } from "../config";
import { WORKFLOW_STATES, stateByName, type StateKey } from "../lib/workflow";
/**
* Upper bound on rows pulled for client-side aggregation. The view-service can
* return `tile_values`/`chart_data` computed server-side; when it does we use
* those instead (see below). This cap keeps the demo honest if it doesn't.
*/
const ROW_CAP = 500;
const AMOUNT_KEYS = ["total_amount", "amount", "net_amount", "gross_amount"];
const DATE_KEYS = ["created_at", "invoice_date", "submitted_at"];
const VENDOR_KEYS = ["vendor_name", "company_name", "supplier_name", "vendor"];
export interface AgingBucket {
label: string;
count: number;
amount: number;
/** Buckets past this point are the ones worth chasing. */
overdue: boolean;
}
export interface TrendPoint {
date: string;
label: string;
count: number;
amount: number;
}
export interface Momentum {
last7: number;
prev7: number;
/** Change vs the preceding 7 days, or null when there is no baseline. */
deltaPct: number | null;
}
export interface VendorTotal {
name: string;
count: number;
amount: number;
}
export interface StateSlice {
key: StateKey;
name: string;
label: string;
count: number;
amount: number;
color: string;
}
export interface InvoiceAnalytics {
total: number;
/** Rows actually aggregated — less than `total` if the cap kicked in. */
sampled: number;
capped: boolean;
byState: StateSlice[];
counts: Record<StateKey, number>;
totalAmount: number;
/** Sitting in clarification or review — money not yet resolved. */
amountAtRisk: number;
approvedAmount: number;
/** Approved / (approved + rejected), or null when nothing is decided yet. */
approvalRate: number | null;
aging: AgingBucket[];
/** Biggest vendors by billed value, highest first. */
topVendors: VendorTotal[];
trend: TrendPoint[];
/** Submission volume this week vs last — drives the tile delta chip. */
momentum: Momentum;
/** Whether the server supplied precomputed tiles for this view. */
serverTiles: boolean;
}
function pickKey(row: Record<string, unknown>, candidates: string[]): string | null {
for (const k of candidates) if (k in row) return k;
return null;
}
function toNumber(v: unknown): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
function daysBetween(from: Date, to: Date): number {
return Math.floor((to.getTime() - from.getTime()) / 86_400_000);
}
function buildAging(rows: Record<string, unknown>[], dateKey: string | null, amountKey: string | null): AgingBucket[] {
const buckets: AgingBucket[] = [
{ label: "07d", count: 0, amount: 0, overdue: false },
{ label: "814d", count: 0, amount: 0, overdue: false },
{ label: "1530d", count: 0, amount: 0, overdue: true },
{ label: "3160d", count: 0, amount: 0, overdue: true },
{ label: "60d+", count: 0, amount: 0, overdue: true },
];
if (!dateKey) return buckets;
const now = new Date();
for (const row of rows) {
// Settled invoices aren't aging any more — only open ones matter here.
const state = stateByName(String(row.current_state_name ?? ""));
if (state?.terminal) continue;
const raw = row[dateKey];
if (!raw) continue;
const d = new Date(String(raw));
if (Number.isNaN(d.getTime())) continue;
const age = daysBetween(d, now);
const i = age <= 7 ? 0 : age <= 14 ? 1 : age <= 30 ? 2 : age <= 60 ? 3 : 4;
buckets[i].count++;
buckets[i].amount += amountKey ? toNumber(row[amountKey]) : 0;
}
return buckets;
}
function buildTrend(
rows: Record<string, unknown>[],
dateKey: string | null,
amountKey: string | null,
days = 14,
): TrendPoint[] {
const points: TrendPoint[] = [];
const index = new Map<string, TrendPoint>();
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const iso = d.toISOString().slice(0, 10);
const p: TrendPoint = {
date: iso,
label: d.toLocaleDateString("en-IN", { day: "numeric", month: "short" }),
count: 0,
amount: 0,
};
points.push(p);
index.set(iso, p);
}
if (!dateKey) return points;
for (const row of rows) {
const raw = row[dateKey];
if (!raw) continue;
const d = new Date(String(raw));
if (Number.isNaN(d.getTime())) continue;
const p = index.get(d.toISOString().slice(0, 10));
if (!p) continue; // outside the window
p.count++;
p.amount += amountKey ? toNumber(row[amountKey]) : 0;
}
return points;
}
function buildTopVendors(
rows: Record<string, unknown>[],
vendorKey: string | null,
amountKey: string | null,
limit = 6,
): VendorTotal[] {
if (!vendorKey) return [];
const totals = new Map<string, VendorTotal>();
for (const row of rows) {
const name = String(row[vendorKey] ?? "").trim();
if (!name) continue;
const entry = totals.get(name) ?? { name, count: 0, amount: 0 };
entry.count++;
entry.amount += amountKey ? toNumber(row[amountKey]) : 0;
totals.set(name, entry);
}
// Fall back to volume when the view carries no amount column.
const by: keyof VendorTotal = amountKey ? "amount" : "count";
return [...totals.values()]
.sort((a, b) => (b[by] as number) - (a[by] as number))
.slice(0, limit);
}
/** Splits a 14-point trend into this week vs last week. */
function buildMomentum(trend: TrendPoint[]): Momentum {
const half = Math.floor(trend.length / 2);
const sum = (pts: TrendPoint[]) => pts.reduce((n, p) => n + p.count, 0);
const prev7 = sum(trend.slice(0, half));
const last7 = sum(trend.slice(half));
return { last7, prev7, deltaPct: prev7 > 0 ? (last7 - prev7) / prev7 : null };
}
function aggregate(
rows: Record<string, unknown>[],
total: number,
serverTiles: boolean,
): InvoiceAnalytics {
const sample = rows[0] ?? {};
const amountKey = pickKey(sample, AMOUNT_KEYS);
const dateKey = pickKey(sample, DATE_KEYS);
const vendorKey = pickKey(sample, VENDOR_KEYS);
const counts = Object.fromEntries(
WORKFLOW_STATES.map((s) => [s.key, 0]),
) as Record<StateKey, number>;
const amounts = Object.fromEntries(
WORKFLOW_STATES.map((s) => [s.key, 0]),
) as Record<StateKey, number>;
let totalAmount = 0;
for (const row of rows) {
const amount = amountKey ? toNumber(row[amountKey]) : 0;
totalAmount += amount;
const state = stateByName(String(row.current_state_name ?? ""));
if (!state) continue;
counts[state.key]++;
amounts[state.key] += amount;
}
const byState: StateSlice[] = WORKFLOW_STATES.map((s) => ({
key: s.key,
name: s.name,
label: s.label,
count: counts[s.key],
amount: amounts[s.key],
color: s.tone.css,
})).filter((s) => s.count > 0);
const decided = counts.approved + counts.rejected;
const trend = buildTrend(rows, dateKey, amountKey);
return {
total,
sampled: rows.length,
capped: total > rows.length,
byState,
counts,
totalAmount,
amountAtRisk: amounts.clarification + amounts.review,
approvedAmount: amounts.approved,
approvalRate: decided > 0 ? counts.approved / decided : null,
aging: buildAging(rows, dateKey, amountKey),
topVendors: buildTopVendors(rows, vendorKey, amountKey),
trend,
momentum: buildMomentum(trend),
serverTiles,
};
}
export function useInvoiceAnalytics() {
return useQuery<InvoiceAnalytics>({
queryKey: ["invoice-analytics", ALL_INVOICES_RV_SCREEN_UUID],
staleTime: 60_000,
queryFn: async () => {
const rvScreen = await getRVScreen(ALL_INVOICES_RV_SCREEN_UUID);
const res = await getRecordView(
rvScreen.rv_template_uid,
RECORD_VIEW_IDS.ALL_INVOICES,
{ page: 1, limit: ROW_CAP },
);
const rows = Array.isArray(res.data) ? res.data : [];
const total = res.pagination?.total_count ?? rows.length;
return aggregate(rows, total, (res.tile_values?.length ?? 0) > 0);
},
});
}
export function formatCurrency(n: number, compact = false): string {
return n.toLocaleString("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
...(compact ? { notation: "compact", compactDisplay: "short" } : {}),
});
}

View File

@ -0,0 +1,65 @@
// Record-view data access.
//
// Replaces the hand-rolled useEffect/useState fetching in RecordViewTable: no
// caching, no request dedupe, and a `dataLoading` flag that flashed an overlay
// on every keystroke-triggered refetch. react-query gives caching and
// `isPlaceholderData` (previous page stays on screen while the next loads).
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getRVScreen,
getRecordView,
type RecordViewField,
type SearchQuery,
} from "../api/viewService";
import { startWorkflowButtons, type StartButton } from "../lib/startButtons";
export interface RecordViewData {
fields: RecordViewField[];
rows: Record<string, unknown>[];
pagination: { page: number; limit: number; total_count: number; total_pages: number };
}
/** rv_screen metadata: the template id to query and any workflow-launch buttons. */
export function useRVScreen(viewId: number | string) {
return useQuery({
queryKey: ["rv-screen", String(viewId)],
staleTime: 5 * 60_000, // screen config is effectively static
queryFn: async () => {
const rv = await getRVScreen(viewId);
return {
templateId: rv.rv_template_uid,
startButtons: startWorkflowButtons(rv.layout) as StartButton[],
};
},
});
}
export function useRecordViewData(
templateId: string | undefined,
viewId: number | string,
query: SearchQuery,
) {
return useQuery<RecordViewData>({
queryKey: ["record-view", templateId, String(viewId), query],
enabled: !!templateId,
placeholderData: keepPreviousData,
queryFn: async () => {
const res = await getRecordView(templateId!, String(viewId), query);
const rows = Array.isArray(res.data) ? res.data : [];
const limit = query.limit ?? 10;
return {
fields: (res.config?.fields ?? []).filter((f) => f.field_key !== ""),
rows,
// RDBMS-backed views return every row without a pagination envelope.
pagination:
res.pagination ?? {
page: query.page ?? 1,
limit,
total_count: rows.length,
total_pages: Math.max(1, Math.ceil(rows.length / limit)),
},
};
},
});
}

48
src/hooks/useSidebar.ts Normal file
View File

@ -0,0 +1,48 @@
// Sidebar open/collapsed state.
//
// This used to live in two places: DynamicHeader owned it and pushed changes up
// to AppShell through an onSidebarChange callback, while AppShell kept its own
// copy to offset the main content. Neither survived a reload. One hook, one
// value, persisted.
import { useCallback, useEffect, useState } from "react";
const KEY = "p2p_sidebar_open";
/** Below this the sidebar becomes an overlay drawer rather than a rail. */
export const MOBILE_BREAKPOINT = 1024;
function readStored(): boolean {
const stored = localStorage.getItem(KEY);
return stored === null ? true : stored === "true";
}
export function useSidebar() {
const [open, setOpenState] = useState(readStored);
const [isMobile, setIsMobile] = useState(
() => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT,
);
/** Separate from `open` — the mobile drawer shouldn't persist. */
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(mq.matches);
if (!mq.matches) setMobileOpen(false);
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
const setOpen = useCallback((next: boolean) => {
setOpenState(next);
localStorage.setItem(KEY, String(next));
}, []);
const toggle = useCallback(() => {
if (isMobile) setMobileOpen((o) => !o);
else setOpen(!open);
}, [isMobile, open, setOpen]);
return { open, setOpen, toggle, isMobile, mobileOpen, setMobileOpen };
}

View File

@ -1,47 +1,333 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @import "tw-animate-css";
@tailwind utilities;
/* Tailwind v4 dropped `darkMode: 'class'` from config the class strategy is
declared here instead. ThemeContext toggles `.dark` on <html>. */
@custom-variant dark (&:is(.dark *));
/* ---------------------------------------------------------------------------
Raw palette
Kept as plain custom properties so they can be re-declared under .dark;
@theme below maps them into Tailwind's utility namespaces.
--------------------------------------------------------------------------- */
:root {
/* Faintly tinted rather than pure white white cards need something to lift
off, and the table now relies on card-vs-page contrast, not zebra rows. */
--background: hsl(210 40% 98%);
--foreground: hsl(217 33% 17%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(217 33% 17%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(217 33% 17%);
/* Brand violet-600. Deep enough to clear WCAG AA on white for body text
and CTAs; lighter violets are reserved for fills, chips and rings. */
--primary: hsl(263 83% 58%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(210 40% 96%);
--secondary-foreground: hsl(217 33% 17%);
--muted: hsl(210 40% 96%);
--muted-foreground: hsl(215 16% 47%);
--accent: hsl(251 91% 95%);
--accent-foreground: hsl(263 83% 58%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(214 32% 91%);
--input: hsl(214 32% 91%);
--ring: hsl(263 83% 58%);
--radius: 0.625rem;
--sidebar: hsl(250 100% 99%);
--sidebar-foreground: hsl(217 33% 17%);
--sidebar-accent: hsl(251 91% 95%);
--sidebar-accent-foreground: hsl(263 83% 58%);
--sidebar-border: hsl(251 91% 95%);
/* Workflow states single source of truth for status colour. lib/workflow.ts
is the only thing that should map a state name onto these. */
--state-submitted: hsl(199 89% 48%);
--state-analysis: hsl(258 90% 66%);
--state-clarification: hsl(38 92% 50%);
--state-review: hsl(239 84% 67%);
--state-approved: hsl(160 84% 39%);
--state-rejected: hsl(0 84% 60%);
--state-hold: hsl(215 20% 65%);
/* Charts reuse the state hues where a series *is* a state, so a status means
the same colour in a table pill and in a donut slice. */
--chart-1: hsl(263 83% 58%);
--chart-2: hsl(292 84% 61%);
--chart-3: hsl(239 84% 67%);
--chart-4: hsl(199 89% 48%);
--chart-5: hsl(160 84% 39%);
}
.dark {
--background: hsl(240 10% 4%);
--foreground: hsl(240 5% 96%);
--card: hsl(240 6% 10%);
--card-foreground: hsl(240 5% 96%);
--popover: hsl(240 6% 10%);
--popover-foreground: hsl(240 5% 96%);
/* Lifted to violet-500 — violet-600 is too dark to read on near-black. */
--primary: hsl(258 90% 66%);
--primary-foreground: hsl(240 10% 4%);
--secondary: hsl(240 4% 16%);
--secondary-foreground: hsl(240 5% 96%);
--muted: hsl(240 4% 16%);
--muted-foreground: hsl(240 5% 65%);
--accent: hsl(263 50% 20%);
--accent-foreground: hsl(258 90% 80%);
--destructive: hsl(0 72% 62%);
--destructive-foreground: hsl(240 10% 4%);
--border: hsl(240 5% 20%);
--input: hsl(240 5% 26%);
--ring: hsl(258 90% 66%);
--sidebar: hsl(240 10% 4%);
--sidebar-foreground: hsl(240 5% 96%);
--sidebar-accent: hsl(263 45% 18%);
--sidebar-accent-foreground: hsl(258 90% 80%);
--sidebar-border: hsl(240 5% 16%);
--state-submitted: hsl(199 89% 60%);
--state-analysis: hsl(258 90% 72%);
--state-clarification: hsl(38 92% 60%);
--state-review: hsl(239 84% 74%);
--state-approved: hsl(160 70% 50%);
--state-rejected: hsl(0 84% 68%);
--state-hold: hsl(240 5% 55%);
--chart-1: hsl(258 90% 70%);
--chart-2: hsl(292 84% 70%);
--chart-3: hsl(239 84% 74%);
--chart-4: hsl(199 89% 60%);
--chart-5: hsl(160 70% 50%);
}
/* ---------------------------------------------------------------------------
Theme maps the palette into Tailwind namespaces.
`inline` so the generated utilities reference var() directly and therefore
respond to the .dark re-declarations above.
--------------------------------------------------------------------------- */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-state-submitted: var(--state-submitted);
--color-state-analysis: var(--state-analysis);
--color-state-clarification: var(--state-clarification);
--color-state-review: var(--state-review);
--color-state-approved: var(--state-approved);
--color-state-rejected: var(--state-rejected);
--color-state-hold: var(--state-hold);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--font-sans: Inter, system-ui, -apple-system, sans-serif;
/* Dense application type scale replaces the ad-hoc text-[10px]text-[26px]
arbitrary values that were scattered through the components. */
--text-2xs: 0.625rem;
--text-2xs--line-height: 0.875rem;
--text-xs: 0.6875rem;
--text-xs--line-height: 1rem;
--text-sm: 0.75rem;
--text-sm--line-height: 1.125rem;
--text-base: 0.8125rem;
--text-base--line-height: 1.25rem;
--text-md: 0.875rem;
--text-md--line-height: 1.375rem;
--text-lg: 0.9375rem;
--text-lg--line-height: 1.5rem;
--text-xl: 1.125rem;
--text-xl--line-height: 1.625rem;
--text-2xl: 1.375rem;
--text-2xl--line-height: 1.75rem;
--text-3xl: 1.625rem;
--text-3xl--line-height: 2rem;
--text-4xl: 2rem;
--text-4xl--line-height: 2.375rem;
--text-5xl: 2.625rem;
--text-5xl--line-height: 1.1;
--animate-fade-in-up: fade-in-up 0.25s ease-out both;
--animate-shimmer: shimmer 1.6s infinite;
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
}
/* --------------------------------------------------------------------------- */
@layer base { @layer base {
body {
@apply antialiased text-slate-800 dark:text-zinc-100 bg-white dark:bg-zinc-950;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
transition: background-color 0.2s ease, color 0.2s ease;
}
* { * {
transition-property: background-color, border-color, color; @apply border-border;
transition-duration: 150ms;
transition-timing-function: ease;
} }
/* Override transition for animations so they still work */ body {
.animate-spin, .animate-pulse { @apply bg-background text-foreground text-base antialiased;
transition: none; font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* Only the theme switch repaints animates. The previous global `*` transition
on background/border/color made every table hover and mount pay for a
transition it never needed. ThemeContext adds .theming for ~220ms. */
.theming,
.theming * {
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
fill 180ms ease;
}
/* One consistent keyboard focus ring. focus-visible (not focus) so it only
appears for keyboard nav; WCAG 2.2 requires a visible indicator. */
:focus-visible {
@apply outline-hidden ring-2 ring-ring/70 ring-offset-2 ring-offset-background;
}
/* Inside table cells keep the ring inset so it isn't clipped by the cell. */
th :focus-visible,
td :focus-visible {
@apply ring-offset-0;
} }
} }
@layer utilities { /* ---------------------------------------------------------------------------
.tabular-nums { Reduced motion
font-variant-numeric: tabular-nums; Decorative motion is stripped; functional feedback (focus rings, and colour
that communicates state) is preserved.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
} }
.glow-violet { /* Spinners still need to convey "working", just without the spin. */
box-shadow: 0 0 20px rgba(139, 92, 246, 0.35); .animate-spin {
} animation: none !important;
opacity: 0.6;
.glow-fuchsia { }
box-shadow: 0 0 20px rgba(217, 70, 239, 0.25); }
}
@utility tabular-nums {
.gradient-brand { font-variant-numeric: tabular-nums;
background: linear-gradient(135deg, #7c3aed, #a855f7, #d946ef); }
}
/* Skip link first focusable element in the shell, visually hidden until
.gradient-text { focused. Must move focus, not merely scroll (WCAG 2.4.1). */
background: linear-gradient(135deg, #7c3aed, #d946ef); @utility skip-link {
-webkit-background-clip: text; @apply absolute left-4 top-4 z-100 -translate-y-24 rounded-md bg-primary px-4
-webkit-text-fill-color: transparent; py-2 text-sm font-semibold text-primary-foreground shadow-lg
background-clip: text; transition-transform focus-visible:translate-y-0;
}
@utility gradient-brand {
background-image: linear-gradient(135deg, var(--primary), hsl(292 84% 61%));
}
@utility gradient-text {
background-image: linear-gradient(135deg, var(--primary), hsl(292 84% 61%));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
@apply rounded-full bg-border;
} }
} }

66
src/lib/format.ts Normal file
View File

@ -0,0 +1,66 @@
// Value formatting shared by the record view and the detail view. Previously
// each had its own near-identical fmtVal/renderCell pair, which drifted.
const MONEY_HINTS = ["amount", "tax", "price", "cost", "total", "payment", "value"];
export const isMoneyField = (key: string) => {
const k = key.toLowerCase();
return MONEY_HINTS.some((h) => k.includes(h));
};
export function formatCurrency(n: number, compact = false): string {
return n.toLocaleString("en-IN", {
style: "currency",
currency: "INR",
maximumFractionDigits: 0,
...(compact ? { notation: "compact", compactDisplay: "short" } : {}),
});
}
export function formatDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" });
}
/** Clock time, or null when the value carries no time component. */
export function formatTime(value: string): string | null {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
if (!/[T\s]\d{2}:\d{2}/.test(value)) return null;
return d.toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit" });
}
/** "3 days ago" / "in 2 days" — for aging and timeline captions. */
export function formatRelative(value: string): string | null {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
const diffDays = Math.round((d.getTime() - Date.now()) / 86_400_000);
if (diffDays === 0) return "today";
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (Math.abs(diffDays) < 31) return rtf.format(diffDays, "day");
return rtf.format(Math.round(diffDays / 30), "month");
}
export function formatBytes(n?: number): string {
if (n == null || !Number.isFinite(n)) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatLabel(label: string): string {
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Plain-text formatting of a view value, honouring its declared data_type. */
export function formatValue(value: unknown, dataType: string, fieldKey: string): string {
if (value == null || value === "") return "—";
if (dataType === "number") {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return isMoneyField(fieldKey) ? formatCurrency(n) : n.toLocaleString("en-IN");
}
if (dataType === "date" || dataType === "datetime") return formatDate(String(value));
return String(value);
}

19
src/lib/palette.ts Normal file
View File

@ -0,0 +1,19 @@
// Categorical colours for things that aren't workflow states — vendor avatars
// and vendor bars. Deliberately separate from --chart-* (which reuses the state
// hues, magenta included) so arbitrary categories read as distinct without
// implying a status.
export const CATEGORY_COLORS = [
"hsl(263 83% 58%)", // violet
"hsl(199 89% 48%)", // sky
"hsl(160 84% 39%)", // emerald
"hsl(38 92% 50%)", // amber
"hsl(239 84% 67%)", // indigo
"hsl(174 72% 41%)", // teal
];
/** Stable colour for a name — the same vendor keeps its hue across pages. */
export function colorForName(name: string): string {
const hash = [...name].reduce((n, c) => n + c.charCodeAt(0), 0);
return CATEGORY_COLORS[hash % CATEGORY_COLORS.length];
}

50
src/lib/tableParams.ts Normal file
View File

@ -0,0 +1,50 @@
// Shared URL state for the record view.
//
// Table sort/page/search/filters used to live in component state, so a refresh
// or a shared link lost the user's view entirely. These parsers put it in the
// query string instead, which also lets the dashboard tiles act as filters —
// a tile writes `status`, the table reads it.
import {
parseAsArrayOf,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
} from "nuqs";
export const DENSITIES = ["compact", "regular", "relaxed"] as const;
export type Density = (typeof DENSITIES)[number];
/** Row heights per density — the 40/48/56px convention. */
export const DENSITY_ROW: Record<Density, string> = {
compact: "h-10",
regular: "h-12",
relaxed: "h-14",
};
export const DENSITY_CELL: Record<Density, string> = {
compact: "py-1.5",
regular: "py-3",
relaxed: "py-4",
};
export const tableParams = {
q: parseAsString.withDefault(""),
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(10),
sort: parseAsString.withDefault(""),
dir: parseAsStringLiteral(["asc", "desc"] as const).withDefault("desc"),
/** Workflow state names, matching `current_state_name`. */
status: parseAsArrayOf(parseAsString).withDefault([]),
density: parseAsStringLiteral(DENSITIES).withDefault("regular"),
};
/**
* Keep the URL short: nuqs omits any value equal to its default, and history
* is replaced rather than pushed so filtering doesn't fill the back button.
*/
export const tableParamsOptions = {
history: "replace" as const,
clearOnDefault: true,
shallow: true,
};

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

330
src/lib/workflow.ts Normal file
View File

@ -0,0 +1,330 @@
// Single source of truth for workflow state presentation.
//
// Before this, STATUS colour maps were duplicated in RecordViewTable and
// DetailViewPanel with different shapes (one used bg/text/dot/dark*, the other
// pill/dot/bar), so a state could legitimately render two different colours in
// two places. Everything state-shaped now resolves through here.
//
// Matching is by `current_state_name` because that is what the record- and
// detail-view payloads actually carry. STATE_IDS UUIDs are kept alongside for
// the places that compare against `current_state_id` (instance payloads).
import {
AlertCircle,
Ban,
CheckCircle2,
Clock,
FileText,
PauseCircle,
Sparkles,
Truck,
XCircle,
type LucideIcon,
} from "lucide-react";
import { ACTIVITY_IDS, STATE_IDS } from "../config";
export type StateKey =
| "submitted"
| "analysis"
| "clarification"
| "review"
| "approved"
| "rejected"
| "hold";
export interface StateTone {
/** Foreground for the label. */
text: string;
/** Tinted fill for pills and row accents. */
bg: string;
/** Hairline for pills. */
border: string;
/** Solid dot / progress bar. */
solid: string;
/** Raw CSS colour for non-Tailwind consumers (recharts fills). */
css: string;
}
export interface WorkflowState {
key: StateKey;
/** STATE_IDS uuid — compare against instance.current_state_id. */
id: string;
/** Canonical `current_state_name` from the view payloads. */
name: string;
/** Other spellings the payloads use for the same state. */
aliases?: string[];
label: string;
icon: LucideIcon;
tone: StateTone;
/** Position on the happy path, or null for off-path outcomes. */
step: number | null;
terminal: boolean;
}
// Class strings are written out literally rather than composed, so Tailwind's
// scanner can see every one of them.
const TONES: Record<StateKey, StateTone> = {
submitted: {
text: "text-state-submitted",
bg: "bg-state-submitted/10",
border: "border-state-submitted/25",
solid: "bg-state-submitted",
css: "var(--state-submitted)",
},
analysis: {
text: "text-state-analysis",
bg: "bg-state-analysis/10",
border: "border-state-analysis/25",
solid: "bg-state-analysis",
css: "var(--state-analysis)",
},
clarification: {
text: "text-state-clarification",
bg: "bg-state-clarification/10",
border: "border-state-clarification/25",
solid: "bg-state-clarification",
css: "var(--state-clarification)",
},
review: {
text: "text-state-review",
bg: "bg-state-review/10",
border: "border-state-review/25",
solid: "bg-state-review",
css: "var(--state-review)",
},
approved: {
text: "text-state-approved",
bg: "bg-state-approved/10",
border: "border-state-approved/25",
solid: "bg-state-approved",
css: "var(--state-approved)",
},
rejected: {
text: "text-state-rejected",
bg: "bg-state-rejected/10",
border: "border-state-rejected/25",
solid: "bg-state-rejected",
css: "var(--state-rejected)",
},
hold: {
text: "text-state-hold",
bg: "bg-state-hold/10",
border: "border-state-hold/25",
solid: "bg-state-hold",
css: "var(--state-hold)",
},
};
/** Ordered by the happy path — drives the detail-view stepper. */
export const WORKFLOW_STATES: WorkflowState[] = [
{
key: "submitted",
id: STATE_IDS.INVOICE_SUBMITTED,
name: "Invoice Submitted",
label: "Submitted",
icon: FileText,
tone: TONES.submitted,
step: 0,
terminal: false,
},
{
key: "analysis",
id: STATE_IDS.AI_ANALYSIS,
name: "AI Analysis",
label: "AI Analysis",
icon: Sparkles,
tone: TONES.analysis,
step: 1,
terminal: false,
},
{
key: "clarification",
id: STATE_IDS.AWAITING_CLARIFICATION,
name: "Awaiting Clarification",
label: "Awaiting Clarification",
icon: AlertCircle,
tone: TONES.clarification,
step: 2,
terminal: false,
},
{
key: "review",
id: STATE_IDS.FINANCE_REVIEW,
// The record view serves "Financial Review"; older payloads say
// "Finance Review". Both must resolve to this state or the rows fall
// through to the unstyled fallback and drop out of every count.
name: "Financial Review",
aliases: ["Finance Review"],
label: "Finance Review",
icon: Clock,
tone: TONES.review,
step: 3,
terminal: false,
},
{
key: "approved",
id: STATE_IDS.APPROVED,
name: "Approved",
label: "Approved",
icon: CheckCircle2,
tone: TONES.approved,
step: 4,
terminal: true,
},
{
key: "rejected",
id: STATE_IDS.REJECTED,
name: "Rejected",
label: "Rejected",
icon: XCircle,
tone: TONES.rejected,
step: null,
terminal: true,
},
{
key: "hold",
id: STATE_IDS.ON_HOLD,
name: "On Hold",
label: "On Hold",
icon: PauseCircle,
tone: TONES.hold,
step: null,
terminal: false,
},
];
/** The happy path, for the stepper. Excludes off-path outcomes. */
export const HAPPY_PATH = WORKFLOW_STATES.filter((s) => s.step !== null).sort(
(a, b) => (a.step as number) - (b.step as number),
);
const BY_NAME = new Map(
WORKFLOW_STATES.flatMap((s) =>
[s.name, ...(s.aliases ?? [])].map((n) => [n.toLowerCase(), s] as const),
),
);
const BY_ID = new Map(WORKFLOW_STATES.map((s) => [s.id, s]));
// The RDBMS purchase-order views carry a `po_status` with its own vocabulary.
// Reusing the invoice tones keeps one colour language across both record types.
const PO_STATUSES: Record<string, { label: string; icon: LucideIcon; tone: StateTone }> = {
pending: { label: "Pending", icon: Clock, tone: TONES.clarification },
delivered: { label: "Delivered", icon: Truck, tone: TONES.submitted },
cancelled: { label: "Cancelled", icon: Ban, tone: TONES.rejected },
};
export interface ResolvedStatus {
label: string;
icon: LucideIcon;
tone: StateTone;
/** True for the state whose dot should pulse (work in progress). */
pulse: boolean;
state?: WorkflowState;
}
const FALLBACK_TONE = TONES.hold;
/** Resolve any status string — invoice state name or PO status — for display. */
export function resolveStatus(value: string | null | undefined): ResolvedStatus | null {
if (!value) return null;
const key = String(value).trim();
if (!key) return null;
const state = BY_NAME.get(key.toLowerCase());
if (state) {
return {
label: state.label,
icon: state.icon,
tone: state.tone,
pulse: state.key === "analysis",
state,
};
}
const po = PO_STATUSES[key.toLowerCase()];
if (po) return { label: po.label, icon: po.icon, tone: po.tone, pulse: false };
// Unknown status still renders, just without a colour opinion.
return { label: key, icon: FileText, tone: FALLBACK_TONE, pulse: false };
}
export const stateByName = (name: string | null | undefined) =>
name ? BY_NAME.get(String(name).trim().toLowerCase()) : undefined;
export const stateById = (id: string | null | undefined) =>
id ? BY_ID.get(id) : undefined;
// ---------------------------------------------------------------------------
// Actions available in each state
// ---------------------------------------------------------------------------
export type ActionStyle = "primary" | "danger" | "ghost";
export interface StateAction {
activityId: string;
label: string;
/** Shorter label for the cramped row menu. */
shortLabel: string;
icon: LucideIcon;
style: ActionStyle;
/** Requires a confirm step / offers undo, because it ends the invoice. */
destructive: boolean;
}
const ACTIONS: Record<string, StateAction> = {
clarify: {
activityId: ACTIVITY_IDS.PROVIDE_CLARIFICATION,
label: "Provide Clarification",
shortLabel: "Clarify",
icon: AlertCircle,
style: "primary",
destructive: false,
},
approve: {
activityId: ACTIVITY_IDS.APPROVE_PAYMENT,
label: "Approve",
shortLabel: "Approve",
icon: CheckCircle2,
style: "primary",
destructive: false,
},
reject: {
activityId: ACTIVITY_IDS.REJECT_PAYMENT,
label: "Reject",
shortLabel: "Reject",
icon: XCircle,
style: "danger",
destructive: true,
},
hold: {
activityId: ACTIVITY_IDS.HOLD_PAYMENT,
label: "Hold",
shortLabel: "Hold",
icon: PauseCircle,
style: "ghost",
destructive: false,
},
};
const ACTIONS_BY_STATE: Partial<Record<StateKey, StateAction[]>> = {
clarification: [ACTIONS.clarify],
review: [ACTIONS.approve, ACTIONS.reject, ACTIONS.hold],
};
/**
* Actions offered for a state name.
*
* Replaces a reverse-lookup in RecordViewTable that tried to rebuild a state
* name by stripping a `p2p-state-` prefix off each STATE_IDS value. Those
* values are UUIDs, so the comparison never matched and the row action menu
* never rendered at all.
*/
export function actionsForStateName(name: string | null | undefined): StateAction[] {
const state = stateByName(name);
return state ? (ACTIONS_BY_STATE[state.key] ?? []) : [];
}
export function actionsForStateId(id: string | null | undefined): StateAction[] {
const state = stateById(id);
return state ? (ACTIONS_BY_STATE[state.key] ?? []) : [];
}

View File

@ -47,21 +47,11 @@ export const STATE_META: Record<string, StateMeta> = {
export const stateMeta = (uid?: string | null): StateMeta => export const stateMeta = (uid?: string | null): StateMeta =>
(uid && STATE_META[uid]) || { label: "—", tone: "slate" }; (uid && STATE_META[uid]) || { label: "—", tone: "slate" };
/** Tailwind classes per tone, for pills and dots. Light + dark. */ // TONE_PILL / TONE_DOT lived here as hardcoded slate/zinc/amber class strings.
export const TONE_PILL: Record<StateTone, string> = { // They were the only remaining pre-migration palette in the app; state colour
sky: "text-sky-700 bg-sky-50 ring-1 ring-sky-200 dark:text-sky-300 dark:bg-sky-950/50 dark:ring-sky-800/60", // now resolves through lib/workflow.ts tones and StatusBadge. STATE_META is
violet: "text-violet-700 bg-violet-50 ring-1 ring-violet-200 dark:text-violet-300 dark:bg-violet-950/50 dark:ring-violet-800/60", // kept because keying display metadata off state UUIDs is more robust than
amber: "text-amber-700 bg-amber-50 ring-1 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/50 dark:ring-amber-800/60", // matching served names, and folding it into lib/workflow.ts is the next step.
indigo: "text-indigo-700 bg-indigo-50 ring-1 ring-indigo-200 dark:text-indigo-300 dark:bg-indigo-950/50 dark:ring-indigo-800/60",
emerald: "text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/50 dark:ring-emerald-800/60",
red: "text-red-700 bg-red-50 ring-1 ring-red-200 dark:text-red-300 dark:bg-red-950/50 dark:ring-red-800/60",
slate: "text-slate-600 bg-slate-100 ring-1 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700",
};
export const TONE_DOT: Record<StateTone, string> = {
sky: "bg-sky-500", violet: "bg-violet-500", amber: "bg-amber-500",
indigo: "bg-indigo-500", emerald: "bg-emerald-500", red: "bg-red-500", slate: "bg-slate-400",
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Actors // Actors

View File

@ -1,9 +1,12 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'
import { ZinoProvider } from './zino-sdk' import { ZinoProvider } from './zino-sdk'
import { AuthProvider } from './hooks/AuthContext' import { AuthProvider } from './hooks/AuthContext'
import { ThemeProvider } from './hooks/ThemeContext' import { ThemeProvider } from './hooks/ThemeContext'
import { TooltipProvider } from './components/ui/tooltip'
import { Toaster } from './components/ui/sonner'
import './index.css' import './index.css'
import App from './App' import App from './App'
@ -14,9 +17,16 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider> <ThemeProvider>
<ZinoProvider baseUrl={apiUrl}> <ZinoProvider baseUrl={apiUrl}>
<BrowserRouter basename={import.meta.env.BASE_URL}> <BrowserRouter basename={import.meta.env.BASE_URL}>
<AuthProvider> {/* NuqsAdapter must sit inside the router it reads and writes the
<App /> router's search params, so table state can live in the URL. */}
</AuthProvider> <NuqsAdapter>
<AuthProvider>
<TooltipProvider delayDuration={300}>
<App />
<Toaster />
</TooltipProvider>
</AuthProvider>
</NuqsAdapter>
</BrowserRouter> </BrowserRouter>
</ZinoProvider> </ZinoProvider>
</ThemeProvider> </ThemeProvider>

View File

@ -1,16 +1,33 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useAuthContext } from "../hooks/AuthContext"; import { ArrowRight, BarChart3, Bot, Loader2, ShieldCheck, Zap } from "lucide-react";
import { ShieldCheck, Zap, BarChart3, Bot, ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuthContext } from "@/hooks/AuthContext";
import { ORG_ID } from "@/config";
const HIGHLIGHTS = [
{
icon: Zap,
title: "Instant Validation",
desc: "AI matches invoices against POs and GRNs in seconds",
},
{ icon: Bot, title: "Smart Routing", desc: "Exceptions auto-route to the right department" },
{
icon: BarChart3,
title: "Full Audit Trail",
desc: "Complete visibility from submission to payment",
},
];
export default function Login() { export default function Login() {
const { login, loading, error, clearError } = useAuthContext(); const { login, loading, error, clearError } = useAuthContext();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [orgId] = useState("47");
const from = (location.state as any)?.from?.pathname || "/"; const from = (location.state as any)?.from?.pathname || "/";
@ -18,132 +35,176 @@ export default function Login() {
e.preventDefault(); e.preventDefault();
clearError(); clearError();
try { try {
await login({ email, password, orgId: orgId || undefined }); // Org id comes from config rather than a hardcoded literal in component state.
await login({ email, password, orgId: ORG_ID });
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch {} } catch {
// AuthContext surfaces the message through `error`.
}
}; };
const inputCls = "w-full h-11 border border-slate-200 dark:border-zinc-700 rounded-lg px-3.5 text-[13px] bg-slate-50 dark:bg-zinc-800/60 text-slate-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-400 dark:focus:border-violet-500 focus:bg-white dark:focus:bg-zinc-800 transition placeholder:text-slate-400 dark:placeholder:text-zinc-600";
return ( return (
<div className="min-h-screen flex dark:bg-zinc-950"> <div className="flex min-h-screen bg-background">
{/* ── Left — branding ───────────────────────────────────────────────── */}
{/* ── Left — branding ─────────────────────────────────────────── */} <div className="relative hidden overflow-hidden p-16 lg:flex lg:w-[45%] lg:items-center lg:justify-center">
<div className="hidden lg:flex lg:w-[45%] items-center justify-center p-16 relative overflow-hidden" <div
style={{ background: "linear-gradient(145deg, #1e1040 0%, #2d1567 40%, #1a0f3a 70%, #0f0a2e 100%)" }}> className="absolute inset-0"
{/* Orbs */} style={{
<div className="absolute inset-0 overflow-hidden pointer-events-none"> background:
<div className="absolute -top-32 -left-32 w-[500px] h-[500px] rounded-full opacity-30" "linear-gradient(145deg, #1e1040 0%, #2d1567 40%, #1a0f3a 70%, #0f0a2e 100%)",
style={{ background: "radial-gradient(circle, #7c3aed 0%, transparent 70%)" }} /> }}
<div className="absolute -bottom-20 -right-20 w-96 h-96 rounded-full opacity-20" aria-hidden
style={{ background: "radial-gradient(circle, #d946ef 0%, transparent 70%)" }} /> />
<div className="absolute inset-0 opacity-[0.04]" {/* Decorative orbs + grid */}
style={{ backgroundImage: "linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)", backgroundSize: "40px 40px" }} /> <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div
className="absolute -left-32 -top-32 size-[500px] rounded-full opacity-30"
style={{ background: "radial-gradient(circle, #7c3aed 0%, transparent 70%)" }}
/>
<div
className="absolute -bottom-20 -right-20 size-96 rounded-full opacity-20"
style={{ background: "radial-gradient(circle, #d946ef 0%, transparent 70%)" }}
/>
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
</div> </div>
<div className="absolute top-10 left-16 z-10"> <img
<img src={import.meta.env.BASE_URL + "zino.svg"} alt="Zino" className="h-5 w-auto brightness-0 invert" /> src={import.meta.env.BASE_URL + "zino.svg"}
</div> alt="Zino"
className="absolute left-16 top-10 z-10 h-5 w-auto brightness-0 invert"
/>
<div className="relative z-10 max-w-lg"> <div className="relative z-10 max-w-lg">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mb-8 border" <span className="mb-8 inline-flex items-center gap-2 rounded-full border border-violet-300/30 bg-primary/20 px-3 py-1.5 text-sm font-medium text-violet-200">
style={{ background: "rgba(124,58,237,0.2)", borderColor: "rgba(167,139,250,0.3)", color: "#c4b5fd" }}> <ShieldCheck className="size-3.5" aria-hidden />
<ShieldCheck size={13} />
Enterprise-grade procurement automation Enterprise-grade procurement automation
</div> </span>
<h1 className="text-[2.6rem] font-bold text-white leading-[1.1] tracking-tight mb-8"> <h1 className="mb-8 text-5xl font-bold tracking-tight text-white">
Procure-to-Pay Procure-to-Pay
<br /> <br />
<span className="text-transparent bg-clip-text" <span
style={{ backgroundImage: "linear-gradient(135deg, #c4b5fd, #f0abfc, #67e8f9)" }}> className="bg-clip-text text-transparent"
style={{ backgroundImage: "linear-gradient(135deg, #c4b5fd, #f0abfc, #67e8f9)" }}
>
Invoice Intelligence Invoice Intelligence
</span> </span>
</h1> </h1>
<div className="space-y-4"> <ul className="space-y-4">
{[ {HIGHLIGHTS.map(({ icon: Icon, title, desc }) => (
{ icon: <Zap size={15} />, title: "Instant Validation", desc: "AI matches invoices against POs and GRNs in seconds" }, <li key={title} className="flex items-start gap-3">
{ icon: <Bot size={15} />, title: "Smart Routing", desc: "Exceptions auto-route to the right department" }, <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-violet-300/25 bg-primary/25 text-violet-200">
{ icon: <BarChart3 size={15} />, title: "Full Audit Trail", desc: "Complete visibility from submission to payment" }, <Icon className="size-3.5" aria-hidden />
].map((item) => ( </span>
<div key={item.title} className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "rgba(124,58,237,0.3)", border: "1px solid rgba(167,139,250,0.25)", color: "#c4b5fd" }}>
{item.icon}
</div>
<div> <div>
<div className="text-[13px] font-semibold text-white">{item.title}</div> <p className="text-base font-semibold text-white">{title}</p>
<div className="text-[12px] mt-0.5" style={{ color: "rgba(167,139,250,0.65)" }}>{item.desc}</div> <p className="mt-0.5 text-sm text-violet-200/70">{desc}</p>
</div> </div>
</div> </li>
))} ))}
</div> </ul>
</div> </div>
</div> </div>
{/* ── Right — login form ───────────────────────────────────────── */} {/* ── Right — form ──────────────────────────────────────────────────── */}
<div className="flex-1 flex items-center justify-center bg-white dark:bg-zinc-950 px-8"> <div className="flex flex-1 items-center justify-center px-6">
<div className="w-full max-w-[340px]"> <div className="w-full max-w-[340px]">
<img
src={import.meta.env.BASE_URL + "zino.svg"}
alt="Zino"
className="mb-10 h-5 w-auto dark:invert lg:hidden"
/>
{/* Logo — visible on mobile only */}
<img src={import.meta.env.BASE_URL + "zino.svg"} alt="Zino"
className="h-5 w-auto mb-10 lg:hidden dark:invert" />
{/* Heading */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-[1.6rem] font-bold text-slate-900 dark:text-zinc-50 tracking-tight leading-none mb-2"> <h2 className="text-4xl font-bold tracking-tight text-foreground">Sign in</h2>
Sign in <p className="mt-1.5 text-base text-muted-foreground">
</h2> Enter your credentials to access the portal.
<p className="text-[13px] text-slate-400 dark:text-zinc-500">
Enter your credentials to access the portal
</p> </p>
</div> </div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-[12px] font-medium text-slate-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide"> <Label htmlFor="email" className="mb-1.5">
Email Email
</label> </Label>
<input <Input
type="email" required autoComplete="email" autoFocus id="email"
value={email} onChange={(e) => { setEmail(e.target.value); clearError(); }} type="email"
className={inputCls} placeholder="you@company.com" required
autoComplete="email"
autoFocus
value={email}
onChange={(e) => {
setEmail(e.target.value);
clearError();
}}
aria-invalid={!!error}
placeholder="you@company.com"
className="h-10"
/> />
</div> </div>
<div> <div>
<label className="block text-[12px] font-medium text-slate-600 dark:text-zinc-400 mb-1.5 uppercase tracking-wide"> <Label htmlFor="password" className="mb-1.5">
Password Password
</label> </Label>
<input <Input
type="password" required autoComplete="current-password" id="password"
value={password} onChange={(e) => { setPassword(e.target.value); clearError(); }} type="password"
className={inputCls} placeholder="••••••••" required
autoComplete="current-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
clearError();
}}
aria-invalid={!!error}
aria-describedby={error ? "login-error" : undefined}
placeholder="••••••••"
className="h-10"
/> />
</div> </div>
{error && ( {error && (
<p className="text-[12px] text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-lg px-3 py-2.5 border border-red-100 dark:border-red-900/60"> <p
id="login-error"
role="alert"
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
>
{error} {error}
</p> </p>
)} )}
<button <Button type="submit" disabled={loading} className="h-10 w-full">
type="submit" disabled={loading} {loading ? (
className="w-full h-11 mt-1 disabled:opacity-50 text-white text-[13px] font-semibold rounded-lg transition-all flex items-center justify-center gap-2 hover:opacity-90 active:scale-[0.99]" <>
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }} <Loader2 className="size-3.5 animate-spin" aria-hidden />
> Signing in
{loading ? "Signing in…" : <>Sign in <ArrowRight size={14} /></>} </>
</button> ) : (
<>
Sign in
<ArrowRight className="size-3.5" aria-hidden />
</>
)}
</Button>
</form> </form>
{/* Footer */} <div className="mt-10 flex items-center justify-center gap-1.5">
<div className="flex items-center justify-center gap-1.5 mt-10"> <span className="text-xs text-muted-foreground">Powered by</span>
<span className="text-[11px] text-slate-400 dark:text-zinc-500">Powered by</span> <img
<img src={import.meta.env.BASE_URL + "zino.svg"} alt="Zino" src={import.meta.env.BASE_URL + "zino.svg"}
className="h-3.5 w-auto dark:invert" /> alt="Zino"
className="h-3.5 w-auto dark:invert"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -137,7 +137,7 @@ function renderField(
onChange: (value: string) => void, onChange: (value: string) => void,
) { ) {
const baseClass = const baseClass =
"w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"; "w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent";
switch (field.type) { switch (field.type) {
case "paragraph": case "paragraph":

View File

@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
},
},
plugins: [],
}

View File

@ -13,7 +13,11 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": false, "strict": false,
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false "noUnusedParameters": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src"]
} }

View File

@ -3,5 +3,11 @@
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" }
] ],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
} }

View File

@ -9,7 +9,8 @@
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"strict": true "strict": true,
"types": ["node"]
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@ -1,11 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
const devTarget = 'https://studio.getzino.in' const devTarget = 'https://studio.getzino.in'
const proxy = (target: string) => ({ target, changeOrigin: true, secure: false }) const proxy = (target: string) => ({ target, changeOrigin: true, secure: false })
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
base: process.env.VITE_BASE_URL ?? '/p2p/', base: process.env.VITE_BASE_URL ?? '/p2p/',
server: { server: {
port: 3007, port: 3007,