From f90b536cd7136ba5485ba0290f05178310ebb181 Mon Sep 17 00:00:00 2001 From: CPrutean Date: Thu, 20 Aug 2026 19:36:49 -0700 Subject: [PATCH 1/4] fix: repoint migration 0011 snapshot to its real parent Snapshots 0010 and 0011 both recorded 0009 as prevId, so drizzle-kit refused to diff the schema and no new migration could be generated. Only the lineage pointer was wrong: 0011's snapshot already contained both 0010's `name_changed_at` column and its own `sim_export_cache` table, and the two migrations touch unrelated tables. No applied SQL changes, so no environment needs re-migrating. Claude-Session: https://claude.ai/code/session_01GvXPAmtJMzagwAWdiEeWiH --- drizzle/migrations/meta/0011_snapshot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 04138be1055acaf7e468a0345322dc29b3c10b72 Mon Sep 17 00:00:00 2001 From: CPrutean Date: Thu, 20 Aug 2026 19:37:09 -0700 Subject: [PATCH 2/4] fix: keep native button semantics on order back links Both "Back" buttons passed a next/link into `render` while `nativeButton` still defaulted to true, so Base UI warned that a non- diff --git a/src/app/(dashboard)/orders/new/page.tsx b/src/app/(dashboard)/orders/new/page.tsx index 26e1059..d06c5f2 100644 --- a/src/app/(dashboard)/orders/new/page.tsx +++ b/src/app/(dashboard)/orders/new/page.tsx @@ -13,7 +13,7 @@ export default function NewOrderPage() { Submit a purchase request for officer approval.

- From ad4756e85033d632dafc51382d78f65188d9e9ba Mon Sep 17 00:00:00 2001 From: CPrutean Date: Thu, 20 Aug 2026 19:37:37 -0700 Subject: [PATCH 3/4] feat: submit orders in batches and assign funds during triage Ordering took six clicks and a full page round-trip per item, and every item re-asked for the fund type and STF bucket. Members also had to know the finance model to file a request at all. Members now submit items only. The form drops fund type and bucket, and one submission can carry up to 50 items: "Add another item" grows the single-item form into a full-width grid, and "Paste a list" ingests rows copied out of a spreadsheet. Items submitted together share a batchId. Vendor is guessed from the link's domain when left blank. Officers own the finance model instead. The queue splits pending orders into "Needs triage" and "Ready to review", and acts on a multi-select: POST /api/orders/assign sets fund, bucket and quarter, and POST /api/orders/bulk-action approves or denies. Balance checks move from submit time to assign/approve time. A member has no bucket to check against, and a balance checked at submit is stale by review. Two consequences: - Approving a selection checks each fund/bucket group's total at once. Checking one at a time would let a batch overdraw a bucket that every individual order fits inside. - Assigning counts the selection plus everything already queued against that bucket. Pending orders don't reduce a bucket's remaining balance, so otherwise an officer could park more there than could be approved. fund_type is now nullable, which forces a SQLite table rebuild. The generated INSERT selected the three new columns from the old table; SQLite's double-quoted-identifier fallback turned them into string literals rather than erroring, writing assigned_by='assigned_by' into every existing row. The migration selects NULL for those columns instead. Claude-Session: https://claude.ai/code/session_01GvXPAmtJMzagwAWdiEeWiH --- drizzle/migrations/0012_smiling_loa.sql | 34 + drizzle/migrations/meta/0012_snapshot.json | 1752 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + scripts/seed.ts | 42 +- scripts/verify-order-form.ts | 98 +- src/app/(dashboard)/admin/orders/page.tsx | 9 +- src/app/(dashboard)/orders/[id]/edit/page.tsx | 2 - src/app/(dashboard)/orders/new/page.tsx | 2 +- src/app/api/orders/[id]/route.ts | 21 +- src/app/api/orders/assign/route.ts | 76 + src/app/api/orders/bulk-action/route.ts | 132 ++ src/app/api/orders/route.ts | 66 +- src/components/orders/AdminOrderQueue.tsx | 551 ++++-- src/components/orders/OrderForm.tsx | 787 ++++---- src/components/orders/OrderTable.tsx | 28 +- src/components/orders/PasteItemsPanel.tsx | 117 ++ src/components/orders/TeamOrderTable.tsx | 28 +- src/components/orders/paste-items.test.ts | 63 + src/lib/db/schema.ts | 63 +- src/lib/finance/finance.test.ts | 227 +++ src/lib/finance/finance.ts | 117 +- src/lib/finance/order-export.ts | 2 +- src/lib/finance/order-pricing.ts | 4 +- src/lib/validation.test.ts | 144 +- src/lib/validation.ts | 62 +- 25 files changed, 3729 insertions(+), 705 deletions(-) create mode 100644 drizzle/migrations/0012_smiling_loa.sql create mode 100644 drizzle/migrations/meta/0012_snapshot.json create mode 100644 src/app/api/orders/assign/route.ts create mode 100644 src/app/api/orders/bulk-action/route.ts create mode 100644 src/components/orders/PasteItemsPanel.tsx create mode 100644 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/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() { +

