From 7e501729cb819073bacb1289d2a08c9b6dafaf9a Mon Sep 17 00:00:00 2001 From: Diego Madero Islas Date: Tue, 16 Jun 2026 11:02:18 -0600 Subject: [PATCH] Introduce Bootstrap v2 operational onboarding --- app/admin/setup/page.tsx | 50 +---- app/bootstrap/page.tsx | 193 +++++------------- .../operational-onboarding-checklist.tsx | 159 +++++++++++++++ docs/bootstrap-v2-onboarding.md | 56 +++++ docs/first-deploy-rehearsal.md | 12 +- docs/first-entry-and-onboarding.md | 13 +- lib/domain/types.ts | 19 ++ lib/server/actions/admin-core.ts | 51 ++++- lib/server/actions/entry-bootstrap.ts | 181 +++++++--------- lib/server/store.ts | 13 ++ 10 files changed, 443 insertions(+), 304 deletions(-) create mode 100644 components/setup/operational-onboarding-checklist.tsx create mode 100644 docs/bootstrap-v2-onboarding.md diff --git a/app/admin/setup/page.tsx b/app/admin/setup/page.tsx index 8b2e26e..c65f3c9 100644 --- a/app/admin/setup/page.tsx +++ b/app/admin/setup/page.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { skossBootstrapRoutes, skossCoreRoutes } from '@/lib/application-planes'; +import { skossCoreRoutes } from '@/lib/application-planes'; import { formatCurrency, formatDateLabel, @@ -26,6 +26,7 @@ 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 = [ @@ -41,6 +42,7 @@ const basicUnits = [ type SetupSearchParams = { section?: + | 'onboarding' | 'business-setup' | 'users' | 'preferences-system' @@ -168,6 +170,10 @@ async function SavedMessage({ saved }: { saved?: string }) { return

Demo workspace reseeded successfully.

; } + if (saved === 'onboarding-section') { + return

Onboarding section updated.

; + } + return null; } @@ -344,6 +350,7 @@ export default async function SetupPage({ const setupSection = params?.section; const sectionNavItems: Array<{ key: + | 'onboarding' | 'business-setup' | 'users' | 'preferences-system' @@ -355,6 +362,7 @@ export default async function SetupPage({ | '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') }, @@ -370,14 +378,6 @@ export default async function SetupPage({ query: { section }, hash: section, }); - const onboardingProgress = identitySetup.instance.onboardingProgress; - const guidedChecklist = [ - { label: 'Admin account', done: onboardingProgress.adminAccount }, - { label: 'Workspace basics', done: onboardingProgress.workspaceBasics }, - { label: 'Team setup', done: onboardingProgress.users }, - { label: 'Operational rhythm', done: onboardingProgress.shifts }, - { label: 'Optional imports', done: onboardingProgress.optionalImports }, - ]; return (
@@ -445,37 +445,7 @@ export default async function SetupPage({
-
-
-
-

Setup checklist (resumable)

-

- Keep activation practical: only the core is required at launch, everything else can be completed gradually. -

-
- - {guidedChecklist.filter((item) => item.done).length}/{guidedChecklist.length} - -
- -
- - Resume guided activation - - - Continue CSV imports - -
-
+
diff --git a/app/bootstrap/page.tsx b/app/bootstrap/page.tsx index 5f45d79..514fee9 100644 --- a/app/bootstrap/page.tsx +++ b/app/bootstrap/page.tsx @@ -1,13 +1,11 @@ import { redirect } from 'next/navigation'; -import { CsvImportCard } from '@/components/setup/csv-import-card'; -import { TeamRosterBuilder } from '@/components/setup/team-roster-builder'; import { TimezoneSelect } from '@/components/setup/timezone-select'; -import { saveBootstrapStepAction, importCsvEntitiesAction } from '@/lib/server/actions'; +import { saveBootstrapStepAction } from '@/lib/server/actions'; import { readAppData } from '@/lib/server/persistence'; import { detectInstanceGatewayState } from '@/lib/server/instance-entry'; -const totalSteps = 8; -const requiredSteps = new Set([1, 2]); +const totalSteps = 3; +const requiredSteps = new Set([1, 2, 3]); function bootstrapStep(step: number) { return Math.max(1, Math.min(totalSteps, step)); @@ -35,19 +33,19 @@ export default async function BootstrapPage({
-

First-time setup

-

Start your kitchen workspace

+

Required activation

+

Create a clean SKOSS instance

- Guided activation first, then optional setup. Required steps are marked so you can launch fast and keep configuring later. + Activation creates the owner account, business identity, language, data mode, and operating profile. Team, customers, products, recipes, delivery, shifts, and first orders continue after sign-in.

Step {step} of {totalSteps}
-

- Required now: steps 1-2 (admin account and workspace basics). Optional and skippable: steps 3-7. + Demo and restore are separate paths from the entry gateway. This flow launches a real instance without fictional operational records.

@@ -59,7 +57,8 @@ export default async function BootstrapPage({ {step === 1 ? (
-

Admin account (required)

+

Owner/admin account

+

This is the first active user kept when the clean instance is launched.

diff --git a/components/setup/operational-onboarding-checklist.tsx b/components/setup/operational-onboarding-checklist.tsx new file mode 100644 index 0000000..e64e813 --- /dev/null +++ b/components/setup/operational-onboarding-checklist.tsx @@ -0,0 +1,159 @@ +import type { OperationalOnboardingSectionKey, OperationalOnboardingSections, OnboardingSectionStatus } from '@/lib/domain/types'; +import { updateOperationalOnboardingSectionAction } from '@/lib/server/actions'; + +type SectionConfig = { + key: OperationalOnboardingSectionKey; + title: string; + description: string; + configureHref: string; + importPlaceholder?: string; + minimum?: boolean; +}; + +const sectionConfigs: SectionConfig[] = [ + { + key: 'business', + title: 'Business profile', + description: 'Business identity, contact details, hours, and fulfillment notes.', + configureHref: '/admin/setup?section=preferences-system#preferences-system', + minimum: true, + }, + { + key: 'team', + title: 'Team and roles', + description: 'People who sign in, practical roles, active state, and default workspaces.', + configureHref: '/admin/setup?section=users#users', + importPlaceholder: 'Team CSV later', + }, + { + key: 'shifts', + title: 'Shifts', + description: 'Working rhythm, handoff expectations, and shift ownership.', + configureHref: '/handoff', + }, + { + key: 'customers', + title: 'Customers', + description: 'Repeat customers, contact details, delivery notes, and preferences.', + configureHref: '/customers', + importPlaceholder: 'Customer CSV later', + minimum: true, + }, + { + key: 'products', + title: 'Products', + description: 'Sellable product names, units, categories, variants, and active state.', + configureHref: '/admin/setup?section=products#products', + importPlaceholder: 'Product CSV later', + minimum: true, + }, + { + key: 'recipes', + title: 'Recipes', + description: 'Recipe/component relationships and production notes when the catalog is ready.', + configureHref: '/admin/setup?section=recipes#recipes', + }, + { + key: 'delivery', + title: 'Delivery and destinations', + description: 'Pickup, counter, delivery stops, couriers, and dispatch notes.', + configureHref: '/admin/setup?section=business-setup#customers-summary', + }, + { + key: 'initialOrders', + title: 'Initial orders', + description: 'First real demand to prove order capture, production, and handoff.', + configureHref: '/orders/new', + importPlaceholder: 'Initial order CSV later', + minimum: true, + }, +]; + +function getStatusLabel(status: OnboardingSectionStatus) { + if (status === 'not_started') { + return 'Not started'; + } + if (status === 'in_progress') { + return 'In progress'; + } + if (status === 'ready') { + return 'Ready'; + } + return 'Skipped for now'; +} + +function getSectionStatus(sections: OperationalOnboardingSections | undefined, key: OperationalOnboardingSectionKey): OnboardingSectionStatus { + return sections?.[key]?.status ?? 'not_started'; +} + +function StatusActionForm({ section, status, label }: { section: OperationalOnboardingSectionKey; status: OnboardingSectionStatus; label: string }) { + return ( +
+ + + +
+ ); +} + +export function OperationalOnboardingChecklist({ sections }: { sections?: OperationalOnboardingSections }) { + const readyCount = sectionConfigs.filter((section) => getSectionStatus(sections, section.key) === 'ready').length; + const skippedCount = sectionConfigs.filter((section) => getSectionStatus(sections, section.key) === 'skipped').length; + const minimumReady = sectionConfigs + .filter((section) => section.minimum) + .every((section) => ['ready', 'skipped'].includes(getSectionStatus(sections, section.key))); + + return ( +
+
+
+

Operational onboarding

+

+ Configure sections as real data becomes available. Skipped means return later, not complete. +

+
+ {readyCount}/{sectionConfigs.length} ready +
+ +
    + {sectionConfigs.map((section) => { + const status = getSectionStatus(sections, section.key); + return ( +
  • +
    + {section.title} + {section.description} + Status: {getStatusLabel(status)} +
    +
    + Configure + {section.importPlaceholder ? ( + + ) : null} + {status === 'skipped' ? ( + + ) : ( + + )} + {status !== 'ready' ? : null} +
    +
  • + ); + })} +
+ +
+
+
+

Launch/readiness summary

+

+ Minimum viable launch expects business profile, customers, products, and initial orders to be ready or intentionally deferred. Recipes, shifts, and delivery can mature later. +

+
+ {minimumReady ? 'Viable' : 'Needs decisions'} +
+

Skipped sections: {skippedCount}. Keep this list visible until skipped work has a real owner.

+
+
+ ); +} diff --git a/docs/bootstrap-v2-onboarding.md b/docs/bootstrap-v2-onboarding.md new file mode 100644 index 0000000..374736c --- /dev/null +++ b/docs/bootstrap-v2-onboarding.md @@ -0,0 +1,56 @@ +# Bootstrap v2 and Operational Onboarding + +SKOSS first run is split into two layers. + +## Required activation + +Activation is short and mandatory. It creates the minimum real instance shell: + +- language +- clean real instance/data mode +- basic business identity +- owner/admin account +- business preset or operational profile +- instance creation confirmation + +Demo mode and restore mode remain separate choices from the entry gateway. A clean real activation must not silently copy demo customers, products, orders, activities, recurring templates, WIP, destinations, suppliers, or demo team users. + +## 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. + +Tracked section states are: + +- `not_started` +- `in_progress` +- `ready` +- `skipped` + +`skipped` means intentionally deferred. It is not the same as complete. + +Current sections are: + +- business profile +- team and roles +- shifts +- customers +- products +- recipes +- delivery/destination methods +- initial orders + +## Future data depth + +Business data can grow to include name, description, logo, contact, address, website, hours, and fulfillment methods. + +Team data can grow to include name, login, phone, email, practical roles, active state, and shift assignment. Avoid unnecessary sensitive personal fields by default. + +Customer data can grow to include person/business type, alias, organization, contact person, phones, email, address, notes, and fulfillment preferences. + +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. + +## Non-goals for this pass + +This pass does not implement full HR, product, recipe, procurement, inventory, or delivery systems. It establishes the first-run information architecture and clean-instance state model. diff --git a/docs/first-deploy-rehearsal.md b/docs/first-deploy-rehearsal.md index c28a6a1..02da893 100644 --- a/docs/first-deploy-rehearsal.md +++ b/docs/first-deploy-rehearsal.md @@ -41,16 +41,16 @@ If the runtime mode is unclear in `/entry`, stop the rehearsal and inspect envir - Failure signs: demo mode appears active when preparing a real rehearsal, or guided activation is unavailable on a clean instance. 2. Run `/bootstrap`. - - Expected: guided activation allows admin creation and workspace basics. Optional setup can remain incomplete. - - Failure signs: bootstrap is locked even though the instance is clean, or setup cannot create/recover an admin user. + - Expected: required activation creates only the owner/admin account, language, clean real data mode, business identity, preset/profile, and confirmation. Operational onboarding continues after sign-in. + - Failure signs: bootstrap is locked even though the instance is clean, setup cannot create/recover an admin user, or the real path looks like a demo-data wizard. 3. Create or recover admin access. - 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 business and workspace setup in `/admin/setup`. - - Expected: business identity, workspace preferences, customers, and basic catalog setup are visible without requiring procurement or inventory setup. - - Failure signs: a user must complete suppliers, raw materials, recipes, costing, or inventory before order capture. +4. Confirm operational onboarding in `/admin/setup?section=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. 5. Confirm customers in `/customers`. - Expected: existing customers can be reviewed or a first real customer can be added. @@ -125,7 +125,7 @@ The first local deploy rehearsal should not attempt to validate: - Confirm the selected runtime mode and persistence mode are documented for the deployment target. - Define a concrete manual backup/export procedure until a user-facing export flow exists. - Verify admin recovery expectations in `production` mode. -- Verify demo seed data is not mixed with real rehearsal records. +- Verify demo seed data is not mixed with real rehearsal records. A clean real activation should keep only the activated owner/admin account and structural preferences, with empty customers, products, orders, recurring templates, WIP, activities, destinations, suppliers, recipes, and demo team users. ## Recommended Next PR diff --git a/docs/first-entry-and-onboarding.md b/docs/first-entry-and-onboarding.md index 8444335..2e6fd35 100644 --- a/docs/first-entry-and-onboarding.md +++ b/docs/first-entry-and-onboarding.md @@ -46,6 +46,7 @@ A lightweight `instance` object is persisted in the store: - `roles` - `shifts` - `optionalImports` +- `operationalOnboarding` section statuses for resumable real-data setup - `operatorOnboardingByUserId` (future role onboarding tracking) Detection is intentionally simple and reversible. @@ -74,17 +75,13 @@ Current routing behavior: Goal: predictable routing with small condition sets. -## Admin setup flow +## Bootstrap v2 activation and onboarding -From gateway, **Start a new kitchen** routes to setup onboarding entry (`/setup?section=business-setup`). +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. -Current onboarding progress support includes: +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. -- step flags in `instance.onboardingProgress` -- save progress by persisted store updates -- resume via deterministic setup route - -Detailed multi-step wizard UX can be layered in later PRs without changing the state contract. +Tracked operational sections are business, team, shifts, customers, products, recipes, delivery/destinations, and initial orders. ## Demo mode flow diff --git a/lib/domain/types.ts b/lib/domain/types.ts index a9a651e..a827ffe 100644 --- a/lib/domain/types.ts +++ b/lib/domain/types.ts @@ -7,6 +7,16 @@ export type WorkspaceSurface = 'home' | 'timeline' | 'orders' | 'customers' | 'p export type CustomerContactMethod = 'phone' | 'email' | 'whatsapp'; export type EnvironmentType = 'dev' | 'demo' | 'pilot' | 'production'; export type OnboardingStatus = 'not_started' | 'in_progress' | 'completed'; +export type OnboardingSectionStatus = 'not_started' | 'in_progress' | 'ready' | 'skipped'; +export type OperationalOnboardingSectionKey = + | 'business' + | 'team' + | 'customers' + | 'products' + | 'recipes' + | 'delivery' + | 'shifts' + | 'initialOrders'; export type OrderStatus = 'draft' | 'active' | 'changed' | 'cancelled' | 'completed'; export type OrderLineStatus = 'pending' | 'in_progress' | 'done' | 'cancelled'; @@ -80,6 +90,14 @@ export interface OnboardingProgress { optionalImports: boolean; } +export type OperationalOnboardingSections = Record< + OperationalOnboardingSectionKey, + { + status: OnboardingSectionStatus; + updatedAt?: string; + } +>; + export interface InstanceState { initialized: boolean; onboardingStatus: OnboardingStatus; @@ -88,6 +106,7 @@ export interface InstanceState { backupHintAvailable: boolean; lastRestoreAt?: string; onboardingProgress: OnboardingProgress; + operationalOnboarding?: OperationalOnboardingSections; operatorOnboardingByUserId?: Record; moduleStates?: Record; } diff --git a/lib/server/actions/admin-core.ts b/lib/server/actions/admin-core.ts index 11bc9ee..434cfec 100644 --- a/lib/server/actions/admin-core.ts +++ b/lib/server/actions/admin-core.ts @@ -37,19 +37,68 @@ import { import { moduleRegistry } from '@/lib/modules'; import { getCurrentUserContext } from '@/lib/server/auth'; import { isNonProductionMode } from '@/lib/server/runtime-mode'; -import type { AppData } from '@/lib/domain/types'; +import type { AppData, OnboardingSectionStatus, OperationalOnboardingSectionKey, OperationalOnboardingSections } from '@/lib/domain/types'; function quoteLabel(label: string) { return `"${label}"`; } const persistence = getPersistenceGateway(); +const operationalOnboardingSections: OperationalOnboardingSectionKey[] = [ + 'business', + 'team', + 'customers', + 'products', + 'recipes', + 'delivery', + 'shifts', + 'initialOrders', +]; +const operationalOnboardingStatuses: OnboardingSectionStatus[] = ['not_started', 'in_progress', 'ready', 'skipped']; + +function buildFallbackOperationalOnboarding(): 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' }, + }; +} async function getActorUserId(data: AppData) { const { currentUser } = await getCurrentUserContext(data); return currentUser?.id; } +export async function updateOperationalOnboardingSectionAction(formData: FormData) { + const section = String(formData.get('section') ?? '') as OperationalOnboardingSectionKey; + 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.')); + } + + const now = new Date().toISOString(); + await persistence.write(({ instance }) => { + const current = instance.getInstanceState(); + instance.updateInstanceState({ + ...current, + operationalOnboarding: { + ...buildFallbackOperationalOnboarding(), + ...(current.operationalOnboarding ?? {}), + [section]: { status, updatedAt: now }, + }, + }); + }); + + revalidateAllWorkspaces(); + redirect('/admin/setup?section=onboarding&saved=onboarding-section'); +} + function revalidateAllWorkspaces() { revalidatePath('/'); revalidatePath('/timeline'); diff --git a/lib/server/actions/entry-bootstrap.ts b/lib/server/actions/entry-bootstrap.ts index 1ab54f8..63a7b2a 100644 --- a/lib/server/actions/entry-bootstrap.ts +++ b/lib/server/actions/entry-bootstrap.ts @@ -9,8 +9,8 @@ import { hashPassword, verifyPassword } from '@/lib/server/passwords'; import { isSupportedLocale, isSupportedPreset, localeCookieName, supportedLocales, presetCookieName, supportedPresets } from '@/lib/i18n/config'; import { themeCookieName } from '@/lib/theme'; import { getDefaultWorkspaceForRole, isPrimaryWorkspaceSurface } from '@/lib/workspaces'; -import { isNonProductionMode } from '@/lib/server/runtime-mode'; -import type { AppData } from '@/lib/domain/types'; +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'; const supportedThemes = ['light', 'dark', 'system'] as const; @@ -48,7 +48,7 @@ function nextBootstrapStep(current: number, direction: 'next' | 'back' | 'stay') return current; } - return Math.min(8, current + 1); + return Math.min(3, current + 1); } function isRestorableBackupShape(value: unknown): value is AppData { @@ -65,6 +65,49 @@ function isRestorableBackupShape(value: unknown): value is AppData { ); } +function getActivationEnvironmentType(): EnvironmentType { + const runtimeMode = getRuntimeMode(); + if (runtimeMode === 'production') { + return 'production'; + } + if (runtimeMode === 'pilot') { + return 'pilot'; + } + return 'dev'; +} + +function buildInitialOperationalOnboarding(now: string): OperationalOnboardingSections { + return { + business: { status: 'ready', updatedAt: now }, + 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' }, + }; +} + +function clearDemoOperationalRecordsForRealInstance(data: AppData) { + const ownerAdmin = data.users.find((user) => user.active && (user.role === 'owner_admin' || user.roles?.includes('owner_admin'))) + ?? data.users.find((user) => user.role === 'owner_admin' || user.roles?.includes('owner_admin')); + + data.users = ownerAdmin ? [ownerAdmin] : []; + data.customers = []; + data.destinations = []; + data.products = []; + data.suppliers = []; + data.rawMaterials = []; + data.supplierPriceEntries = []; + data.recipes = []; + data.recurringTemplates = []; + data.orders = []; + data.wipEntries = []; + data.shiftLogs = []; + data.activities = []; +} + export async function resetLocalRuntimeDataAction() { if (!isNonProductionMode()) { redirect('/entry?error=' + encodeURIComponent('Local runtime reset is disabled in production mode.')); @@ -362,6 +405,11 @@ export async function saveBootstrapStepAction(formData: FormData) { const preset = String(formData.get('preset') ?? '').trim(); const operatingMode = String(formData.get('operatingMode') ?? '').trim(); const theme = String(formData.get('theme') ?? '').trim(); + const dataMode = String(formData.get('dataMode') ?? 'real_clean').trim(); + + if (dataMode !== 'real_clean') { + redirect('/bootstrap?step=2&error=' + encodeURIComponent('Use the entry gateway for demo or restore. Real activation creates a clean instance.')); + } if (!businessName) { redirect('/bootstrap?step=2&error=' + encodeURIComponent('Business name is required.')); @@ -379,95 +427,10 @@ export async function saveBootstrapStepAction(formData: FormData) { } data.instance.onboardingProgress.workspaceBasics = true; data.instance.onboardingProgress.timezone = Boolean(data.workspace.timezone); - } - - if (step === 4) { - const now = new Date().toISOString(); - for (const row of Array.from({ length: 12 }, (_, index) => index + 1)) { - const displayName = String(formData.get(`teamDisplayName${row}`) ?? '').trim(); - const username = slugifyUsername(String(formData.get(`teamUsername${row}`) ?? '')); - const selectedRoles = formData.getAll(`teamRoles${row}`).map((value) => String(value)); - const defaultWorkspace = String(formData.get(`teamWorkspace${row}`) ?? 'orders'); - const enabled = formData.get(`teamEnabled${row}`) !== null; - const roleList = selectedRoles - .filter((role) => ['owner_admin', 'shift_lead', 'kitchen', 'sales'].includes(role)) - .filter((role, index, list) => list.indexOf(role) === index) as Array<'owner_admin' | 'shift_lead' | 'kitchen' | 'sales'>; - const primaryRole = roleList[0] ?? 'sales'; - - if (!displayName || !username) { - continue; - } - - const existing = data.users.find((user) => user.loginIdentifier.toLowerCase() === username); - if (existing) { - existing.displayName = displayName; - existing.role = primaryRole; - existing.roles = roleList.length > 0 ? roleList : [primaryRole]; - existing.defaultWorkspace = isPrimaryWorkspaceSurface(defaultWorkspace as 'home' | 'timeline' | 'orders' | 'customers' | 'production' | 'handoff' | 'preferences' | 'admin') - ? (defaultWorkspace as 'home' | 'timeline' | 'orders' | 'customers' | 'production' | 'handoff' | 'preferences' | 'admin') - : existing.defaultWorkspace; - existing.active = enabled; - existing.updatedAt = now; - } else { - data.users.push({ - id: `user-${crypto.randomUUID()}`, - displayName, - loginIdentifier: username, - passwordHash: hashPassword('skoss-demo'), - passwordUpdatedAt: now, - mustChangePassword: true, - role: primaryRole, - roles: roleList.length > 0 ? roleList : [primaryRole], - workspaceId: data.workspace.id, - defaultWorkspace: isPrimaryWorkspaceSurface(defaultWorkspace as 'home' | 'timeline' | 'orders' | 'customers' | 'production' | 'handoff' | 'preferences' | 'admin') - ? (defaultWorkspace as 'home' | 'timeline' | 'orders' | 'customers' | 'production' | 'handoff' | 'preferences' | 'admin') - : 'orders', - active: enabled, - username, - preferences: { - defaultWorkspace: isPrimaryWorkspaceSurface(defaultWorkspace as 'home' | 'timeline' | 'orders' | 'customers' | 'production' | 'handoff' | 'preferences' | 'admin') - ? (defaultWorkspace as 'home' | 'timeline' | 'orders' | 'customers' | 'production' | 'handoff' | 'preferences' | 'admin') - : 'orders', - }, - createdAt: now, - updatedAt: now, - }); - } - } - data.instance.onboardingProgress.users = true; - data.instance.onboardingProgress.roles = true; - } - - if (step === 5) { - data.instance.onboardingProgress.shifts = true; - } - - if (step === 6) { - const productName = String(formData.get('starterProductName') ?? '').trim(); - const productUnit = String(formData.get('starterProductUnit') ?? '').trim() || 'pieces'; - if (productName) { - const existing = data.products.find((product) => product.name.trim().toLowerCase() === productName.toLowerCase()); - if (!existing) { - data.products.push({ - id: `product-${crypto.randomUUID()}`, - name: productName, - defaultUnit: productUnit, - active: true, - variants: [ - { - id: `variant-${crypto.randomUUID()}`, - name: 'Default', - defaultUnit: productUnit, - active: true, - }, - ], - }); - } - } - } - - if (step === 7) { - data.instance.onboardingProgress.optionalImports = true; + data.instance.operationalOnboarding = { + ...(data.instance.operationalOnboarding ?? buildInitialOperationalOnboarding(new Date().toISOString())), + business: { status: 'ready', updatedAt: new Date().toISOString() }, + }; } if (intent === 'launch') { @@ -478,11 +441,15 @@ export async function saveBootstrapStepAction(formData: FormData) { redirect('/bootstrap?step=2&error=' + encodeURIComponent('Save workspace basics before launch.')); } + const now = new Date().toISOString(); + clearDemoOperationalRecordsForRealInstance(data); data.preferences.onboardingCompleted = true; - data.preferences.completedAt = data.preferences.completedAt ?? new Date().toISOString(); - data.preferences.updatedAt = new Date().toISOString(); + data.preferences.completedAt = data.preferences.completedAt ?? now; + data.preferences.updatedAt = now; data.instance.onboardingStatus = 'completed'; data.instance.demoModeActive = false; + data.instance.environmentType = getActivationEnvironmentType(); + data.instance.operationalOnboarding = buildInitialOperationalOnboarding(now); data.session.currentUserId = undefined; await persistence.write(({ raw, instance, users }) => { instance.updateInstanceState(data.instance); @@ -490,27 +457,33 @@ export async function saveBootstrapStepAction(formData: FormData) { instance.updatePreferences(data.preferences); instance.updateSessionState(data.session); users.replaceAll(data.users); - if (step === 6) { - raw.products = data.products; - } + raw.customers = data.customers; + raw.destinations = data.destinations; + raw.products = data.products; + raw.suppliers = data.suppliers; + raw.rawMaterials = data.rawMaterials; + raw.supplierPriceEntries = data.supplierPriceEntries; + raw.recipes = data.recipes; + raw.recurringTemplates = data.recurringTemplates; + raw.orders = data.orders; + raw.wipEntries = data.wipEntries; + raw.shiftLogs = data.shiftLogs; + raw.activities = data.activities; }); revalidateAllWorkspaces(); - redirect('/login?redirectTo=/'); + redirect('/login?redirectTo=/admin/setup?section=onboarding'); } - await persistence.write(({ raw, instance, users }) => { + await persistence.write(({ instance, users }) => { instance.updateInstanceState(data.instance); instance.updateWorkspace(data.workspace); instance.updatePreferences(data.preferences); instance.updateSessionState(data.session); users.replaceAll(data.users); - if (step === 6) { - raw.products = data.products; - } }); revalidateAllWorkspaces(); if (intent === 'skip') { - redirect(`/bootstrap?step=${Math.min(8, step + 1)}&saved=progress`); + redirect(`/bootstrap?step=${Math.min(3, step + 1)}&saved=progress`); } const nextStep = nextBootstrapStep(step, intent === 'back' ? 'back' : intent === 'stay' ? 'stay' : 'next'); @@ -612,7 +585,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('/bootstrap?step=1'); + redirect('/admin/setup?section=onboarding'); } revalidateAllWorkspaces(); diff --git a/lib/server/store.ts b/lib/server/store.ts index 60efb8a..e72b640 100644 --- a/lib/server/store.ts +++ b/lib/server/store.ts @@ -9,6 +9,7 @@ import type { Order, OrderLine, OnboardingProgress, + OperationalOnboardingSections, Recipe, RecurringTemplate, User, @@ -397,6 +398,17 @@ 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 operationalOnboarding: OperationalOnboardingSections = { + 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 ?? {}), + }; const instance: InstanceState = { initialized: rawData.instance?.initialized ?? users.length > 0, onboardingStatus: rawData.instance?.onboardingStatus @@ -414,6 +426,7 @@ function hydrateStore(rawData: AppData): AppData { users: rawData.instance?.onboardingProgress?.users ?? users.length > 0, roles: rawData.instance?.onboardingProgress?.roles ?? hasAdminUser, }, + operationalOnboarding, operatorOnboardingByUserId: rawData.instance?.operatorOnboardingByUserId ?? {}, moduleStates: getModuleStateMap(preferences.preset, rawData.instance?.moduleStates), };