Add menu item allergens, drop nav Help link, animate customizer price
- Menu items can now list allergens (shared catalog + join table), shown on the menu grid and item detail pages when applicable, and editable from the admin menu item form. - Removed the top-nav "Help" link now that the footer links to it. - The item customization page's price now uses the sliding AnimatedNumber, matching the basket line price. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "allergens" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "menu_item_allergens" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"menu_item_id" integer NOT NULL,
|
||||||
|
"allergen_id" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_menu_item_id_menu_items_id_fk" FOREIGN KEY ("menu_item_id") REFERENCES "public"."menu_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "menu_item_allergens" ADD CONSTRAINT "menu_item_allergens_allergen_id_allergens_id_fk" FOREIGN KEY ("allergen_id") REFERENCES "public"."allergens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "allergens_name_idx" ON "allergens" USING btree ("name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "menu_item_allergens_item_allergen_idx" ON "menu_item_allergens" USING btree ("menu_item_id","allergen_id");
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
{
|
||||||
|
"id": "4086d6cd-5c93-4617-a15b-a90331b459a1",
|
||||||
|
"prevId": "83dc14cf-82fb-43fc-8b7d-fd377bf48689",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.allergens": {
|
||||||
|
"name": "allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"allergens_name_idx": {
|
||||||
|
"name": "allergens_name_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "name",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.brands": {
|
||||||
|
"name": "brands",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.contact_messages": {
|
||||||
|
"name": "contact_messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.machine_tags": {
|
||||||
|
"name": "machine_tags",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"machine_tags_name_idx": {
|
||||||
|
"name": "machine_tags_name_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "name",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.machines": {
|
||||||
|
"name": "machines",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"machines_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "machines_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "machines",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_allergens": {
|
||||||
|
"name": "menu_item_allergens",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"allergen_id": {
|
||||||
|
"name": "allergen_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"menu_item_allergens_item_allergen_idx": {
|
||||||
|
"name": "menu_item_allergens_item_allergen_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "menu_item_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "allergen_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_allergens_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_allergens_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_allergens_allergen_id_allergens_id_fk": {
|
||||||
|
"name": "menu_item_allergens_allergen_id_allergens_id_fk",
|
||||||
|
"tableFrom": "menu_item_allergens",
|
||||||
|
"tableTo": "allergens",
|
||||||
|
"columnsFrom": [
|
||||||
|
"allergen_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_options": {
|
||||||
|
"name": "menu_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_options_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_options_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_options",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_item_steps": {
|
||||||
|
"name": "menu_item_steps",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"name": "sequence",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"estimated_duration_seconds": {
|
||||||
|
"name": "estimated_duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_item_steps_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "menu_item_steps",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "menu_item_steps",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.menu_items": {
|
||||||
|
"name": "menu_items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"brand_id": {
|
||||||
|
"name": "brand_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"menu_items_brand_id_brands_id_fk": {
|
||||||
|
"name": "menu_items_brand_id_brands_id_fk",
|
||||||
|
"tableFrom": "menu_items",
|
||||||
|
"tableTo": "brands",
|
||||||
|
"columnsFrom": [
|
||||||
|
"brand_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.order_item_options": {
|
||||||
|
"name": "order_item_options",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_delta_cents": {
|
||||||
|
"name": "price_delta_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_options_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_options_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_options",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.order_item_steps": {
|
||||||
|
"name": "order_item_steps",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"name": "sequence",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"estimated_duration_seconds": {
|
||||||
|
"name": "estimated_duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_item_steps_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "order_item_steps_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "order_item_steps",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "order_item_steps",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.order_items": {
|
||||||
|
"name": "order_items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_id": {
|
||||||
|
"name": "order_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"menu_item_id": {
|
||||||
|
"name": "menu_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"price_cents": {
|
||||||
|
"name": "price_cents",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'placed'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"order_items_order_id_orders_id_fk": {
|
||||||
|
"name": "order_items_order_id_orders_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "orders",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"order_items_menu_item_id_menu_items_id_fk": {
|
||||||
|
"name": "order_items_menu_item_id_menu_items_id_fk",
|
||||||
|
"tableFrom": "order_items",
|
||||||
|
"tableTo": "menu_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"menu_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.orders": {
|
||||||
|
"name": "orders",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guest_name": {
|
||||||
|
"name": "guest_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guest_contact": {
|
||||||
|
"name": "guest_contact",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guest_access_token": {
|
||||||
|
"name": "guest_access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'placed'"
|
||||||
|
},
|
||||||
|
"payment_charge_id": {
|
||||||
|
"name": "payment_charge_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.preparation_step_instances": {
|
||||||
|
"name": "preparation_step_instances",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"order_item_id": {
|
||||||
|
"name": "order_item_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"name": "sequence",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"machine_tag_id": {
|
||||||
|
"name": "machine_tag_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"estimated_duration_seconds": {
|
||||||
|
"name": "estimated_duration_seconds",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"machine_id": {
|
||||||
|
"name": "machine_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"done_at": {
|
||||||
|
"name": "done_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"preparation_step_instances_order_item_id_order_items_id_fk": {
|
||||||
|
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
|
||||||
|
"tableFrom": "preparation_step_instances",
|
||||||
|
"tableTo": "order_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"order_item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
|
||||||
|
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
|
||||||
|
"tableFrom": "preparation_step_instances",
|
||||||
|
"tableTo": "machine_tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"preparation_step_instances_machine_id_machines_id_fk": {
|
||||||
|
"name": "preparation_step_instances_machine_id_machines_id_fk",
|
||||||
|
"tableFrom": "preparation_step_instances",
|
||||||
|
"tableTo": "machines",
|
||||||
|
"columnsFrom": [
|
||||||
|
"machine_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,13 @@
|
|||||||
"when": 1789636006749,
|
"when": 1789636006749,
|
||||||
"tag": "0005_tan_power_man",
|
"tag": "0005_tan_power_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789637974330,
|
||||||
|
"tag": "0006_worried_shooting_star",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,9 @@ export const AdminMenuListView = ({
|
|||||||
{item.steps.length === 1 ? '' : 's'} ·{' '}
|
{item.steps.length === 1 ? '' : 's'} ·{' '}
|
||||||
{item.options.length} option
|
{item.options.length} option
|
||||||
{item.options.length === 1 ? '' : 's'}
|
{item.options.length === 1 ? '' : 's'}
|
||||||
|
{item.allergens.length > 0
|
||||||
|
? ` · Contains: ${item.allergens.map((allergen) => allergen.name).join(', ')}`
|
||||||
|
: ''}
|
||||||
{item.description ? ` · ${item.description}` : ''}
|
{item.description ? ` · ${item.description}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -117,6 +117,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.allergenList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allergenOption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $color-text;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { Link } from '@tanstack/react-router'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
import type { AdminMenuItem, MenuItemInput } from '#/domain/menu.ts'
|
import type {
|
||||||
|
AdminMenuItem,
|
||||||
|
AllergenOption,
|
||||||
|
MenuItemInput,
|
||||||
|
} from '#/domain/menu.ts'
|
||||||
import type { MachineTagOption } from '#/domain/kitchen.ts'
|
import type { MachineTagOption } from '#/domain/kitchen.ts'
|
||||||
|
|
||||||
import styles from './MenuItemForm.module.scss'
|
import styles from './MenuItemForm.module.scss'
|
||||||
@@ -25,6 +29,7 @@ const toStepRows = (item?: AdminMenuItem): Array<StepRow> =>
|
|||||||
|
|
||||||
type MenuItemFormProps = {
|
type MenuItemFormProps = {
|
||||||
machineTags: Array<MachineTagOption>
|
machineTags: Array<MachineTagOption>
|
||||||
|
allergenOptions: Array<AllergenOption>
|
||||||
initialItem?: AdminMenuItem
|
initialItem?: AdminMenuItem
|
||||||
submitLabel: string
|
submitLabel: string
|
||||||
onSubmit: (input: MenuItemInput) => Promise<void>
|
onSubmit: (input: MenuItemInput) => Promise<void>
|
||||||
@@ -32,6 +37,7 @@ type MenuItemFormProps = {
|
|||||||
|
|
||||||
export const MenuItemForm = ({
|
export const MenuItemForm = ({
|
||||||
machineTags,
|
machineTags,
|
||||||
|
allergenOptions,
|
||||||
initialItem,
|
initialItem,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -47,6 +53,9 @@ export const MenuItemForm = ({
|
|||||||
toOptionRows(initialItem),
|
toOptionRows(initialItem),
|
||||||
)
|
)
|
||||||
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
|
const [steps, setSteps] = useState<Array<StepRow>>(toStepRows(initialItem))
|
||||||
|
const [allergenIds, setAllergenIds] = useState<Array<number>>(
|
||||||
|
initialItem?.allergens.map((allergen) => allergen.id) ?? [],
|
||||||
|
)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -58,6 +67,12 @@ export const MenuItemForm = ({
|
|||||||
setSteps((rows) =>
|
setSteps((rows) =>
|
||||||
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
rows.map((row, i) => (i === index ? { ...row, ...patch } : row)),
|
||||||
)
|
)
|
||||||
|
const toggleAllergen = (allergenId: number) =>
|
||||||
|
setAllergenIds((ids) =>
|
||||||
|
ids.includes(allergenId)
|
||||||
|
? ids.filter((id) => id !== allergenId)
|
||||||
|
: [...ids, allergenId],
|
||||||
|
)
|
||||||
|
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -83,6 +98,7 @@ export const MenuItemForm = ({
|
|||||||
machineTagId: Number(step.machineTagId),
|
machineTagId: Number(step.machineTagId),
|
||||||
estimatedDurationSeconds: Math.round(Number(step.durationSeconds)),
|
estimatedDurationSeconds: Math.round(Number(step.durationSeconds)),
|
||||||
})),
|
})),
|
||||||
|
allergenIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
@@ -249,6 +265,24 @@ export const MenuItemForm = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<p className={styles.sectionTitle}>Allergens</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.allergenList}>
|
||||||
|
{allergenOptions.map((allergen) => (
|
||||||
|
<label key={allergen.id} className={styles.allergenOption}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allergenIds.includes(allergen.id)}
|
||||||
|
onChange={() => toggleAllergen(allergen.id)}
|
||||||
|
/>
|
||||||
|
{allergen.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ export const MenuItemCustomizer = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.stepperRow}>
|
<div className={styles.stepperRow}>
|
||||||
<span className={styles.price}>{formatPrice(unitPriceCents)}</span>
|
<AnimatedNumber
|
||||||
|
value={unitPriceCents}
|
||||||
|
format={formatPrice}
|
||||||
|
className={styles.price}
|
||||||
|
/>
|
||||||
<div className={styles.stepper}>
|
<div className={styles.stepper}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -25,3 +25,9 @@
|
|||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.allergens {
|
||||||
|
margin: $space-1 0 0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ export const MenuItemDetail = ({
|
|||||||
<p className={styles.meta}>
|
<p className={styles.meta}>
|
||||||
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
</p>
|
</p>
|
||||||
|
{item.allergens.length > 0 && (
|
||||||
|
<p className={styles.allergens}>
|
||||||
|
Contains:{' '}
|
||||||
|
{item.allergens.map((allergen) => allergen.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MenuItemCustomizer
|
<MenuItemCustomizer
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.allergens {
|
||||||
|
margin: $space-1 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
.quantity {
|
.quantity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export const MenuList = ({ menu, lines, onAdd, onRemove }: MenuListProps) => (
|
|||||||
<p className={styles.meta}>
|
<p className={styles.meta}>
|
||||||
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
{formatPrice(item.priceCents)} · {formatDuration(totalSeconds)}
|
||||||
</p>
|
</p>
|
||||||
|
{item.allergens.length > 0 && (
|
||||||
|
<p className={styles.allergens}>
|
||||||
|
Contains:{' '}
|
||||||
|
{item.allergens.map((allergen) => allergen.name).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,6 +76,38 @@ export const menuItemOptions = pgTable('menu_item_options', {
|
|||||||
priceDeltaCents: integer('price_delta_cents').notNull().default(0),
|
priceDeltaCents: integer('price_delta_cents').notNull().default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A shared catalog of allergens (e.g. "Gluten", "Dairy") that Menu Items
|
||||||
|
// draw from — unlike menuItemOptions, this list is reused across items
|
||||||
|
// rather than owned by any one of them.
|
||||||
|
export const allergens = pgTable(
|
||||||
|
'allergens',
|
||||||
|
{
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
name: text().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('allergens_name_idx').on(table.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Which Allergens a Menu Item's recipe contains, as ordered by an operator.
|
||||||
|
export const menuItemAllergens = pgTable(
|
||||||
|
'menu_item_allergens',
|
||||||
|
{
|
||||||
|
id: serial().primaryKey(),
|
||||||
|
menuItemId: integer('menu_item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => menuItems.id),
|
||||||
|
allergenId: integer('allergen_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => allergens.id),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('menu_item_allergens_item_allergen_idx').on(
|
||||||
|
table.menuItemId,
|
||||||
|
table.allergenId,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
export const orderStatusValues = ['placed', 'paid', 'complete'] as const
|
||||||
export type OrderStatus = (typeof orderStatusValues)[number]
|
export type OrderStatus = (typeof orderStatusValues)[number]
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,29 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
])
|
])
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
const allergens = await db
|
||||||
|
.insert(schema.allergens)
|
||||||
|
.values([
|
||||||
|
{ name: 'Gluten' },
|
||||||
|
{ name: 'Dairy' },
|
||||||
|
{ name: 'Eggs' },
|
||||||
|
{ name: 'Soy' },
|
||||||
|
{ name: 'Peanuts' },
|
||||||
|
{ name: 'Tree nuts' },
|
||||||
|
{ name: 'Sesame' },
|
||||||
|
])
|
||||||
|
.returning()
|
||||||
|
const allergenByName = Object.fromEntries(
|
||||||
|
allergens.map((allergen) => [allergen.name, allergen]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.insert(schema.menuItemAllergens).values([
|
||||||
|
{ menuItemId: pizza.id, allergenId: allergenByName['Gluten'].id },
|
||||||
|
{ menuItemId: pizza.id, allergenId: allergenByName['Dairy'].id },
|
||||||
|
{ menuItemId: burger.id, allergenId: allergenByName['Gluten'].id },
|
||||||
|
{ menuItemId: burger.id, allergenId: allergenByName['Sesame'].id },
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
brand,
|
brand,
|
||||||
machineTags: {
|
machineTags: {
|
||||||
@@ -156,6 +179,7 @@ export const seedGhostKitchen = async (db: NodePgDatabase<typeof schema>) => {
|
|||||||
machines: { prep1, oven1, grill1, assembly1 },
|
machines: { prep1, oven1, grill1, assembly1 },
|
||||||
menuItems: { pizza, burger },
|
menuItems: { pizza, burger },
|
||||||
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
|
menuItemOptions: { pizza: pizzaOptions, burger: burgerOptions },
|
||||||
|
allergens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-4
@@ -26,6 +26,7 @@ export type AdminMenuItem = {
|
|||||||
machineTagName: string
|
machineTagName: string
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
}>
|
}>
|
||||||
|
allergens: Array<{ id: number; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuItemInput = {
|
export type MenuItemInput = {
|
||||||
@@ -38,8 +39,19 @@ export type MenuItemInput = {
|
|||||||
machineTagId: number
|
machineTagId: number
|
||||||
estimatedDurationSeconds: number
|
estimatedDurationSeconds: number
|
||||||
}>
|
}>
|
||||||
|
allergenIds: Array<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AllergenOption = { id: number; name: string }
|
||||||
|
|
||||||
|
export const getAllergens = async (
|
||||||
|
db: Db,
|
||||||
|
): Promise<Array<AllergenOption>> =>
|
||||||
|
db
|
||||||
|
.select({ id: schema.allergens.id, name: schema.allergens.name })
|
||||||
|
.from(schema.allergens)
|
||||||
|
.orderBy(schema.allergens.name)
|
||||||
|
|
||||||
const assertHasSteps = (input: MenuItemInput) => {
|
const assertHasSteps = (input: MenuItemInput) => {
|
||||||
if (input.steps.length === 0) {
|
if (input.steps.length === 0) {
|
||||||
throw new Error('A menu item needs at least one Preparation Step')
|
throw new Error('A menu item needs at least one Preparation Step')
|
||||||
@@ -74,6 +86,18 @@ export const getMenuItemsForAdmin = async (
|
|||||||
)
|
)
|
||||||
.orderBy(schema.menuItemSteps.sequence)
|
.orderBy(schema.menuItemSteps.sequence)
|
||||||
|
|
||||||
|
const itemAllergens = await db
|
||||||
|
.select({
|
||||||
|
menuItemId: schema.menuItemAllergens.menuItemId,
|
||||||
|
id: schema.allergens.id,
|
||||||
|
name: schema.allergens.name,
|
||||||
|
})
|
||||||
|
.from(schema.menuItemAllergens)
|
||||||
|
.innerJoin(
|
||||||
|
schema.allergens,
|
||||||
|
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
|
||||||
|
)
|
||||||
|
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -89,6 +113,9 @@ export const getMenuItemsForAdmin = async (
|
|||||||
steps: steps
|
steps: steps
|
||||||
.filter((step) => step.menuItemId === item.id)
|
.filter((step) => step.menuItemId === item.id)
|
||||||
.map(({ menuItemId: _menuItemId, ...step }) => step),
|
.map(({ menuItemId: _menuItemId, ...step }) => step),
|
||||||
|
allergens: itemAllergens
|
||||||
|
.filter((allergen) => allergen.menuItemId === item.id)
|
||||||
|
.map(({ id, name }) => ({ id, name })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,14 +157,24 @@ export const createMenuItem = async (
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (input.allergenIds.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemAllergens).values(
|
||||||
|
input.allergenIds.map((allergenId) => ({
|
||||||
|
menuItemId: item.id,
|
||||||
|
allergenId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return item.id
|
return item.id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options and Steps are replaced wholesale on every edit rather than diffed
|
// Options, Steps, and Allergens are replaced wholesale on every edit rather
|
||||||
// — simplest correct behavior for a form that resubmits its full list each
|
// than diffed — simplest correct behavior for a form that resubmits its
|
||||||
// time, and safe because placed Order Items snapshot their own copy (see
|
// full list each time, and safe because placed Order Items snapshot their
|
||||||
// orderItemSteps/orderItemOptions) so this never rewrites order history.
|
// own copy (see orderItemSteps/orderItemOptions) so this never rewrites
|
||||||
|
// order history.
|
||||||
export const updateMenuItem = async (
|
export const updateMenuItem = async (
|
||||||
db: Db,
|
db: Db,
|
||||||
menuItemId: number,
|
menuItemId: number,
|
||||||
@@ -161,6 +198,9 @@ export const updateMenuItem = async (
|
|||||||
await tx
|
await tx
|
||||||
.delete(schema.menuItemSteps)
|
.delete(schema.menuItemSteps)
|
||||||
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemAllergens)
|
||||||
|
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
|
||||||
|
|
||||||
if (input.options.length > 0) {
|
if (input.options.length > 0) {
|
||||||
await tx.insert(schema.menuItemOptions).values(
|
await tx.insert(schema.menuItemOptions).values(
|
||||||
@@ -181,6 +221,12 @@ export const updateMenuItem = async (
|
|||||||
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
estimatedDurationSeconds: step.estimatedDurationSeconds,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (input.allergenIds.length > 0) {
|
||||||
|
await tx.insert(schema.menuItemAllergens).values(
|
||||||
|
input.allergenIds.map((allergenId) => ({ menuItemId, allergenId })),
|
||||||
|
)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +251,9 @@ export const deleteMenuItem = async (
|
|||||||
await tx
|
await tx
|
||||||
.delete(schema.menuItemSteps)
|
.delete(schema.menuItemSteps)
|
||||||
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
.where(eq(schema.menuItemSteps.menuItemId, menuItemId))
|
||||||
|
await tx
|
||||||
|
.delete(schema.menuItemAllergens)
|
||||||
|
.where(eq(schema.menuItemAllergens.menuItemId, menuItemId))
|
||||||
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
|
// No ON DELETE CASCADE from orderItems.menuItemId — this throws (and
|
||||||
// rolls back the deletes above) if any Order ever referenced it.
|
// rolls back the deletes above) if any Order ever referenced it.
|
||||||
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
|
await tx.delete(schema.menuItems).where(eq(schema.menuItems.id, menuItemId))
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export type MenuItemWithRecipe = {
|
|||||||
name: string
|
name: string
|
||||||
priceDeltaCents: number
|
priceDeltaCents: number
|
||||||
}>
|
}>
|
||||||
|
allergens: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMenu = async (
|
export const getMenu = async (
|
||||||
@@ -99,6 +103,22 @@ export const getMenu = async (
|
|||||||
.from(schema.menuItemOptions)
|
.from(schema.menuItemOptions)
|
||||||
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
|
.where(inArray(schema.menuItemOptions.menuItemId, menuItemIds))
|
||||||
|
|
||||||
|
const itemAllergens =
|
||||||
|
menuItemIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
menuItemId: schema.menuItemAllergens.menuItemId,
|
||||||
|
id: schema.allergens.id,
|
||||||
|
name: schema.allergens.name,
|
||||||
|
})
|
||||||
|
.from(schema.menuItemAllergens)
|
||||||
|
.innerJoin(
|
||||||
|
schema.allergens,
|
||||||
|
eq(schema.menuItemAllergens.allergenId, schema.allergens.id),
|
||||||
|
)
|
||||||
|
.where(inArray(schema.menuItemAllergens.menuItemId, menuItemIds))
|
||||||
|
|
||||||
return items.map((item) => ({
|
return items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
@@ -115,6 +135,9 @@ export const getMenu = async (
|
|||||||
options: options
|
options: options
|
||||||
.filter((option) => option.menuItemId === item.id)
|
.filter((option) => option.menuItemId === item.id)
|
||||||
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
|
.map(({ id, name, priceDeltaCents }) => ({ id, name, priceDeltaCents })),
|
||||||
|
allergens: itemAllergens
|
||||||
|
.filter((allergen) => allergen.menuItemId === item.id)
|
||||||
|
.map(({ id, name }) => ({ id, name })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,3 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $space-2;
|
gap: $space-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.helpLink {
|
|
||||||
color: $color-text-muted;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $color-text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ const RootDocument = ({ children }: { children: React.ReactNode }) => {
|
|||||||
<Logo name={brand.name} />
|
<Logo name={brand.name} />
|
||||||
</Link>
|
</Link>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Link to="/help" className={styles.helpLink}>
|
|
||||||
Help
|
|
||||||
</Link>
|
|
||||||
<BasketLink />
|
<BasketLink />
|
||||||
<BetterAuthHeader />
|
<BetterAuthHeader />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { createFileRoute, notFound, useNavigate } from '@tanstack/react-router'
|
|||||||
|
|
||||||
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
||||||
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
import { getAdminMenu, getMachineTagOptions, updateMenuItem } from '#/server/menu-admin.ts'
|
import {
|
||||||
|
getAdminMenu,
|
||||||
|
getAllergenOptions,
|
||||||
|
getMachineTagOptions,
|
||||||
|
updateMenuItem,
|
||||||
|
} from '#/server/menu-admin.ts'
|
||||||
|
|
||||||
const EditMenuItemRoute = () => {
|
const EditMenuItemRoute = () => {
|
||||||
const { item, machineTags } = Route.useLoaderData()
|
const { item, machineTags, allergenOptions } = Route.useLoaderData()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +18,7 @@ const EditMenuItemRoute = () => {
|
|||||||
<h1>Edit {item.name}</h1>
|
<h1>Edit {item.name}</h1>
|
||||||
<MenuItemForm
|
<MenuItemForm
|
||||||
machineTags={machineTags}
|
machineTags={machineTags}
|
||||||
|
allergenOptions={allergenOptions}
|
||||||
initialItem={item}
|
initialItem={item}
|
||||||
submitLabel="Save changes"
|
submitLabel="Save changes"
|
||||||
onSubmit={async (input) => {
|
onSubmit={async (input) => {
|
||||||
@@ -26,12 +32,16 @@ const EditMenuItemRoute = () => {
|
|||||||
|
|
||||||
export const Route = createFileRoute('/admin/menu/$menuItemId')({
|
export const Route = createFileRoute('/admin/menu/$menuItemId')({
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const [items, machineTags] = await withAdminGuard(() =>
|
const [items, machineTags, allergenOptions] = await withAdminGuard(() =>
|
||||||
Promise.all([getAdminMenu(), getMachineTagOptions()]),
|
Promise.all([
|
||||||
|
getAdminMenu(),
|
||||||
|
getMachineTagOptions(),
|
||||||
|
getAllergenOptions(),
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
const item = items.find((i) => i.id === Number(params.menuItemId))
|
const item = items.find((i) => i.id === Number(params.menuItemId))
|
||||||
if (!item) throw notFound()
|
if (!item) throw notFound()
|
||||||
return { item, machineTags }
|
return { item, machineTags, allergenOptions }
|
||||||
},
|
},
|
||||||
component: EditMenuItemRoute,
|
component: EditMenuItemRoute,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|||||||
|
|
||||||
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
import { MenuItemForm } from '#/components/admin/MenuItemForm.tsx'
|
||||||
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
import { withAdminGuard } from '#/lib/admin-guard.ts'
|
||||||
import { createMenuItem, getMachineTagOptions } from '#/server/menu-admin.ts'
|
import {
|
||||||
|
createMenuItem,
|
||||||
|
getAllergenOptions,
|
||||||
|
getMachineTagOptions,
|
||||||
|
} from '#/server/menu-admin.ts'
|
||||||
|
|
||||||
const NewMenuItemRoute = () => {
|
const NewMenuItemRoute = () => {
|
||||||
const machineTags = Route.useLoaderData()
|
const { machineTags, allergenOptions } = Route.useLoaderData()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,6 +17,7 @@ const NewMenuItemRoute = () => {
|
|||||||
<h1>Add menu item</h1>
|
<h1>Add menu item</h1>
|
||||||
<MenuItemForm
|
<MenuItemForm
|
||||||
machineTags={machineTags}
|
machineTags={machineTags}
|
||||||
|
allergenOptions={allergenOptions}
|
||||||
submitLabel="Create item"
|
submitLabel="Create item"
|
||||||
onSubmit={async (input) => {
|
onSubmit={async (input) => {
|
||||||
await createMenuItem({ data: input })
|
await createMenuItem({ data: input })
|
||||||
@@ -24,6 +29,10 @@ const NewMenuItemRoute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/menu/new')({
|
export const Route = createFileRoute('/admin/menu/new')({
|
||||||
loader: () => withAdminGuard(() => getMachineTagOptions()),
|
loader: () =>
|
||||||
|
withAdminGuard(async () => ({
|
||||||
|
machineTags: await getMachineTagOptions(),
|
||||||
|
allergenOptions: await getAllergenOptions(),
|
||||||
|
})),
|
||||||
component: NewMenuItemRoute,
|
component: NewMenuItemRoute,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const menuItemInputSchema = z.object({
|
|||||||
estimatedDurationSeconds: z.number().int().positive(),
|
estimatedDurationSeconds: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
allergenIds: z.array(z.number()),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getAdminMenu = createServerFn({ method: 'GET' }).handler(
|
export const getAdminMenu = createServerFn({ method: 'GET' }).handler(
|
||||||
@@ -38,6 +39,13 @@ export const getMachineTagOptions = createServerFn({ method: 'GET' }).handler(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const getAllergenOptions = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async () => {
|
||||||
|
await requireAdmin()
|
||||||
|
return menu.getAllergens(db)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export const createMenuItem = createServerFn({ method: 'POST' })
|
export const createMenuItem = createServerFn({ method: 'POST' })
|
||||||
.validator(menuItemInputSchema)
|
.validator(menuItemInputSchema)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user