New Order

diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts index 1366519..85052ec 100644 --- a/src/app/api/orders/[id]/route.ts +++ b/src/app/api/orders/[id]/route.ts @@ -5,10 +5,8 @@ import { db } from "@/lib/db"; import { order, orderHistory } from "@/lib/db/schema"; import { ensureFinanceSettingsRow, - getActiveQuarter, orderTotalCents, restoreGiftFundForDeletion, - validateOrderBalance, } from "@/lib/finance/finance"; import { getSessionUser } from "@/lib/auth/session"; import { orderInputSchema } from "@/lib/validation"; @@ -58,27 +56,12 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const d = parsed.data; ensureFinanceSettingsRow(); const unitCostCents = Math.round(d.unitCost * 100); - const totalCostCents = orderTotalCents(d.quantity, unitCostCents, d.fundType); - - const balanceCheck = validateOrderBalance(d.fundType, d.stfBucketId, totalCostCents); - if (!balanceCheck.ok) { - return NextResponse.json({ error: balanceCheck.message }, { status: 400 }); - } - - const activeQuarter = d.fundType === "STF" ? getActiveQuarter() : null; - if (d.fundType === "STF" && !activeQuarter) { - return NextResponse.json( - { error: "No active STF school year is configured. Contact an officer." }, - { status: 400 } - ); - } + // Editing sends the order back for triage: an officer re-checks the fund + // assignment against the changed cost when they review it again. const updated = db .update(order) .set({ - fundType: d.fundType, - stfBucketId: d.fundType === "STF" ? d.stfBucketId! : null, - quarterId: activeQuarter?.id ?? null, vendor: d.vendor, link: d.link, itemName: d.itemName, diff --git a/src/app/api/orders/assign/route.ts b/src/app/api/orders/assign/route.ts new file mode 100644 index 0000000..462dbb1 --- /dev/null +++ b/src/app/api/orders/assign/route.ts @@ -0,0 +1,76 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { NextResponse, type NextRequest } from "next/server"; + +import { db } from "@/lib/db"; +import { order } from "@/lib/db/schema"; +import { + assignOrdersToFund, + ensureFinanceSettingsRow, + getActiveQuarter, + validateAssignmentBalance, +} from "@/lib/finance/finance"; +import { getSessionUser } from "@/lib/auth/session"; +import { orderAssignSchema } from "@/lib/validation"; + +export async function POST(req: NextRequest) { + const sessionUser = await getSessionUser(); + if (!sessionUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (sessionUser.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => null); + const parsed = orderAssignSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { orderIds, fundType, stfBucketId } = parsed.data; + ensureFinanceSettingsRow(); + + if (fundType === "STF" && !getActiveQuarter()) { + return NextResponse.json( + { error: "No active STF school year is configured." }, + { status: 400 } + ); + } + + const targets = db + .select() + .from(order) + .where(and(inArray(order.id, orderIds), eq(order.status, "pending"))) + .all(); + + if (targets.length !== orderIds.length) { + return NextResponse.json( + { error: "Only pending orders can be assigned to a fund." }, + { status: 400 } + ); + } + + // Assigning does not spend the fund, but block parking more in a bucket + // than it can cover so the approval step is not a dead end. + const balanceCheck = validateAssignmentBalance( + fundType, + stfBucketId ?? null, + orderIds, + targets + ); + if (!balanceCheck.ok) { + return NextResponse.json({ error: balanceCheck.message }, { status: 400 }); + } + + const assignedCount = assignOrdersToFund( + orderIds, + fundType, + stfBucketId ?? null, + sessionUser.id + ); + + return NextResponse.json({ assignedCount }); +} diff --git a/src/app/api/orders/bulk-action/route.ts b/src/app/api/orders/bulk-action/route.ts new file mode 100644 index 0000000..08139d9 --- /dev/null +++ b/src/app/api/orders/bulk-action/route.ts @@ -0,0 +1,132 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { NextResponse, type NextRequest } from "next/server"; + +import { db } from "@/lib/db"; +import { order, orderHistory, user } from "@/lib/db/schema"; +import { + deductGiftFundForApproval, + ensureFinanceSettingsRow, + orderTotalCents, + sendOrderApprovedEmail, + sendOrderDeniedEmail, + validateBatchBalance, +} from "@/lib/finance/finance"; +import { getSessionUser } from "@/lib/auth/session"; +import { ORDER_ACTION_STATUS, orderBulkActionSchema } from "@/lib/validation"; + +export async function POST(req: NextRequest) { + const sessionUser = await getSessionUser(); + if (!sessionUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (sessionUser.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => null); + const parsed = orderBulkActionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { orderIds, action, denialComment } = parsed.data; + ensureFinanceSettingsRow(); + + const targets = db + .select() + .from(order) + .where(and(inArray(order.id, orderIds), eq(order.status, "pending"))) + .all(); + + if (targets.length === 0) { + return NextResponse.json({ error: "No pending orders in selection" }, { status: 400 }); + } + + if (action === "approve") { + const untriaged = targets.filter((o) => !o.fundType); + if (untriaged.length > 0) { + return NextResponse.json( + { + error: `Assign ${untriaged.length} order${untriaged.length === 1 ? "" : "s"} to a fund before approving.`, + }, + { status: 400 } + ); + } + + // Group by fund and bucket so each pool is checked against its own + // balance with the whole selection counted at once. + const groups = new Map(); + for (const target of targets) { + const key = `${target.fundType}:${target.stfBucketId ?? "none"}`; + const group = groups.get(key); + if (group) group.push(target); + else groups.set(key, [target]); + } + + for (const group of groups.values()) { + const check = validateBatchBalance(group[0].fundType, group[0].stfBucketId, group); + if (!check.ok) { + return NextResponse.json({ error: check.message }, { status: 400 }); + } + } + } + + const newStatus = ORDER_ACTION_STATUS[action]; + const reviewedAt = new Date(); + + for (const target of targets) { + db.update(order) + .set({ + status: newStatus, + denialComment: action === "deny" ? (denialComment ?? null) : null, + reviewedBy: sessionUser.id, + reviewedAt, + }) + .where(eq(order.id, target.id)) + .run(); + + db.insert(orderHistory) + .values({ + orderId: target.id, + fromStatus: target.status, + toStatus: newStatus, + changedBy: sessionUser.id, + note: denialComment ?? null, + }) + .run(); + + if (action === "approve" && target.fundType === "Gift") { + deductGiftFundForApproval( + target.id, + orderTotalCents(target.quantity, target.unitCostCents, target.fundType), + sessionUser.id + ); + } + } + + const requesters = new Map(); + for (const target of targets) { + if (requesters.has(target.userId)) continue; + const requester = db.select().from(user).where(eq(user.id, target.userId)).get(); + if (requester?.email) requesters.set(target.userId, requester.email); + } + + for (const target of targets) { + const email = requesters.get(target.userId); + if (!email) continue; + try { + if (action === "approve") { + await sendOrderApprovedEmail(email, target.itemName); + } else { + await sendOrderDeniedEmail(email, target.itemName, denialComment); + } + } catch { + // Email failure should not roll back the order action. + } + } + + return NextResponse.json({ count: targets.length, status: newStatus }); +} diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index a56e811..a0c78fc 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -1,15 +1,12 @@ +import { randomUUID } from "node:crypto"; + import { NextResponse, type NextRequest } from "next/server"; import { db } from "@/lib/db"; import { order } from "@/lib/db/schema"; -import { - getActiveQuarter, - ensureFinanceSettingsRow, - orderTotalCents, - validateOrderBalance, -} from "@/lib/finance/finance"; +import { ensureFinanceSettingsRow } from "@/lib/finance/finance"; import { getSessionUser } from "@/lib/auth/session"; -import { orderInputSchema } from "@/lib/validation"; +import { orderBatchInputSchema } from "@/lib/validation"; export async function POST(req: NextRequest) { const user = await getSessionUser(); @@ -18,7 +15,7 @@ export async function POST(req: NextRequest) { } const body = await req.json().catch(() => null); - const parsed = orderInputSchema.safeParse(body); + const parsed = orderBatchInputSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", issues: parsed.error.flatten() }, @@ -26,41 +23,24 @@ export async function POST(req: NextRequest) { ); } - const d = parsed.data; ensureFinanceSettingsRow(); - const unitCostCents = Math.round(d.unitCost * 100); - const totalCostCents = orderTotalCents(d.quantity, unitCostCents, d.fundType); - - const balanceCheck = validateOrderBalance(d.fundType, d.stfBucketId, totalCostCents); - if (!balanceCheck.ok) { - return NextResponse.json({ error: balanceCheck.message }, { status: 400 }); - } - - const activeQuarter = d.fundType === "STF" ? getActiveQuarter() : null; - if (d.fundType === "STF" && !activeQuarter) { - return NextResponse.json( - { error: "No active STF school year is configured. Contact an officer." }, - { status: 400 } - ); - } - - const created = db - .insert(order) - .values({ - userId: user.id, - fundType: d.fundType, - stfBucketId: d.fundType === "STF" ? d.stfBucketId! : null, - quarterId: activeQuarter?.id ?? null, - vendor: d.vendor, - link: d.link, - itemName: d.itemName, - partNumber: d.partNumber?.trim() || null, - quantity: d.quantity, - unitCostCents, - notes: d.notes?.trim() || null, - }) - .returning() - .get(); - return NextResponse.json({ order: created }, { status: 201 }); + // Orders arrive untriaged: no fund type, bucket or quarter. An officer + // assigns those later, and the balance check runs at that point. + const batchId = parsed.data.items.length > 1 ? randomUUID() : null; + const rows = parsed.data.items.map((item) => ({ + userId: user.id, + batchId, + vendor: item.vendor, + link: item.link, + itemName: item.itemName, + partNumber: item.partNumber?.trim() || null, + quantity: item.quantity, + unitCostCents: Math.round(item.unitCost * 100), + notes: item.notes?.trim() || null, + })); + + const created = db.insert(order).values(rows).returning().all(); + + return NextResponse.json({ orders: created, count: created.length }, { status: 201 }); } diff --git a/src/components/orders/AdminOrderQueue.tsx b/src/components/orders/AdminOrderQueue.tsx index 27a85da..61f5b21 100644 --- a/src/components/orders/AdminOrderQueue.tsx +++ b/src/components/orders/AdminOrderQueue.tsx @@ -1,13 +1,20 @@ "use client"; import { Copy, PackageCheck, Trash2 } from "lucide-react"; -import { Fragment, type ReactNode } from "react"; +import { Fragment, type ReactNode, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; import { toast } from "sonner"; +import { isOverBudget, StfBucketSelectItemContent } from "@/components/BalanceAmount"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Table, TableBody, @@ -28,15 +35,17 @@ import { displayPercentToBps, type OrderPricingSettings, } from "@/lib/finance/order-pricing"; -import { formatDate, formatPriceCents } from "@/lib/utils"; +import { cn, formatDate, formatPriceCents } from "@/lib/utils"; import { OrderStatusBadge } from "./OrderStatusBadge"; export type AdminOrderRow = { id: number; itemName: string; - fundType: FundType; + fundType: FundType | null; + stfBucketId: number | null; stfBucketName: string | null; + batchId: string | null; requesterName: string | null; requesterEmail: string | null; quantity: number; @@ -50,8 +59,19 @@ export type AdminOrderRow = { createdAt: Date; }; +export type StfBucketOption = { + id: number; + name: string; + remainingBalanceCents: number; +}; + type Action = "approve" | "deny"; +export type OrderPricing = { + taxPercent: number; + shippingPercent: number; +}; + function toPricingSettings(orderPricing: OrderPricing): OrderPricingSettings { return { taxPercentBps: displayPercentToBps(orderPricing.taxPercent), @@ -72,16 +92,17 @@ async function copyText(text: string, label: string) { } } -export type OrderPricing = { - taxPercent: number; - shippingPercent: number; -}; +function plural(count: number, noun: string) { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} export function AdminOrderQueue({ orders, + stfBuckets, orderPricing, }: { orders: AdminOrderRow[]; + stfBuckets: StfBucketOption[]; orderPricing: OrderPricing; }) { const router = useRouter(); @@ -90,31 +111,84 @@ export function AdminOrderQueue({ const [pending, setPending] = useState(null); const [deletingId, setDeletingId] = useState(null); const [markingOrdered, setMarkingOrdered] = useState(false); - const [selectedApprovedIds, setSelectedApprovedIds] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); const pricingSettings = toPricingSettings(orderPricing); - const pendingOrders = orders.filter((o) => o.status === "pending"); - const approvedOrders = orders.filter((o) => o.status === "approved"); - const orderedOrders = orders.filter((o) => o.status === "ordered"); - const deniedOrders = orders.filter((o) => o.status === "denied"); + const { untriagedOrders, reviewOrders, approvedOrders, orderedOrders, deniedOrders } = useMemo( + () => ({ + untriagedOrders: orders.filter((o) => o.status === "pending" && !o.fundType), + reviewOrders: orders.filter((o) => o.status === "pending" && o.fundType), + approvedOrders: orders.filter((o) => o.status === "approved"), + orderedOrders: orders.filter((o) => o.status === "ordered"), + deniedOrders: orders.filter((o) => o.status === "denied"), + }), + [orders] + ); + const approvedStfCount = approvedOrders.filter((o) => o.fundType === "STF").length; const approvedGiftCount = approvedOrders.filter((o) => o.fundType === "Gift").length; + function selectedIn(rows: AdminOrderRow[]): number[] { + return rows.filter((o) => selectedIds.has(o.id)).map((o) => o.id); + } + + function toggleSelection(orderId: number) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(orderId)) next.delete(orderId); + else next.add(orderId); + return next; + }); + } + + function toggleAllIn(rows: AdminOrderRow[]) { + setSelectedIds((prev) => { + const next = new Set(prev); + const allSelected = rows.every((o) => next.has(o.id)); + for (const row of rows) { + if (allSelected) next.delete(row.id); + else next.add(row.id); + } + return next; + }); + } + + function makeSelection(rows: AdminOrderRow[]) { + const selectedCount = rows.filter((o) => selectedIds.has(o.id)).length; + return { + selectedIds, + onToggle: toggleSelection, + onToggleAll: () => toggleAllIn(rows), + allSelected: selectedCount === rows.length && rows.length > 0, + someSelected: selectedCount > 0 && selectedCount < rows.length, + }; + } + + async function post(url: string, body: unknown, failureMessage: string) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.error ?? failureMessage); + } + return res.json().catch(() => null); + } + async function runAction(order: AdminOrderRow, action: Action) { setPending(action); try { - const res = await fetch(`/api/orders/${order.id}/action`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ + await post( + `/api/orders/${order.id}/action`, + { action, denialComment: action === "deny" ? denialComment || undefined : undefined, - }), - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Action failed"); - } + }, + "Action failed" + ); toast.success(action === "approve" ? "Order approved" : "Order denied"); setExpandedId(null); setDenialComment(""); @@ -126,35 +200,66 @@ export function AdminOrderQueue({ } } + async function runBulkAction(orderIds: number[], action: Action, comment?: string) { + if (orderIds.length === 0) return; + if (action === "deny" && !confirm(`Deny ${plural(orderIds.length, "order")}?`)) return; + + setBusy(true); + try { + const data = await post( + "/api/orders/bulk-action", + { orderIds, action, denialComment: comment || undefined }, + "Bulk action failed" + ); + toast.success( + `${plural(data?.count ?? orderIds.length, "order")} ${action === "approve" ? "approved" : "denied"}` + ); + setSelectedIds(new Set()); + setExpandedId(null); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setBusy(false); + } + } + + async function assignOrders(orderIds: number[], fundType: FundType, bucketId: number | null) { + if (orderIds.length === 0) return; + setBusy(true); + try { + await post( + "/api/orders/assign", + { orderIds, fundType, stfBucketId: bucketId ?? undefined }, + "Failed to assign orders" + ); + toast.success(`${plural(orderIds.length, "order")} assigned`); + setSelectedIds(new Set()); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setBusy(false); + } + } + async function markApprovedAsOrdered(orderIds?: number[]) { const count = orderIds?.length ?? approvedOrders.length; if (count === 0) return; - const message = - count === 1 - ? "Move 1 approved order to the ordered archive? It will no longer appear in Excel exports." - : `Move ${count} approved order${count === 1 ? "" : "s"} to the ordered archive? They will no longer appear in Excel exports.`; + const message = `Move ${plural(count, "approved order")} to the ordered archive? ${count === 1 ? "It" : "They"} will no longer appear in Excel exports.`; if (!confirm(message)) return; setMarkingOrdered(true); try { - const res = await fetch("/api/orders/mark-ordered", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(orderIds ? { orderIds } : {}), - }); - if (!res.ok) { - const data = await res.json().catch(() => null); - throw new Error(data?.error ?? "Failed to mark orders as ordered"); - } - const data = await res.json(); - toast.success( - data.movedCount === 1 - ? "1 order moved to ordered" - : `${data.movedCount} orders moved to ordered` + const data = await post( + "/api/orders/mark-ordered", + orderIds ? { orderIds } : {}, + "Failed to mark orders as ordered" ); + toast.success(`${plural(data?.movedCount ?? count, "order")} moved to ordered`); setExpandedId(null); - setSelectedApprovedIds(new Set()); + setSelectedIds(new Set()); router.refresh(); } catch (err) { toast.error(err instanceof Error ? err.message : "Something went wrong"); @@ -163,22 +268,6 @@ export function AdminOrderQueue({ } } - function toggleApprovedSelection(orderId: number) { - setSelectedApprovedIds((prev) => { - const next = new Set(prev); - if (next.has(orderId)) next.delete(orderId); - else next.add(orderId); - return next; - }); - } - - function toggleAllApprovedSelection() { - setSelectedApprovedIds((prev) => { - if (prev.size === approvedOrders.length) return new Set(); - return new Set(approvedOrders.map((o) => o.id)); - }); - } - async function deleteOrder(order: AdminOrderRow) { const message = order.status === "approved" || order.status === "ordered" @@ -211,9 +300,22 @@ export function AdminOrderQueue({ ); } + const sharedSectionProps = { + expandedId, + onToggle: (id: number) => { + setExpandedId((prev) => (prev === id ? null : id)); + setDenialComment(""); + }, + denialComment, + onDenialCommentChange: setDenialComment, + pending, + onAction: runAction, + orderPricing, + }; + return (
- {(approvedStfCount > 0 || approvedGiftCount > 0 || approvedOrders.length > 0) && ( + {approvedOrders.length > 0 && (
)} + + {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) + +