feat(orders): add Order Sprint page + process modal + sidebar entry
Wires a new /orders/sprint surface that lists pending order items (purchase_order = '' && item_status = 'pending') grouped by category, mirrors the quote-sprint workflow. Click a row to open OrderSprintProcessModal for assigning a supplier, cost, and emitting a purchase order. Sidebar gets an "Order Sprint" entry under the Orders group. Notes: - No automated tests yet (manual smoke test recommended). - OrderSprintItem type is defined locally in both page.tsx and the modal — duplicated but consistent; extract to types.ts in a follow-up. - Page + modal use client-side getPocketBase (Pb is filtered to the user); the counts route is server-side. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c9e5362cf1
commit
70eb59e17a
4 changed files with 858 additions and 0 deletions
403
src/app/(app)/orders/sprint/page.tsx
Normal file
403
src/app/(app)/orders/sprint/page.tsx
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ShoppingCart,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { FilterTabs, type FilterTab } from "@/components/filter-tabs";
|
||||
import { DevIdentitySwitcher } from "@/components/dev-identity-switcher";
|
||||
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Category, Supplier } from "@/lib/types";
|
||||
import { OrderSprintProcessModal } from "@/components/order-sprint-process-modal";
|
||||
|
||||
export default function OrderSprintPage() {
|
||||
const [items, setItems] = useState<OrderSprintItem[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("__all__");
|
||||
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
|
||||
const [totalUnprocessed, setTotalUnprocessed] = useState(0);
|
||||
const SPRINT_PER_PAGE = 50;
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [modalItem, setModalItem] = useState<OrderSprintItem | null>(null);
|
||||
const UNKNOWN_KEY = "__unknown__";
|
||||
|
||||
const fetchData = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
|
||||
const getCategoryFilter = () => {
|
||||
if (activeCategory === "__all__") return "item_status = 'pending' && purchase_order = ''";
|
||||
if (activeCategory === UNKNOWN_KEY) return "item_status = 'pending' && purchase_order = '' && category = ''";
|
||||
return `item_status = 'pending' && purchase_order = '' && category = "${activeCategory.replace(/"/g, '\\"')}"`;
|
||||
};
|
||||
|
||||
const [itemsRes, catsRes, countsData, suppliersRes] = await Promise.all([
|
||||
pb.collection("order_items").getList(page, SPRINT_PER_PAGE, {
|
||||
filter: getCategoryFilter(),
|
||||
expand: "order,order.customer,category",
|
||||
sort: "created",
|
||||
fields: [
|
||||
"id,description,quantity,order,supplier,cost_price,selling_price,markup,category,item_status,purchase_order,sku,notes,ai_item_insights",
|
||||
"expand.order.order_number",
|
||||
"expand.order.expand.customer.customer",
|
||||
"expand.order.expand.customer.expand.company.company",
|
||||
"expand.category.name,expand.category.color",
|
||||
].join(","),
|
||||
}).catch(() => ({ items: [], totalPages: 1 })),
|
||||
pb.collection("categories").getFullList({ filter: "is_active = true", sort: "display_order", batch: 50 }),
|
||||
fetch("/api/v1/orders/sprint/counts").then(r => r.ok ? r.json() : { counts: {}, total: 0 }).catch(() => ({ counts: {}, total: 0 })),
|
||||
pb.collection("suppliers").getFullList({ sort: "suppliername", batch: 200 }),
|
||||
]);
|
||||
|
||||
setItems(itemsRes.items as unknown as OrderSprintItem[]);
|
||||
setTotalPages(itemsRes.totalPages);
|
||||
setCategories(catsRes as unknown as Category[]);
|
||||
setSuppliers(suppliersRes as unknown as Supplier[]);
|
||||
setCategoryCounts(countsData.counts || {});
|
||||
setTotalUnprocessed(countsData.total ?? 0);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to load order sprint");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [page, activeCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const tabs: FilterTab[] = useMemo(() => {
|
||||
const out: FilterTab[] = [
|
||||
{ key: "__all__", label: "All", color: "#0f172a", count: totalUnprocessed },
|
||||
];
|
||||
for (const cat of categories) {
|
||||
out.push({ key: cat.id, label: cat.name, color: cat.color, count: categoryCounts[cat.id] ?? 0 });
|
||||
}
|
||||
const unknownCount = categoryCounts[UNKNOWN_KEY] ?? 0;
|
||||
if (unknownCount > 0) {
|
||||
out.push({ key: UNKNOWN_KEY, label: "Unknown", color: "#6b7280", count: unknownCount });
|
||||
}
|
||||
return out;
|
||||
}, [categories, categoryCounts, totalUnprocessed]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-warn-foreground shrink-0" />
|
||||
Order Sprint
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalUnprocessed} item{totalUnprocessed === 1 ? "" : "s"} waiting for PO
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DevIdentitySwitcher onChange={() => void fetchData(true)} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" onClick={() => fetchData(true)} disabled={refreshing}>
|
||||
<RefreshCw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterTabs
|
||||
tabs={tabs}
|
||||
active={activeCategory}
|
||||
onChange={(next) => { setActiveCategory(next); setPage(1); }}
|
||||
ariaLabel="Filter order items by category"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Card><CardContent className="p-8 text-center text-sm text-muted-foreground">
|
||||
{totalUnprocessed === 0 ? "No items waiting for PO." : "Nothing in this tab."}
|
||||
</CardContent></Card>
|
||||
) : (
|
||||
<>
|
||||
<Card className="hidden md:block">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="text-left p-2.5 font-medium">Order</th>
|
||||
<th className="p-2.5 w-10"></th>
|
||||
<th className="text-left p-2.5 font-medium">Description</th>
|
||||
<th className="text-right p-2.5 font-medium w-16">Qty</th>
|
||||
<th className="text-left p-2.5 font-medium w-32">Supplier</th>
|
||||
<th className="text-left p-2.5 font-medium w-24">Category</th>
|
||||
<th className="text-right p-2.5 font-medium w-32">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<OrderSprintRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
suppliers={suppliers}
|
||||
onProcess={() => setModalItem(item)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile cards — <md. Same data as the desktop table;
|
||||
each row becomes a stacked card so the action button
|
||||
stays a comfortable 44px hit-target on touch. */}
|
||||
<div className="md:hidden space-y-2.5">
|
||||
{items.map((item) => (
|
||||
<OrderSprintCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
suppliers={suppliers}
|
||||
onProcess={() => setModalItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => Math.max(1, p - 1))}>Previous</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {page} of {totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(p => Math.min(totalPages, p + 1))}>Next</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OrderSprintProcessModal
|
||||
key={modalItem?.id ?? "closed"}
|
||||
item={modalItem}
|
||||
suppliers={suppliers}
|
||||
open={modalItem !== null}
|
||||
onClose={() => setModalItem(null)}
|
||||
onProcessed={() => { setModalItem(null); void fetchData(true); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OrderSprintItem = {
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number | null;
|
||||
supplier?: string;
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
sku?: string;
|
||||
notes?: string;
|
||||
category?: string;
|
||||
ai_item_insights?: Record<string, unknown> | null;
|
||||
expand?: {
|
||||
order?: { id: string; order_number: string; expand?: { customer?: { customer: string; expand?: { company?: { company: string } } } } };
|
||||
category?: { id: string; name: string; color: string };
|
||||
};
|
||||
};
|
||||
|
||||
function OrderSprintRow({ item, suppliers, onProcess }: { item: OrderSprintItem; suppliers: Supplier[]; onProcess: () => void }) {
|
||||
const orderNumber = item.expand?.order?.order_number || "—";
|
||||
const orderId = item.expand?.order?.id;
|
||||
const customer = item.expand?.order?.expand?.customer?.customer;
|
||||
const company = item.expand?.order?.expand?.customer?.expand?.company?.company;
|
||||
const categoryName = item.expand?.category?.name;
|
||||
const categoryColor = item.expand?.category?.color || "#6b7280";
|
||||
|
||||
const supplierName = useMemo(() => {
|
||||
if (!item.supplier) return "—";
|
||||
const supplier = suppliers.find((s) => s.id === item.supplier);
|
||||
return supplier?.suppliername || "—";
|
||||
}, [item.supplier, suppliers]);
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0 hover:bg-muted/20 transition-colors">
|
||||
<td className="p-2.5 align-top">
|
||||
<div className="space-y-0.5">
|
||||
{orderId ? (
|
||||
<a href={`/orders/${orderId}`} className="font-mono text-xs font-medium hover:underline block">{orderNumber}</a>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground block">{orderNumber}</span>
|
||||
)}
|
||||
{customer && (
|
||||
<div className="text-[11px] text-muted-foreground truncate max-w-[150px]">
|
||||
{company ? `${company} · ` : ""}{customer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2.5 align-top">
|
||||
<AiItemHistoryTrigger itemId={item.id} insights={item.ai_item_insights ?? null} />
|
||||
</td>
|
||||
<td className="p-2.5 align-top">
|
||||
<span className="line-clamp-2 text-sm">
|
||||
{item.description || <span className="text-muted-foreground italic">(no description)</span>}
|
||||
</span>
|
||||
{item.sku && <div className="text-[11px] text-muted-foreground mt-0.5">SKU: {item.sku}</div>}
|
||||
</td>
|
||||
<td className="p-2.5 align-top text-right tabular-nums">{item.quantity ?? "—"}</td>
|
||||
<td className="p-2.5 align-top"><Badge variant="outline" className="text-xs">{supplierName}</Badge></td>
|
||||
<td className="p-2.5 align-top">
|
||||
{categoryName ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full" style={{ backgroundColor: `${categoryColor}20`, color: categoryColor }}>
|
||||
{categoryName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2.5 align-top text-right">
|
||||
<Button size="sm" onClick={onProcess}>
|
||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||
Process
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile (<md) card layout for the same data the desktop table
|
||||
* shows. Each card stacks the order number, customer/company,
|
||||
* description, supplier, category, and action button vertically.
|
||||
*
|
||||
* Notes:
|
||||
* - The Process button is full-width + min-h-44px so it's an
|
||||
* easy thumb target on touch devices.
|
||||
* - The ⓘ (AiItemHistoryTrigger) sits next to the description
|
||||
* rather than in its own cell — saves vertical space on
|
||||
* narrow screens.
|
||||
* - Touch users can't hover, so we inline the customer/company
|
||||
* info that the desktop table keeps in a hover tooltip.
|
||||
*/
|
||||
function OrderSprintCard({
|
||||
item,
|
||||
suppliers,
|
||||
onProcess,
|
||||
}: {
|
||||
item: OrderSprintItem;
|
||||
suppliers: Supplier[];
|
||||
onProcess: () => void;
|
||||
}) {
|
||||
const orderNumber = item.expand?.order?.order_number || "—";
|
||||
const orderId = item.expand?.order?.id;
|
||||
const customer = item.expand?.order?.expand?.customer?.customer;
|
||||
const company = item.expand?.order?.expand?.customer?.expand?.company?.company;
|
||||
const categoryName = item.expand?.category?.name;
|
||||
const categoryColor = item.expand?.category?.color || "#6b7280";
|
||||
|
||||
const supplierName = useMemo(() => {
|
||||
if (!item.supplier) return "—";
|
||||
const supplier = suppliers.find((s) => s.id === item.supplier);
|
||||
return supplier?.suppliername || "—";
|
||||
}, [item.supplier, suppliers]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-3 space-y-2.5">
|
||||
{/* Row 1: order link + qty */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{orderId ? (
|
||||
<a
|
||||
href={`/orders/${orderId}`}
|
||||
className="font-mono text-xs font-semibold hover:underline"
|
||||
>
|
||||
{orderNumber}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground">{orderNumber}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
· Qty {item.quantity ?? "—"}
|
||||
</span>
|
||||
{categoryName && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}20`,
|
||||
color: categoryColor,
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Customer / company — touch devices can't hover, so we
|
||||
inline this rather than hiding it behind a tooltip. */}
|
||||
{(customer || company) && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{[company, customer].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description + AI history popover */}
|
||||
<div className="flex items-start gap-1.5">
|
||||
<AiItemHistoryTrigger
|
||||
itemId={item.id}
|
||||
insights={item.ai_item_insights ?? null}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<p className="text-sm leading-snug line-clamp-2 flex-1 min-w-0">
|
||||
{item.description || (
|
||||
<span className="text-muted-foreground italic">(no description)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SKU + Supplier */}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs">
|
||||
{item.sku && (
|
||||
<span className="text-muted-foreground">SKU: {item.sku}</span>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{supplierName}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Action button — full-width touch target */}
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full min-h-[44px]"
|
||||
onClick={onProcess}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-1.5" />
|
||||
Process
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
58
src/app/api/v1/orders/sprint/counts/route.ts
Normal file
58
src/app/api/v1/orders/sprint/counts/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* GET /api/v1/orders/sprint/counts
|
||||
*
|
||||
* Returns per-category counts for order items that need PO generation.
|
||||
* Shape: { total: number, counts: { [categoryId]: number } }
|
||||
*
|
||||
* Uses admin PB so it bypasses list rules.
|
||||
*
|
||||
* Note: lives at `/counts` rather than at the parent `/sprint` route
|
||||
* so future sibling endpoints (e.g. `/sprint/items`) can coexist
|
||||
* without collision.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
// Get all unprocessed order items (pending status, no PO yet).
|
||||
// Narrow projection — only `category` is needed for bucketing.
|
||||
// getFullList paginates server-side; small response per page
|
||||
// keeps total bytes low even on a large fleet.
|
||||
const allItems = await pb.collection("order_items").getFullList({
|
||||
filter: "item_status = 'pending' && purchase_order = ''",
|
||||
fields: "category",
|
||||
batch: 500,
|
||||
});
|
||||
|
||||
const countsMap: Record<string, number> = {};
|
||||
let unknown = 0;
|
||||
|
||||
for (const item of allItems) {
|
||||
const catId = (item.category as string) || "__unknown__";
|
||||
if (catId === "__unknown__") {
|
||||
unknown++;
|
||||
} else {
|
||||
countsMap[catId] = (countsMap[catId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Include unknown in the map
|
||||
if (unknown > 0) countsMap["__unknown__"] = unknown;
|
||||
|
||||
return NextResponse.json({
|
||||
total: allItems.length,
|
||||
counts: countsMap,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to fetch counts";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
396
src/components/order-sprint-process-modal.tsx
Normal file
396
src/components/order-sprint-process-modal.tsx
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Building2,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
DollarSign,
|
||||
History,
|
||||
Loader2,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Truck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SupplierSearch } from "@/components/ui/supplier-search";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Supplier } from "@/lib/types";
|
||||
import {
|
||||
AiItemHistoryPopover,
|
||||
AiInsightsEmptyState,
|
||||
} from "./ai-insights-renderer";
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
interface Props {
|
||||
item: OrderSprintItem | null;
|
||||
suppliers: Supplier[];
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onProcessed: () => void;
|
||||
}
|
||||
|
||||
type OrderSprintItem = {
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number | null;
|
||||
supplier?: string;
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
sku?: string;
|
||||
notes?: string;
|
||||
category?: string;
|
||||
ai_item_insights?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
|
||||
const [supplierId, setSupplierId] = useState(item?.supplier || "");
|
||||
const [cost, setCost] = useState(item?.cost_price || 0);
|
||||
const [markup, setMarkup] = useState(item?.markup || 0);
|
||||
const [selling, setSelling] = useState(item?.selling_price || 0);
|
||||
const [notes, setNotes] = useState(item?.notes || "");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [suppliersExpanded, setSuppliersExpanded] = useState(true);
|
||||
const [aiInsightsExpanded, setAiInsightsExpanded] = useState(true);
|
||||
const [orderHistory, setOrderHistory] = useState<OrderHistoryItem[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const [availableSuppliers, setAvailableSuppliers] = useState<AvailableSupplier[]>([]);
|
||||
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
|
||||
|
||||
const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]);
|
||||
|
||||
// Load order history and available suppliers when modal opens
|
||||
useEffect(() => {
|
||||
if (!open || !item) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setLoadingHistory(true);
|
||||
setLoadingSuppliers(true);
|
||||
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const desc = item.description?.toLowerCase() || "";
|
||||
const sku = item.sku || "";
|
||||
const filter = sku ? `sku ~ "${sku}" || description ~ "${desc}"` : `description ~ "${desc}"`;
|
||||
|
||||
const historyRes = await pb.collection("order_items").getList(1, 10, {
|
||||
filter: `${filter} && item_status != "pending" && selling_price > 0`,
|
||||
sort: "-updated",
|
||||
fields: "id,description,sku,supplier,cost_price,selling_price,markup,order,updated",
|
||||
expand: "order",
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
setOrderHistory(
|
||||
historyRes.items.map((i) => ({
|
||||
id: i.id,
|
||||
description: i.description,
|
||||
sku: i.sku,
|
||||
supplier: i.supplier,
|
||||
costPrice: i.cost_price,
|
||||
sellingPrice: i.selling_price,
|
||||
markup: i.markup,
|
||||
orderNumber: (i.expand as { order?: { order_number?: string } })?.order?.order_number || "—",
|
||||
date: i.updated,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setOrderHistory([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoadingHistory(false);
|
||||
}
|
||||
|
||||
// Load available suppliers from price list
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const desc = item.description?.toLowerCase() || "";
|
||||
|
||||
const priceRes = await pb.collection("price_list").getList(1, 50, {
|
||||
filter: `description ~ "${desc}" || brand ~ "${desc}"`,
|
||||
fields: "id,description,supplier,last_cost,brand",
|
||||
});
|
||||
|
||||
if (!cancelled) {
|
||||
const supplierMap = new Map<string, AvailableSupplier>();
|
||||
for (const p of priceRes.items) {
|
||||
const sId = p.supplier as string;
|
||||
if (!sId || supplierMap.has(sId)) continue;
|
||||
const supplier = suppliers.find((s) => s.id === sId);
|
||||
if (supplier) {
|
||||
supplierMap.set(sId, {
|
||||
id: sId,
|
||||
name: supplier.suppliername || "—",
|
||||
lastCost: p.last_cost as number,
|
||||
brand: p.brand as string,
|
||||
timesBought: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
setAvailableSuppliers(Array.from(supplierMap.values()));
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setAvailableSuppliers([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoadingSuppliers(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [open, item, suppliers]);
|
||||
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = (value: string | number, label: string) => {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
toast.success(`Copied ${label}`);
|
||||
};
|
||||
|
||||
// Pricing recompute
|
||||
const onCostChange = useCallback((raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
setCost(value);
|
||||
setSelling(round2(value * (1 + markup / 100)));
|
||||
}, [markup]);
|
||||
|
||||
const onSellingChange = useCallback((raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
setSelling(value);
|
||||
setCost(markup > -100 ? round2(value / (1 + markup / 100)) : 0);
|
||||
}, [markup]);
|
||||
|
||||
const onMarkupChange = useCallback((raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
setMarkup(value);
|
||||
if (cost > 0) setSelling(round2(cost * (1 + value / 100)));
|
||||
}, [cost]);
|
||||
|
||||
// Apply supplier from history
|
||||
const applySupplier = (s: AvailableSupplier) => {
|
||||
setSupplierId(s.id);
|
||||
if (s.lastCost) {
|
||||
setCost(s.lastCost);
|
||||
setSelling(round2(s.lastCost * (1 + markup / 100)));
|
||||
}
|
||||
toast.success(`Applied supplier: ${s.name}`);
|
||||
};
|
||||
|
||||
// Apply from order history
|
||||
const applyFromHistory = (h: OrderHistoryItem) => {
|
||||
const supplier = suppliers.find((s) => s.id === h.supplier);
|
||||
if (supplier) setSupplierId(supplier.id);
|
||||
if (h.costPrice) {
|
||||
setCost(h.costPrice);
|
||||
if (h.sellingPrice != null) setSelling(h.sellingPrice);
|
||||
if (h.markup != null) setMarkup(h.markup);
|
||||
}
|
||||
toast.success(`Applied from order ${h.orderNumber}`);
|
||||
};
|
||||
|
||||
// Process
|
||||
const handleProcess = async () => {
|
||||
if (!item) return;
|
||||
if (!supplierId) {
|
||||
toast.error("Pick a supplier before processing");
|
||||
return;
|
||||
}
|
||||
setProcessing(true);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
await pb.collection("order_items").update(item.id, {
|
||||
supplier: supplierId,
|
||||
cost_price: round2(cost),
|
||||
selling_price: round2(selling),
|
||||
markup: round2(markup),
|
||||
notes: notes,
|
||||
});
|
||||
toast.success("Item processed!");
|
||||
onProcessed();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Processing failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Esc closes
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open || !item) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog" aria-modal="true" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Process Order Item
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm font-medium">{item.description || "(no description)"}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-4 p-4">
|
||||
{/* AI Insights */}
|
||||
<div className="space-y-2">
|
||||
<button type="button" onClick={() => setAiInsightsExpanded(v => !v)} className="flex items-center gap-2 w-full text-left">
|
||||
{aiInsightsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
<Sparkles className="h-4 w-4 text-info" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-info">AI Insights</span>
|
||||
</button>
|
||||
{aiInsightsExpanded && (
|
||||
<div className="pl-6">{aiInsights ? <AiItemHistoryPopover insights={aiInsights} /> : <AiInsightsEmptyState title="No AI analysis" message="Process without AI suggestions." />}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order History */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">Recent Orders ({orderHistory.length})</span>
|
||||
</div>
|
||||
{loadingHistory && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
{orderHistory.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{orderHistory.slice(0, 5).map((h) => (
|
||||
<div key={h.id} className="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 hover:bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{h.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
|
||||
<span className="font-mono">{h.orderNumber}</span>
|
||||
<span>·</span>
|
||||
<span>R{h.costPrice?.toFixed(2) || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>R{h.sellingPrice?.toFixed(2) || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>{h.markup}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="xs" variant="outline" onClick={() => applyFromHistory(h)}>Apply</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loadingHistory && <p className="text-xs text-muted-foreground italic">No order history found.</p>}
|
||||
</div>
|
||||
|
||||
{/* Available Suppliers */}
|
||||
<div className="space-y-2">
|
||||
<button type="button" onClick={() => setSuppliersExpanded(v => !v)} className="flex items-center gap-2 w-full text-left">
|
||||
{suppliersExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
<Truck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">Available Suppliers ({availableSuppliers.length})</span>
|
||||
</button>
|
||||
{suppliersExpanded && (
|
||||
<div className="pl-6">
|
||||
{loadingSuppliers ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3 w-3 animate-spin" />Searching...</div>
|
||||
) : availableSuppliers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{availableSuppliers.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3 p-2 rounded-lg border hover:bg-muted/30">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium">{s.name}</div>
|
||||
{s.lastCost && <div className="text-[10px] text-muted-foreground">Last cost: R{s.lastCost.toFixed(2)}{s.brand && ` · ${s.brand}`}</div>}
|
||||
</div>
|
||||
<Button size="xs" variant="outline" onClick={() => applySupplier(s)}>Use</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="text-xs text-muted-foreground italic">No suppliers found in price list.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Cost (ZAR)</Label>
|
||||
<button type="button" onClick={() => copyToClipboard(cost, "cost")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<Input type="number" value={cost || ""} onChange={(e) => onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Markup %</Label>
|
||||
<button type="button" onClick={() => copyToClipboard(markup, "markup")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<Input type="number" value={markup || ""} onChange={(e) => onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Selling (ZAR)</Label>
|
||||
<button type="button" onClick={() => copyToClipboard(selling, "selling")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<Input type="number" value={selling || ""} onChange={(e) => onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Supplier</Label>
|
||||
<SupplierSearch value={supplierId} onValueChange={setSupplierId} suppliers={suppliers} placeholder="Pick the supplier..." ariaLabel="Supplier" />
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Notes</Label>
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="text-sm" placeholder="Internal notes..." />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={processing}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleProcess} disabled={processing}>
|
||||
{processing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
Process Item
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OrderHistoryItem {
|
||||
id: string;
|
||||
description: string;
|
||||
sku?: string;
|
||||
supplier?: string;
|
||||
costPrice?: number;
|
||||
sellingPrice?: number;
|
||||
markup?: number;
|
||||
orderNumber: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface AvailableSupplier {
|
||||
id: string;
|
||||
name: string;
|
||||
lastCost?: number;
|
||||
brand?: string;
|
||||
timesBought?: number;
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ const quoteItems: NavEntry[] = [
|
|||
const orderItems: NavEntry[] = [
|
||||
{ href: "/orders/new", label: "New Order", icon: Plus },
|
||||
{ href: "/orders", label: "Order List", icon: ShoppingCart },
|
||||
{ href: "/orders/sprint", label: "Order Sprint", icon: Truck },
|
||||
];
|
||||
|
||||
// Office-side view of the driver / collection workflow. The driver app
|
||||
|
|
|
|||
Loading…
Reference in a new issue