New Order
diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts
index 1366519..85052ec 100644
--- a/src/app/api/orders/[id]/route.ts
+++ b/src/app/api/orders/[id]/route.ts
@@ -5,10 +5,8 @@ import { db } from "@/lib/db";
import { order, orderHistory } from "@/lib/db/schema";
import {
ensureFinanceSettingsRow,
- getActiveQuarter,
orderTotalCents,
restoreGiftFundForDeletion,
- validateOrderBalance,
} from "@/lib/finance/finance";
import { getSessionUser } from "@/lib/auth/session";
import { orderInputSchema } from "@/lib/validation";
@@ -58,27 +56,12 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
const d = parsed.data;
ensureFinanceSettingsRow();
const unitCostCents = Math.round(d.unitCost * 100);
- const totalCostCents = orderTotalCents(d.quantity, unitCostCents, d.fundType);
-
- const balanceCheck = validateOrderBalance(d.fundType, d.stfBucketId, totalCostCents);
- if (!balanceCheck.ok) {
- return NextResponse.json({ error: balanceCheck.message }, { status: 400 });
- }
-
- const activeQuarter = d.fundType === "STF" ? getActiveQuarter() : null;
- if (d.fundType === "STF" && !activeQuarter) {
- return NextResponse.json(
- { error: "No active STF school year is configured. Contact an officer." },
- { status: 400 }
- );
- }
+ // Editing sends the order back for triage: an officer re-checks the fund
+ // assignment against the changed cost when they review it again.
const updated = db
.update(order)
.set({
- fundType: d.fundType,
- stfBucketId: d.fundType === "STF" ? d.stfBucketId! : null,
- quarterId: activeQuarter?.id ?? null,
vendor: d.vendor,
link: d.link,
itemName: d.itemName,
diff --git a/src/app/api/orders/assign/route.ts b/src/app/api/orders/assign/route.ts
new file mode 100644
index 0000000..462dbb1
--- /dev/null
+++ b/src/app/api/orders/assign/route.ts
@@ -0,0 +1,76 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { NextResponse, type NextRequest } from "next/server";
+
+import { db } from "@/lib/db";
+import { order } from "@/lib/db/schema";
+import {
+ assignOrdersToFund,
+ ensureFinanceSettingsRow,
+ getActiveQuarter,
+ validateAssignmentBalance,
+} from "@/lib/finance/finance";
+import { getSessionUser } from "@/lib/auth/session";
+import { orderAssignSchema } from "@/lib/validation";
+
+export async function POST(req: NextRequest) {
+ const sessionUser = await getSessionUser();
+ if (!sessionUser) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (sessionUser.role !== "admin") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => null);
+ const parsed = orderAssignSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const { orderIds, fundType, stfBucketId } = parsed.data;
+ ensureFinanceSettingsRow();
+
+ if (fundType === "STF" && !getActiveQuarter()) {
+ return NextResponse.json(
+ { error: "No active STF school year is configured." },
+ { status: 400 }
+ );
+ }
+
+ const targets = db
+ .select()
+ .from(order)
+ .where(and(inArray(order.id, orderIds), eq(order.status, "pending")))
+ .all();
+
+ if (targets.length !== orderIds.length) {
+ return NextResponse.json(
+ { error: "Only pending orders can be assigned to a fund." },
+ { status: 400 }
+ );
+ }
+
+ // Assigning does not spend the fund, but block parking more in a bucket
+ // than it can cover so the approval step is not a dead end.
+ const balanceCheck = validateAssignmentBalance(
+ fundType,
+ stfBucketId ?? null,
+ orderIds,
+ targets
+ );
+ if (!balanceCheck.ok) {
+ return NextResponse.json({ error: balanceCheck.message }, { status: 400 });
+ }
+
+ const assignedCount = assignOrdersToFund(
+ orderIds,
+ fundType,
+ stfBucketId ?? null,
+ sessionUser.id
+ );
+
+ return NextResponse.json({ assignedCount });
+}
diff --git a/src/app/api/orders/bulk-action/route.ts b/src/app/api/orders/bulk-action/route.ts
new file mode 100644
index 0000000..08139d9
--- /dev/null
+++ b/src/app/api/orders/bulk-action/route.ts
@@ -0,0 +1,132 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { NextResponse, type NextRequest } from "next/server";
+
+import { db } from "@/lib/db";
+import { order, orderHistory, user } from "@/lib/db/schema";
+import {
+ deductGiftFundForApproval,
+ ensureFinanceSettingsRow,
+ orderTotalCents,
+ sendOrderApprovedEmail,
+ sendOrderDeniedEmail,
+ validateBatchBalance,
+} from "@/lib/finance/finance";
+import { getSessionUser } from "@/lib/auth/session";
+import { ORDER_ACTION_STATUS, orderBulkActionSchema } from "@/lib/validation";
+
+export async function POST(req: NextRequest) {
+ const sessionUser = await getSessionUser();
+ if (!sessionUser) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (sessionUser.role !== "admin") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const body = await req.json().catch(() => null);
+ const parsed = orderBulkActionSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", issues: parsed.error.flatten() },
+ { status: 400 }
+ );
+ }
+
+ const { orderIds, action, denialComment } = parsed.data;
+ ensureFinanceSettingsRow();
+
+ const targets = db
+ .select()
+ .from(order)
+ .where(and(inArray(order.id, orderIds), eq(order.status, "pending")))
+ .all();
+
+ if (targets.length === 0) {
+ return NextResponse.json({ error: "No pending orders in selection" }, { status: 400 });
+ }
+
+ if (action === "approve") {
+ const untriaged = targets.filter((o) => !o.fundType);
+ if (untriaged.length > 0) {
+ return NextResponse.json(
+ {
+ error: `Assign ${untriaged.length} order${untriaged.length === 1 ? "" : "s"} to a fund before approving.`,
+ },
+ { status: 400 }
+ );
+ }
+
+ // Group by fund and bucket so each pool is checked against its own
+ // balance with the whole selection counted at once.
+ const groups = new Map
();
+ for (const target of targets) {
+ const key = `${target.fundType}:${target.stfBucketId ?? "none"}`;
+ const group = groups.get(key);
+ if (group) group.push(target);
+ else groups.set(key, [target]);
+ }
+
+ for (const group of groups.values()) {
+ const check = validateBatchBalance(group[0].fundType, group[0].stfBucketId, group);
+ if (!check.ok) {
+ return NextResponse.json({ error: check.message }, { status: 400 });
+ }
+ }
+ }
+
+ const newStatus = ORDER_ACTION_STATUS[action];
+ const reviewedAt = new Date();
+
+ for (const target of targets) {
+ db.update(order)
+ .set({
+ status: newStatus,
+ denialComment: action === "deny" ? (denialComment ?? null) : null,
+ reviewedBy: sessionUser.id,
+ reviewedAt,
+ })
+ .where(eq(order.id, target.id))
+ .run();
+
+ db.insert(orderHistory)
+ .values({
+ orderId: target.id,
+ fromStatus: target.status,
+ toStatus: newStatus,
+ changedBy: sessionUser.id,
+ note: denialComment ?? null,
+ })
+ .run();
+
+ if (action === "approve" && target.fundType === "Gift") {
+ deductGiftFundForApproval(
+ target.id,
+ orderTotalCents(target.quantity, target.unitCostCents, target.fundType),
+ sessionUser.id
+ );
+ }
+ }
+
+ const requesters = new Map();
+ for (const target of targets) {
+ if (requesters.has(target.userId)) continue;
+ const requester = db.select().from(user).where(eq(user.id, target.userId)).get();
+ if (requester?.email) requesters.set(target.userId, requester.email);
+ }
+
+ for (const target of targets) {
+ const email = requesters.get(target.userId);
+ if (!email) continue;
+ try {
+ if (action === "approve") {
+ await sendOrderApprovedEmail(email, target.itemName);
+ } else {
+ await sendOrderDeniedEmail(email, target.itemName, denialComment);
+ }
+ } catch {
+ // Email failure should not roll back the order action.
+ }
+ }
+
+ return NextResponse.json({ count: targets.length, status: newStatus });
+}
diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts
index a56e811..a0c78fc 100644
--- a/src/app/api/orders/route.ts
+++ b/src/app/api/orders/route.ts
@@ -1,15 +1,12 @@
+import { randomUUID } from "node:crypto";
+
import { NextResponse, type NextRequest } from "next/server";
import { db } from "@/lib/db";
import { order } from "@/lib/db/schema";
-import {
- getActiveQuarter,
- ensureFinanceSettingsRow,
- orderTotalCents,
- validateOrderBalance,
-} from "@/lib/finance/finance";
+import { ensureFinanceSettingsRow } from "@/lib/finance/finance";
import { getSessionUser } from "@/lib/auth/session";
-import { orderInputSchema } from "@/lib/validation";
+import { orderBatchInputSchema } from "@/lib/validation";
export async function POST(req: NextRequest) {
const user = await getSessionUser();
@@ -18,7 +15,7 @@ export async function POST(req: NextRequest) {
}
const body = await req.json().catch(() => null);
- const parsed = orderInputSchema.safeParse(body);
+ const parsed = orderBatchInputSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", issues: parsed.error.flatten() },
@@ -26,41 +23,24 @@ export async function POST(req: NextRequest) {
);
}
- const d = parsed.data;
ensureFinanceSettingsRow();
- const unitCostCents = Math.round(d.unitCost * 100);
- const totalCostCents = orderTotalCents(d.quantity, unitCostCents, d.fundType);
-
- const balanceCheck = validateOrderBalance(d.fundType, d.stfBucketId, totalCostCents);
- if (!balanceCheck.ok) {
- return NextResponse.json({ error: balanceCheck.message }, { status: 400 });
- }
-
- const activeQuarter = d.fundType === "STF" ? getActiveQuarter() : null;
- if (d.fundType === "STF" && !activeQuarter) {
- return NextResponse.json(
- { error: "No active STF school year is configured. Contact an officer." },
- { status: 400 }
- );
- }
-
- const created = db
- .insert(order)
- .values({
- userId: user.id,
- fundType: d.fundType,
- stfBucketId: d.fundType === "STF" ? d.stfBucketId! : null,
- quarterId: activeQuarter?.id ?? null,
- vendor: d.vendor,
- link: d.link,
- itemName: d.itemName,
- partNumber: d.partNumber?.trim() || null,
- quantity: d.quantity,
- unitCostCents,
- notes: d.notes?.trim() || null,
- })
- .returning()
- .get();
- return NextResponse.json({ order: created }, { status: 201 });
+ // Orders arrive untriaged: no fund type, bucket or quarter. An officer
+ // assigns those later, and the balance check runs at that point.
+ const batchId = parsed.data.items.length > 1 ? randomUUID() : null;
+ const rows = parsed.data.items.map((item) => ({
+ userId: user.id,
+ batchId,
+ vendor: item.vendor,
+ link: item.link,
+ itemName: item.itemName,
+ partNumber: item.partNumber?.trim() || null,
+ quantity: item.quantity,
+ unitCostCents: Math.round(item.unitCost * 100),
+ notes: item.notes?.trim() || null,
+ }));
+
+ const created = db.insert(order).values(rows).returning().all();
+
+ return NextResponse.json({ orders: created, count: created.length }, { status: 201 });
}
diff --git a/src/components/orders/AdminOrderQueue.tsx b/src/components/orders/AdminOrderQueue.tsx
index 27a85da..61f5b21 100644
--- a/src/components/orders/AdminOrderQueue.tsx
+++ b/src/components/orders/AdminOrderQueue.tsx
@@ -1,13 +1,20 @@
"use client";
import { Copy, PackageCheck, Trash2 } from "lucide-react";
-import { Fragment, type ReactNode } from "react";
+import { Fragment, type ReactNode, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
-import { useState } from "react";
import { toast } from "sonner";
+import { isOverBudget, StfBucketSelectItemContent } from "@/components/BalanceAmount";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -28,15 +35,17 @@ import {
displayPercentToBps,
type OrderPricingSettings,
} from "@/lib/finance/order-pricing";
-import { formatDate, formatPriceCents } from "@/lib/utils";
+import { cn, formatDate, formatPriceCents } from "@/lib/utils";
import { OrderStatusBadge } from "./OrderStatusBadge";
export type AdminOrderRow = {
id: number;
itemName: string;
- fundType: FundType;
+ fundType: FundType | null;
+ stfBucketId: number | null;
stfBucketName: string | null;
+ batchId: string | null;
requesterName: string | null;
requesterEmail: string | null;
quantity: number;
@@ -50,8 +59,19 @@ export type AdminOrderRow = {
createdAt: Date;
};
+export type StfBucketOption = {
+ id: number;
+ name: string;
+ remainingBalanceCents: number;
+};
+
type Action = "approve" | "deny";
+export type OrderPricing = {
+ taxPercent: number;
+ shippingPercent: number;
+};
+
function toPricingSettings(orderPricing: OrderPricing): OrderPricingSettings {
return {
taxPercentBps: displayPercentToBps(orderPricing.taxPercent),
@@ -72,16 +92,17 @@ async function copyText(text: string, label: string) {
}
}
-export type OrderPricing = {
- taxPercent: number;
- shippingPercent: number;
-};
+function plural(count: number, noun: string) {
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
+}
export function AdminOrderQueue({
orders,
+ stfBuckets,
orderPricing,
}: {
orders: AdminOrderRow[];
+ stfBuckets: StfBucketOption[];
orderPricing: OrderPricing;
}) {
const router = useRouter();
@@ -90,31 +111,84 @@ export function AdminOrderQueue({
const [pending, setPending] = useState(null);
const [deletingId, setDeletingId] = useState(null);
const [markingOrdered, setMarkingOrdered] = useState(false);
- const [selectedApprovedIds, setSelectedApprovedIds] = useState>(new Set());
+ const [busy, setBusy] = useState(false);
+ const [selectedIds, setSelectedIds] = useState>(new Set());
const pricingSettings = toPricingSettings(orderPricing);
- const pendingOrders = orders.filter((o) => o.status === "pending");
- const approvedOrders = orders.filter((o) => o.status === "approved");
- const orderedOrders = orders.filter((o) => o.status === "ordered");
- const deniedOrders = orders.filter((o) => o.status === "denied");
+ const { untriagedOrders, reviewOrders, approvedOrders, orderedOrders, deniedOrders } = useMemo(
+ () => ({
+ untriagedOrders: orders.filter((o) => o.status === "pending" && !o.fundType),
+ reviewOrders: orders.filter((o) => o.status === "pending" && o.fundType),
+ approvedOrders: orders.filter((o) => o.status === "approved"),
+ orderedOrders: orders.filter((o) => o.status === "ordered"),
+ deniedOrders: orders.filter((o) => o.status === "denied"),
+ }),
+ [orders]
+ );
+
const approvedStfCount = approvedOrders.filter((o) => o.fundType === "STF").length;
const approvedGiftCount = approvedOrders.filter((o) => o.fundType === "Gift").length;
+ function selectedIn(rows: AdminOrderRow[]): number[] {
+ return rows.filter((o) => selectedIds.has(o.id)).map((o) => o.id);
+ }
+
+ function toggleSelection(orderId: number) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(orderId)) next.delete(orderId);
+ else next.add(orderId);
+ return next;
+ });
+ }
+
+ function toggleAllIn(rows: AdminOrderRow[]) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ const allSelected = rows.every((o) => next.has(o.id));
+ for (const row of rows) {
+ if (allSelected) next.delete(row.id);
+ else next.add(row.id);
+ }
+ return next;
+ });
+ }
+
+ function makeSelection(rows: AdminOrderRow[]) {
+ const selectedCount = rows.filter((o) => selectedIds.has(o.id)).length;
+ return {
+ selectedIds,
+ onToggle: toggleSelection,
+ onToggleAll: () => toggleAllIn(rows),
+ allSelected: selectedCount === rows.length && rows.length > 0,
+ someSelected: selectedCount > 0 && selectedCount < rows.length,
+ };
+ }
+
+ async function post(url: string, body: unknown, failureMessage: string) {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ throw new Error(data?.error ?? failureMessage);
+ }
+ return res.json().catch(() => null);
+ }
+
async function runAction(order: AdminOrderRow, action: Action) {
setPending(action);
try {
- const res = await fetch(`/api/orders/${order.id}/action`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ await post(
+ `/api/orders/${order.id}/action`,
+ {
action,
denialComment: action === "deny" ? denialComment || undefined : undefined,
- }),
- });
- if (!res.ok) {
- const data = await res.json().catch(() => null);
- throw new Error(data?.error ?? "Action failed");
- }
+ },
+ "Action failed"
+ );
toast.success(action === "approve" ? "Order approved" : "Order denied");
setExpandedId(null);
setDenialComment("");
@@ -126,35 +200,66 @@ export function AdminOrderQueue({
}
}
+ async function runBulkAction(orderIds: number[], action: Action, comment?: string) {
+ if (orderIds.length === 0) return;
+ if (action === "deny" && !confirm(`Deny ${plural(orderIds.length, "order")}?`)) return;
+
+ setBusy(true);
+ try {
+ const data = await post(
+ "/api/orders/bulk-action",
+ { orderIds, action, denialComment: comment || undefined },
+ "Bulk action failed"
+ );
+ toast.success(
+ `${plural(data?.count ?? orderIds.length, "order")} ${action === "approve" ? "approved" : "denied"}`
+ );
+ setSelectedIds(new Set());
+ setExpandedId(null);
+ router.refresh();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function assignOrders(orderIds: number[], fundType: FundType, bucketId: number | null) {
+ if (orderIds.length === 0) return;
+ setBusy(true);
+ try {
+ await post(
+ "/api/orders/assign",
+ { orderIds, fundType, stfBucketId: bucketId ?? undefined },
+ "Failed to assign orders"
+ );
+ toast.success(`${plural(orderIds.length, "order")} assigned`);
+ setSelectedIds(new Set());
+ router.refresh();
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
+ } finally {
+ setBusy(false);
+ }
+ }
+
async function markApprovedAsOrdered(orderIds?: number[]) {
const count = orderIds?.length ?? approvedOrders.length;
if (count === 0) return;
- const message =
- count === 1
- ? "Move 1 approved order to the ordered archive? It will no longer appear in Excel exports."
- : `Move ${count} approved order${count === 1 ? "" : "s"} to the ordered archive? They will no longer appear in Excel exports.`;
+ const message = `Move ${plural(count, "approved order")} to the ordered archive? ${count === 1 ? "It" : "They"} will no longer appear in Excel exports.`;
if (!confirm(message)) return;
setMarkingOrdered(true);
try {
- const res = await fetch("/api/orders/mark-ordered", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(orderIds ? { orderIds } : {}),
- });
- if (!res.ok) {
- const data = await res.json().catch(() => null);
- throw new Error(data?.error ?? "Failed to mark orders as ordered");
- }
- const data = await res.json();
- toast.success(
- data.movedCount === 1
- ? "1 order moved to ordered"
- : `${data.movedCount} orders moved to ordered`
+ const data = await post(
+ "/api/orders/mark-ordered",
+ orderIds ? { orderIds } : {},
+ "Failed to mark orders as ordered"
);
+ toast.success(`${plural(data?.movedCount ?? count, "order")} moved to ordered`);
setExpandedId(null);
- setSelectedApprovedIds(new Set());
+ setSelectedIds(new Set());
router.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Something went wrong");
@@ -163,22 +268,6 @@ export function AdminOrderQueue({
}
}
- function toggleApprovedSelection(orderId: number) {
- setSelectedApprovedIds((prev) => {
- const next = new Set(prev);
- if (next.has(orderId)) next.delete(orderId);
- else next.add(orderId);
- return next;
- });
- }
-
- function toggleAllApprovedSelection() {
- setSelectedApprovedIds((prev) => {
- if (prev.size === approvedOrders.length) return new Set();
- return new Set(approvedOrders.map((o) => o.id));
- });
- }
-
async function deleteOrder(order: AdminOrderRow) {
const message =
order.status === "approved" || order.status === "ordered"
@@ -211,9 +300,22 @@ export function AdminOrderQueue({
);
}
+ const sharedSectionProps = {
+ expandedId,
+ onToggle: (id: number) => {
+ setExpandedId((prev) => (prev === id ? null : id));
+ setDenialComment("");
+ },
+ denialComment,
+ onDenialCommentChange: setDenialComment,
+ pending,
+ onAction: runAction,
+ orderPricing,
+ };
+
return (
- {(approvedStfCount > 0 || approvedGiftCount > 0 || approvedOrders.length > 0) && (
+ {approvedOrders.length > 0 && (
copyText(
formatApprovedStfOrders(orders, pricingSettings),
- `${approvedStfCount} approved STF order${approvedStfCount === 1 ? "" : "s"}`
+ plural(approvedStfCount, "approved STF order")
)
}
>
@@ -236,7 +338,7 @@ export function AdminOrderQueue({
onClick={() =>
copyText(
formatApprovedGiftOrders(orders),
- `${approvedGiftCount} approved Gift order${approvedGiftCount === 1 ? "" : "s"}`
+ plural(approvedGiftCount, "approved Gift order")
)
}
>
@@ -245,7 +347,7 @@ export function AdminOrderQueue({
markApprovedAsOrdered()}
>
@@ -253,88 +355,99 @@ export function AdminOrderQueue({
)}
+
+ {untriagedOrders.length > 0 ? (
+
+ assignOrders(selectedIn(untriagedOrders), fundType, bucketId)
+ }
+ onDeny={(comment) =>
+ runBulkAction(selectedIn(untriagedOrders), "deny", comment)
+ }
+ />
+ }
+ />
+ ) : null}
+
{
- setExpandedId((prev) => (prev === id ? null : id));
- setDenialComment("");
- }}
- denialComment={denialComment}
- onDenialCommentChange={setDenialComment}
- pending={pending}
- onAction={runAction}
+ {...sharedSectionProps}
+ title="Ready to review"
+ description={
+ reviewOrders.length > 0
+ ? "Assigned to a fund and waiting on an approval decision."
+ : undefined
+ }
+ orders={reviewOrders}
showActions
- orderPricing={orderPricing}
+ selection={makeSelection(reviewOrders)}
+ toolbar={
+ runBulkAction(selectedIn(reviewOrders), "approve")}
+ onDeny={(comment) =>
+ runBulkAction(selectedIn(reviewOrders), "deny", comment)
+ }
+ onReassign={(fundType, bucketId) =>
+ assignOrders(selectedIn(reviewOrders), fundType, bucketId)
+ }
+ />
+ }
/>
+
{approvedOrders.length > 0 ? (
setExpandedId((prev) => (prev === id ? null : id))}
- denialComment={denialComment}
- onDenialCommentChange={setDenialComment}
- pending={pending}
- onAction={runAction}
showActions={false}
- orderPricing={orderPricing}
onDelete={deleteOrder}
deletingId={deletingId}
- selection={{
- selectedIds: selectedApprovedIds,
- onToggle: toggleApprovedSelection,
- onToggleAll: toggleAllApprovedSelection,
- allSelected:
- selectedApprovedIds.size === approvedOrders.length &&
- approvedOrders.length > 0,
- someSelected:
- selectedApprovedIds.size > 0 &&
- selectedApprovedIds.size < approvedOrders.length,
- }}
+ selection={makeSelection(approvedOrders)}
headerAction={
markApprovedAsOrdered(Array.from(selectedApprovedIds))}
+ disabled={selectedIn(approvedOrders).length === 0 || markingOrdered}
+ onClick={() => markApprovedAsOrdered(selectedIn(approvedOrders))}
>
- Move selected to ordered ({selectedApprovedIds.size})
+ Move selected to ordered ({selectedIn(approvedOrders).length})
}
/>
) : null}
+
{deniedOrders.length > 0 ? (
setExpandedId((prev) => (prev === id ? null : id))}
- denialComment={denialComment}
- onDenialCommentChange={setDenialComment}
- pending={pending}
- onAction={runAction}
showActions={false}
- orderPricing={orderPricing}
onDelete={deleteOrder}
deletingId={deletingId}
/>
) : null}
+
{orderedOrders.length > 0 ? (
setExpandedId((prev) => (prev === id ? null : id))}
- denialComment={denialComment}
- onDenialCommentChange={setDenialComment}
- pending={pending}
- onAction={runAction}
showActions={false}
- orderPricing={orderPricing}
onDelete={deleteOrder}
deletingId={deletingId}
/>
@@ -343,6 +456,193 @@ export function AdminOrderQueue({
);
}
+function BucketSelect({
+ stfBuckets,
+ value,
+ onChange,
+}: {
+ stfBuckets: StfBucketOption[];
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+ [String(b.id), b.name]))}
+ value={value}
+ onValueChange={(v) => onChange(v ?? "")}
+ >
+
+
+
+
+ {stfBuckets.map((bucket) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+function FundAssignControls({
+ stfBuckets,
+ selectedCount,
+ busy,
+ onAssign,
+ label,
+}: {
+ stfBuckets: StfBucketOption[];
+ selectedCount: number;
+ busy: boolean;
+ onAssign: (fundType: FundType, bucketId: number | null) => void;
+ label: string;
+}) {
+ const [fundType, setFundType] = useState("");
+ const [bucketId, setBucketId] = useState("");
+
+ const ready = selectedCount > 0 && fundType !== "" && (fundType === "Gift" || bucketId !== "");
+
+ return (
+
+ {
+ setFundType((v as FundType | null) ?? "");
+ setBucketId("");
+ }}
+ >
+
+
+
+
+ STF
+ Gift
+
+
+
+ {fundType === "STF" ? (
+
+ ) : null}
+
+ onAssign(fundType as FundType, bucketId ? Number(bucketId) : null)}
+ >
+ {label} ({selectedCount})
+
+
+ );
+}
+
+function BulkDenyControl({
+ selectedCount,
+ busy,
+ onDeny,
+}: {
+ selectedCount: number;
+ busy: boolean;
+ onDeny: (comment: string) => void;
+}) {
+ const [comment, setComment] = useState("");
+ return (
+
+ setComment(e.target.value)}
+ placeholder="Denial reason (optional)"
+ aria-label="Denial reason for selected orders"
+ className="border-input bg-background h-9 w-56 rounded-md border px-3 text-sm"
+ />
+ onDeny(comment)}
+ >
+ Deny selected ({selectedCount})
+
+
+ );
+}
+
+function TriageToolbar({
+ stfBuckets,
+ selectedCount,
+ busy,
+ onAssign,
+ onDeny,
+}: {
+ stfBuckets: StfBucketOption[];
+ selectedCount: number;
+ busy: boolean;
+ onAssign: (fundType: FundType, bucketId: number | null) => void;
+ onDeny: (comment: string) => void;
+}) {
+ return (
+
+
+ {selectedCount > 0 ? `${selectedCount} selected` : "Select orders to assign"}
+
+
+
+
+ );
+}
+
+function ReviewToolbar({
+ stfBuckets,
+ selectedCount,
+ busy,
+ onApprove,
+ onDeny,
+ onReassign,
+}: {
+ stfBuckets: StfBucketOption[];
+ selectedCount: number;
+ busy: boolean;
+ onApprove: () => void;
+ onDeny: (comment: string) => void;
+ onReassign: (fundType: FundType, bucketId: number | null) => void;
+}) {
+ return (
+
+
+ {selectedCount > 0 ? `${selectedCount} selected` : "Select orders to review"}
+
+
+ Approve selected ({selectedCount})
+
+
+
+
+ );
+}
+
function OrderSection({
title,
description,
@@ -359,6 +659,7 @@ function OrderSection({
deletingId,
selection,
headerAction,
+ toolbar,
}: {
title: string;
description?: string;
@@ -381,6 +682,7 @@ function OrderSection({
someSelected: boolean;
};
headerAction?: ReactNode;
+ toolbar?: ReactNode;
}) {
if (orders.length === 0) return null;
@@ -391,13 +693,17 @@ function OrderSection({
-
{title}
+
+ {title}{" "}
+ ({orders.length})
+
{description ? (
{description}
) : null}
{headerAction ?
{headerAction}
: null}
+ {toolbar}
@@ -406,7 +712,7 @@ function OrderSection({
{
if (el) el.indeterminate = selection.someSelected;
@@ -432,7 +738,6 @@ function OrderSection({
return (
onToggle(o.id)}
>
@@ -454,10 +759,21 @@ function OrderSection({
{o.itemName}
- {o.fundType}
- {o.stfBucketName ? ` · ${o.stfBucketName}` : ""}
+ {o.fundType ? (
+ <>
+ {o.fundType}
+ {o.stfBucketName ? ` · ${o.stfBucketName}` : ""}
+ >
+ ) : (
+
+ Unassigned
+
+ )}
+ {o.fundType ? null : (
+ est.
+ )}
{formatPriceCents(
orderChargeCents(
o.fundType,
@@ -475,7 +791,7 @@ function OrderSection({
{expanded ? (
-
+
-
+
Link
diff --git a/src/components/orders/OrderForm.tsx b/src/components/orders/OrderForm.tsx
index 59330f4..a0b73e1 100644
--- a/src/components/orders/OrderForm.tsx
+++ b/src/components/orders/OrderForm.tsx
@@ -1,9 +1,10 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
+import { Copy, Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
-import { useEffect, useMemo, useState } from "react";
-import { useForm, useWatch } from "react-hook-form";
+import { type ReactNode, useEffect, useMemo, useState } from "react";
+import { type ControllerRenderProps, useFieldArray, useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -18,91 +19,59 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
-import {
- BalanceAmount,
- isOverBudget,
- stfBucketSelectLabel,
- StfBucketSelectItemContent,
-} from "@/components/BalanceAmount";
-import type { FundType, OrderStatus } from "@/lib/db/schema";
-import { orderChargeCents, displayPercentToBps } from "@/lib/finance/order-pricing";
+import type { OrderStatus } from "@/lib/db/schema";
+import { computeOrderTotalCents, displayPercentToBps } from "@/lib/finance/order-pricing";
+import { MAX_ORDER_BATCH_ITEMS } from "@/lib/validation";
import { cn, formatPriceCents } from "@/lib/utils";
-type StfBucketBalance = {
- id: number;
- name: string;
- remainingBalanceCents: number;
-};
+import { PasteItemsPanel, type ParsedItem } from "./PasteItemsPanel";
+
+type OrderPricing = { taxPercent: number; shippingPercent: number };
+
+const itemSchema = z.object({
+ vendor: z.string().min(1, "Vendor is required").max(200),
+ link: z.string().url("Enter a valid URL").max(500),
+ itemName: z.string().min(1, "Item name is required").max(200),
+ partNumber: z.string().max(100).optional(),
+ quantity: z
+ .string()
+ .min(1, "Required")
+ .regex(/^\d+$/, "Whole number")
+ .refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"),
+ unitCost: z
+ .string()
+ .min(1, "Unit cost is required")
+ .refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid amount"),
+ notes: z.string().max(2000).optional(),
+});
+
+const formSchema = z.object({
+ items: z.array(itemSchema).min(1).max(MAX_ORDER_BATCH_ITEMS),
+});
-type Balances = {
- giftBalanceCents: number;
- stfBuckets: StfBucketBalance[];
- orderPricing: {
- taxPercent: number;
- shippingPercent: number;
- };
+type FormValues = z.infer
;
+type ItemValues = z.infer;
+
+const emptyItem: ItemValues = {
+ vendor: "",
+ link: "",
+ itemName: "",
+ partNumber: "",
+ quantity: "1",
+ unitCost: "",
+ notes: "",
};
-const formSchema = z
- .object({
- fundType: z.enum(["STF", "Gift"], { message: "Select a fund type" }),
- stfBucketId: z.string().optional(),
- vendor: z.string().min(1, "Vendor is required").max(200),
- link: z.string().url("Enter a valid URL").max(500),
- itemName: z.string().min(1, "Item name is required").max(200),
- partNumber: z.string().max(100).optional(),
- quantity: z
- .string()
- .min(1, "Required")
- .regex(/^\d+$/, "Whole number")
- .refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"),
- unitCost: z
- .string()
- .min(1, "Unit cost is required")
- .refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid amount"),
- notes: z.string().max(2000).optional(),
- })
- .superRefine((data, ctx) => {
- if (data.fundType === "STF") {
- if (!data.stfBucketId) {
- ctx.addIssue({
- code: "custom",
- message: "Select an STF bucket",
- path: ["stfBucketId"],
- });
- }
- if (!data.partNumber?.trim()) {
- ctx.addIssue({
- code: "custom",
- message: "Part number is required for STF orders",
- path: ["partNumber"],
- });
- }
- }
- if (data.fundType === "Gift" && !data.notes?.trim()) {
- ctx.addIssue({
- code: "custom",
- message: "Notes are required for Gift orders",
- path: ["notes"],
- });
- }
- });
-
-type FormValues = z.infer;
+// One shared track definition keeps the header labels aligned with every row.
+// Every flexible track uses a 0 minimum so the row always fits the viewport
+// instead of forcing a horizontal scrollbar; only the fixed tracks hold width.
+const GRID_COLUMNS =
+ "lg:grid-cols-[26px_minmax(0,1.5fr)_minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,0.9fr)_56px_84px_minmax(0,1.2fr)_64px]";
export type OrderFormInitial = {
id: number;
status: OrderStatus;
- fundType: FundType;
- stfBucketId: number | null;
vendor: string;
link: string;
itemName: string;
@@ -112,10 +81,8 @@ export type OrderFormInitial = {
notes: string | null;
};
-function toFormValues(order: OrderFormInitial): FormValues {
+function toItemValues(order: OrderFormInitial): ItemValues {
return {
- fundType: order.fundType,
- stfBucketId: order.stfBucketId != null ? String(order.stfBucketId) : "",
vendor: order.vendor,
link: order.link,
itemName: order.itemName,
@@ -126,119 +93,131 @@ function toFormValues(order: OrderFormInitial): FormValues {
};
}
+// "https://www.mcmaster.com/91251A542/" -> "Mcmaster"
+function vendorFromLink(link: string): string | null {
+ try {
+ const host = new URL(link).hostname.replace(/^www\./, "");
+ const name = host.split(".")[0];
+ if (!name || name.length < 2) return null;
+ return name.charAt(0).toUpperCase() + name.slice(1);
+ } catch {
+ return null;
+ }
+}
+
+// Only fills a vendor the user has not typed themselves.
+function fillVendorFromLink(
+ form: ReturnType>,
+ index: number,
+ link: string
+) {
+ const guess = vendorFromLink(link);
+ if (guess && !form.getValues(`items.${index}.vendor`)) {
+ form.setValue(`items.${index}.vendor`, guess, { shouldValidate: true });
+ }
+}
+
export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial }) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
- const [balances, setBalances] = useState(null);
- const [loadingBalances, setLoadingBalances] = useState(true);
+ const [pricing, setPricing] = useState(null);
+
+ const isEditing = initialOrder != null;
const form = useForm({
resolver: zodResolver(formSchema),
- defaultValues: initialOrder
- ? toFormValues(initialOrder)
- : {
- fundType: undefined,
- stfBucketId: "",
- vendor: "",
- link: "",
- itemName: "",
- partNumber: "",
- quantity: "1",
- unitCost: "",
- notes: "",
- },
+ defaultValues: {
+ items: [initialOrder ? toItemValues(initialOrder) : { ...emptyItem }],
+ },
});
- const [fundType, stfBucketId, quantity, unitCost] = useWatch({
- control: form.control,
- name: ["fundType", "stfBucketId", "quantity", "unitCost"],
- });
+ const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" });
+ const items = useWatch({ control: form.control, name: "items" });
useEffect(() => {
fetch("/api/orders/balances")
.then((res) => (res.ok ? res.json() : null))
- .then((data: Balances | null) => setBalances(data))
- .finally(() => setLoadingBalances(false));
+ .then((data: { orderPricing?: OrderPricing } | null) =>
+ setPricing(data?.orderPricing ?? null)
+ )
+ .catch(() => setPricing(null));
}, []);
- const totalCostCents = useMemo(() => {
- const qty = Number(quantity);
- const cost = Number(unitCost);
- const pricing = balances?.orderPricing;
- if (!Number.isFinite(qty) || !Number.isFinite(cost) || qty < 1 || cost <= 0) return null;
- if (!pricing || !fundType) return null;
- const unitCostCents = Math.round(cost * 100);
+ const estimatedTotalCents = useMemo(() => {
+ if (!pricing || !items) return null;
const settings = {
taxPercentBps: displayPercentToBps(pricing.taxPercent),
shippingPercentBps: displayPercentToBps(pricing.shippingPercent),
};
- return orderChargeCents(fundType, qty, unitCostCents, settings);
- }, [quantity, unitCost, balances?.orderPricing, fundType]);
-
- const balanceError = useMemo(() => {
- if (!fundType || totalCostCents == null || !balances) return null;
-
- if (fundType === "Gift") {
- if (totalCostCents > balances.giftBalanceCents) {
- return `This order exceeds the remaining balance in Gift Fund. Available: ${formatPriceCents(balances.giftBalanceCents)}, Order total: ${formatPriceCents(totalCostCents)}.`;
- }
- return null;
+ let total = 0;
+ for (const item of items) {
+ const qty = Number(item?.quantity);
+ const cost = Number(item?.unitCost);
+ if (!Number.isFinite(qty) || !Number.isFinite(cost) || qty < 1 || cost <= 0) continue;
+ total += computeOrderTotalCents(qty, Math.round(cost * 100), settings);
}
-
- const bucket = balances.stfBuckets.find((b) => String(b.id) === stfBucketId);
- if (!bucket) return null;
- if (totalCostCents > bucket.remainingBalanceCents) {
- const availableLabel = isOverBudget(bucket.remainingBalanceCents)
- ? `Over by ${formatPriceCents(Math.abs(bucket.remainingBalanceCents))}`
- : formatPriceCents(bucket.remainingBalanceCents);
- return `This order exceeds the remaining balance in ${bucket.name}. Available: ${availableLabel}, Order total: ${formatPriceCents(totalCostCents)}.`;
+ return total > 0 ? total : null;
+ }, [items, pricing]);
+
+ function addItems(parsed: ParsedItem[]) {
+ const room = MAX_ORDER_BATCH_ITEMS - fields.length;
+ const accepted = parsed.slice(0, Math.max(0, room));
+ if (accepted.length === 0) {
+ toast.error(`Limit is ${MAX_ORDER_BATCH_ITEMS} items per submission`);
+ return;
}
- return null;
- }, [balances, fundType, stfBucketId, totalCostCents]);
+ append(accepted.map((item) => ({ ...emptyItem, ...item })));
+ toast.success(`Added ${accepted.length} item${accepted.length === 1 ? "" : "s"}`);
+ if (accepted.length < parsed.length) {
+ toast.warning(`${parsed.length - accepted.length} skipped — batch limit reached`);
+ }
+ }
- const canSubmit =
- !!fundType &&
- !balanceError &&
- !submitting &&
- !loadingBalances &&
- balances !== null &&
- (fundType !== "STF" || balances.stfBuckets.length > 0);
+ function duplicateItem(index: number) {
+ if (fields.length >= MAX_ORDER_BATCH_ITEMS) {
+ toast.error(`Limit is ${MAX_ORDER_BATCH_ITEMS} items per submission`);
+ return;
+ }
+ append({ ...form.getValues(`items.${index}`) });
+ }
async function onSubmit(values: FormValues) {
- if (balanceError) return;
setSubmitting(true);
try {
- const payload = {
- fundType: values.fundType,
- stfBucketId: values.fundType === "STF" ? Number(values.stfBucketId) : undefined,
- vendor: values.vendor,
- link: values.link,
- itemName: values.itemName,
- partNumber: values.partNumber || undefined,
- quantity: Number(values.quantity),
- unitCost: Number(values.unitCost),
- notes: values.notes || undefined,
- };
-
- const res = await fetch(
- initialOrder ? `/api/orders/${initialOrder.id}` : "/api/orders",
- {
- method: initialOrder ? "PATCH" : "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- }
- );
+ const payloadItems = values.items.map((item) => ({
+ vendor: item.vendor,
+ link: item.link,
+ itemName: item.itemName,
+ partNumber: item.partNumber || undefined,
+ quantity: Number(item.quantity),
+ unitCost: Number(item.unitCost),
+ notes: item.notes || undefined,
+ }));
+
+ const res = await fetch(isEditing ? `/api/orders/${initialOrder.id}` : "/api/orders", {
+ method: isEditing ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(isEditing ? payloadItems[0] : { items: payloadItems }),
+ });
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error ?? "Failed to submit order");
}
- toast.success(
- initialOrder
- ? initialOrder.status === "denied"
+
+ if (isEditing) {
+ toast.success(
+ initialOrder.status === "denied"
? "Order resubmitted for review"
: "Order updated"
- : "Order submitted for review"
- );
+ );
+ } else {
+ const count = payloadItems.length;
+ toast.success(
+ count === 1
+ ? "Order submitted for review"
+ : `${count} orders submitted for review`
+ );
+ }
router.push("/orders");
router.refresh();
} catch (err) {
@@ -247,128 +226,180 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial })
}
}
- const fundTypeItems = { STF: "STF", Gift: "Gift" };
+ const multiple = fields.length > 1;
return (