feat(stock-quick-draft): no-history supplier dropdown fallback
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
98e1f25fd5
commit
dfae2d0c8b
3 changed files with 197 additions and 7 deletions
44
src/app/api/v1/stock-quick-draft/suppliers/route.ts
Normal file
44
src/app/api/v1/stock-quick-draft/suppliers/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* GET /api/v1/stock-quick-draft/suppliers
|
||||
*
|
||||
* Lists every supplier sorted by `suppliername`. The Quick-Draft modal
|
||||
* uses this when the picked stock item has no purchase history and the
|
||||
* new-SKU path has no history either, to populate a fallback supplier
|
||||
* dropdown.
|
||||
*
|
||||
* Why a dedicated route instead of `GET /api/v1/suppliers`:
|
||||
* - the agent-API route uses bearer auth (AGENT_API_KEY) that the
|
||||
* browser doesn't carry, so the modal always 401'd against it.
|
||||
* - the modal wants a minimal `{ id, name }[]` shape; the agent-API
|
||||
* returns the raw PB records.
|
||||
*
|
||||
* Response: { ok: true, items: { id: string; name: string }[] }
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
const records = await pb.collection("suppliers").getFullList({
|
||||
sort: "suppliername",
|
||||
});
|
||||
const items = records.map((r) => ({
|
||||
id: r.id,
|
||||
name: (r.suppliername as string) ?? "",
|
||||
}));
|
||||
return NextResponse.json({ ok: true, items });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
reason: "list_failed",
|
||||
message: err instanceof Error ? err.message : "Failed to list suppliers",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -536,3 +536,51 @@ describe("QuickDraftModal — footer", () => {
|
|||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuickDraftModal — no-history fallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
global.fetch = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (url.includes("/search")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, items: [] }),
|
||||
};
|
||||
}
|
||||
if (url.includes("/supplier-history")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, history: [] }),
|
||||
};
|
||||
}
|
||||
if (url.includes("/suppliers")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
items: [
|
||||
{ id: "sup-a", name: "Acme Tools" },
|
||||
{ id: "sup-b", name: "BuildPro" },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({ ok: true, items: [] }) };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders a supplier select when history is empty in new-SKU mode", async () => {
|
||||
render(<QuickDraftModal open onOpenChange={() => {}} />);
|
||||
fireEvent.click(screen.getByTestId("quick-draft-toggle-new-sku"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(
|
||||
screen.getByTestId("quick-draft-supplier-select"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,50 @@ function formatMonthYear(date: Date): string {
|
|||
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback supplier picker used when there is no purchase history for
|
||||
* the picked stock item (or in new-SKU mode, where there's no item at
|
||||
* all). Renders a full `<select>` dropdown of every supplier, plus a
|
||||
* loading hint while the list is fetched.
|
||||
*/
|
||||
function NoHistorySupplierPicker({
|
||||
suppliersLoading,
|
||||
allSuppliers,
|
||||
supplierChoice,
|
||||
onPick,
|
||||
}: {
|
||||
suppliersLoading: boolean;
|
||||
allSuppliers: { id: string; name: string }[];
|
||||
supplierChoice: string | null;
|
||||
onPick: (supplierId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="quick-draft-supplier-select">Supplier</Label>
|
||||
<select
|
||||
id="quick-draft-supplier-select"
|
||||
data-testid="quick-draft-supplier-select"
|
||||
value={supplierChoice ?? ""}
|
||||
onChange={(e) => onPick(e.target.value)}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
disabled={suppliersLoading && allSuppliers.length === 0}
|
||||
>
|
||||
<option value="">— pick a supplier —</option>
|
||||
{allSuppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{suppliersLoading && allSuppliers.length === 0
|
||||
? "Loading suppliers…"
|
||||
: "No prior history for this item — pick any supplier."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
|
||||
const [defaultReference] = useState(
|
||||
() => `Stock order entry — ${formatMonthYear(new Date())}`,
|
||||
|
|
@ -66,6 +110,12 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
|
|||
const [history, setHistory] = useState<SupplierHistoryEntry[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [supplierChoice, setSupplierChoice] = useState<string | null>(null);
|
||||
// Fallback supplier list when there is no purchase history for the
|
||||
// selected item (or in new-SKU mode, where there's no item yet).
|
||||
const [allSuppliers, setAllSuppliers] = useState<
|
||||
{ id: string; name: string }[]
|
||||
>([]);
|
||||
const [suppliersLoading, setSuppliersLoading] = useState(false);
|
||||
const [qty, setQty] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
// New-SKU toggle state (named for readability; plan uses isNewSku/newName/newSku)
|
||||
|
|
@ -163,6 +213,49 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
|
|||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
// Fetch the full supplier list whenever we land in a state with no
|
||||
// history-driven supplier choice (no history rows for the picked
|
||||
// item, or new-SKU mode where there is no item at all). Only fires
|
||||
// once per modal session — the list is cached in `allSuppliers`.
|
||||
useEffect(() => {
|
||||
// Only fetch the full supplier list when we don't have a
|
||||
// history-driven supplier choice to show.
|
||||
if (!selectedItem && !newSkuMode) return;
|
||||
if (historyLoading) return; // Wait for history fetch to settle first
|
||||
if (history.length > 0) return; // History already gives us the choices
|
||||
if (allSuppliers.length > 0) return; // Already cached
|
||||
|
||||
let cancelled = false;
|
||||
setSuppliersLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/stock-quick-draft/suppliers");
|
||||
const data = (await res.json()) as {
|
||||
ok: boolean;
|
||||
items?: { id: string; name: string }[];
|
||||
message?: string;
|
||||
};
|
||||
if (!cancelled && data.ok && data.items) setAllSuppliers(data.items);
|
||||
} catch (err) {
|
||||
if (!cancelled)
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to load suppliers",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) setSuppliersLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectedItem,
|
||||
newSkuMode,
|
||||
historyLoading,
|
||||
history.length,
|
||||
allSuppliers.length,
|
||||
]);
|
||||
|
||||
const onPickItem = async (item: StockItemSearchResult) => {
|
||||
setSelectedItem(item);
|
||||
setSupplierChoice(null);
|
||||
|
|
@ -358,9 +451,12 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
|
|||
</div>
|
||||
)}
|
||||
{!historyLoading && history.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No prior history for this item.
|
||||
</div>
|
||||
<NoHistorySupplierPicker
|
||||
suppliersLoading={suppliersLoading}
|
||||
allSuppliers={allSuppliers}
|
||||
supplierChoice={supplierChoice}
|
||||
onPick={setSupplierChoice}
|
||||
/>
|
||||
)}
|
||||
{!historyLoading && history.length > 0 && (
|
||||
<div className="text-xs space-y-1" role="radiogroup">
|
||||
|
|
@ -480,10 +576,12 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
|
|||
</div>
|
||||
)}
|
||||
{!historyLoading && history.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Pick a supplier — Task 10 will add a full supplier dropdown
|
||||
when there's no history.
|
||||
</div>
|
||||
<NoHistorySupplierPicker
|
||||
suppliersLoading={suppliersLoading}
|
||||
allSuppliers={allSuppliers}
|
||||
supplierChoice={supplierChoice}
|
||||
onPick={setSupplierChoice}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Reference in a new issue