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')}
-
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[];
}