diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 28a4e78..68ebdc9 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -56,6 +56,40 @@ Access control isn't handled in one place - it's layered, and each layer exists Only three files in the entire `src/app` tree are `"use client"` at the page/layout level (`global-error.tsx`, the dashboard `error.tsx`, and the Sentry example page) - all three are boundaries Next.js or Sentry require to be client components. Everything else that needs interactivity is a small client component imported _into_ a server page, not the page itself. +## The ordering workflow + +Parts ordering is the largest feature, and it splits deliberately between what a +member does and what an officer does. + +**Members submit items only.** The submission form asks for vendor, link, item +name, part number, quantity, cost and notes - no fund type and no STF bucket. +One form can carry many items (see the repeatable-rows section in +[Forms & Validation](/tech-stack/forms-validation/)), and `POST /api/orders` +writes them all in one request, stamping a shared `batchId` when there is more +than one. Orders therefore land with `fundType`, `stfBucketId` and `quarterId` +all null. + +**Officers triage in batches.** `/admin/orders` groups pending orders into +"Needs triage" (no fund yet) and "Ready to review" (assigned). Officers +multi-select rows and act on the whole selection: + +| Endpoint | What it does | +| ------------------------------ | ---------------------------------------------------------------- | +| `POST /api/orders/assign` | Sets fund type, bucket and active quarter on the selected orders | +| `POST /api/orders/bulk-action` | Approves or denies the selected orders | + +**Balance checks run at assignment and approval, never at submission.** A member +has no bucket to check against, and a balance checked at submit time is stale by +the time an officer reviews it. Two consequences worth knowing: + +- Approving a selection checks each fund/bucket group's total _at once_ + (`validateBatchBalance`). Checking orders one at a time would let a batch + overdraw a bucket that each individual order fits inside. +- Assigning checks the selection _plus everything already queued against that + bucket_ (`validateAssignmentBalance`). Pending orders don't reduce a bucket's + remaining balance - only approved and ordered ones do - so without this an + officer could quietly park more in a bucket than could ever be approved. + ## Where each concern is documented | Concern | Page | diff --git a/docs/tech-stack/database.mdx b/docs/tech-stack/database.mdx index 3b57ee1..6518dae 100644 --- a/docs/tech-stack/database.mdx +++ b/docs/tech-stack/database.mdx @@ -21,19 +21,18 @@ description: How the schema, migrations, seeding, and query patterns work. Tables are defined with Drizzle's `sqliteTable()`. This file re-exports the auth tables from `src/lib/db/auth-schema.ts` (see below) and defines the app-specific ones: -| Table | Represents | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `team` | The club's FRC subteams (Arm, Autonomous, Chassis, etc.) | -| `stfQuarter` / `stfBucket` | School-year funding periods and their budget buckets ("STF" = Student Tech Fund), each with a starting balance in cents | -| `giftFund` / `financeSettings` | Singleton rows (fixed `id`) tracking the gift-fund balance and tax/shipping percentages | -| `orders` | Part purchase requests - vendor, link, item, cost, `status` (pending/approved/denied/ordered), fund type (STF/Gift) | -| `giftFundLog`, `orderHistory` | Audit trails for balance changes and order status transitions | -| `apiKey` | One-way hashed service API keys for the external simulation-script API (distinct from the vault - see [Security](/tech-stack/security/)) | -| `vaultEntry` / `vaultEntryAccess` | The shared credential vault and its per-user read grants | -| `minecraftWhitelist` | Minecraft whitelist requests (username, status, admin review) | -| `networkJoinRequest` | Tailscale/network device join requests | -| `userFeature` | Per-user feature-flag grant requests (pending/granted/rejected) - what `middleware.ts` checks for non-admin routes | -| `simExportCache` | On-disk cache index for generated OnShape simulation export archives, keyed on document/workspace/element ID - see [External Integrations](/tech-stack/external-integrations/#onshape-simulation-export-proxy) | +| Table | Represents | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `team` | The club's FRC subteams (Arm, Autonomous, Chassis, etc.) | +| `stfQuarter` / `stfBucket` | School-year funding periods and their budget buckets ("STF" = Student Tech Fund), each with a starting balance in cents | +| `giftFund` / `financeSettings` | Singleton rows (fixed `id`) tracking the gift-fund balance and tax/shipping percentages | +| `orders` | Part purchase requests - vendor, link, item, cost, `status` (pending/approved/denied/ordered), fund type (STF/Gift, null until triaged), `batchId` grouping a multi-item submission | +| `giftFundLog`, `orderHistory` | Audit trails for balance changes and order status transitions | +| `apiKey` | One-way hashed service API keys for the external simulation-script API (distinct from the vault - see [Security](/tech-stack/security/)) | +| `vaultEntry` / `vaultEntryAccess` | The shared credential vault and its per-user read grants | +| `minecraftWhitelist` | Minecraft whitelist requests (username, status, admin review) | +| `networkJoinRequest` | Tailscale/network device join requests | +| `userFeature` | Per-user feature-flag grant requests (pending/granted/rejected) - what `middleware.ts` checks for non-admin routes | Money is always stored as integer cents (`unitCostCents`, `startingBalanceCents`, etc.), never floats - this avoids floating-point rounding errors in financial arithmetic. Timestamps are stored as millisecond integers via a shared SQL fragment: diff --git a/docs/tech-stack/forms-validation.mdx b/docs/tech-stack/forms-validation.mdx index d7995c5..1757655 100644 --- a/docs/tech-stack/forms-validation.mdx +++ b/docs/tech-stack/forms-validation.mdx @@ -15,36 +15,36 @@ None of these are Next.js-specific - this is a general pattern for any React app Every form-heavy component (`OrderForm`, `LoginForm`, `SettingsForm`, `JoinRequestForm`, `WhitelistRequestForm`, `VaultEntryDialog`) follows the same shape. Using `src/components/orders/OrderForm.tsx` as the concrete example: -**1. Define a Zod schema at the top of the file**, including cross-field rules via `.superRefine()`: +**1. Define a Zod schema at the top of the file.** `OrderForm` submits a list of +items, so its schema wraps a per-item schema in an array: ```ts -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), - quantity: z - .string() - .min(1, "Required") - .regex(/^\d+$/, "Whole number") - .refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"), - // ... - }) - .superRefine((data, ctx) => { - if (data.fundType === "STF" && !data.stfBucketId) { - ctx.addIssue({ - code: "custom", - message: "Select an STF bucket", - path: ["stfBucketId"], - }); - } - }); +const itemSchema = z.object({ + vendor: z.string().min(1, "Vendor is required").max(200), + link: z.string().url("Enter a valid URL").max(500), + quantity: z + .string() + .min(1, "Required") + .regex(/^\d+$/, "Whole number") + .refine((v) => Number(v) >= 1 && Number(v) <= 9999, "Between 1 and 9999"), + // ... +}); + +const formSchema = z.object({ + items: z.array(itemSchema).min(1).max(MAX_ORDER_BATCH_ITEMS), +}); type FormValues = z.infer; ``` -`.superRefine()` is what handles validation that depends on _more than one field at once_ - here, "an STF bucket is required, but only if fundType is STF." A field-level `z.string().min(1)` can't express that; `superRefine` can add an issue to any field's `path` based on the whole object. +Errors are reported per row, at paths like `items.2.link`, so one bad row in a +batch highlights that row rather than failing the whole form opaquely. + +When validation depends on _more than one field at once_, use `.superRefine()`, +which can attach an issue to any field's `path` based on the whole object. A +field-level `z.string().min(1)` can't express that. `orderAssignSchema` in +`src/lib/validation.ts` is the current example: an STF assignment requires a +bucket, a Gift assignment does not. **2. Wire the schema into `useForm`:** @@ -79,7 +79,10 @@ const form = useForm({ async function onSubmit(values: FormValues) { setSubmitting(true); try { - const res = await fetch("/api/orders", { method: "POST", body: JSON.stringify(values) }); + const res = await fetch("/api/orders", { + method: "POST", + body: JSON.stringify({ items: values.items }), + }); if (!res.ok) throw new Error(await res.text()); toast.success("Order submitted"); router.push("/orders"); @@ -97,4 +100,25 @@ Notice `quantity` and `unitCost` are `z.string()` with a `.regex()`/`.refine()` ## Where else this pattern shows up -If you're adding a new form, don't design a new pattern - copy the shape from the closest existing example: `src/components/auth/LoginForm.tsx` (simple, no cross-field validation), or `src/components/orders/OrderForm.tsx` (cross-field validation via `superRefine`, edit-vs-create mode via an optional `initialOrder` prop). +If you're adding a new form, don't design a new pattern - copy the shape from the closest existing example: `src/components/auth/LoginForm.tsx` (simple, single-object form), or `src/components/orders/OrderForm.tsx` (repeatable rows via `useFieldArray`, edit-vs-create mode via an optional `initialOrder` prop). + +## Repeatable rows + +`OrderForm` lets a member submit many items at once. It uses react-hook-form's +`useFieldArray` over the `items` array, rendering one set of fields per row: + +```ts +const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" }); +``` + +Two rules keep this from feeling heavier than a single-item form: + +- A lone row renders as a plain form, with no card chrome, index heading or + remove button. The batch affordances only appear once there is more than one + row, so the common case is unchanged. +- Field names are template paths (`` `items.${index}.vendor` ``), which is what + lets Zod's per-row issue paths line up with the right input. + +`PasteItemsPanel` is the bulk entry path: it parses tab- or comma-separated rows +pasted from a spreadsheet into item objects and appends them all at once. Its +parser is pure and unit-tested in `src/components/orders/paste-items.test.ts`. diff --git a/drizzle/migrations/0012_smiling_loa.sql b/drizzle/migrations/0012_smiling_loa.sql new file mode 100644 index 0000000..5fa0718 --- /dev/null +++ b/drizzle/migrations/0012_smiling_loa.sql @@ -0,0 +1,34 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_orders` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` text NOT NULL, + `fund_type` text, + `stf_bucket_id` integer, + `batch_id` text, + `assigned_by` text, + `assigned_at` integer, + `quarter_id` integer, + `vendor` text NOT NULL, + `link` text NOT NULL, + `item_name` text NOT NULL, + `part_number` text, + `quantity` integer DEFAULT 1 NOT NULL, + `unit_cost_cents` integer NOT NULL, + `notes` text, + `status` text DEFAULT 'pending' NOT NULL, + `denial_comment` text, + `reviewed_by` text, + `reviewed_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`stf_bucket_id`) REFERENCES `stf_bucket`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`assigned_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`quarter_id`) REFERENCES `stf_quarter`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`reviewed_by`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +INSERT INTO `__new_orders`("id", "user_id", "fund_type", "stf_bucket_id", "batch_id", "assigned_by", "assigned_at", "quarter_id", "vendor", "link", "item_name", "part_number", "quantity", "unit_cost_cents", "notes", "status", "denial_comment", "reviewed_by", "reviewed_at", "created_at") SELECT "id", "user_id", "fund_type", "stf_bucket_id", NULL, NULL, NULL, "quarter_id", "vendor", "link", "item_name", "part_number", "quantity", "unit_cost_cents", "notes", "status", "denial_comment", "reviewed_by", "reviewed_at", "created_at" FROM `orders`;--> statement-breakpoint +DROP TABLE `orders`;--> statement-breakpoint +ALTER TABLE `__new_orders` RENAME TO `orders`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `orders_batch_id_idx` ON `orders` (`batch_id`); \ No newline at end of file diff --git a/drizzle/migrations/meta/0011_snapshot.json b/drizzle/migrations/meta/0011_snapshot.json index a49dd7c..552250b 100644 --- a/drizzle/migrations/meta/0011_snapshot.json +++ b/drizzle/migrations/meta/0011_snapshot.json @@ -2,7 +2,7 @@ "version": "6", "dialect": "sqlite", "id": "e1fe9584-4c06-4391-9802-c7b4bd100d23", - "prevId": "8e304c55-9e95-4399-94dc-e6aa2727175c", + "prevId": "a2f3d812-5c71-4b88-a063-9f1e2d3c4b5a", "tables": { "api_key": { "name": "api_key", diff --git a/drizzle/migrations/meta/0012_snapshot.json b/drizzle/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..5b6817e --- /dev/null +++ b/drizzle/migrations/meta/0012_snapshot.json @@ -0,0 +1,1752 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f785fb2f-0f17-4547-abc6-058c1af76eff", + "prevId": "e1fe9584-4c06-4391-9802-c7b4bd100d23", + "tables": { + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_revoked": { + "name": "is_revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "finance_settings": { + "name": "finance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tax_percent_bps": { + "name": "tax_percent_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1100 + }, + "shipping_percent_bps": { + "name": "shipping_percent_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2000 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gift_fund": { + "name": "gift_fund", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "current_value_cents": { + "name": "current_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gift_fund_log": { + "name": "gift_fund_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "previous_value_cents": { + "name": "previous_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_value_cents": { + "name": "new_value_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "gift_fund_log_changed_by_user_id_fk": { + "name": "gift_fund_log_changed_by_user_id_fk", + "tableFrom": "gift_fund_log", + "tableTo": "user", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "gift_fund_log_order_id_orders_id_fk": { + "name": "gift_fund_log_order_id_orders_id_fk", + "tableFrom": "gift_fund_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "minecraft_whitelist": { + "name": "minecraft_whitelist", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_directly": { + "name": "added_directly", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "minecraft_whitelist_user_id_user_id_fk": { + "name": "minecraft_whitelist_user_id_user_id_fk", + "tableFrom": "minecraft_whitelist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "minecraft_whitelist_reviewed_by_user_id_fk": { + "name": "minecraft_whitelist_reviewed_by_user_id_fk", + "tableFrom": "minecraft_whitelist", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "network_join_request": { + "name": "network_join_request", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "machine_key": { + "name": "machine_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "network_join_request_user_id_user_id_fk": { + "name": "network_join_request_user_id_user_id_fk", + "tableFrom": "network_join_request", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "network_join_request_reviewed_by_user_id_fk": { + "name": "network_join_request_reviewed_by_user_id_fk", + "tableFrom": "network_join_request", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orders": { + "name": "orders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fund_type": { + "name": "fund_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stf_bucket_id": { + "name": "stf_bucket_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quarter_id": { + "name": "quarter_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_name": { + "name": "item_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "denial_comment": { + "name": "denial_comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "orders_batch_id_idx": { + "name": "orders_batch_id_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orders_user_id_user_id_fk": { + "name": "orders_user_id_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orders_stf_bucket_id_stf_bucket_id_fk": { + "name": "orders_stf_bucket_id_stf_bucket_id_fk", + "tableFrom": "orders", + "tableTo": "stf_bucket", + "columnsFrom": [ + "stf_bucket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_assigned_by_user_id_fk": { + "name": "orders_assigned_by_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_quarter_id_stf_quarter_id_fk": { + "name": "orders_quarter_id_stf_quarter_id_fk", + "tableFrom": "orders", + "tableTo": "stf_quarter", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "orders_reviewed_by_user_id_fk": { + "name": "orders_reviewed_by_user_id_fk", + "tableFrom": "orders", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "order_history": { + "name": "order_history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "order_id": { + "name": "order_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_status": { + "name": "from_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_status": { + "name": "to_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_at": { + "name": "changed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "order_history_order_id_orders_id_fk": { + "name": "order_history_order_id_orders_id_fk", + "tableFrom": "order_history", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_history_changed_by_user_id_fk": { + "name": "order_history_changed_by_user_id_fk", + "tableFrom": "order_history", + "tableTo": "user", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sim_export_cache": { + "name": "sim_export_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "element_id": { + "name": "element_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_path": { + "name": "archive_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archive_size_bytes": { + "name": "archive_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cached_at": { + "name": "cached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "sim_export_cache_key": { + "name": "sim_export_cache_key", + "columns": [ + "document_id", + "workspace_id", + "element_id" + ], + "isUnique": true + }, + "sim_export_cache_accessed": { + "name": "sim_export_cache_accessed", + "columns": [ + "last_accessed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stf_bucket": { + "name": "stf_bucket", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "quarter_id": { + "name": "quarter_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starting_balance_cents": { + "name": "starting_balance_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "stf_bucket_quarter_id_stf_quarter_id_fk": { + "name": "stf_bucket_quarter_id_stf_quarter_id_fk", + "tableFrom": "stf_bucket", + "tableTo": "stf_quarter", + "columnsFrom": [ + "quarter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stf_quarter": { + "name": "stf_quarter", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "stf_quarter_name_unique": { + "name": "stf_quarter_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_name_unique": { + "name": "team_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_feature": { + "name": "user_feature", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feature_key": { + "name": "feature_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "request_note": { + "name": "request_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_note": { + "name": "admin_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_at": { + "name": "requested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_feature_unique": { + "name": "user_feature_unique", + "columns": [ + "user_id", + "feature_key" + ], + "isUnique": true + }, + "user_feature_userId_idx": { + "name": "user_feature_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_feature_user_id_user_id_fk": { + "name": "user_feature_user_id_user_id_fk", + "tableFrom": "user_feature", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_feature_reviewed_by_user_id_fk": { + "name": "user_feature_reviewed_by_user_id_fk", + "tableFrom": "user_feature", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_entry": { + "name": "vault_entry", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_entry_created_by_user_id_fk": { + "name": "vault_entry_created_by_user_id_fk", + "tableFrom": "vault_entry", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vault_entry_access": { + "name": "vault_entry_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entry_id": { + "name": "entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "vault_entry_access_entry_user": { + "name": "vault_entry_access_entry_user", + "columns": [ + "entry_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "vault_entry_access_entry_id_vault_entry_id_fk": { + "name": "vault_entry_access_entry_id_vault_entry_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "vault_entry", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_entry_access_user_id_user_id_fk": { + "name": "vault_entry_access_user_id_user_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vault_entry_access_granted_by_user_id_fk": { + "name": "vault_entry_access_granted_by_user_id_fk", + "tableFrom": "vault_entry_access", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'member'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "can_access_vault": { + "name": "can_access_vault", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "approved": { + "name": "approved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "name_changed_at": { + "name": "name_changed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index d3edc43..9897df6 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1784613473079, "tag": "0011_common_pepper_potts", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1787267817158, + "tag": "0012_smiling_loa", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/seed.ts b/scripts/seed.ts index 387e152..ebc8299 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -118,6 +118,7 @@ async function main() { const electronics = db.select().from(stfBucket).where(eq(stfBucket.name, "Electronics")).get(); const SEED_ITEM_PREFIX = "[seed] "; + const UNTRIAGED_BATCH_ID = "seed-untriaged-batch"; const seedOrders = [ { itemName: `${SEED_ITEM_PREFIX}1/4-20 hex bolt assortment`, @@ -276,6 +277,43 @@ async function main() { status: "denied" as const, denialComment: "Too expensive for this application. Resubmit with steel hardware.", }, + // Untriaged: submitted together as one batch, awaiting officer triage. + { + itemName: `${SEED_ITEM_PREFIX}M3 x 10mm standoff (100pk)`, + fundType: null, + stfBucketName: null, + vendor: "McMaster-Carr", + link: "https://example.com/m3-standoff", + partNumber: "93657A101", + quantity: 1, + unitCostCents: 1899, + notes: "Electronics board mounting", + status: "pending" as const, + }, + { + itemName: `${SEED_ITEM_PREFIX}Heat shrink tubing kit`, + fundType: null, + stfBucketName: null, + vendor: "Amazon", + link: "https://example.com/heat-shrink", + partNumber: null, + quantity: 2, + unitCostCents: 1299, + notes: null, + status: "pending" as const, + }, + { + itemName: `${SEED_ITEM_PREFIX}Loctite 242 threadlocker`, + fundType: null, + stfBucketName: null, + vendor: "Grainger", + link: "https://example.com/loctite-242", + partNumber: "24221", + quantity: 3, + unitCostCents: 1150, + notes: "Drivetrain fastener retention", + status: "pending" as const, + }, ]; if (adminUser && quarter && mechanical && electronics) { @@ -296,6 +334,7 @@ async function main() { const values = { userId: adminUser.id, fundType: seed.fundType, + batchId: seed.fundType === null ? UNTRIAGED_BATCH_ID : null, stfBucketId, quarterId: seed.fundType === "STF" ? quarter.id : null, vendor: seed.vendor, @@ -326,10 +365,11 @@ async function main() { const approved = seedOrders.filter((o) => o.status === "approved").length; const pending = seedOrders.filter((o) => o.status === "pending").length; const denied = seedOrders.filter((o) => o.status === "denied").length; + const untriaged = seedOrders.filter((o) => o.fundType === null).length; if (inserted > 0 || reset > 0) { console.log( - `Sample orders: ${inserted} inserted, ${reset} reset (${ordered} ordered, ${approved} approved, ${pending} pending, ${denied} denied).` + `Sample orders: ${inserted} inserted, ${reset} reset (${ordered} ordered, ${approved} approved, ${pending} pending incl. ${untriaged} untriaged, ${denied} denied).` ); } } diff --git a/scripts/verify-order-form.ts b/scripts/verify-order-form.ts index 8b9ed9c..6753b35 100644 --- a/scripts/verify-order-form.ts +++ b/scripts/verify-order-form.ts @@ -18,10 +18,12 @@ import { getBucketRemainingCents, getGiftFundValueCents, getStfBucketsWithBalances, + assignOrdersToFund, orderTotalCents, + validateAssignmentBalance, validateOrderBalance, } from "../src/lib/finance/finance"; -import { orderInputSchema } from "../src/lib/validation"; +import { orderAssignSchema, orderBatchInputSchema } from "../src/lib/validation"; import { formatApprovedGiftOrders, formatApprovedStfOrders, @@ -82,40 +84,47 @@ const mechanical = buckets.find((b) => b.name === "Mechanical")!; assert(mechanical.remainingBalanceCents > 0, "Mechanical bucket should have remaining balance"); const mechanicalRemainingBefore = mechanical.remainingBalanceCents; -// --- Validation schema --- -const stfParsed = orderInputSchema.safeParse({ +// --- Validation schema (members submit items with no fund assigned) --- +const batchParsed = orderBatchInputSchema.safeParse({ + items: [ + { + vendor: "McMaster-Carr", + link: "https://example.com/part", + itemName: "Test bolt", + partNumber: "91290A115", + quantity: 2, + unitCost: 4.5, + }, + { + vendor: "Amazon", + link: "https://example.com/gift-item", + itemName: "Tape", + quantity: 1, + unitCost: 12, + notes: "For pit organization", + }, + ], +}); +assert(batchParsed.success, "Multi-item submission should validate"); +assert(batchParsed.data!.items.length === 2, "Both items should parse"); +const stfData = batchParsed.data!.items[0]; +const giftData = batchParsed.data!.items[1]; + +const emptyBatch = orderBatchInputSchema.safeParse({ items: [] }); +assert(!emptyBatch.success, "Empty submission should fail"); + +const assignParsed = orderAssignSchema.safeParse({ + orderIds: [1], fundType: "STF", stfBucketId: mechanical.id, - vendor: "McMaster-Carr", - link: "https://example.com/part", - itemName: "Test bolt", - partNumber: "91290A115", - quantity: 2, - unitCost: 4.5, }); -assert(stfParsed.success, "STF order input should validate"); -const stfData = stfParsed.data!; - -const giftParsed = orderInputSchema.safeParse({ - fundType: "Gift", - vendor: "Amazon", - link: "https://example.com/gift-item", - itemName: "Tape", - quantity: 1, - unitCost: 12, - notes: "For pit organization", -}); -assert(giftParsed.success, "Gift order input should validate"); +assert(assignParsed.success, "STF assignment should validate"); -const giftMissingNotes = orderInputSchema.safeParse({ - fundType: "Gift", - vendor: "Amazon", - link: "https://example.com/gift-item", - itemName: "Tape", - quantity: 1, - unitCost: 12, -}); -assert(!giftMissingNotes.success, "Gift order without notes should fail"); +const assignMissingBucket = orderAssignSchema.safeParse({ orderIds: [1], fundType: "STF" }); +assert(!assignMissingBucket.success, "STF assignment without a bucket should fail"); + +const assignGift = orderAssignSchema.safeParse({ orderIds: [1], fundType: "Gift" }); +assert(assignGift.success, "Gift assignment needs no bucket"); // --- Create STF order (mirrors POST /api/orders) --- const admin = db.select().from(user).limit(1).get(); @@ -123,19 +132,15 @@ assert(admin != null, "Need a user in the database"); const stfUnitCents = Math.round(stfData.unitCost * 100); const stfTotal = orderTotalCents(stfData.quantity, stfUnitCents, "STF"); -const stfBalance = validateOrderBalance("STF", stfData.stfBucketId, stfTotal); -assert(stfBalance.ok, "STF balance check should pass before insert"); const quarter = getActiveQuarter(); assert(quarter != null, "Active school year required"); +// Submitted untriaged: no fund type, bucket or quarter yet. const stfOrder = db .insert(order) .values({ userId: admin!.id, - fundType: "STF", - stfBucketId: stfData.stfBucketId!, - quarterId: quarter!.id, vendor: stfData.vendor, link: stfData.link, itemName: stfData.itemName, @@ -148,6 +153,26 @@ const stfOrder = db .returning() .get(); +assert(stfOrder.fundType == null, "New orders start with no fund type"); +assert( + !validateOrderBalance(null, null, stfTotal).ok, + "An untriaged order must not pass the balance check" +); + +const assignCheck = validateAssignmentBalance( + "STF", + mechanical.id, + [stfOrder.id], + [{ quantity: stfData.quantity, unitCostCents: stfUnitCents }] +); +assert(assignCheck.ok, "Assignment should fit inside the bucket"); + +assignOrdersToFund([stfOrder.id], "STF", mechanical.id, admin!.id); +const assigned = db.select().from(order).where(eq(order.id, stfOrder.id)).get()!; +assert(assigned.fundType === "STF", "Assignment should set the fund type"); +assert(assigned.stfBucketId === mechanical.id, "Assignment should set the bucket"); +assert(assigned.quarterId === quarter!.id, "Assignment should stamp the active quarter"); + const remainingAfterPending = getBucketRemainingCents(mechanical.id); assert( remainingAfterPending === mechanicalRemainingBefore, @@ -169,7 +194,6 @@ assert( adjustGiftFund(50_000, admin!.id, "Test deposit"); assert(getGiftFundValueCents() === 50_000, "Gift fund adjustment failed"); -const giftData = giftParsed.data!; const giftUnitCents = Math.round(giftData.unitCost * 100); const giftTotal = orderTotalCents(giftData.quantity, giftUnitCents, "Gift"); diff --git a/src/app/(dashboard)/admin/orders/page.tsx b/src/app/(dashboard)/admin/orders/page.tsx index 6b3bd8e..989d80b 100644 --- a/src/app/(dashboard)/admin/orders/page.tsx +++ b/src/app/(dashboard)/admin/orders/page.tsx @@ -3,7 +3,11 @@ import { asc, desc, eq, sql } from "drizzle-orm"; import { AdminOrderQueue, type AdminOrderRow } from "@/components/orders/AdminOrderQueue"; import { db } from "@/lib/db"; import { order, stfBucket, user } from "@/lib/db/schema"; -import { ensureFinanceSettingsRow, getOrderPricingSettings } from "@/lib/finance/finance"; +import { + ensureFinanceSettingsRow, + getOrderPricingSettings, + getStfBucketsWithBalances, +} from "@/lib/finance/finance"; import { percentBpsToDisplay } from "@/lib/finance/order-pricing"; export default async function AdminOrdersPage() { @@ -14,7 +18,9 @@ export default async function AdminOrdersPage() { id: order.id, itemName: order.itemName, fundType: order.fundType, + stfBucketId: order.stfBucketId, stfBucketName: stfBucket.name, + batchId: order.batchId, requesterName: user.name, requesterEmail: user.email, quantity: order.quantity, @@ -48,6 +54,7 @@ export default async function AdminOrdersPage() { - @@ -50,8 +50,6 @@ export default async function EditOrderPage({ params }: PageProps) { initialOrder={{ id: existing.id, status: existing.status, - fundType: existing.fundType, - stfBucketId: existing.stfBucketId, vendor: existing.vendor, link: existing.link, itemName: existing.itemName, diff --git a/src/app/(dashboard)/orders/new/page.tsx b/src/app/(dashboard)/orders/new/page.tsx index 26e1059..bd14b3c 100644 --- a/src/app/(dashboard)/orders/new/page.tsx +++ b/src/app/(dashboard)/orders/new/page.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; export default function NewOrderPage() { return ( -
+

