diff --git a/app/admin/setup/[section]/page.tsx b/app/admin/setup/[section]/page.tsx
new file mode 100644
index 0000000..de94bdb
--- /dev/null
+++ b/app/admin/setup/[section]/page.tsx
@@ -0,0 +1,12 @@
+import { AdminSetupPage, type SetupSearchParams } from '../setup-page';
+
+export default async function SetupSectionPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ section: string }>;
+ searchParams?: Promise;
+}) {
+ const { section } = await params;
+ return ;
+}
diff --git a/app/admin/setup/page.tsx b/app/admin/setup/page.tsx
index c65f3c9..a044038 100644
--- a/app/admin/setup/page.tsx
+++ b/app/admin/setup/page.tsx
@@ -1,2288 +1,9 @@
-import Link from 'next/link';
-import { skossCoreRoutes } from '@/lib/application-planes';
-import {
- formatCurrency,
- formatDateLabel,
- formatUnitRate,
-} from '@/lib/domain/formatters';
-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,
- updateUserAction,
- resetDemoWorkspaceAction,
-} from '@/lib/server/actions';
-import { requireAdminPlaneAccess } from '@/lib/server/application-access';
-import { getSetupWorkspace } from '@/lib/server/demo-data';
-import { getServerTranslator } from '@/lib/i18n/server';
-import { getVisibleWorkspacesForRole } from '@/lib/workspaces';
-import { ThemeSwitcher } from '@/components/theme-switcher';
-import { OperationalOnboardingChecklist } from '@/components/setup/operational-onboarding-checklist';
-import { getRuntimeMode, isNonProductionMode } from '@/lib/server/runtime-mode';
-
-const basicUnits = [
- 'g',
- 'kg',
- 'ml',
- 'l',
- 'unit',
- 'piece',
- 'dozen',
- 'eggs',
-] as const;
-
-type SetupSearchParams = {
- section?:
- | 'onboarding'
- | 'business-setup'
- | 'users'
- | 'preferences-system'
- | 'products'
- | 'suppliers'
- | 'raw-materials'
- | 'recipes'
- | 'costing'
- | 'price-history';
- error?: string;
- saved?: string;
- supplier?: string;
- material?: string;
- product?: string;
- productSetup?: string;
- historySupplier?: string;
- historyMaterial?: string;
- recipe?: string;
- user?: string;
- costingStatus?:
- | 'all'
- | 'fully_costed'
- | 'partially_costed'
- | 'missing_cost_evidence'
- | 'no_recipe';
- costingItem?: string;
- importedEntity?: 'customers' | 'suppliers' | 'rawMaterials';
- importedCount?: string;
- skippedCount?: string;
-};
-
-function buildSetupHref(params: Record) {
- return {
- pathname: skossCoreRoutes.adminSetup,
- query: Object.fromEntries(
- Object.entries(params).filter(([, value]) => Boolean(value)),
- ),
- };
-}
-
-function getProductLabel(
- product: Pick,
- variant?: { name: string } | null,
-) {
- return variant ? `${product.name} / ${variant.name}` : product.name;
-}
-
-function getRecipeLinkLabel(recipe: Recipe, products: Product[]) {
- const product = products.find((entry) => entry.id === recipe.productId);
- if (!product) {
- return recipe.title;
- }
-
- const variant = recipe.productVariantId
- ? (product.variants.find((entry) => entry.id === recipe.productVariantId) ??
- null)
- : null;
-
- return getProductLabel(product, variant);
-}
-
-function buildRecipeLineRows(recipe?: Recipe | null) {
- const existing = recipe?.lines ?? [];
- const blankCount = Math.max(3, 5 - existing.length);
-
- return [
- ...existing.map((line) => ({ ...line, isBlank: false, key: line.id })),
- ...Array.from({ length: blankCount }, (_, index) => ({
- id: '',
- rawMaterialId: '',
- rawMaterialLabel: '',
- quantity: undefined,
- unit: '',
- note: '',
- isBlank: true,
- key: `blank-${index}`,
- })),
- ];
-}
-
-function renderRequiredMark() {
- return (
-
- *
-
- );
-}
-
-async function SavedMessage({ saved }: { saved?: string }) {
- const { t } = await getServerTranslator();
-
- if (saved === 'supplier') {
- return {t('setup.saved.supplier')}
;
- }
-
- if (saved === 'raw-material') {
- return {t('setup.saved.rawMaterial')}
;
- }
-
- if (saved === 'price') {
- return {t('setup.saved.price')}
;
- }
-
- if (saved === 'recipe') {
- return {t('setup.saved.recipe')}
;
- }
-
- if (saved === 'product') {
- return Product saved.
;
- }
-
- if (saved === 'preferences') {
- return {t('setup.saved.preferences')}
;
- }
-
- if (saved === 'user') {
- return {t('setup.saved.user')}
;
- }
-
- if (saved === 'import') {
- return {t('setup.saved.import')}
;
- }
-
- if (saved === 'demo-reset') {
- return Demo workspace reseeded successfully.
;
- }
-
- if (saved === 'onboarding-section') {
- return Onboarding section updated.
;
- }
-
- return null;
-}
+import { AdminSetupPage, type SetupSearchParams } from './setup-page';
export default async function SetupPage({
searchParams,
}: {
searchParams?: Promise;
}) {
- const userContext = await requireAdminPlaneAccess(skossCoreRoutes.adminSetup);
- const [data, params, { t, locale, term }] = await Promise.all([
- getSetupWorkspace(),
- searchParams,
- getServerTranslator(),
- ]);
- const today = new Date().toISOString().slice(0, 10);
- const runtimeMode = getRuntimeMode();
- const canResetDemoWorkspace = isNonProductionMode();
- const identitySetup = data.identitySetup;
- const customerSetup = data.customerSetup;
- const businessSetup = data.businessSetup;
- const procurementSetup = data.procurementSetup;
- const catalogSetup = data.catalogSetup;
-
- const editingSupplier = params?.supplier
- ? (procurementSetup.suppliers.find((supplier) => supplier.id === params.supplier) ??
- null)
- : null;
- const editingMaterial = params?.material
- ? (procurementSetup.rawMaterials.find((material) => material.id === params.material) ??
- null)
- : null;
- 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;
- const editingUser = params?.user
- ? (identitySetup.users.find((user) => user.id === params.user) ?? null)
- : null;
- const historySupplier = params?.historySupplier
- ? (procurementSetup.suppliers.find(
- (supplier) => supplier.id === params.historySupplier,
- ) ?? null)
- : null;
- const historyMaterial = params?.historyMaterial
- ? (procurementSetup.rawMaterials.find(
- (material) => material.id === params.historyMaterial,
- ) ?? null)
- : null;
-
- const supplierFormAction = editingSupplier
- ? updateSupplierAction.bind(null, editingSupplier.id)
- : createSupplierAction;
- 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;
- const userFormAction = editingUser
- ? updateUserAction.bind(null, editingUser.id)
- : createUserAction;
-
- const supplierPriceCounts = new Map();
- const materialPriceCounts = new Map();
- for (const entry of procurementSetup.supplierPriceEntries) {
- supplierPriceCounts.set(
- entry.supplierId,
- (supplierPriceCounts.get(entry.supplierId) ?? 0) + 1,
- );
- materialPriceCounts.set(
- entry.rawMaterialId,
- (materialPriceCounts.get(entry.rawMaterialId) ?? 0) + 1,
- );
- }
-
- const filteredPriceEntries = procurementSetup.supplierPriceEntries.filter((entry) => {
- if (historySupplier && entry.supplierId !== historySupplier.id) {
- return false;
- }
-
- if (historyMaterial && entry.rawMaterialId !== historyMaterial.id) {
- return false;
- }
-
- return true;
- });
-
- const supplierHistoryHref = (supplierId: string) =>
- buildSetupHref({
- historySupplier: supplierId,
- historyMaterial: historyMaterial?.id,
- supplier: editingSupplier?.id,
- material: editingMaterial?.id,
- recipe: editingRecipe?.id,
- });
-
- const materialHistoryHref = (materialId: string) =>
- buildSetupHref({
- historySupplier: historySupplier?.id,
- historyMaterial: materialId,
- supplier: editingSupplier?.id,
- material: editingMaterial?.id,
- recipe: editingRecipe?.id,
- });
-
- const recipeLineRows = buildRecipeLineRows(editingRecipe);
- const recipeProductId = editingRecipe?.productId ?? selectedProduct?.id ?? '';
- const editingRecipeCost = editingRecipe
- ? (catalogSetup.recipeCostById.get(editingRecipe.id) ?? null)
- : null;
- const activeRecipes = catalogSetup.recipes.filter((recipe) => recipe.active).length;
- const linkedProductCount = new Set(
- catalogSetup.recipes.map((recipe) => recipe.productVariantId ?? recipe.productId),
- ).size;
- const recipesByProduct = new Map();
- for (const recipe of catalogSetup.recipes) {
- const existingRecipes = recipesByProduct.get(recipe.productId) ?? [];
- recipesByProduct.set(recipe.productId, [...existingRecipes, recipe]);
- }
- const costingItems = buildCostingSnapshotItems(
- catalogSetup.products,
- catalogSetup.recipes,
- catalogSetup.recipeCostById,
- );
- const costingStatusFilter = params?.costingStatus ?? 'all';
- const filteredCostingItems = costingItems.filter(
- (item) =>
- costingStatusFilter === 'all' || item.status === costingStatusFilter,
- );
- const selectedCostingItem =
- (params?.costingItem
- ? (filteredCostingItems.find((item) => item.id === params.costingItem) ??
- costingItems.find((item) => item.id === params.costingItem) ??
- null)
- : null) ??
- filteredCostingItems[0] ??
- costingItems[0] ??
- null;
- const costingSummary = {
- fullyCosted: costingItems.filter((item) => item.status === 'fully_costed')
- .length,
- partiallyCosted: costingItems.filter(
- (item) => item.status === 'partially_costed',
- ).length,
- missingEvidence: costingItems.filter(
- (item) => item.status === 'missing_cost_evidence',
- ).length,
- noRecipe: costingItems.filter((item) => item.status === 'no_recipe').length,
- };
- const buildCostingHref = (overrides: Record) =>
- buildSetupHref({
- supplier: editingSupplier?.id,
- material: editingMaterial?.id,
- historySupplier: historySupplier?.id,
- historyMaterial: historyMaterial?.id,
- recipe: editingRecipe?.id,
- product: selectedProduct?.id,
- costingStatus: costingStatusFilter,
- costingItem: selectedCostingItem?.id,
- ...overrides,
- });
- const importedCount = Number(params?.importedCount ?? 0);
- const skippedCount = Number(params?.skippedCount ?? 0);
- const hasImportFeedback =
- params?.saved === 'import' && params?.importedEntity;
- const setupSection = params?.section;
- const sectionNavItems: Array<{
- key:
- | 'onboarding'
- | 'business-setup'
- | 'users'
- | 'preferences-system'
- | 'products'
- | 'suppliers'
- | 'raw-materials'
- | 'recipes'
- | 'costing'
- | 'price-history';
- label: string;
- }> = [
- { key: 'onboarding', label: 'Onboarding' },
- { 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') },
- { key: 'costing', label: t('setup.sections.costing') },
- { key: 'price-history', label: t('setup.sections.priceHistory') },
- ];
- const buildSectionLink = (section: (typeof sectionNavItems)[number]['key']) => ({
- pathname: skossCoreRoutes.adminSetup,
- query: { section },
- hash: section,
- });
-
- return (
-
-
- {basicUnits.map((unit) => (
-
- ))}
-
-
-
-
-
{t('setup.workspace')}
-
{t('setup.title')}
-
{t('setup.description')}
-
-
-
-
- {params?.error ?
{params.error}
: null}
- {hasImportFeedback ? (
-
- {t('setup.import.result', {
- entity: t(`setup.import.entityLabels.${params.importedEntity}`),
- imported: Number.isFinite(importedCount) ? importedCount : 0,
- skipped: Number.isFinite(skippedCount) ? skippedCount : 0,
- })}
-
- ) : null}
-
- {canResetDemoWorkspace ? (
-
-
-
-
Local demo data safety
-
- Runtime mode: {runtimeMode} . Use reset only for local/pilot testing so real records stay separate.
-
-
-
-
-
- ) : null}
-
-
-
-
-
{t('setup.title')}
-
{t('setup.adminReadinessHelp')}
-
-
-
- {sectionNavItems.map((item) => (
-
- {item.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
{t('setup.customerMemoryTitle')}
-
{t('setup.customerMemoryHelp')}
-
-
- {customerSetup.customers.length} {t('common.customers')}
-
-
- {customerSetup.customers.length > 0 ? (
-
- ) : (
- {t('setup.customerMemoryEmpty')}
- )}
-
-
- {t('setup.actions.manageCustomers')}
-
-
- {t('setup.actions.addCustomer')}
-
-
-
-
-
-
-
-
{t('setup.sections.imports')}
-
Choose manual entry or CSV import based on what your business already has.
-
-
-
-
-
-
- {!userContext.canManageSettings ? (
-
{t('setup.roleShapingNote')}
- ) : null}
-
-
-
-
-
-
{t('setup.sections.preferencesSystem')}
-
{t('setup.preferencesAreaBody')}
-
-
-
- {t('setup.openPreferences')}
-
-
-
-
-
-
-
{t('setup.appearance')}
-
{t('setup.appearanceHelp')}
-
-
- {userContext.currentUser
- ? userContext.currentUser.displayName
- : t('nav.preferences')}
-
-
-
-
-
-
-
-
- {t('setup.activeSuppliers')}
-
- {procurementSetup.suppliers.filter((supplier) => supplier.active).length}
-
- {t('setup.activeSuppliersHelp')}
-
-
- {t('setup.rawMaterials')}
- {procurementSetup.rawMaterials.length}
- {t('setup.rawMaterialsHelp')}
-
-
- {t('setup.recordedPrices')}
- {procurementSetup.supplierPriceEntries.length}
- {t('setup.recordedPricesHelp')}
-
-
- {t('setup.recipes')}
- {activeRecipes}
- {t('setup.recipesHelp')}
-
-
-
-
-
-
-
-
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.filter((product) => product.active).length}/{catalogSetup.products.length} active
-
-
-
- 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.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.addRecipe')}
-
-
-
- ))}
-
-
-
-
-
-
-
-
-
{t('setup.destinations')}
-
{t('setup.destinationsHelp')}
-
-
- {businessSetup.destinations.length} {term('destination', 'many')}
-
-
-
- {t('setup.destinationsManagedFromOrders')}{' '}
-
- {t('setup.openOrderCapture')}
-
-
-
- {businessSetup.destinations.map((destination) => (
-
- {destination.name}
- {destination.kind}
-
- ))}
-
-
-
-
-
-
-
-
-
{t('setup.users')}
-
{t('setup.usersHelp')}
-
-
- {identitySetup.users.length} {t('common.users')}
-
-
-
-
- {identitySetup.users.filter((user) => user.active).length}{' '}
- {t('setup.labels.activeUsers')}
-
-
- {identitySetup.users.filter((user) => !user.active).length}{' '}
- {t('setup.labels.inactiveUsers')}
-
-
- {new Set(identitySetup.users.flatMap((user) => user.roles ?? [user.role])).size}{' '}
- {t('setup.labels.rolesInUse')}
-
-
- {t('setup.openPreferences')}
-
-
-
-
- {identitySetup.users.length > 0 ? (
-
- {identitySetup.users.map((user) => (
-
-
-
- {user.displayName}
- {!user.active
- ? ` · ${t('common.inactive').toLowerCase()}`
- : ''}
-
-
- {(user.roles?.length ? user.roles : [user.role]).map((role) => t(`roles.${role}.label`)).join(' · ')} ·{' '}
- {user.loginIdentifier}
-
-
- {t('setup.labels.defaultWorkspace')}:{' '}
- {t(
- `nav.${user.preferences?.defaultWorkspace ?? user.defaultWorkspace}`,
- )}
-
-
- {t('setup.labels.visibleWorkspaces')}:{' '}
- {getVisibleWorkspacesForRole((user.roles?.[0] ?? user.role))
- .map((workspace) => t(`nav.${workspace}`))
- .join(' · ')}
-
-
- {t('setup.labels.password')}:{' '}
- {user.mustChangePassword
- ? t('setup.labels.passwordNeedsReset')
- : t('setup.labels.passwordReady')}
-
-
-
-
- {t('setup.actions.edit')}
-
-
-
- ))}
-
- ) : (
-
{t('setup.usersEmpty')}
- )}
-
-
-
-
-
-
-
-
-
-
{t('setup.sections.catalogData')}
-
{t('setup.rawMaterialsHelp')}
-
-
-
-
-
-
-
{t('setup.suppliers')}
-
{t('setup.suppliersHelp')}
-
-
-
-
- {procurementSetup.suppliers.length > 0 ? (
-
- {procurementSetup.suppliers.map((supplier) => (
-
-
-
- {supplier.name}
- {!supplier.active
- ? ` · ${t('common.inactive').toLowerCase()}`
- : ''}
-
-
- {supplier.contact ??
- supplier.notes ??
- t('setup.noExtraContactYet')}
-
-
- {supplierPriceCounts.get(supplier.id) ?? 0}{' '}
- {t('setup.labels.priceEntries')}
-
-
-
-
- {t('setup.actions.edit')}
-
-
- {t('setup.actions.viewHistory')}
-
-
-
- ))}
-
- ) : (
-
{t('setup.suppliersEmpty')}
- )}
-
-
-
-
-
-
-
-
{t('setup.rawMaterialsSection')}
-
{t('setup.rawMaterialsSectionHelp')}
-
-
-
-
- {procurementSetup.rawMaterials.length > 0 ? (
-
- {procurementSetup.rawMaterials.map((material) => {
- const latestPrice = procurementSetup.latestPriceByMaterial.get(
- material.id,
- );
-
- return (
-
-
-
- {material.name}
- {!material.active
- ? ` · ${t('common.inactive').toLowerCase()}`
- : ''}
-
-
- {[
- material.category,
- material.defaultUnit
- ? `${material.defaultUnit} ${t('setup.labels.defaultUnitSuffix')}`
- : t('setup.labels.noDefaultUnit'),
- latestPrice
- ? `${t('setup.labels.latest')} ${formatUnitRate(latestPrice, locale)}`
- : null,
- ]
- .filter(Boolean)
- .join(' · ')}
-
-
- {materialPriceCounts.get(material.id) ?? 0}{' '}
- {t('setup.labels.priceEntries')}
-
-
-
-
- {t('setup.actions.edit')}
-
-
- {t('setup.actions.viewHistory')}
-
-
-
- );
- })}
-
- ) : (
-
{t('setup.rawMaterialsEmpty')}
- )}
-
-
-
-
- {editingMaterial
- ? t('setup.editRawMaterial')
- : t('setup.addRawMaterial')}
-
-
- {editingMaterial
- ? t('setup.editRawMaterialHelp')
- : t('setup.addRawMaterialHelp')}
-
-
- {editingMaterial ? (
-
- {t('setup.actions.cancelEdit')}
-
- ) : null}
-
-
-
-
- {t('setup.fields.rawMaterialName')} {renderRequiredMark()}
-
-
-
-
-
- {t('setup.fields.category')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
-
- {t('setup.fields.defaultUnit')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
-
- {t('setup.fields.brand')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
- {t('setup.fields.unitQuickHelp')}
-
-
- {t('setup.fields.notes')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
-
-
- {t('setup.fields.activeRawMaterial')}
-
- {t('setup.fields.activeRawMaterialHelp')}
-
-
-
-
- {editingMaterial
- ? t('setup.actions.updateRawMaterial')
- : t('setup.actions.saveRawMaterial')}
-
-
-
-
-
-
-
-
-
-
-
{t('setup.recipeFoundationTitle')}
-
{t('setup.recipeFoundationHelp')}
-
-
- {linkedProductCount} {t('setup.recipeLabels.linkedProducts')}
-
-
-
-
-
-
-
-
{t('setup.recipeListTitle')}
-
{t('setup.recipeListHelp')}
-
-
- {catalogSetup.recipes.length} {t('common.recipes')}
-
-
- {catalogSetup.recipes.length > 0 ? (
-
- {catalogSetup.recipes.map((recipe) => {
- const recipeCost = catalogSetup.recipeCostById.get(recipe.id);
- const productLabel = getRecipeLinkLabel(
- recipe,
- catalogSetup.products,
- );
-
- return (
-
-
-
- {recipe.title}
- {!recipe.active
- ? ` · ${t('common.inactive').toLowerCase()}`
- : ''}
-
- {productLabel}
-
- {recipe.lines.length} {t('setup.recipeLabels.lines')}
- {recipeCost?.lineCount
- ? ` · ${
- recipeCost.complete
- ? `${t('setup.recipeLabels.costed')} ${formatCurrency(recipeCost.totalEstimatedCost, locale)}`
- : `${formatCurrency(recipeCost.totalEstimatedCost, locale)} · ${recipeCost.incompleteLineCount} ${t('setup.recipeLabels.incomplete')}`
- }`
- : ` · ${t('setup.recipeLabels.noLinesYet')}`}
-
-
-
-
- {t('setup.actions.edit')}
-
-
-
- );
- })}
-
- ) : (
- {t('setup.recipeEmpty')}
- )}
-
-
-
-
-
-
-
- {editingRecipe
- ? t('setup.editRecipe')
- : t('setup.addRecipe')}
-
-
- {editingRecipe
- ? t('setup.editRecipeHelp')
- : t('setup.addRecipeHelp')}
-
-
- {editingRecipe ? (
-
- {t('setup.actions.cancelEdit')}
-
- ) : null}
-
-
-
-
-
- {t('setup.fields.product')} {renderRequiredMark()}
-
-
-
- {t('common.selectProduct')}
-
- {catalogSetup.products.map((product) => (
-
- {product.name}
-
- ))}
-
-
-
-
- {t('setup.fields.variant')}{' '}
-
- {t('common.optional')}
-
-
-
-
- {t('setup.recipeLabels.productLevel')}
-
- {catalogSetup.products.map((product) => (
-
- {product.variants.map((variant) => (
-
- {variant.name}
-
- ))}
-
- ))}
-
-
-
-
- {t('setup.fields.recipeTitle')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
-
- {t('setup.fields.batchYieldQuantity')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
-
- {t('setup.fields.batchYieldUnit')}{' '}
-
- {t('common.optional')}
-
-
-
-
-
- {t('setup.recipeYieldHelp')}
-
-
- {t('setup.fields.instructions')}{' '}
- {t('common.optional')}
-
-
-
-
-
-
- {t('setup.fields.activeRecipe')}
-
- {t('setup.fields.activeRecipeHelp')}
-
-
-
-
-
-
-
{t('setup.recipeLinesTitle')}
-
{t('setup.recipeLinesHelp')}
-
-
- {recipeLineRows.map((line, index) => (
-
- ))}
-
-
-
-
- {editingRecipe
- ? t('setup.actions.updateRecipe')
- : t('setup.actions.saveRecipe')}
-
-
-
-
-
-
-
{t('setup.recipeCostingTitle')}
-
{t('setup.recipeCostingHelp')}
-
-
- {t('setup.recipeLabels.latestPricesOnly')}
-
-
- {editingRecipe && editingRecipeCost ? (
- editingRecipeCost.lineCount > 0 ? (
- <>
-
-
- {formatCurrency(
- editingRecipeCost.totalEstimatedCost,
- locale,
- )}
-
-
- {editingRecipeCost.complete
- ? t('setup.recipeCostComplete')
- : `${editingRecipeCost.costedLineCount}/${editingRecipeCost.lineCount} ${t('setup.recipeLabels.linesCosted')}`}
-
- {editingRecipe.batchYieldQuantity &&
- editingRecipe.batchYieldUnit ? (
-
- {t('setup.recipeLabels.approxPerYield')}{' '}
- {formatCurrency(
- editingRecipeCost.totalEstimatedCost /
- editingRecipe.batchYieldQuantity,
- locale,
- )}{' '}
- / {editingRecipe.batchYieldUnit}
-
- ) : null}
-
-
- {editingRecipeCost.lines.map((line) => (
-
-
- {line.line.rawMaterialLabel} · {line.line.quantity}{' '}
- {line.line.unit}
-
- {line.status === 'costed' ? (
- <>
-
- {line.latestPriceEntry?.supplierLabel} ·{' '}
- {formatDateLabel(
- line.latestPriceEntry?.priceDate ?? today,
- locale,
- )}
-
-
- {t('setup.recipeLabels.estimatedLineCost')}{' '}
- {formatCurrency(
- line.estimatedCost ?? 0,
- locale,
- )}
- {line.latestPriceEntry
- ? ` · ${formatUnitRate(line.latestPriceEntry, locale)}`
- : ''}
-
- >
- ) : (
-
- {line.status === 'missing_price'
- ? t('setup.recipeCostMissingPrice')
- : line.status === 'missing_package'
- ? t('setup.recipeCostMissingPackage')
- : t('setup.recipeCostUnitMismatch')}
-
- )}
- {line.line.note ? (
-
- {line.line.note}
-
- ) : null}
-
- ))}
-
- {!editingRecipeCost.complete ? (
-
- {t('setup.recipeCostIncomplete')}
-
- ) : null}
- >
- ) : (
- {t('setup.recipeCostEmpty')}
- )
- ) : (
- {t('setup.recipeCostStart')}
- )}
-
-
-
-
-
-
-
-
-
{t('setup.costingSnapshotTitle')}
-
{t('setup.costingSnapshotHelp')}
-
-
- {t('setup.costingLabels.latestRecipeEvidence')}
-
-
-
-
-
-
- {t('setup.costingSummary.fullyCosted')}
-
- {costingSummary.fullyCosted}
- {t('setup.costingSummary.fullyCostedHelp')}
-
-
-
- {t('setup.costingSummary.partiallyCosted')}
-
- {costingSummary.partiallyCosted}
- {t('setup.costingSummary.partiallyCostedHelp')}
-
-
-
- {t('setup.costingSummary.missingEvidence')}
-
- {costingSummary.missingEvidence}
- {t('setup.costingSummary.missingEvidenceHelp')}
-
-
-
- {t('setup.costingSummary.noRecipe')}
-
- {costingSummary.noRecipe}
- {t('setup.costingSummary.noRecipeHelp')}
-
-
-
-
- {(
- [
- 'all',
- 'fully_costed',
- 'partially_costed',
- 'missing_cost_evidence',
- 'no_recipe',
- ] as const
- ).map((statusKey) => (
- item.status === statusKey))[0]
- ?.id,
- })}
- className={`summary-pill ${costingStatusFilter === statusKey ? 'is-selected' : ''}`}
- >
- {t(`setup.costingFilters.${statusKey}`)}
-
- ))}
-
-
-
-
-
-
-
{t('setup.costingListTitle')}
-
{t('setup.costingListHelp')}
-
-
- {filteredCostingItems.length} {t('setup.costingLabels.items')}
-
-
- {filteredCostingItems.length > 0 ? (
-
- {filteredCostingItems.map((item) => (
-
-
- {item.productLabel}
-
- {item.hasRecipe
- ? `${item.recipeTitle ?? item.label} · ${t(`setup.costingStatuses.${item.status}`)}`
- : t('setup.costingNoRecipe')}
-
-
- {item.estimatedBatchCost !== undefined
- ? `${t('setup.costingLabels.batch')} ${formatCurrency(item.estimatedBatchCost, locale)}`
- : t('setup.costingLabels.noBatchEstimate')}
- {item.estimatedUnitCost !== undefined &&
- item.batchYieldUnit
- ? ` · ${t('setup.costingLabels.unit')} ${formatCurrency(item.estimatedUnitCost, locale)} / ${item.batchYieldUnit}`
- : ''}
-
-
-
-
- {t('setup.actions.viewCosting')}
-
- {item.recipeId ? (
-
- {t('setup.actions.editRecipe')}
-
- ) : null}
-
-
- ))}
-
- ) : (
- {t('setup.costingEmptyForFilter')}
- )}
-
-
-
-
-
-
{t('setup.costingDetailTitle')}
-
{t('setup.costingDetailHelp')}
-
- {selectedCostingItem ? (
-
- {t(`setup.costingStatuses.${selectedCostingItem.status}`)}
-
- ) : null}
-
-
- {selectedCostingItem ? (
- <>
-
-
{selectedCostingItem.productLabel}
-
- {selectedCostingItem.hasRecipe
- ? selectedCostingItem.recipeTitle
- : t('setup.costingNoRecipe')}
-
-
-
-
- {t('setup.costingLabels.batchCost')}
-
-
- {selectedCostingItem.estimatedBatchCost !== undefined
- ? formatCurrency(
- selectedCostingItem.estimatedBatchCost,
- locale,
- )
- : '—'}
-
-
-
-
- {t('setup.costingLabels.unitCost')}
-
-
- {selectedCostingItem.estimatedUnitCost !== undefined &&
- selectedCostingItem.batchYieldUnit
- ? `${formatCurrency(selectedCostingItem.estimatedUnitCost, locale)} / ${selectedCostingItem.batchYieldUnit}`
- : t('setup.costingLabels.noYield')}
-
-
-
-
- {t(
- `setup.costingStatusesHelp.${selectedCostingItem.status}`,
- )}
-
- {selectedCostingItem.hasRecipe ? (
-
- {selectedCostingItem.costedLineCount}/
- {selectedCostingItem.lineCount}{' '}
- {t('setup.recipeLabels.linesCosted')}
- {selectedCostingItem.missingEvidenceCount
- ? ` · ${selectedCostingItem.missingEvidenceCount} ${t('setup.costingLabels.missingPrice')}`
- : ''}
- {selectedCostingItem.missingPackageCount
- ? ` · ${selectedCostingItem.missingPackageCount} ${t('setup.costingLabels.missingPackage')}`
- : ''}
- {selectedCostingItem.unitMismatchCount
- ? ` · ${selectedCostingItem.unitMismatchCount} ${t('setup.costingLabels.unitMismatch')}`
- : ''}
-
- ) : null}
-
-
- {selectedCostingItem.recipeCost?.lineCount ? (
-
- ) : selectedCostingItem.hasRecipe ? (
- {t('setup.recipeCostEmpty')}
- ) : (
-
- {t('setup.costingNoRecipeHelp')}
-
- )}
- >
- ) : (
- {t('setup.costingEmpty')}
- )}
-
-
-
-
-
-
-
-
{t('setup.supplierPriceMemory')}
-
{t('setup.supplierPriceMemoryHelp')}
-
-
- {procurementSetup.supplierPriceEntries.length} {t('common.entries')}
-
-
-
-
- {historySupplier ? (
-
- {t('setup.history.supplierFilter')}: {historySupplier.name}
-
- ) : null}
- {historyMaterial ? (
-
- {t('setup.history.materialFilter')}: {historyMaterial.name}
-
- ) : null}
- {historySupplier || historyMaterial ? (
-
- {t('setup.actions.clearHistoryFilters')}
-
- ) : (
- {t('setup.history.allEntries')}
- )}
-
-
-
-
-
-
-
{t('setup.history.title')}
-
{t('setup.history.help')}
-
-
- {filteredPriceEntries.length} {t('common.entries')}
-
-
- {filteredPriceEntries.length > 0 ? (
-
- {filteredPriceEntries.map((entry) => (
-
-
- {entry.rawMaterialLabel} ·{' '}
- {formatCurrency(entry.price, locale)}
-
-
- {entry.supplierLabel} ·{' '}
- {formatDateLabel(entry.priceDate, locale)}
-
-
- {[
- entry.presentation,
- entry.brand,
- entry.packageQuantity && entry.packageUnit
- ? `${entry.packageQuantity} ${entry.packageUnit}`
- : null,
- entry.packageQuantity && entry.packageUnit
- ? formatUnitRate(entry, locale)
- : t('setup.labels.noPackageDetails'),
- ]
- .filter(Boolean)
- .join(' · ')}
-
- {entry.note ? (
- {entry.note}
- ) : null}
-
- ))}
-
- ) : (
-
{t('setup.history.empty')}
- )}
-
-
-
-
-
-
{t('setup.addSupplierPrice')}
-
{t('setup.addSupplierPriceHelp')}
-
-
-
-
-
- {t('setup.fields.supplier')} {renderRequiredMark()}
-
-
-
- {t('common.selectSupplier')}
-
- {procurementSetup.suppliers.map((supplier) => (
-
- {supplier.name}
-
- ))}
-
-
-
-
- {t('setup.fields.rawMaterial')} {renderRequiredMark()}
-
-
-
- {t('common.selectRawMaterial')}
-
- {procurementSetup.rawMaterials.map((material) => (
-
- {material.name}
-
- ))}
-
-
-
-
- {t('setup.fields.price')} {renderRequiredMark()}
-
-
-
-
-
- {t('setup.fields.date')} {renderRequiredMark()}
-
-
-
-
-
- {t('setup.fields.presentation')}{' '}
- {t('common.optional')}
-
-
-
-
-
- {t('setup.fields.brand')}{' '}
- {t('common.optional')}
-
-
-
-
-
- {t('setup.fields.packageQuantity')}{' '}
- {t('common.optional')}
-
-
-
-
-
- {t('setup.fields.packageUnit')}{' '}
- {t('common.optional')}
-
-
-
-
-
- {t('setup.fields.packageOptionalHelp')}
-
-
-
- {t('setup.fields.note')}{' '}
- {t('common.optional')}
-
-
-
-
- {t('setup.actions.saveSupplierPrice')}
-
-
-
-
-
-
- );
+ return ;
}
diff --git a/app/admin/setup/setup-page.tsx b/app/admin/setup/setup-page.tsx
new file mode 100644
index 0000000..2ededbe
--- /dev/null
+++ b/app/admin/setup/setup-page.tsx
@@ -0,0 +1,2397 @@
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+import { skossCoreRoutes } from '@/lib/application-planes';
+import {
+ formatCurrency,
+ formatDateLabel,
+ formatUnitRate,
+} from '@/lib/domain/formatters';
+import type { OperationalOnboardingSectionKey, Product, Recipe } from '@/lib/domain/types';
+import { buildCostingSnapshotItems } from '@/lib/domain/recipe-costing';
+import {
+ createProductAction,
+ createRawMaterialAction,
+ createRecipeAction,
+ createSupplierAction,
+ createSupplierPriceEntryAction,
+ createUserAction,
+ updateProductAction,
+ updateRawMaterialAction,
+ updateRecipeAction,
+ updateSupplierAction,
+ updateUserAction,
+ resetDemoWorkspaceAction,
+ updateOperationalOnboardingSectionAction,
+} from '@/lib/server/actions';
+import { requireAdminPlaneAccess } from '@/lib/server/application-access';
+import { getSetupWorkspace } from '@/lib/server/demo-data';
+import { getServerTranslator } from '@/lib/i18n/server';
+import { getVisibleWorkspacesForRole } from '@/lib/workspaces';
+import { ThemeSwitcher } from '@/components/theme-switcher';
+import { OperationalOnboardingChecklist } from '@/components/setup/operational-onboarding-checklist';
+import { getRuntimeMode, isNonProductionMode } from '@/lib/server/runtime-mode';
+
+const basicUnits = [
+ 'g',
+ 'kg',
+ 'ml',
+ 'l',
+ 'unit',
+ 'piece',
+ 'dozen',
+ 'eggs',
+] as const;
+
+export type SetupSection =
+ | 'onboarding'
+ | 'business'
+ | 'customers'
+ | 'users'
+ | 'products'
+ | 'suppliers'
+ | 'materials'
+ | 'recipes'
+ | 'costing'
+ | 'system';
+
+const legacySectionMap: Record = {
+ onboarding: 'onboarding',
+ 'business-setup': 'business',
+ business: 'business',
+ customers: 'customers',
+ users: 'users',
+ products: 'products',
+ suppliers: 'suppliers',
+ 'raw-materials': 'materials',
+ materials: 'materials',
+ recipes: 'recipes',
+ costing: 'costing',
+ 'price-history': 'costing',
+ 'preferences-system': 'system',
+ system: 'system',
+};
+
+function normalizeSetupSection(value?: string): SetupSection {
+ return value ? (legacySectionMap[value] ?? 'onboarding') : 'onboarding';
+}
+
+function buildLegacyRedirect(section: string, params: SetupSearchParams) {
+ const nextParams = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (key !== 'section' && typeof value === 'string' && value) {
+ nextParams.set(key, value);
+ }
+ }
+ const query = nextParams.toString();
+ return `/admin/setup/${normalizeSetupSection(section)}${query ? `?${query}` : ''}`;
+}
+
+export type SetupSearchParams = {
+ section?:
+ | 'onboarding'
+ | 'business-setup'
+ | 'users'
+ | 'preferences-system'
+ | 'products'
+ | 'suppliers'
+ | 'raw-materials'
+ | 'recipes'
+ | 'costing'
+ | 'price-history';
+ error?: string;
+ saved?: string;
+ supplier?: string;
+ material?: string;
+ product?: string;
+ productSetup?: string;
+ historySupplier?: string;
+ historyMaterial?: string;
+ recipe?: string;
+ user?: string;
+ costingStatus?:
+ | 'all'
+ | 'fully_costed'
+ | 'partially_costed'
+ | 'missing_cost_evidence'
+ | 'no_recipe';
+ costingItem?: string;
+ importedEntity?: 'customers' | 'suppliers' | 'rawMaterials';
+ importedCount?: string;
+ skippedCount?: string;
+};
+
+function inferSetupSectionFromParams(params: Record): SetupSection {
+ if (params.user) return 'users';
+ if (params.product || params.productSetup) return 'products';
+ if (params.recipe) return 'recipes';
+ if (params.material || params.historyMaterial) return 'materials';
+ if (params.supplier || params.historySupplier) return 'suppliers';
+ if (params.costingStatus || params.costingItem) return 'costing';
+ return 'onboarding';
+}
+
+function buildSetupHref(params: Record, fallbackSection?: SetupSection) {
+ const section = fallbackSection ?? inferSetupSectionFromParams(params);
+ return {
+ pathname: `/admin/setup/${section}`,
+ query: Object.fromEntries(
+ Object.entries(params).filter(([, value]) => Boolean(value)),
+ ),
+ };
+}
+
+function getProductLabel(
+ product: Pick,
+ variant?: { name: string } | null,
+) {
+ return variant ? `${product.name} / ${variant.name}` : product.name;
+}
+
+function getRecipeLinkLabel(recipe: Recipe, products: Product[]) {
+ const product = products.find((entry) => entry.id === recipe.productId);
+ if (!product) {
+ return recipe.title;
+ }
+
+ const variant = recipe.productVariantId
+ ? (product.variants.find((entry) => entry.id === recipe.productVariantId) ??
+ null)
+ : null;
+
+ return getProductLabel(product, variant);
+}
+
+function buildRecipeLineRows(recipe?: Recipe | null) {
+ const existing = recipe?.lines ?? [];
+ const blankCount = Math.max(3, 5 - existing.length);
+
+ return [
+ ...existing.map((line) => ({ ...line, isBlank: false, key: line.id })),
+ ...Array.from({ length: blankCount }, (_, index) => ({
+ id: '',
+ rawMaterialId: '',
+ rawMaterialLabel: '',
+ quantity: undefined,
+ unit: '',
+ note: '',
+ isBlank: true,
+ key: `blank-${index}`,
+ })),
+ ];
+}
+
+const setupSectionOnboardingMap: Partial> = {
+ business: 'business',
+ users: 'team',
+ customers: 'customers',
+ products: 'products',
+ recipes: 'recipes',
+};
+
+function renderRequiredMark() {
+ return (
+
+ *
+
+ );
+}
+
+async function SavedMessage({ saved }: { saved?: string }) {
+ const { t } = await getServerTranslator();
+
+ if (saved === 'supplier') {
+ return {t('setup.saved.supplier')}
;
+ }
+
+ if (saved === 'raw-material') {
+ return {t('setup.saved.rawMaterial')}
;
+ }
+
+ if (saved === 'price') {
+ return {t('setup.saved.price')}
;
+ }
+
+ if (saved === 'recipe') {
+ return {t('setup.saved.recipe')}
;
+ }
+
+ if (saved === 'product') {
+ return Product saved.
;
+ }
+
+ if (saved === 'preferences') {
+ return {t('setup.saved.preferences')}
;
+ }
+
+ if (saved === 'user') {
+ return {t('setup.saved.user')}
;
+ }
+
+ if (saved === 'import') {
+ return {t('setup.saved.import')}
;
+ }
+
+ if (saved === 'demo-reset') {
+ return Demo workspace reseeded successfully.
;
+ }
+
+ if (saved === 'onboarding-section') {
+ return Onboarding section updated.
;
+ }
+
+ return null;
+}
+
+export async function AdminSetupPage({
+ searchParams,
+ pathSection,
+}: {
+ searchParams?: Promise;
+ pathSection?: string;
+}) {
+ const userContext = await requireAdminPlaneAccess(skossCoreRoutes.adminSetup);
+ const [data, params, { t, locale, term }] = await Promise.all([
+ getSetupWorkspace(),
+ searchParams,
+ getServerTranslator(),
+ ]);
+ if (!pathSection && params?.section) {
+ redirect(buildLegacyRedirect(params.section, params));
+ }
+
+ const activeSection = normalizeSetupSection(pathSection ?? params?.section);
+ const today = new Date().toISOString().slice(0, 10);
+ const runtimeMode = getRuntimeMode();
+ const canResetDemoWorkspace = isNonProductionMode();
+ const identitySetup = data.identitySetup;
+ const customerSetup = data.customerSetup;
+ const businessSetup = data.businessSetup;
+ const procurementSetup = data.procurementSetup;
+ const catalogSetup = data.catalogSetup;
+
+ const editingSupplier = params?.supplier
+ ? (procurementSetup.suppliers.find((supplier) => supplier.id === params.supplier) ??
+ null)
+ : null;
+ const editingMaterial = params?.material
+ ? (procurementSetup.rawMaterials.find((material) => material.id === params.material) ??
+ null)
+ : null;
+ 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;
+ const editingUser = params?.user
+ ? (identitySetup.users.find((user) => user.id === params.user) ?? null)
+ : null;
+ const historySupplier = params?.historySupplier
+ ? (procurementSetup.suppliers.find(
+ (supplier) => supplier.id === params.historySupplier,
+ ) ?? null)
+ : null;
+ const historyMaterial = params?.historyMaterial
+ ? (procurementSetup.rawMaterials.find(
+ (material) => material.id === params.historyMaterial,
+ ) ?? null)
+ : null;
+
+ const supplierFormAction = editingSupplier
+ ? updateSupplierAction.bind(null, editingSupplier.id)
+ : createSupplierAction;
+ 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;
+ const userFormAction = editingUser
+ ? updateUserAction.bind(null, editingUser.id)
+ : createUserAction;
+
+ const supplierPriceCounts = new Map();
+ const materialPriceCounts = new Map();
+ for (const entry of procurementSetup.supplierPriceEntries) {
+ supplierPriceCounts.set(
+ entry.supplierId,
+ (supplierPriceCounts.get(entry.supplierId) ?? 0) + 1,
+ );
+ materialPriceCounts.set(
+ entry.rawMaterialId,
+ (materialPriceCounts.get(entry.rawMaterialId) ?? 0) + 1,
+ );
+ }
+
+ const filteredPriceEntries = procurementSetup.supplierPriceEntries.filter((entry) => {
+ if (historySupplier && entry.supplierId !== historySupplier.id) {
+ return false;
+ }
+
+ if (historyMaterial && entry.rawMaterialId !== historyMaterial.id) {
+ return false;
+ }
+
+ return true;
+ });
+
+ const supplierHistoryHref = (supplierId: string) =>
+ buildSetupHref({
+ historySupplier: supplierId,
+ historyMaterial: historyMaterial?.id,
+ supplier: editingSupplier?.id,
+ material: editingMaterial?.id,
+ recipe: editingRecipe?.id,
+ });
+
+ const materialHistoryHref = (materialId: string) =>
+ buildSetupHref({
+ historySupplier: historySupplier?.id,
+ historyMaterial: materialId,
+ supplier: editingSupplier?.id,
+ material: editingMaterial?.id,
+ recipe: editingRecipe?.id,
+ });
+
+ const recipeLineRows = buildRecipeLineRows(editingRecipe);
+ const recipeProductId = editingRecipe?.productId ?? selectedProduct?.id ?? '';
+ const editingRecipeCost = editingRecipe
+ ? (catalogSetup.recipeCostById.get(editingRecipe.id) ?? null)
+ : null;
+ const activeRecipes = catalogSetup.recipes.filter((recipe) => recipe.active).length;
+ const linkedProductCount = new Set(
+ catalogSetup.recipes.map((recipe) => recipe.productVariantId ?? recipe.productId),
+ ).size;
+ const recipesByProduct = new Map();
+ for (const recipe of catalogSetup.recipes) {
+ const existingRecipes = recipesByProduct.get(recipe.productId) ?? [];
+ recipesByProduct.set(recipe.productId, [...existingRecipes, recipe]);
+ }
+ const costingItems = buildCostingSnapshotItems(
+ catalogSetup.products,
+ catalogSetup.recipes,
+ catalogSetup.recipeCostById,
+ );
+ const costingStatusFilter = params?.costingStatus ?? 'all';
+ const filteredCostingItems = costingItems.filter(
+ (item) =>
+ costingStatusFilter === 'all' || item.status === costingStatusFilter,
+ );
+ const selectedCostingItem =
+ (params?.costingItem
+ ? (filteredCostingItems.find((item) => item.id === params.costingItem) ??
+ costingItems.find((item) => item.id === params.costingItem) ??
+ null)
+ : null) ??
+ filteredCostingItems[0] ??
+ costingItems[0] ??
+ null;
+ const costingSummary = {
+ fullyCosted: costingItems.filter((item) => item.status === 'fully_costed')
+ .length,
+ partiallyCosted: costingItems.filter(
+ (item) => item.status === 'partially_costed',
+ ).length,
+ missingEvidence: costingItems.filter(
+ (item) => item.status === 'missing_cost_evidence',
+ ).length,
+ noRecipe: costingItems.filter((item) => item.status === 'no_recipe').length,
+ };
+ const buildCostingHref = (overrides: Record) =>
+ buildSetupHref({
+ supplier: editingSupplier?.id,
+ material: editingMaterial?.id,
+ historySupplier: historySupplier?.id,
+ historyMaterial: historyMaterial?.id,
+ recipe: editingRecipe?.id,
+ product: selectedProduct?.id,
+ costingStatus: costingStatusFilter,
+ costingItem: selectedCostingItem?.id,
+ ...overrides,
+ });
+ const importedCount = Number(params?.importedCount ?? 0);
+ const skippedCount = Number(params?.skippedCount ?? 0);
+ const hasImportFeedback =
+ params?.saved === 'import' && params?.importedEntity;
+ const setupSection = activeSection;
+ const sectionNavItems: Array<{ key: SetupSection; label: string }> = [
+ { key: 'onboarding', label: 'Onboarding' },
+ { key: 'business', label: t('setup.sections.businessSetup') },
+ { key: 'customers', label: t('common.customers') },
+ { key: 'users', label: t('setup.sections.users') },
+ { key: 'products', label: 'Products' },
+ { key: 'suppliers', label: t('setup.sections.suppliers') },
+ { key: 'materials', label: t('setup.sections.rawMaterials') },
+ { key: 'recipes', label: t('setup.sections.recipes') },
+ { key: 'costing', label: t('setup.sections.costing') },
+ { key: 'system', label: t('setup.sections.preferencesSystem') },
+ ];
+ const buildSectionLink = (section: SetupSection) => `/admin/setup/${section}`;
+ const activeSectionIndex = sectionNavItems.findIndex((item) => item.key === activeSection);
+ const previousSection = activeSectionIndex > 0 ? sectionNavItems[activeSectionIndex - 1] : null;
+ const nextSection = activeSectionIndex >= 0 && activeSectionIndex < sectionNavItems.length - 1 ? sectionNavItems[activeSectionIndex + 1] : null;
+ const onboardingSectionKey = setupSectionOnboardingMap[activeSection];
+ const onboardingSectionStatus = onboardingSectionKey ? identitySetup.instance.operationalOnboarding?.[onboardingSectionKey]?.status ?? 'not_started' : undefined;
+
+ return (
+
+
+ {basicUnits.map((unit) => (
+
+ ))}
+
+
+
+
+
{t('setup.workspace')}
+
{t('setup.title')}
+
{t('setup.description')}
+
+
+
+
+ {params?.error ?
{params.error}
: null}
+ {hasImportFeedback ? (
+
+ {t('setup.import.result', {
+ entity: t(`setup.import.entityLabels.${params.importedEntity}`),
+ imported: Number.isFinite(importedCount) ? importedCount : 0,
+ skipped: Number.isFinite(skippedCount) ? skippedCount : 0,
+ })}
+
+ ) : null}
+
+ {activeSection === 'system' && canResetDemoWorkspace ? (
+
+
+
+
Local demo data safety
+
+ Runtime mode: {runtimeMode} . Use reset only for local/pilot testing so real records stay separate.
+
+
+
+
+
+ Reset demo workspace to seed data
+
+
+
+ ) : null}
+
+
+
+
+
{t('setup.title')}
+
{t('setup.adminReadinessHelp')}
+
+
+
+ {sectionNavItems.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ {activeSection !== 'onboarding' ? (
+
+
+
+
Setup chapter
+
{sectionNavItems[activeSectionIndex]?.label ?? 'Setup'}
+
One configuration area at a time. Return to onboarding when this chapter is ready or intentionally deferred.
+
+ {onboardingSectionStatus ?
{onboardingSectionStatus.replace('_', ' ')} : null}
+
+
+ Onboarding overview
+ {previousSection ? Previous: {previousSection.label} : null}
+ {nextSection ? Next: {nextSection.label} : null}
+ {onboardingSectionKey ? (
+ <>
+
+
+
+ Skip for now
+
+
+
+
+ Mark ready
+
+ >
+ ) : null}
+
+
+ ) : null}
+
+ {activeSection === 'onboarding' ? (
+
+ ) : null}
+
+ {activeSection === 'business' ? (
+
+
+
+
Business profile
+
Keep the permanent business settings focused. Operational records live in their own setup pages.
+
+
{businessSetup.destinations.length} {term('destination', 'many')}
+
+
+ Open preferences
+ Open order capture
+
+ {businessSetup.destinations.length > 0 ? (
+
+ ) : (
+ Destinations can be added from order capture when real delivery or pickup patterns are known.
+ )}
+
+ ) : null}
+
+ {activeSection === 'customers' ? (
+
+
+
+
+
{t('setup.customerMemoryTitle')}
+
{t('setup.customerMemoryHelp')}
+
+
+ {customerSetup.customers.length} {t('common.customers')}
+
+
+ {customerSetup.customers.length > 0 ? (
+
+ ) : (
+ {t('setup.customerMemoryEmpty')}
+ )}
+
+
+ {t('setup.actions.manageCustomers')}
+
+
+ {t('setup.actions.addCustomer')}
+
+
+
+
+
+
+
+
{t('setup.sections.imports')}
+
CSV import screens are planned but not active yet.
+
+
+
+
+ Add records manually
+
+ Customer CSV coming later
+
+
+
+ ) : null}
+
+ {!userContext.canManageSettings && activeSection === 'users' ? (
+
{t('setup.roleShapingNote')}
+ ) : null}
+
+ {activeSection === 'system' ? (
+
+
+
+
+
{t('setup.sections.preferencesSystem')}
+
{t('setup.preferencesAreaBody')}
+
+
+
+ {t('setup.openPreferences')}
+
+
+
+
+
+
+
{t('setup.appearance')}
+
{t('setup.appearanceHelp')}
+
+
+ {userContext.currentUser
+ ? userContext.currentUser.displayName
+ : t('nav.preferences')}
+
+
+
+
+
+ ) : null}
+
+ {activeSection === 'costing' ? (
+
+
+ {t('setup.activeSuppliers')}
+
+ {procurementSetup.suppliers.filter((supplier) => supplier.active).length}
+
+ {t('setup.activeSuppliersHelp')}
+
+
+ {t('setup.rawMaterials')}
+ {procurementSetup.rawMaterials.length}
+ {t('setup.rawMaterialsHelp')}
+
+
+ {t('setup.recordedPrices')}
+ {procurementSetup.supplierPriceEntries.length}
+ {t('setup.recordedPricesHelp')}
+
+
+ {t('setup.recipes')}
+ {activeRecipes}
+ {t('setup.recipesHelp')}
+
+
+ ) : null}
+
+ {activeSection === 'products' ? (
+
+
+
+
+
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.filter((product) => product.active).length}/{catalogSetup.products.length} active
+
+
+
+ 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.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.addRecipe')}
+
+
+
+ ))}
+
+
+
+
+
+
{editingProduct ? 'Edit product' : 'Add product'}
+
Keep this to the name and unit needed for first orders.
+
+ {editingProduct ? (
+
+ {t('setup.actions.cancelEdit')}
+
+ ) : null}
+
+
+
+ Product name {renderRequiredMark()}
+
+
+
+ Default unit {renderRequiredMark()}
+
+
+
+ Category {t('common.optional')}
+
+
+
+
+
+ Active for order suggestions
+ Inactive products stay saved but stop appearing as active order suggestions.
+
+
+
+
+ {editingProduct ? 'Update product' : 'Save product'}
+
+
+
+
+
+ ) : null}
+
+ {activeSection === 'users' ? (
+
+
+
+
+
{t('setup.users')}
+
{t('setup.usersHelp')}
+
+
+ {identitySetup.users.length} {t('common.users')}
+
+
+
+
+ {identitySetup.users.filter((user) => user.active).length}{' '}
+ {t('setup.labels.activeUsers')}
+
+
+ {identitySetup.users.filter((user) => !user.active).length}{' '}
+ {t('setup.labels.inactiveUsers')}
+
+
+ {new Set(identitySetup.users.flatMap((user) => user.roles ?? [user.role])).size}{' '}
+ {t('setup.labels.rolesInUse')}
+
+
+ {t('setup.openPreferences')}
+
+
+
+
+ {identitySetup.users.length > 0 ? (
+
+ {identitySetup.users.map((user) => (
+
+
+
+ {user.displayName}
+ {!user.active
+ ? ` · ${t('common.inactive').toLowerCase()}`
+ : ''}
+
+
+ {(user.roles?.length ? user.roles : [user.role]).map((role) => t(`roles.${role}.label`)).join(' · ')} ·{' '}
+ {user.loginIdentifier}
+
+
+ {t('setup.labels.defaultWorkspace')}:{' '}
+ {t(
+ `nav.${user.preferences?.defaultWorkspace ?? user.defaultWorkspace}`,
+ )}
+
+
+ {t('setup.labels.visibleWorkspaces')}:{' '}
+ {getVisibleWorkspacesForRole((user.roles?.[0] ?? user.role))
+ .map((workspace) => t(`nav.${workspace}`))
+ .join(' · ')}
+
+
+ {t('setup.labels.password')}:{' '}
+ {user.mustChangePassword
+ ? t('setup.labels.passwordNeedsReset')
+ : t('setup.labels.passwordReady')}
+
+
+
+
+ {t('setup.actions.edit')}
+
+
+
+ ))}
+
+ ) : (
+
{t('setup.usersEmpty')}
+ )}
+
+
+
+
+
+ {editingUser ? t('setup.editUser') : t('setup.addUser')}
+
+
+ {editingUser
+ ? t('setup.editUserHelp')
+ : t('setup.addUserHelp')}
+
+
+ {editingUser ? (
+
+ {t('setup.actions.cancelEdit')}
+
+ ) : null}
+
+
+
+
+ {t('setup.fields.userDisplayName')} {renderRequiredMark()}
+
+
+
+
+
+ {t('setup.fields.loginIdentifier')} {renderRequiredMark()}
+
+
+
+
+
+
+
+ {t('setup.fields.role')} {renderRequiredMark()}
+
+
+ {['owner_admin', 'shift_lead', 'kitchen', 'sales'].map((role) => {
+ const selectedRoles = editingUser?.roles?.length ? editingUser.roles : [editingUser?.role ?? 'sales'];
+ return (
+
+
+ {t(`roles.${role}.label`)}
+
+ );
+ })}
+
+
+
+
+ {t('setup.fields.defaultWorkspace')} {renderRequiredMark()}
+
+
+ {[
+ 'timeline',
+ 'orders',
+ 'customers',
+ 'production',
+ 'handoff',
+ 'preferences',
+ 'admin',
+ ].map((workspace) => (
+
+ {t(`nav.${workspace}`)}
+
+ ))}
+
+
+
+
+
+ Email
+
+
+
+ Phone
+
+
+
+
+
+
+
+ {t('setup.fields.userActive')}
+
+ {t('setup.fields.userActiveHelp')}
+
+
+
+
+ {editingUser
+ ? t('setup.actions.updateUser')
+ : t('setup.actions.saveUser')}
+
+
+
+
+
+ ) : null}
+
+ {activeSection === 'suppliers' || activeSection === 'materials' ? (
+
+
+
+
{activeSection === 'suppliers' ? t('setup.suppliers') : t('setup.rawMaterialsSection')}
+
{t('setup.rawMaterialsHelp')}
+
+
+
+ {activeSection === 'suppliers' ? (
+
+
+
+
{t('setup.suppliers')}
+
{t('setup.suppliersHelp')}
+
+
+
+ {procurementSetup.suppliers.length} {t('common.suppliers')}
+
+ Supplier CSV coming later
+
+
+
+ {procurementSetup.suppliers.length > 0 ? (
+
+ {procurementSetup.suppliers.map((supplier) => (
+
+
+
+ {supplier.name}
+ {!supplier.active
+ ? ` · ${t('common.inactive').toLowerCase()}`
+ : ''}
+
+
+ {supplier.contact ??
+ supplier.notes ??
+ t('setup.noExtraContactYet')}
+
+
+ {supplierPriceCounts.get(supplier.id) ?? 0}{' '}
+ {t('setup.labels.priceEntries')}
+
+
+
+
+ {t('setup.actions.edit')}
+
+
+ {t('setup.actions.viewHistory')}
+
+
+
+ ))}
+
+ ) : (
+
{t('setup.suppliersEmpty')}
+ )}
+
+
+
+
+ {editingSupplier
+ ? t('setup.editSupplier')
+ : t('setup.addSupplier')}
+
+
+ {editingSupplier
+ ? t('setup.editSupplierHelp')
+ : t('setup.addSupplierHelp')}
+
+
+ {editingSupplier ? (
+
+ {t('setup.actions.cancelEdit')}
+
+ ) : null}
+
+
+
+
+ {t('setup.fields.supplierName')} {renderRequiredMark()}
+
+
+
+
+
+ {t('setup.fields.contact')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+
+ {t('setup.fields.notes')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+
+ {t('setup.fields.activeSupplier')}
+
+ {t('setup.fields.activeSupplierHelp')}
+
+
+
+
+ {editingSupplier
+ ? t('setup.actions.updateSupplier')
+ : t('setup.actions.saveSupplier')}
+
+
+
+
+ ) : null}
+
+ {activeSection === 'materials' ? (
+
+
+
+
{t('setup.rawMaterialsSection')}
+
{t('setup.rawMaterialsSectionHelp')}
+
+
+
+ {procurementSetup.rawMaterials.length} {t('common.materials')}
+
+ Material CSV coming later
+
+
+
+ {procurementSetup.rawMaterials.length > 0 ? (
+
+ {procurementSetup.rawMaterials.map((material) => {
+ const latestPrice = procurementSetup.latestPriceByMaterial.get(
+ material.id,
+ );
+
+ return (
+
+
+
+ {material.name}
+ {!material.active
+ ? ` · ${t('common.inactive').toLowerCase()}`
+ : ''}
+
+
+ {[
+ material.category,
+ material.defaultUnit
+ ? `${material.defaultUnit} ${t('setup.labels.defaultUnitSuffix')}`
+ : t('setup.labels.noDefaultUnit'),
+ latestPrice
+ ? `${t('setup.labels.latest')} ${formatUnitRate(latestPrice, locale)}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+
+ {materialPriceCounts.get(material.id) ?? 0}{' '}
+ {t('setup.labels.priceEntries')}
+
+
+
+
+ {t('setup.actions.edit')}
+
+
+ {t('setup.actions.viewHistory')}
+
+
+
+ );
+ })}
+
+ ) : (
+
{t('setup.rawMaterialsEmpty')}
+ )}
+
+
+
+
+ {editingMaterial
+ ? t('setup.editRawMaterial')
+ : t('setup.addRawMaterial')}
+
+
+ {editingMaterial
+ ? t('setup.editRawMaterialHelp')
+ : t('setup.addRawMaterialHelp')}
+
+
+ {editingMaterial ? (
+
+ {t('setup.actions.cancelEdit')}
+
+ ) : null}
+
+
+
+
+ {t('setup.fields.rawMaterialName')} {renderRequiredMark()}
+
+
+
+
+
+ {t('setup.fields.category')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+ {t('setup.fields.defaultUnit')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+ {t('setup.fields.brand')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+ {t('setup.fields.unitQuickHelp')}
+
+
+ {t('setup.fields.notes')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+
+ {t('setup.fields.activeRawMaterial')}
+
+ {t('setup.fields.activeRawMaterialHelp')}
+
+
+
+
+ {editingMaterial
+ ? t('setup.actions.updateRawMaterial')
+ : t('setup.actions.saveRawMaterial')}
+
+
+
+
+ ) : null}
+
+
+ ) : null}
+
+ {activeSection === 'recipes' ? (
+
+
+
+
{t('setup.recipeFoundationTitle')}
+
{t('setup.recipeFoundationHelp')}
+
+
+ {linkedProductCount} {t('setup.recipeLabels.linkedProducts')}
+
+
+
+
+
+
+
+
{t('setup.recipeListTitle')}
+
{t('setup.recipeListHelp')}
+
+
+ {catalogSetup.recipes.length} {t('common.recipes')}
+
+
+ {catalogSetup.recipes.length > 0 ? (
+
+ {catalogSetup.recipes.map((recipe) => {
+ const recipeCost = catalogSetup.recipeCostById.get(recipe.id);
+ const productLabel = getRecipeLinkLabel(
+ recipe,
+ catalogSetup.products,
+ );
+
+ return (
+
+
+
+ {recipe.title}
+ {!recipe.active
+ ? ` · ${t('common.inactive').toLowerCase()}`
+ : ''}
+
+ {productLabel}
+
+ {recipe.lines.length} {t('setup.recipeLabels.lines')}
+ {recipeCost?.lineCount
+ ? ` · ${
+ recipeCost.complete
+ ? `${t('setup.recipeLabels.costed')} ${formatCurrency(recipeCost.totalEstimatedCost, locale)}`
+ : `${formatCurrency(recipeCost.totalEstimatedCost, locale)} · ${recipeCost.incompleteLineCount} ${t('setup.recipeLabels.incomplete')}`
+ }`
+ : ` · ${t('setup.recipeLabels.noLinesYet')}`}
+
+
+
+
+ {t('setup.actions.edit')}
+
+
+
+ );
+ })}
+
+ ) : (
+ {t('setup.recipeEmpty')}
+ )}
+
+
+
+
+
+
+
+ {editingRecipe
+ ? t('setup.editRecipe')
+ : t('setup.addRecipe')}
+
+
+ {editingRecipe
+ ? t('setup.editRecipeHelp')
+ : t('setup.addRecipeHelp')}
+
+
+ {editingRecipe ? (
+
+ {t('setup.actions.cancelEdit')}
+
+ ) : null}
+
+
+
+
+
+ {t('setup.fields.product')} {renderRequiredMark()}
+
+
+
+ {t('common.selectProduct')}
+
+ {catalogSetup.products.map((product) => (
+
+ {product.name}
+
+ ))}
+
+
+
+
+ {t('setup.fields.variant')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+ {t('setup.recipeLabels.productLevel')}
+
+ {catalogSetup.products.map((product) => (
+
+ {product.variants.map((variant) => (
+
+ {variant.name}
+
+ ))}
+
+ ))}
+
+
+
+
+ {t('setup.fields.recipeTitle')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+ {t('setup.fields.batchYieldQuantity')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+
+ {t('setup.fields.batchYieldUnit')}{' '}
+
+ {t('common.optional')}
+
+
+
+
+
+ {t('setup.recipeYieldHelp')}
+
+
+ {t('setup.fields.instructions')}{' '}
+ {t('common.optional')}
+
+
+
+
+
+
+ {t('setup.fields.activeRecipe')}
+
+ {t('setup.fields.activeRecipeHelp')}
+
+
+
+
+
+
+
{t('setup.recipeLinesTitle')}
+
{t('setup.recipeLinesHelp')}
+
+
+ {recipeLineRows.map((line, index) => (
+
+ ))}
+
+
+
+
+ {editingRecipe
+ ? t('setup.actions.updateRecipe')
+ : t('setup.actions.saveRecipe')}
+
+
+
+
+
+
+
{t('setup.recipeCostingTitle')}
+
{t('setup.recipeCostingHelp')}
+
+
+ {t('setup.recipeLabels.latestPricesOnly')}
+
+
+ {editingRecipe && editingRecipeCost ? (
+ editingRecipeCost.lineCount > 0 ? (
+ <>
+
+
+ {formatCurrency(
+ editingRecipeCost.totalEstimatedCost,
+ locale,
+ )}
+
+
+ {editingRecipeCost.complete
+ ? t('setup.recipeCostComplete')
+ : `${editingRecipeCost.costedLineCount}/${editingRecipeCost.lineCount} ${t('setup.recipeLabels.linesCosted')}`}
+
+ {editingRecipe.batchYieldQuantity &&
+ editingRecipe.batchYieldUnit ? (
+
+ {t('setup.recipeLabels.approxPerYield')}{' '}
+ {formatCurrency(
+ editingRecipeCost.totalEstimatedCost /
+ editingRecipe.batchYieldQuantity,
+ locale,
+ )}{' '}
+ / {editingRecipe.batchYieldUnit}
+
+ ) : null}
+
+
+ {editingRecipeCost.lines.map((line) => (
+
+
+ {line.line.rawMaterialLabel} · {line.line.quantity}{' '}
+ {line.line.unit}
+
+ {line.status === 'costed' ? (
+ <>
+
+ {line.latestPriceEntry?.supplierLabel} ·{' '}
+ {formatDateLabel(
+ line.latestPriceEntry?.priceDate ?? today,
+ locale,
+ )}
+
+
+ {t('setup.recipeLabels.estimatedLineCost')}{' '}
+ {formatCurrency(
+ line.estimatedCost ?? 0,
+ locale,
+ )}
+ {line.latestPriceEntry
+ ? ` · ${formatUnitRate(line.latestPriceEntry, locale)}`
+ : ''}
+
+ >
+ ) : (
+
+ {line.status === 'missing_price'
+ ? t('setup.recipeCostMissingPrice')
+ : line.status === 'missing_package'
+ ? t('setup.recipeCostMissingPackage')
+ : t('setup.recipeCostUnitMismatch')}
+
+ )}
+ {line.line.note ? (
+
+ {line.line.note}
+
+ ) : null}
+
+ ))}
+
+ {!editingRecipeCost.complete ? (
+
+ {t('setup.recipeCostIncomplete')}
+
+ ) : null}
+ >
+ ) : (
+ {t('setup.recipeCostEmpty')}
+ )
+ ) : (
+ {t('setup.recipeCostStart')}
+ )}
+
+
+
+
+ ) : null}
+
+ {activeSection === 'costing' ? (
+ <>
+
+
+
+
{t('setup.costingSnapshotTitle')}
+
{t('setup.costingSnapshotHelp')}
+
+
+ {t('setup.costingLabels.latestRecipeEvidence')}
+
+
+
+
+
+
+ {t('setup.costingSummary.fullyCosted')}
+
+ {costingSummary.fullyCosted}
+ {t('setup.costingSummary.fullyCostedHelp')}
+
+
+
+ {t('setup.costingSummary.partiallyCosted')}
+
+ {costingSummary.partiallyCosted}
+ {t('setup.costingSummary.partiallyCostedHelp')}
+
+
+
+ {t('setup.costingSummary.missingEvidence')}
+
+ {costingSummary.missingEvidence}
+ {t('setup.costingSummary.missingEvidenceHelp')}
+
+
+
+ {t('setup.costingSummary.noRecipe')}
+
+ {costingSummary.noRecipe}
+ {t('setup.costingSummary.noRecipeHelp')}
+
+
+
+
+ {(
+ [
+ 'all',
+ 'fully_costed',
+ 'partially_costed',
+ 'missing_cost_evidence',
+ 'no_recipe',
+ ] as const
+ ).map((statusKey) => (
+ item.status === statusKey))[0]
+ ?.id,
+ })}
+ className={`summary-pill ${costingStatusFilter === statusKey ? 'is-selected' : ''}`}
+ >
+ {t(`setup.costingFilters.${statusKey}`)}
+
+ ))}
+
+
+
+
+
+
+
{t('setup.costingListTitle')}
+
{t('setup.costingListHelp')}
+
+
+ {filteredCostingItems.length} {t('setup.costingLabels.items')}
+
+
+ {filteredCostingItems.length > 0 ? (
+
+ {filteredCostingItems.map((item) => (
+
+
+ {item.productLabel}
+
+ {item.hasRecipe
+ ? `${item.recipeTitle ?? item.label} · ${t(`setup.costingStatuses.${item.status}`)}`
+ : t('setup.costingNoRecipe')}
+
+
+ {item.estimatedBatchCost !== undefined
+ ? `${t('setup.costingLabels.batch')} ${formatCurrency(item.estimatedBatchCost, locale)}`
+ : t('setup.costingLabels.noBatchEstimate')}
+ {item.estimatedUnitCost !== undefined &&
+ item.batchYieldUnit
+ ? ` · ${t('setup.costingLabels.unit')} ${formatCurrency(item.estimatedUnitCost, locale)} / ${item.batchYieldUnit}`
+ : ''}
+
+
+
+
+ {t('setup.actions.viewCosting')}
+
+ {item.recipeId ? (
+
+ {t('setup.actions.editRecipe')}
+
+ ) : null}
+
+
+ ))}
+
+ ) : (
+ {t('setup.costingEmptyForFilter')}
+ )}
+
+
+
+
+
+
{t('setup.costingDetailTitle')}
+
{t('setup.costingDetailHelp')}
+
+ {selectedCostingItem ? (
+
+ {t(`setup.costingStatuses.${selectedCostingItem.status}`)}
+
+ ) : null}
+
+
+ {selectedCostingItem ? (
+ <>
+
+
{selectedCostingItem.productLabel}
+
+ {selectedCostingItem.hasRecipe
+ ? selectedCostingItem.recipeTitle
+ : t('setup.costingNoRecipe')}
+
+
+
+
+ {t('setup.costingLabels.batchCost')}
+
+
+ {selectedCostingItem.estimatedBatchCost !== undefined
+ ? formatCurrency(
+ selectedCostingItem.estimatedBatchCost,
+ locale,
+ )
+ : '—'}
+
+
+
+
+ {t('setup.costingLabels.unitCost')}
+
+
+ {selectedCostingItem.estimatedUnitCost !== undefined &&
+ selectedCostingItem.batchYieldUnit
+ ? `${formatCurrency(selectedCostingItem.estimatedUnitCost, locale)} / ${selectedCostingItem.batchYieldUnit}`
+ : t('setup.costingLabels.noYield')}
+
+
+
+
+ {t(
+ `setup.costingStatusesHelp.${selectedCostingItem.status}`,
+ )}
+
+ {selectedCostingItem.hasRecipe ? (
+
+ {selectedCostingItem.costedLineCount}/
+ {selectedCostingItem.lineCount}{' '}
+ {t('setup.recipeLabels.linesCosted')}
+ {selectedCostingItem.missingEvidenceCount
+ ? ` · ${selectedCostingItem.missingEvidenceCount} ${t('setup.costingLabels.missingPrice')}`
+ : ''}
+ {selectedCostingItem.missingPackageCount
+ ? ` · ${selectedCostingItem.missingPackageCount} ${t('setup.costingLabels.missingPackage')}`
+ : ''}
+ {selectedCostingItem.unitMismatchCount
+ ? ` · ${selectedCostingItem.unitMismatchCount} ${t('setup.costingLabels.unitMismatch')}`
+ : ''}
+
+ ) : null}
+
+
+ {selectedCostingItem.recipeCost?.lineCount ? (
+
+ ) : selectedCostingItem.hasRecipe ? (
+ {t('setup.recipeCostEmpty')}
+ ) : (
+
+ {t('setup.costingNoRecipeHelp')}
+
+ )}
+ >
+ ) : (
+ {t('setup.costingEmpty')}
+ )}
+
+
+
+
+
+
+
+
{t('setup.supplierPriceMemory')}
+
{t('setup.supplierPriceMemoryHelp')}
+
+
+ {procurementSetup.supplierPriceEntries.length} {t('common.entries')}
+
+
+
+
+ {historySupplier ? (
+
+ {t('setup.history.supplierFilter')}: {historySupplier.name}
+
+ ) : null}
+ {historyMaterial ? (
+
+ {t('setup.history.materialFilter')}: {historyMaterial.name}
+
+ ) : null}
+ {historySupplier || historyMaterial ? (
+
+ {t('setup.actions.clearHistoryFilters')}
+
+ ) : (
+ {t('setup.history.allEntries')}
+ )}
+
+
+
+
+
+
+
{t('setup.history.title')}
+
{t('setup.history.help')}
+
+
+ {filteredPriceEntries.length} {t('common.entries')}
+
+
+ {filteredPriceEntries.length > 0 ? (
+
+ {filteredPriceEntries.map((entry) => (
+
+
+ {entry.rawMaterialLabel} ·{' '}
+ {formatCurrency(entry.price, locale)}
+
+
+ {entry.supplierLabel} ·{' '}
+ {formatDateLabel(entry.priceDate, locale)}
+
+
+ {[
+ entry.presentation,
+ entry.brand,
+ entry.packageQuantity && entry.packageUnit
+ ? `${entry.packageQuantity} ${entry.packageUnit}`
+ : null,
+ entry.packageQuantity && entry.packageUnit
+ ? formatUnitRate(entry, locale)
+ : t('setup.labels.noPackageDetails'),
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+ {entry.note ? (
+ {entry.note}
+ ) : null}
+
+ ))}
+
+ ) : (
+
{t('setup.history.empty')}
+ )}
+
+
+
+
+
+
{t('setup.addSupplierPrice')}
+
{t('setup.addSupplierPriceHelp')}
+
+
+
+
+
+ {t('setup.fields.supplier')} {renderRequiredMark()}
+
+
+
+ {t('common.selectSupplier')}
+
+ {procurementSetup.suppliers.map((supplier) => (
+
+ {supplier.name}
+
+ ))}
+
+
+
+
+ {t('setup.fields.rawMaterial')} {renderRequiredMark()}
+
+
+
+ {t('common.selectRawMaterial')}
+
+ {procurementSetup.rawMaterials.map((material) => (
+
+ {material.name}
+
+ ))}
+
+
+
+
+ {t('setup.fields.price')} {renderRequiredMark()}
+
+
+
+
+
+ {t('setup.fields.date')} {renderRequiredMark()}
+
+
+
+
+
+ {t('setup.fields.presentation')}{' '}
+ {t('common.optional')}
+
+
+
+
+
+ {t('setup.fields.brand')}{' '}
+ {t('common.optional')}
+
+
+
+
+
+ {t('setup.fields.packageQuantity')}{' '}
+ {t('common.optional')}
+
+
+
+
+
+ {t('setup.fields.packageUnit')}{' '}
+ {t('common.optional')}
+
+
+
+
+
+ {t('setup.fields.packageOptionalHelp')}
+
+
+
+ {t('setup.fields.note')}{' '}
+ {t('common.optional')}
+
+
+
+
+ {t('setup.actions.saveSupplierPrice')}
+
+
+
+
+ >
+ ) : null}
+
+
+ );
+}
+
diff --git a/app/bootstrap/page.tsx b/app/bootstrap/page.tsx
index 514fee9..21a754a 100644
--- a/app/bootstrap/page.tsx
+++ b/app/bootstrap/page.tsx
@@ -26,6 +26,7 @@ export default async function BootstrapPage({
const step = bootstrapStep(Number(params?.step ?? 1));
const progress = Math.round((step / totalSteps) * 100);
const adminUser = data.users.find((user) => user.role === 'owner_admin' || user.roles?.includes('owner_admin'));
+ const initialBusinessName = data.instance.demoModeActive || data.workspace.name.toLowerCase().includes('demo') ? '' : data.workspace.name;
const isRequiredStep = requiredSteps.has(step);
return (
@@ -93,7 +94,7 @@ export default async function BootstrapPage({
Business name *
-
+
diff --git a/app/entry/page.tsx b/app/entry/page.tsx
index 4f9068c..0f4bd6a 100644
--- a/app/entry/page.tsx
+++ b/app/entry/page.tsx
@@ -112,8 +112,8 @@ export default async function EntryGatewayPage({
Local admin access reset for {params.recoveryUser ?? 'admin'} . Temporary password: skoss-local-admin.
) : null}
- {params?.saved === 'users-reset' ? Local users reset from seed data.
: null}
- {params?.saved === 'runtime-reset' ? Local runtime data reset from seed data.
: null}
+ {params?.saved === 'session-cleared' ? Local session cleared. Existing users and instance data were preserved.
: null}
+ {params?.saved === 'instance-reset' ? Local instance destroyed and returned to an uninitialized state.
: null}
{state.hasInstance ? (
@@ -204,16 +204,16 @@ export default async function EntryGatewayPage({
- Reset runtime data
+ Destroy local instance
- Reset users/admin credentials
+ Clear local session
- Reload demo data
+ Load initialized demo data
- These actions are destructive and intended only for local development and testing.
+ Destroy local instance is destructive. Demo load replaces runtime data with initialized demo records. Session clear only logs out.
) : null}
diff --git a/app/globals.css b/app/globals.css
index 404e93b..60a4ea8 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2354,3 +2354,83 @@ select:disabled {
color: var(--ink-soft);
background: color-mix(in srgb, var(--surface-soft) 70%, transparent);
}
+
+.setup-focused-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.setup-action-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.onboarding-checklist-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: var(--space-3);
+ align-items: center;
+}
+
+.onboarding-checklist-main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: var(--space-3);
+ align-items: start;
+}
+
+.onboarding-checklist-main > div {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.onboarding-checklist-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ justify-content: flex-end;
+}
+
+.onboarding-status-pill.is-ready {
+ background: var(--success-soft);
+ color: var(--success);
+}
+
+.onboarding-status-pill.is-skipped {
+ background: var(--warning-soft);
+ color: var(--warning);
+}
+
+.onboarding-status-pill.is-in_progress {
+ background: var(--info-soft);
+ color: var(--info);
+}
+
+button:disabled,
+.button-secondary:disabled,
+.button-primary:disabled,
+.button-ghost:disabled {
+ cursor: not-allowed;
+ opacity: 0.58;
+}
+
+@media (min-width: 900px) {
+ .admin-split-layout.is-empty-list {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .admin-split-layout.is-empty-list > :first-child {
+ display: none;
+ }
+}
+
+@media (max-width: 720px) {
+ .onboarding-checklist-item,
+ .onboarding-checklist-main {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .onboarding-checklist-actions {
+ justify-content: flex-start;
+ }
+}
diff --git a/app/orders/page.tsx b/app/orders/page.tsx
index 18180e0..53d5423 100644
--- a/app/orders/page.tsx
+++ b/app/orders/page.tsx
@@ -137,7 +137,7 @@ export default async function OrdersPage({
{t('orders.newRecurringTemplate')}
-
+
Confirm products
@@ -166,7 +166,7 @@ export default async function OrdersPage({
Create order
Confirm customers
- Confirm products
+ Confirm products
@@ -218,7 +218,7 @@ export default async function OrdersPage({
Create first order
Confirm customers
- Confirm products
+ Confirm products
diff --git a/components/orders/order-form.tsx b/components/orders/order-form.tsx
index 20b4b63..f185dd1 100644
--- a/components/orders/order-form.tsx
+++ b/components/orders/order-form.tsx
@@ -394,7 +394,7 @@ export function OrderForm({
Start with one item. Add another line only when this order needs it.
- Confirmed products appear as suggestions, but you can still type a draft item when setup is incomplete. Confirm products.
+ Confirmed products appear as suggestions, but you can still type a draft item when setup is incomplete. Confirm products.
{
const status = getSectionStatus(sections, section.key);
return (
-
-
-
{section.title}
-
{section.description}
-
Status: {getStatusLabel(status)}
+
+
+
+ {section.title}
+ {section.description}
+
+
{getStatusLabel(status)}
-
-
Configure
+
+
Configure
{section.importPlaceholder ? (
{section.importPlaceholder}
) : null}
diff --git a/docs/bootstrap-v2-onboarding.md b/docs/bootstrap-v2-onboarding.md
index 374736c..56a8ce8 100644
--- a/docs/bootstrap-v2-onboarding.md
+++ b/docs/bootstrap-v2-onboarding.md
@@ -17,7 +17,7 @@ Demo mode and restore mode remain separate choices from the entry gateway. A cle
## Resumable operational onboarding
-After activation, admins continue from a section checklist. Each section can be configured now, skipped temporarily, marked ready, and returned to later.
+After activation, admins continue from `/admin/setup/onboarding`, a section checklist. Each section can be configured now, skipped temporarily, marked ready, and returned to later. The overview renders only the checklist and readiness summary; chapter content opens on focused setup pages.
Tracked section states are:
@@ -28,7 +28,7 @@ Tracked section states are:
`skipped` means intentionally deferred. It is not the same as complete.
-Current sections are:
+Current onboarding chapters are:
- business profile
- team and roles
@@ -49,7 +49,32 @@ Customer data can grow to include person/business type, alias, organization, con
Product data can grow to include name, category, description, unit, presentation, variant, active state, optional price, and future recipe/component relations.
-Imports should use a reusable CSV mapping architecture for customers, team, products, and later initial orders. This pass only shows honest placeholders where import support belongs; it does not implement format-specific importers.
+Imports should use a reusable CSV mapping architecture for customers, team, products, and later initial orders. Until implemented, import controls must be disabled or clearly marked as coming later.
+
+Future functional import screens should follow this pattern:
+
+1. Download CSV template
+2. Upload file
+3. Map columns
+4. Preview rows and validation warnings
+5. Confirm import
+
+## Setup route structure
+
+Permanent setup pages are focused routes, not one long tab-like page:
+
+- `/admin/setup/onboarding`
+- `/admin/setup/business`
+- `/admin/setup/customers`
+- `/admin/setup/users`
+- `/admin/setup/products`
+- `/admin/setup/suppliers`
+- `/admin/setup/materials`
+- `/admin/setup/recipes`
+- `/admin/setup/costing`
+- `/admin/setup/system`
+
+Legacy query URLs such as `/admin/setup?section=products` redirect to the matching focused route.
## Non-goals for this pass
diff --git a/docs/first-deploy-rehearsal.md b/docs/first-deploy-rehearsal.md
index 02da893..bc37525 100644
--- a/docs/first-deploy-rehearsal.md
+++ b/docs/first-deploy-rehearsal.md
@@ -48,7 +48,7 @@ If the runtime mode is unclear in `/entry`, stop the rehearsal and inspect envir
- Expected: a working admin account can sign in after activation.
- Failure signs: no active admin user is detected, login loops back to `/entry`, or recovery is needed in `production` mode.
-4. Confirm operational onboarding in `/admin/setup?section=onboarding`.
+4. Confirm operational onboarding in `/admin/setup/onboarding`.
- Expected: the checklist shows business, team, shifts, customers, products, recipes, delivery, and initial orders with section status, configure actions, skip/return-later behavior, and honest import placeholders.
- Failure signs: skipped sections appear complete, imports pretend to be implemented, or order capture is blocked by suppliers, raw materials, recipes, costing, or inventory.
@@ -56,7 +56,7 @@ If the runtime mode is unclear in `/entry`, stop the rehearsal and inspect envir
- Expected: existing customers can be reviewed or a first real customer can be added.
- Failure signs: customer setup only shows demo customers without clear route to real records.
-6. Confirm basic products in `/admin/setup?section=products#products`.
+6. Confirm basic products in `/admin/setup/products`.
- Expected: sellable products can be reviewed, created, or marked active/inactive with simple labels.
- Failure signs: order capture requires recipes, costing, SKU/barcode setup, procurement, or inventory data.
diff --git a/docs/first-entry-and-onboarding.md b/docs/first-entry-and-onboarding.md
index 2e6fd35..d3b752f 100644
--- a/docs/first-entry-and-onboarding.md
+++ b/docs/first-entry-and-onboarding.md
@@ -70,7 +70,7 @@ Current routing behavior:
- fresh or missing-admin state -> `/entry`
- no session user -> `/entry` then sign-in path
-- onboarding incomplete + admin/manager login -> `/setup?section=business-setup`
+- onboarding incomplete + admin/manager login -> `/admin/setup/onboarding`
- returning user with valid state -> workspace home flow
Goal: predictable routing with small condition sets.
@@ -79,7 +79,7 @@ Goal: predictable routing with small condition sets.
From gateway, **Start a new kitchen** routes to required activation (`/bootstrap`). Activation is intentionally short: owner/admin account, language, clean real data mode, business identity, preset/profile, and confirmation.
-After activation, admins continue from `/admin/setup?section=onboarding`. Operational onboarding is section-based and resumable. Section statuses are `not_started`, `in_progress`, `ready`, and `skipped`. Skipped sections remain visible as deferred work, not completed work.
+After activation, admins continue from `/admin/setup/onboarding`. Operational onboarding is section-based and resumable. Each chapter opens a focused setup route instead of rendering the full setup system on one page. Section statuses are `not_started`, `in_progress`, `ready`, and `skipped`. Skipped sections remain visible as deferred work, not completed work.
Tracked operational sections are business, team, shifts, customers, products, recipes, delivery/destinations, and initial orders.
diff --git a/lib/server/actions/admin-core.ts b/lib/server/actions/admin-core.ts
index 434cfec..5b9b41e 100644
--- a/lib/server/actions/admin-core.ts
+++ b/lib/server/actions/admin-core.ts
@@ -23,7 +23,7 @@ import {
validateSupplierPriceEntryForm,
validateUserForm,
} from '@/lib/server/demo-data';
-import { getPersistenceGateway, readAppData, readPersistence, mutateAppData, reseedRuntimeAppData } from '@/lib/server/persistence';
+import { getPersistenceGateway, readAppData, readPersistence, mutateAppData, readSeedAppData } from '@/lib/server/persistence';
import { appendActivity } from '@/lib/server/activity';
import { hashPassword } from '@/lib/server/passwords';
import {
@@ -37,13 +37,38 @@ import {
import { moduleRegistry } from '@/lib/modules';
import { getCurrentUserContext } from '@/lib/server/auth';
import { isNonProductionMode } from '@/lib/server/runtime-mode';
-import type { AppData, OnboardingSectionStatus, OperationalOnboardingSectionKey, OperationalOnboardingSections } from '@/lib/domain/types';
+import { withDerivedTeamOnboarding } from '@/lib/server/instance-state';
+import type { ActivityEntry, AppData, OnboardingSectionStatus, OperationalOnboardingSectionKey, OperationalOnboardingSections, User } from '@/lib/domain/types';
function quoteLabel(label: string) {
return `"${label}"`;
}
const persistence = getPersistenceGateway();
+
+function createActivityEntry(entry: Omit
): ActivityEntry {
+ return {
+ id: 'activity-' + crypto.randomUUID(),
+ timestamp: new Date().toISOString(),
+ ...entry,
+ };
+}
+
+function hasOwnerAdminRole(user: Pick) {
+ return user.role === 'owner_admin' || user.roles?.includes('owner_admin');
+}
+
+function wouldRemoveLastOwnerAdmin(existing: User, nextUser: User, users: User[]) {
+ if (!existing.active || !hasOwnerAdminRole(existing)) {
+ return false;
+ }
+
+ if (nextUser.active && hasOwnerAdminRole(nextUser)) {
+ return false;
+ }
+
+ return !users.some((user) => user.id !== existing.id && user.active && hasOwnerAdminRole(user));
+}
const operationalOnboardingSections: OperationalOnboardingSectionKey[] = [
'business',
'team',
@@ -79,7 +104,7 @@ export async function updateOperationalOnboardingSectionAction(formData: FormDat
const status = String(formData.get('status') ?? '') as OnboardingSectionStatus;
if (!operationalOnboardingSections.includes(section) || !operationalOnboardingStatuses.includes(status)) {
- redirect('/admin/setup?section=onboarding&error=' + encodeURIComponent('Choose a valid onboarding section status.'));
+ redirect('/admin/setup/onboarding?error=' + encodeURIComponent('Choose a valid onboarding section status.'));
}
const now = new Date().toISOString();
@@ -96,7 +121,7 @@ export async function updateOperationalOnboardingSectionAction(formData: FormDat
});
revalidateAllWorkspaces();
- redirect('/admin/setup?section=onboarding&saved=onboarding-section');
+ redirect('/admin/setup/onboarding?saved=onboarding-section');
}
function revalidateAllWorkspaces() {
@@ -122,12 +147,27 @@ function shouldAllowEmpty(formData: FormData) {
export async function resetDemoWorkspaceAction() {
if (!isNonProductionMode()) {
- redirect('/admin/setup?error=' + encodeURIComponent('Demo reset is disabled in production mode.'));
+ redirect('/admin/setup/system?error=' + encodeURIComponent('Demo reset is disabled in production mode.'));
}
- await reseedRuntimeAppData();
+ const seedData = await readSeedAppData();
+ await mutateAppData({
+ ...seedData,
+ instance: {
+ ...seedData.instance,
+ initialized: true,
+ onboardingStatus: 'completed',
+ demoModeActive: true,
+ environmentType: 'demo',
+ },
+ preferences: {
+ ...seedData.preferences,
+ onboardingCompleted: true,
+ },
+ session: { currentUserId: undefined, lastLoginAt: undefined },
+ });
revalidateAllWorkspaces();
- redirect('/admin/setup?saved=demo-reset');
+ redirect('/admin/setup/system?saved=demo-reset');
}
export async function updateModuleRegistryAction(formData: FormData) {
@@ -153,7 +193,7 @@ export async function createUserAction(formData: FormData) {
const context = await readPersistence();
const data = context.raw;
const actorUserId = await getActorUserId(data);
- const redirectTo = resolveRedirectTo(formData, '/admin/setup');
+ const redirectTo = resolveRedirectTo(formData, '/admin/setup/users');
const allowEmpty = shouldAllowEmpty(formData);
const values = normalizeUserForm(formData);
if (allowEmpty && !values.displayName.trim() && !values.loginIdentifier.trim()) {
@@ -162,7 +202,7 @@ export async function createUserAction(formData: FormData) {
const error = validateUserForm(values, data);
if (error) {
- redirect(`${redirectTo}?error=${encodeURIComponent(error)}`);
+ redirect(redirectTo + '?error=' + encodeURIComponent(error));
}
const user = buildUserRecord(values, data);
@@ -170,27 +210,25 @@ export async function createUserAction(formData: FormData) {
user.passwordUpdatedAt = new Date().toISOString();
user.mustChangePassword = !values.password?.trim();
const createdOwnerAdmin = user.roles.includes('owner_admin') || user.role === 'owner_admin';
- appendActivity(data, {
+ const activity = createActivityEntry({
entityType: 'user',
entityId: user.id,
action: 'created',
userId: actorUserId,
- summary: `User ${quoteLabel(user.displayName)} created.`,
+ summary: 'User ' + quoteLabel(user.displayName) + ' created.',
});
+
await persistence.write(({ raw, instance, users }) => {
users.upsert(user);
+ const nextUsers = users.list();
if (createdOwnerAdmin) {
instance.setInitialized(true);
- instance.updateOnboardingProgress({
- adminAccount: true,
- roles: true,
- users: true,
- });
}
- raw.activities = data.activities;
+ instance.updateInstanceState(withDerivedTeamOnboarding(instance.getInstanceState(), nextUsers));
+ raw.activities = [activity, ...raw.activities];
});
revalidateAllWorkspaces();
- redirect(`${redirectTo}?saved=user`);
+ redirect(redirectTo + '?saved=user');
}
export async function updateUserAction(userId: string, formData: FormData) {
@@ -199,51 +237,56 @@ export async function updateUserAction(userId: string, formData: FormData) {
const existing = context.users.getById(userId);
if (!existing) {
- redirect('/admin/setup?error=missing-user');
+ redirect('/admin/setup/users?error=missing-user');
}
const values = normalizeUserForm(formData);
+ if (formData.getAll('roles').length === 0) {
+ values.roles = existing.roles.length > 0 ? existing.roles : [existing.role];
+ }
const error = validateUserForm(values, data, userId);
if (error) {
- redirect(`/admin/setup?user=${userId}&error=${encodeURIComponent(error)}`);
+ redirect('/admin/setup/users?user=' + userId + '&error=' + encodeURIComponent(error));
}
const user = buildUserRecord(values, data, existing);
+ if (wouldRemoveLastOwnerAdmin(existing, user, data.users)) {
+ redirect('/admin/setup/users?user=' + userId + '&error=' + encodeURIComponent('At least one active owner/admin is required.'));
+ }
+
if (values.resetPassword || values.password?.trim()) {
user.passwordHash = hashPassword(values.password?.trim() || 'skoss-demo');
user.passwordUpdatedAt = new Date().toISOString();
user.mustChangePassword = !values.password?.trim();
}
- appendActivity(data, {
+
+ const activity = createActivityEntry({
entityType: 'user',
entityId: user.id,
action: existing.active !== user.active ? 'status_changed' : 'updated',
userId: data.session.currentUserId,
summary: existing.active !== user.active
- ? `User ${quoteLabel(user.displayName)} marked as ${user.active ? 'active' : 'inactive'}.`
- : `User ${quoteLabel(user.displayName)} updated.`,
+ ? 'User ' + quoteLabel(user.displayName) + ' marked as ' + (user.active ? 'active' : 'inactive') + '.'
+ : 'User ' + quoteLabel(user.displayName) + ' updated.',
});
const wasSessionUser = data.session.currentUserId === userId;
const nextSessionUserId = data.users.find((entry) => entry.active && entry.id !== userId)?.id;
- if (!user.active && wasSessionUser) {
- data.session.currentUserId = nextSessionUserId;
- }
-
await persistence.write(({ raw, instance, users }) => {
users.upsert(user);
+ const nextUsers = users.list();
if (!user.active && wasSessionUser) {
instance.setSessionUser(nextSessionUserId, data.session.lastLoginAt);
}
- raw.activities = data.activities;
+ instance.updateInstanceState(withDerivedTeamOnboarding(instance.getInstanceState(), nextUsers));
+ raw.activities = [activity, ...raw.activities];
});
revalidateAllWorkspaces();
- redirect('/admin/setup?saved=user');
+ redirect('/admin/setup/users?saved=user');
}
-
export async function createProductAction(formData: FormData) {
const context = await readPersistence();
const data = context.raw;
@@ -273,7 +316,7 @@ export async function createProductAction(formData: FormData) {
raw.activities = data.activities;
});
revalidateAllWorkspaces();
- redirect(`${redirectTo}?saved=product&productSetup=${product.id}#products`);
+ redirect(`${redirectTo}?saved=product&productSetup=${product.id}`);
}
export async function updateProductAction(productId: string, formData: FormData) {
@@ -282,14 +325,14 @@ export async function updateProductAction(productId: string, formData: FormData)
const existing = context.catalog.getProductById(productId);
if (!existing) {
- redirect('/admin/setup?error=missing-product#products');
+ redirect('/admin/setup/products?error=missing-product');
}
const values = normalizeProductForm(formData);
const error = validateProductForm(values, data, productId);
if (error) {
- redirect(`/admin/setup?productSetup=${productId}&error=${encodeURIComponent(error)}#products`);
+ redirect(`/admin/setup/products?productSetup=${productId}&error=${encodeURIComponent(error)}`);
}
const actorUserId = await getActorUserId(data);
@@ -308,7 +351,7 @@ export async function updateProductAction(productId: string, formData: FormData)
raw.activities = data.activities;
});
revalidateAllWorkspaces();
- redirect(`/admin/setup?saved=product&productSetup=${product.id}#products`);
+ redirect(`/admin/setup/products?saved=product&productSetup=${product.id}`);
}
export async function createSupplierAction(formData: FormData) {
diff --git a/lib/server/actions/entry-bootstrap.ts b/lib/server/actions/entry-bootstrap.ts
index 63a7b2a..2a94c2a 100644
--- a/lib/server/actions/entry-bootstrap.ts
+++ b/lib/server/actions/entry-bootstrap.ts
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getCurrentUserContext, loggedOutSessionValue, sessionUserCookieName } from '@/lib/server/auth';
-import { getPersistenceGateway, readSeedAppData, readAppData, readPersistence, reseedRuntimeAppData, mutateAppData } from '@/lib/server/persistence';
+import { getPersistenceGateway, readSeedAppData, readAppData, readPersistence, mutateAppData } from '@/lib/server/persistence';
import { hashPassword, verifyPassword } from '@/lib/server/passwords';
import { isSupportedLocale, isSupportedPreset, localeCookieName, supportedLocales, presetCookieName, supportedPresets } from '@/lib/i18n/config';
import { themeCookieName } from '@/lib/theme';
@@ -12,6 +12,7 @@ import { getDefaultWorkspaceForRole, isPrimaryWorkspaceSurface } from '@/lib/wor
import { getRuntimeMode, isNonProductionMode } from '@/lib/server/runtime-mode';
import type { AppData, EnvironmentType, OperationalOnboardingSections } from '@/lib/domain/types';
import { detectInstanceGatewayState, shouldRouteToEntryGateway } from '@/lib/server/instance-entry';
+import { buildUninitializedLocalInstance } from '@/lib/server/instance-state';
const supportedThemes = ['light', 'dark', 'system'] as const;
const supportedOperatingModes = ['pickup', 'delivery', 'mixed'] as const;
@@ -110,12 +111,15 @@ function clearDemoOperationalRecordsForRealInstance(data: AppData) {
export async function resetLocalRuntimeDataAction() {
if (!isNonProductionMode()) {
- redirect('/entry?error=' + encodeURIComponent('Local runtime reset is disabled in production mode.'));
+ redirect('/entry?error=' + encodeURIComponent('Local instance reset is disabled in production mode.'));
}
- await reseedRuntimeAppData();
+ const seedData = await readSeedAppData();
+ await mutateAppData(buildUninitializedLocalInstance(seedData));
+ const cookieStore = await cookies();
+ cookieStore.set(sessionUserCookieName, loggedOutSessionValue, { path: '/', maxAge: sessionMaxAgeSeconds, sameSite: 'lax' });
revalidateAllWorkspaces();
- redirect('/entry?saved=runtime-reset');
+ redirect('/entry?saved=instance-reset');
}
export async function launchDemoModeAction() {
@@ -124,21 +128,25 @@ export async function launchDemoModeAction() {
}
const data = await readSeedAppData();
- await persistence.write(({ instance }) => {
- instance.updateInstanceState({
+ await mutateAppData({
+ ...data,
+ instance: {
...data.instance,
demoModeActive: true,
- initialized: false,
- onboardingStatus: 'not_started',
+ initialized: true,
+ onboardingStatus: 'completed',
environmentType: 'demo',
- });
- instance.updatePreferences({
+ },
+ preferences: {
...data.preferences,
- onboardingCompleted: false,
- });
+ onboardingCompleted: true,
+ },
+ session: { currentUserId: undefined, lastLoginAt: undefined },
});
+ const cookieStore = await cookies();
+ cookieStore.set(sessionUserCookieName, loggedOutSessionValue, { path: '/', maxAge: sessionMaxAgeSeconds, sameSite: 'lax' });
revalidateAllWorkspaces();
- redirect('/?demo=1');
+ redirect('/login?redirectTo=/?demo=1');
}
export async function recoverLocalAdminAccessAction() {
@@ -179,33 +187,17 @@ export async function recoverLocalAdminAccessAction() {
export async function resetLocalUsersAndCredentialsAction() {
if (!isNonProductionMode()) {
- redirect('/entry?error=' + encodeURIComponent('Local user reset is disabled in production mode.'));
+ redirect('/entry?error=' + encodeURIComponent('Local session clear is disabled in production mode.'));
}
- const seedData = await readSeedAppData();
- const data = await readAppData();
- await persistence.write(({ users, instance }) => {
- users.replaceAll(seedData.users);
- instance.updateSessionState({
- ...data.session,
- currentUserId: undefined,
- lastLoginAt: undefined,
- });
- instance.updateInstanceState({
- ...data.instance,
- initialized: true,
- onboardingStatus: 'in_progress',
- });
- instance.updatePreferences({
- ...data.preferences,
- onboardingCompleted: false,
- });
+ await persistence.write(({ instance }) => {
+ instance.setSessionUser(undefined);
});
const cookieStore = await cookies();
cookieStore.set(sessionUserCookieName, loggedOutSessionValue, { path: '/', maxAge: sessionMaxAgeSeconds, sameSite: 'lax' });
revalidateAllWorkspaces();
- redirect('/entry?saved=users-reset');
+ redirect('/entry?saved=session-cleared');
}
export async function restoreInstanceFromBackupAction(formData: FormData) {
@@ -471,7 +463,7 @@ export async function saveBootstrapStepAction(formData: FormData) {
raw.activities = data.activities;
});
revalidateAllWorkspaces();
- redirect('/login?redirectTo=/admin/setup?section=onboarding');
+ redirect('/login?redirectTo=/admin/setup/onboarding');
}
await persistence.write(({ instance, users }) => {
@@ -585,7 +577,7 @@ export async function loginAction(formData: FormData) {
if (gatewayState.onboardingIncomplete && (user.roles?.includes('owner_admin') || user.roles?.includes('shift_lead') || user.role === 'owner_admin' || user.role === 'shift_lead')) {
revalidateAllWorkspaces();
- redirect('/admin/setup?section=onboarding');
+ redirect('/admin/setup/onboarding');
}
revalidateAllWorkspaces();
diff --git a/lib/server/auth.ts b/lib/server/auth.ts
index cae8c5a..e1c1083 100644
--- a/lib/server/auth.ts
+++ b/lib/server/auth.ts
@@ -7,10 +7,6 @@ export const sessionUserCookieName = 'skoss-user';
export const loggedOutSessionValue = '__logged_out__';
const sessionMaxAgeMs = 1000 * 60 * 60 * 24 * 14;
-function getFallbackUser(users: User[]) {
- return users.find((user) => user.active) ?? users[0] ?? null;
-}
-
export function resolveUserHomeWorkspace(user: User | null | undefined): WorkspaceSurface {
if (!user) {
return 'home';
@@ -48,7 +44,7 @@ export async function getCurrentUserContext(sourceData?: AppData) {
const currentUser = requestedUserId === loggedOutSessionValue
|| sessionExpired
? null
- : persisted.users.find((user) => user.id === sessionUserId && user.active) ?? getFallbackUser(persisted.users);
+ : persisted.users.find((user) => user.id === sessionUserId && user.active) ?? null;
const visibleWorkspaces: WorkspaceSurface[] = currentUser ? getVisibleWorkspacesForRole(currentUser.role) : ['home'];
const homeWorkspace = resolveUserHomeWorkspace(currentUser);
diff --git a/lib/server/instance-state.ts b/lib/server/instance-state.ts
new file mode 100644
index 0000000..4ac031c
--- /dev/null
+++ b/lib/server/instance-state.ts
@@ -0,0 +1,111 @@
+import type { AppData, OperationalOnboardingSections, User } from '@/lib/domain/types';
+import { defaultLocale, defaultPreset } from '@/lib/i18n/config';
+
+export function buildDefaultOperationalOnboarding(): OperationalOnboardingSections {
+ return {
+ business: { status: 'not_started' },
+ team: { status: 'not_started' },
+ customers: { status: 'not_started' },
+ products: { status: 'not_started' },
+ recipes: { status: 'not_started' },
+ delivery: { status: 'not_started' },
+ shifts: { status: 'not_started' },
+ initialOrders: { status: 'not_started' },
+ };
+}
+
+export function deriveTeamOnboardingStatus(users: User[], existing?: OperationalOnboardingSections['team']) {
+ const activeUsers = users.filter((user) => user.active);
+ if (activeUsers.length > 1) {
+ return 'ready' as const;
+ }
+
+ if (users.length > 1 || existing?.status === 'in_progress') {
+ return 'in_progress' as const;
+ }
+
+ if (existing?.status === 'skipped') {
+ return 'skipped' as const;
+ }
+
+ return 'not_started' as const;
+}
+
+export function withDerivedTeamOnboarding(instance: AppData['instance'], users: User[], now = new Date().toISOString()): AppData['instance'] {
+ const operationalOnboarding = {
+ ...buildDefaultOperationalOnboarding(),
+ ...(instance.operationalOnboarding ?? {}),
+ };
+ const nextStatus = deriveTeamOnboardingStatus(users, operationalOnboarding.team);
+
+ return {
+ ...instance,
+ onboardingProgress: {
+ ...instance.onboardingProgress,
+ users: users.some((user) => user.active),
+ roles: users.some((user) => user.active && user.roles.length > 0),
+ },
+ operationalOnboarding: {
+ ...operationalOnboarding,
+ team: {
+ status: nextStatus,
+ updatedAt: nextStatus !== operationalOnboarding.team.status ? now : operationalOnboarding.team.updatedAt,
+ },
+ },
+ };
+}
+
+export function buildUninitializedLocalInstance(seed: AppData): AppData {
+ return {
+ ...seed,
+ workspace: {
+ ...seed.workspace,
+ id: seed.workspace.id || 'ws-local',
+ name: 'SKOSS',
+ slug: 'skoss-local',
+ },
+ preferences: {
+ locale: seed.preferences?.locale ?? defaultLocale,
+ preset: seed.preferences?.preset ?? defaultPreset,
+ operatingMode: seed.preferences?.operatingMode ?? 'mixed',
+ theme: seed.preferences?.theme ?? 'system',
+ onboardingCompleted: false,
+ },
+ instance: {
+ ...seed.instance,
+ initialized: false,
+ onboardingStatus: 'not_started',
+ demoModeActive: false,
+ backupHintAvailable: false,
+ lastRestoreAt: undefined,
+ onboardingProgress: {
+ adminAccount: false,
+ workspaceBasics: false,
+ timezone: false,
+ users: false,
+ roles: false,
+ shifts: false,
+ optionalImports: false,
+ },
+ operationalOnboarding: buildDefaultOperationalOnboarding(),
+ operatorOnboardingByUserId: {},
+ },
+ session: {
+ currentUserId: undefined,
+ lastLoginAt: undefined,
+ },
+ users: [],
+ customers: [],
+ destinations: [],
+ products: [],
+ suppliers: [],
+ rawMaterials: [],
+ supplierPriceEntries: [],
+ recipes: [],
+ recurringTemplates: [],
+ orders: [],
+ wipEntries: [],
+ shiftLogs: [],
+ activities: [],
+ };
+}
diff --git a/lib/server/store.ts b/lib/server/store.ts
index e72b640..aec1f38 100644
--- a/lib/server/store.ts
+++ b/lib/server/store.ts
@@ -23,6 +23,7 @@ import { getDefaultWorkspaceForRole } from '@/lib/workspaces';
import { fallbackDemoPassword, hashPassword } from '@/lib/server/passwords';
import { getRuntimeMode } from '@/lib/server/runtime-mode';
import { getModuleStateMap } from '@/lib/modules';
+import { buildDefaultOperationalOnboarding, deriveTeamOnboardingStatus } from '@/lib/server/instance-state';
const seedStorePath = path.join(process.cwd(), 'data', 'seeds', 'demo-store.seed.json');
const runtimeStorePath = path.join(process.cwd(), 'data', 'runtime', 'demo-store.json');
@@ -398,16 +399,21 @@ function hydrateStore(rawData: AppData): AppData {
const users = (rawData.users ?? []).map((user) => normalizeUser(user as Partial & { email?: string; role?: string }, rawData.workspace.id));
const hasAdminUser = users.some((user) => user.active && (user.roles?.includes('owner_admin') || user.role === 'owner_admin'));
const onboardingCompleted = rawData.preferences?.onboardingCompleted ?? false;
+ const existingOperationalOnboarding: Partial = rawData.instance?.operationalOnboarding ?? {};
const operationalOnboarding: OperationalOnboardingSections = {
+ ...buildDefaultOperationalOnboarding(),
business: { status: rawData.instance?.onboardingProgress?.workspaceBasics ? 'ready' : 'not_started' },
- team: { status: users.filter((user) => user.active).length > 1 ? 'ready' : 'not_started' },
customers: { status: (rawData.customers ?? []).length > 0 ? 'ready' : 'not_started' },
products: { status: (rawData.products ?? []).length > 0 ? 'ready' : 'not_started' },
recipes: { status: (rawData.recipes ?? []).length > 0 ? 'ready' : 'not_started' },
delivery: { status: (rawData.destinations ?? []).length > 0 ? 'ready' : 'not_started' },
shifts: { status: rawData.instance?.onboardingProgress?.shifts || (rawData.shiftLogs ?? []).length > 0 ? 'ready' : 'not_started' },
initialOrders: { status: (rawData.orders ?? []).length > 0 ? 'ready' : 'not_started' },
- ...(rawData.instance?.operationalOnboarding ?? {}),
+ ...existingOperationalOnboarding,
+ team: {
+ ...existingOperationalOnboarding.team,
+ status: deriveTeamOnboardingStatus(users, existingOperationalOnboarding.team),
+ },
};
const instance: InstanceState = {
initialized: rawData.instance?.initialized ?? users.length > 0,
diff --git a/tests/state-integrity.test.ts b/tests/state-integrity.test.ts
new file mode 100644
index 0000000..b55f842
--- /dev/null
+++ b/tests/state-integrity.test.ts
@@ -0,0 +1,99 @@
+import assert from 'node:assert/strict';
+import type { AppData, User } from '@/lib/domain/types';
+import { buildUserRecord, normalizeUserForm } from '@/lib/server/demo-data';
+import { hashPassword, verifyPassword } from '@/lib/server/passwords';
+import {
+ buildDefaultOperationalOnboarding,
+ buildUninitializedLocalInstance,
+ deriveTeamOnboardingStatus,
+ withDerivedTeamOnboarding,
+} from '@/lib/server/instance-state';
+
+const now = '2026-06-17T00:00:00.000Z';
+
+function user(overrides: Partial = {}): User {
+ return {
+ id: overrides.id ?? 'user-owner',
+ displayName: overrides.displayName ?? 'Owner',
+ loginIdentifier: overrides.loginIdentifier ?? 'owner',
+ username: overrides.username ?? overrides.loginIdentifier ?? 'owner',
+ passwordHash: overrides.passwordHash ?? hashPassword('owner-pass'),
+ passwordUpdatedAt: overrides.passwordUpdatedAt ?? now,
+ mustChangePassword: overrides.mustChangePassword ?? false,
+ role: overrides.role ?? 'owner_admin',
+ roles: overrides.roles ?? ['owner_admin'],
+ workspaceId: overrides.workspaceId ?? 'ws-test',
+ defaultWorkspace: overrides.defaultWorkspace ?? 'admin',
+ active: overrides.active ?? true,
+ preferences: overrides.preferences ?? { defaultWorkspace: overrides.defaultWorkspace ?? 'admin' },
+ createdAt: overrides.createdAt ?? now,
+ updatedAt: overrides.updatedAt ?? now,
+ email: overrides.email,
+ phone: overrides.phone,
+ };
+}
+
+function appData(users: User[]): AppData {
+ return {
+ workspace: { id: 'ws-test', name: 'Test Kitchen', slug: 'test-kitchen', timezone: 'UTC', defaultProductionCutoffHour: 4 },
+ preferences: { locale: 'en', preset: 'bakery', operatingMode: 'mixed', theme: 'system', onboardingCompleted: true },
+ instance: {
+ initialized: true,
+ onboardingStatus: 'completed',
+ demoModeActive: false,
+ environmentType: 'dev',
+ backupHintAvailable: false,
+ onboardingProgress: { adminAccount: true, workspaceBasics: true, timezone: true, users: true, roles: true, shifts: false, optionalImports: false },
+ operationalOnboarding: buildDefaultOperationalOnboarding(),
+ operatorOnboardingByUserId: {},
+ },
+ session: { currentUserId: users[0]?.id, lastLoginAt: now },
+ users,
+ customers: [],
+ destinations: [],
+ products: [],
+ suppliers: [],
+ rawMaterials: [],
+ supplierPriceEntries: [],
+ recipes: [],
+ recurringTemplates: [],
+ orders: [],
+ wipEntries: [],
+ shiftLogs: [],
+ activities: [],
+ };
+}
+
+const owner = user();
+const editWithoutPassword = new FormData();
+editWithoutPassword.set('displayName', 'Owner Edited');
+editWithoutPassword.set('loginIdentifier', 'owner');
+editWithoutPassword.set('defaultWorkspace', 'admin');
+editWithoutPassword.set('active', 'on');
+editWithoutPassword.append('roles', 'owner_admin');
+const editedValues = normalizeUserForm(editWithoutPassword);
+const editedOwner = buildUserRecord(editedValues, appData([owner]), owner);
+assert.equal(editedOwner.passwordHash, owner.passwordHash);
+assert.equal(verifyPassword('owner-pass', editedOwner.passwordHash), true);
+assert.equal(editedOwner.roles.includes('owner_admin'), true);
+
+const oneOwnerState = withDerivedTeamOnboarding(appData([owner]).instance, [owner], now);
+assert.equal(oneOwnerState.initialized, true);
+assert.equal(oneOwnerState.operationalOnboarding?.team.status, 'not_started');
+assert.equal(oneOwnerState.onboardingProgress.users, true);
+
+const secondUser = user({ id: 'user-sales', displayName: 'Sales', loginIdentifier: 'sales', role: 'sales', roles: ['sales'], defaultWorkspace: 'orders' });
+const teamState = withDerivedTeamOnboarding(appData([owner, secondUser]).instance, [owner, secondUser], now);
+assert.equal(deriveTeamOnboardingStatus([owner, secondUser]), 'ready');
+assert.equal(teamState.initialized, true);
+assert.equal(teamState.operationalOnboarding?.team.status, 'ready');
+
+const seed = appData([owner, secondUser]);
+seed.instance.demoModeActive = true;
+seed.customers = [{ id: 'customer-demo', displayName: 'Demo Customer', active: true, createdAt: now, updatedAt: now }];
+const reset = buildUninitializedLocalInstance(seed);
+assert.equal(reset.instance.initialized, false);
+assert.equal(reset.instance.demoModeActive, false);
+assert.equal(reset.users.length, 0);
+assert.equal(reset.customers.length, 0);
+assert.equal(reset.session.currentUserId, undefined);