diff --git a/app/admin/setup/page.tsx b/app/admin/setup/page.tsx index 43994a3..8b2e26e 100644 --- a/app/admin/setup/page.tsx +++ b/app/admin/setup/page.tsx @@ -8,11 +8,13 @@ import { import type { Product, Recipe } from '@/lib/domain/types'; import { buildCostingSnapshotItems } from '@/lib/domain/recipe-costing'; import { + createProductAction, createRawMaterialAction, createRecipeAction, createSupplierAction, createSupplierPriceEntryAction, createUserAction, + updateProductAction, updateRawMaterialAction, updateRecipeAction, updateSupplierAction, @@ -42,6 +44,7 @@ type SetupSearchParams = { | 'business-setup' | 'users' | 'preferences-system' + | 'products' | 'suppliers' | 'raw-materials' | 'recipes' @@ -52,6 +55,7 @@ type SetupSearchParams = { supplier?: string; material?: string; product?: string; + productSetup?: string; historySupplier?: string; historyMaterial?: string; recipe?: string; @@ -144,6 +148,10 @@ async function SavedMessage({ saved }: { saved?: string }) { return

{t('setup.saved.recipe')}

; } + if (saved === 'product') { + return

Product saved.

; + } + if (saved === 'preferences') { return

{t('setup.saved.preferences')}

; } @@ -194,6 +202,9 @@ export default async function SetupPage({ const selectedProduct = params?.product ? (catalogSetup.products.find((product) => product.id === params.product) ?? null) : null; + const editingProduct = params?.productSetup + ? (catalogSetup.products.find((product) => product.id === params.productSetup) ?? null) + : null; const editingRecipe = params?.recipe ? (catalogSetup.recipes.find((recipe) => recipe.id === params.recipe) ?? null) : null; @@ -217,6 +228,9 @@ export default async function SetupPage({ const rawMaterialFormAction = editingMaterial ? updateRawMaterialAction.bind(null, editingMaterial.id) : createRawMaterialAction; + const productFormAction = editingProduct + ? updateProductAction.bind(null, editingProduct.id) + : createProductAction; const recipeFormAction = editingRecipe ? updateRecipeAction.bind(null, editingRecipe.id) : createRecipeAction; @@ -333,6 +347,7 @@ export default async function SetupPage({ | 'business-setup' | 'users' | 'preferences-system' + | 'products' | 'suppliers' | 'raw-materials' | 'recipes' @@ -343,6 +358,7 @@ export default async function SetupPage({ { key: 'business-setup', label: t('setup.sections.businessSetup') }, { key: 'users', label: t('setup.sections.users') }, { key: 'preferences-system', label: t('setup.sections.preferencesSystem') }, + { key: 'products', label: 'Products' }, { key: 'suppliers', label: t('setup.sections.suppliers') }, { key: 'raw-materials', label: t('setup.sections.rawMaterials') }, { key: 'recipes', label: t('setup.sections.recipes') }, @@ -597,68 +613,109 @@ export default async function SetupPage({
-
+
-

{t('setup.productsAndVariants')}

-

{t('setup.productsAndVariantsHelp')}

+

Products for order capture

+

Confirm the basic sellable items your team needs for first orders. Recipes, costing, inventory, and procurement can come later.

- {catalogSetup.products.length} {t('common.products')} + {catalogSetup.products.filter((product) => product.active).length}/{catalogSetup.products.length} active

- {t('setup.managedFromOrders')}{' '} + Confirmed products appear as suggestions during order capture. Operators can still type draft product names when real work needs to move before setup is complete.{' '} {t('setup.openOrderCapture')}

-
    - {catalogSetup.products.map((product) => ( -
  • -
    - {product.name} - - {product.variants.map((variant) => variant.name).join(', ')} - - - {(recipesByProduct.get(product.id) ?? []).length}{' '} - {t('common.recipes')} - -
    -
    - - {t('setup.actions.addRecipe')} - - {(recipesByProduct.get(product.id) ?? [])[0] ? ( +
    +
      + {catalogSetup.products.map((product) => ( +
    • +
      + + {product.name} + {!product.active ? ` · ${t('common.inactive').toLowerCase()}` : ''} + + + {[product.category, `${product.defaultUnit} default unit`].filter(Boolean).join(' · ')} + + + {product.variants.length} variant{product.variants.length === 1 ? '' : 's'} · {(recipesByProduct.get(product.id) ?? []).length} {t('common.recipes')} + +
      +
      + + {t('setup.actions.edit')} + - {t('setup.actions.editRecipe')} + {t('setup.actions.addRecipe')} - ) : null} +
      +
    • + ))} +
    +
    + +
    +
    +

    {editingProduct ? 'Edit product' : 'Add product'}

    +

    Keep this to the name and unit needed for first orders.

    -
  • - ))} -
+ {editingProduct ? ( + + {t('setup.actions.cancelEdit')} + + ) : null} + +
+ + + + +
+ + +
diff --git a/app/orders/page.tsx b/app/orders/page.tsx index 14bdc68..309f8b3 100644 --- a/app/orders/page.tsx +++ b/app/orders/page.tsx @@ -137,6 +137,9 @@ export default async function OrdersPage({ {t('orders.newRecurringTemplate')} + + Confirm products + {t('orders.newOrder')} @@ -199,6 +202,7 @@ export default async function OrdersPage({
Create first order Confirm customers + Confirm products
diff --git a/components/orders/order-form.tsx b/components/orders/order-form.tsx index 6ec1817..40a9762 100644 --- a/components/orders/order-form.tsx +++ b/components/orders/order-form.tsx @@ -388,6 +388,7 @@ export function OrderForm({

{t('orders.orderForm.sections.linesHelp')}

+

Confirmed products appear as suggestions, but you can still type a draft item when setup is incomplete. Confirm products.

{ + catalog.upsertProduct(product); + raw.activities = data.activities; + }); + revalidateAllWorkspaces(); + redirect(`${redirectTo}?saved=product&productSetup=${product.id}#products`); +} + +export async function updateProductAction(productId: string, formData: FormData) { + const context = await readPersistence(); + const data = context.raw; + const existing = context.catalog.getProductById(productId); + + if (!existing) { + redirect('/admin/setup?error=missing-product#products'); + } + + const values = normalizeProductForm(formData); + const error = validateProductForm(values, data, productId); + + if (error) { + redirect(`/admin/setup?productSetup=${productId}&error=${encodeURIComponent(error)}#products`); + } + + const actorUserId = await getActorUserId(data); + const product = buildProductRecord(values, existing); + appendActivity(data, { + entityType: 'product', + entityId: product.id, + action: existing.active !== product.active ? 'status_changed' : 'updated', + userId: actorUserId, + summary: existing.active !== product.active + ? `Product ${quoteLabel(product.name)} marked as ${product.active ? 'active' : 'inactive'}.` + : `Product ${quoteLabel(product.name)} updated.`, + }); + await persistence.write(({ raw, catalog }) => { + catalog.upsertProduct(product); + raw.activities = data.activities; + }); + revalidateAllWorkspaces(); + redirect(`/admin/setup?saved=product&productSetup=${product.id}#products`); +} + export async function createSupplierAction(formData: FormData) { const data = await readAppData(); const redirectTo = resolveRedirectTo(formData, '/admin/setup'); diff --git a/lib/server/demo-data.ts b/lib/server/demo-data.ts index 6b26978..0e12c3b 100644 --- a/lib/server/demo-data.ts +++ b/lib/server/demo-data.ts @@ -95,6 +95,13 @@ export interface RawMaterialFormValues { active: boolean; } +export interface ProductFormValues { + name: string; + category?: string; + defaultUnit: string; + active: boolean; +} + export interface RecipeFormValues { productId: string; productVariantId?: string; @@ -182,8 +189,15 @@ function getProductLabel(product: Pick, variant?: { name: strin return variant ? `${product.name} / ${variant.name}` : product.name; } -function getProductSuggestions(products: Array<{ name: string; variants: Array<{ name: string }> }>) { - return products.flatMap((product) => product.variants.map((variant) => `${product.name} / ${variant.name}`)); +function getProductSuggestions(products: Array<{ name: string; active?: boolean; variants: Array<{ name: string; active?: boolean }> }>) { + return products + .filter((product) => product.active !== false) + .flatMap((product) => [ + product.name, + ...product.variants + .filter((variant) => variant.active !== false) + .map((variant) => `${product.name} / ${variant.name}`), + ]); } function getCustomerById(customers: Customer[], customerId?: string) { @@ -1294,6 +1308,51 @@ export function buildRawMaterialRecord(values: RawMaterialFormValues, actorUserI }; } +export function normalizeProductForm(formData: FormData): ProductFormValues { + return { + name: String(formData.get('name') ?? ''), + category: String(formData.get('category') ?? ''), + defaultUnit: String(formData.get('defaultUnit') ?? 'piece'), + active: formData.get('active') !== null, + }; +} + +export function validateProductForm(values: ProductFormValues, data: Pick, existingProductId?: string) { + if (!values.name.trim()) { + return 'Product name is required.'; + } + + const normalizedName = values.name.trim().toLowerCase(); + const duplicate = data.products.find((product) => product.name.trim().toLowerCase() === normalizedName && product.id !== existingProductId); + if (duplicate) { + return 'Product name must stay unique.'; + } + + if (!values.defaultUnit.trim()) { + return 'Default unit is required.'; + } + + return null; +} + +export function buildProductRecord(values: ProductFormValues, existingProduct?: Product): Product { + const defaultUnit = values.defaultUnit.trim() || 'piece'; + const existingVariants = existingProduct?.variants ?? []; + const variants = existingVariants.length > 0 + ? existingVariants.map((variant, index) => index === 0 ? { ...variant, defaultUnit } : variant) + : [{ id: `variant-${crypto.randomUUID()}`, name: 'Default', defaultUnit, active: true }]; + + return { + id: existingProduct?.id ?? `product-${crypto.randomUUID()}`, + name: values.name.trim(), + category: values.category?.trim() || undefined, + baseDoughId: existingProduct?.baseDoughId, + defaultUnit, + active: values.active, + variants, + }; +} + export function normalizeSupplierPriceEntryForm(formData: FormData): SupplierPriceEntryFormValues { const quantityValue = String(formData.get('packageQuantity') ?? '').trim(); diff --git a/lib/server/persistence/context.ts b/lib/server/persistence/context.ts index 5b8598e..9d7fa5a 100644 --- a/lib/server/persistence/context.ts +++ b/lib/server/persistence/context.ts @@ -98,6 +98,16 @@ class InMemoryRecurringTemplatesRepository implements RecurringTemplatesReposito class InMemoryCatalogRepository implements CatalogRepository { constructor(private readonly data: AppData) {} listProducts(): Product[] { return this.data.products; } + getProductById(productId: string) { return this.data.products.find((product) => product.id === productId); } + upsertProduct(product: Product) { + const existingIndex = this.data.products.findIndex((entry) => entry.id === product.id); + if (existingIndex >= 0) { + this.data.products[existingIndex] = product; + } else { + this.data.products.push(product); + } + this.data.products.sort((left, right) => left.name.localeCompare(right.name)); + } listDestinations(): Destination[] { return this.data.destinations; } } diff --git a/lib/server/persistence/contracts.ts b/lib/server/persistence/contracts.ts index bd03d80..1206fa0 100644 --- a/lib/server/persistence/contracts.ts +++ b/lib/server/persistence/contracts.ts @@ -59,6 +59,8 @@ export interface RecurringTemplatesRepository { export interface CatalogRepository { listProducts(): Product[]; + getProductById(productId: string): Product | undefined; + upsertProduct(product: Product): void; listDestinations(): Destination[]; }