New Order

@@ -13,7 +13,7 @@ export default function NewOrderPage() { Submit a purchase request for officer approval.

-
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 && (
)} + + {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={ } /> ) : 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 ( + + ); +} + +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 ( +
+ + + {fundType === "STF" ? ( + + ) : null} + + +
+ ); +} + +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" + /> + +
+ ); +} + +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"} + + + + +
+ ); +} + 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 (
- - ( - - Fund type - - - - )} - /> - - {fundType === "Gift" && balances ? ( -
- Gift fund balance: - + + {multiple ? ( +
+ {/* Column headers stand in for per-field labels, so they + only exist where the row layout is side by side. */} + + {fields.map((field, index) => ( + remove(index)} + onDuplicate={() => duplicateItem(index)} + /> + ))} +
+ ) : ( + fields.map((field, index) => ( + + )) + )} + + {!isEditing ? ( +
+ + + {multiple ? ( + + {fields.length} items + {estimatedTotalCents != null + ? ` · est. ${formatPriceCents(estimatedTotalCents)}` + : ""} + + ) : null}
) : null} - {fundType === "STF" ? ( - ( - - STF bucket - - {balances?.stfBuckets.length === 0 ? ( - - No STF buckets are configured. Contact an officer. - - ) : null} - - - )} - /> + {!isEditing && estimatedTotalCents != null ? ( +

+ Estimated total (incl. tax & shipping):{" "} + + {formatPriceCents(estimatedTotalCents)} + + . An officer assigns each item to a fund when they review it, which sets the + final cost. +

) : null} - ( - - Vendor - - - - - - )} - /> +
+ + +
+ + + ); +} +function ItemFields({ + form, + index, +}: { + form: ReturnType>; + index: number; +}) { + return ( +
+ ( + + Vendor + + + + + + )} + /> + + ( + + Link + + { + field.onBlur(); + fillVendorFromLink(form, index, e.target.value); + }} + /> + + Direct link to the product page. + + + )} + /> + + ( + + Item name + + + + + + )} + /> + +
( - Link + Part number (optional) - + - Direct link to the product page. )} @@ -376,53 +407,21 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial }) ( - Item name + Quantity - + )} /> -
- ( - - - Part number{fundType === "Gift" ? " (optional)" : ""} - - - - - - - )} - /> - - ( - - Quantity - - - - - - )} - /> -
- ( Cost (unit) @@ -435,67 +434,135 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial }) {...field} /> - {totalCostCents != null ? ( - - Total cost (incl. tax & shipping):{" "} - - {formatPriceCents(totalCostCents)} - - - ) : null} )} /> +
+ + ( + + Notes (optional) + +