diff --git a/app/admin/onboarding/[chapter]/page.tsx b/app/admin/onboarding/[chapter]/page.tsx new file mode 100644 index 0000000..934b18b --- /dev/null +++ b/app/admin/onboarding/[chapter]/page.tsx @@ -0,0 +1,19 @@ +import { OperationalOnboardingPage } from '../onboarding-page'; + +export default async function AdminOnboardingChapterPage({ + params, +}: { + params: Promise<{ chapter: string }>; +}) { + const { chapter } = await params; + + if (chapter === 'intro') { + return ; + } + + if (chapter === 'summary') { + return ; + } + + return ; +} diff --git a/app/admin/onboarding/onboarding-page.tsx b/app/admin/onboarding/onboarding-page.tsx new file mode 100644 index 0000000..da9c695 --- /dev/null +++ b/app/admin/onboarding/onboarding-page.tsx @@ -0,0 +1,293 @@ +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import type { OperationalOnboardingChapter } from '@/lib/server/operational-onboarding'; +import { + getOperationalOnboardingChapterBySlug, + getOperationalOnboardingProgress, + operationalOnboardingChapters, +} from '@/lib/server/operational-onboarding'; +import { requireAdminPlaneAccess } from '@/lib/server/application-access'; +import { skossCoreRoutes } from '@/lib/application-planes'; +import { getServerTranslator } from '@/lib/i18n/server'; +import { readAppData } from '@/lib/server/persistence'; +import { + continueOperationalOnboardingSectionAction, + updateOperationalOnboardingSectionAction, +} from '@/lib/server/actions'; +import type { OnboardingSectionStatus } from '@/lib/domain/types'; + +type Translator = (key: string, values?: Record) => string; + +type OnboardingPageMode = 'intro' | 'chapter' | 'summary'; + +function chapterRoute(chapter: OperationalOnboardingChapter) { + return `/admin/onboarding/${chapter.slug}`; +} + +function getChapterIndex(chapter: OperationalOnboardingChapter) { + return operationalOnboardingChapters.findIndex((entry) => entry.key === chapter.key); +} + +function getStatusLabel(t: Translator, status: OnboardingSectionStatus) { + return t(`onboarding.status.${status}`); +} + +function StatusPill({ status, t }: { status: OnboardingSectionStatus; t: Translator }) { + return {getStatusLabel(t, status)}; +} + +function ProgressHeader({ + chapter, + t, + readyCount, + totalCount, +}: { + chapter?: OperationalOnboardingChapter; + t: Translator; + readyCount: number; + totalCount: number; +}) { + const index = chapter ? getChapterIndex(chapter) + 1 : 0; + const percent = Math.round((readyCount / totalCount) * 100); + + return ( +
+
+
+

{t('onboarding.progress.label')}

+

{chapter ? t('onboarding.progress.chapterOf', { current: index, total: totalCount }) : t('onboarding.progress.intro')}

+
+ {readyCount}/{totalCount} {t('onboarding.summary.readyShort')} +
+ +
+ ); +} + +function ChapterStepper({ + current, + sections, + t, +}: { + current?: OperationalOnboardingChapter; + sections: ReturnType['sections']; + t: Translator; +}) { + return ( +
    + {operationalOnboardingChapters.map((chapter) => { + const status = sections[chapter.key].status; + const isCurrent = current?.key === chapter.key; + return ( +
  1. + +
  2. + ); + })} +
+ ); +} + +function SkipForm({ chapter, nextHref, t }: { chapter: OperationalOnboardingChapter; nextHref: string; t: Translator }) { + if (!chapter.skippable) return null; + + return ( +
+ + + + +
+ ); +} + +function ContinueForm({ chapter, nextHref, t }: { chapter: OperationalOnboardingChapter; nextHref: string; t: Translator }) { + return ( +
+ + + +
+ ); +} + +function ChapterCounts({ chapter, data, t }: { chapter: OperationalOnboardingChapter; data: Awaited>; t: Translator }) { + const value = (() => { + if (chapter.key === 'business') return data.workspace.name; + if (chapter.key === 'team') return String(data.users.filter((user) => user.active).length); + if (chapter.key === 'customers') return String(data.customers.filter((customer) => customer.active).length); + if (chapter.key === 'products') return String(data.products.filter((product) => product.active).length); + if (chapter.key === 'recipes') return String(data.recipes.filter((recipe) => recipe.active).length); + if (chapter.key === 'delivery') return String(data.destinations.filter((destination) => destination.active).length); + if (chapter.key === 'shifts') return String(data.shiftLogs.length); + return String(data.orders.filter((order) => order.source !== 'generated' && order.status !== 'cancelled').length); + })(); + + return {t(`onboarding.metrics.${chapter.key}`, { value })}; +} + +function IntroScreen({ t, nextHref }: { t: Translator; nextHref: string }) { + return ( +
+
+
+

{t('onboarding.intro.eyebrow')}

+

{t('onboarding.intro.title')}

+

{t('onboarding.intro.body')}

+
+
+ +
+
    +
  • {t('onboarding.intro.minimumTitle')}{t('onboarding.intro.minimumBody')}
  • +
  • {t('onboarding.intro.resumeTitle')}{t('onboarding.intro.resumeBody')}
  • +
  • {t('onboarding.intro.skipTitle')}{t('onboarding.intro.skipBody')}
  • +
  • {t('onboarding.intro.expandTitle')}{t('onboarding.intro.expandBody')}
  • +
+
+ {t('onboarding.actions.start')} + {t('onboarding.actions.setupOverview')} +
+
+
+ ); +} + +function SummaryScreen({ + t, + progress, +}: { + t: Translator; + progress: ReturnType; +}) { + const groups = [ + { key: 'ready', title: t('onboarding.summary.readySections'), chapters: progress.ready }, + { key: 'skipped', title: t('onboarding.summary.skippedSections'), chapters: progress.skipped }, + { key: 'incomplete', title: t('onboarding.summary.incompleteSections'), chapters: progress.incomplete }, + ]; + + return ( +
+ +
+
+
+

{t('onboarding.summary.eyebrow')}

+

{t('onboarding.summary.title')}

+

{t('onboarding.summary.body')}

+
+ {progress.minimumReady ? t('onboarding.summary.viable') : t('onboarding.summary.needsDecisions')} +
+
+ {groups.map((group) => ( +
+

{group.title}

+ {group.chapters.length > 0 ? ( +
    + {group.chapters.map((chapter) => ( +
  • {t(chapter.titleKey)}{getStatusLabel(t, progress.sections[chapter.key].status)}
  • + ))} +
+ ) :

{t('onboarding.summary.none')}

} +
+ ))} +
+
+ {t('onboarding.actions.beginOperating')} + {progress.nextChapter ? {t('onboarding.actions.resumeNext')} : null} + {t('onboarding.actions.setupOverview')} +
+
+
+ ); +} + +function ChapterScreen({ + chapter, + data, + progress, + t, +}: { + chapter: OperationalOnboardingChapter; + data: Awaited>; + progress: ReturnType; + t: Translator; +}) { + const index = getChapterIndex(chapter); + const previous = index > 0 ? operationalOnboardingChapters[index - 1] : null; + const next = operationalOnboardingChapters[index + 1] ?? null; + const nextHref = next ? chapterRoute(next) : '/admin/onboarding/summary'; + const status = progress.sections[chapter.key].status; + + return ( +
+ +
+
+
+
+

{t('onboarding.progress.chapterOf', { current: index + 1, total: progress.totalCount })}

+

{t(chapter.titleKey)}

+

{t(chapter.purposeKey)}

+
+ +
+ +
+
+
+

{t('onboarding.chapter.focusTitle')}

+

{t(`onboarding.chapter.focus.${chapter.key}`)}

+
+ +
+ {t('onboarding.actions.configure')} + {chapter.key === 'shifts' || chapter.key === 'delivery' ? ( +

{t(`onboarding.limitations.${chapter.key}`)}

+ ) : null} +
+ +
+ {previous ? {t('onboarding.actions.back')} : {t('onboarding.actions.back')}} + + + {t('onboarding.actions.exit')} +
+
+ +
+
+ ); +} + +export async function OperationalOnboardingPage({ mode, chapterSlug }: { mode: OnboardingPageMode; chapterSlug?: string }) { + await requireAdminPlaneAccess(skossCoreRoutes.adminSetup); + const [{ t }, data] = await Promise.all([getServerTranslator(), readAppData()]); + const progress = getOperationalOnboardingProgress(data); + const nextHref = progress.nextChapter ? chapterRoute(progress.nextChapter) : '/admin/onboarding/summary'; + + if (mode === 'intro') { + return ; + } + + if (mode === 'summary') { + return ; + } + + const chapter = chapterSlug ? getOperationalOnboardingChapterBySlug(chapterSlug) : null; + if (!chapter) { + redirect('/admin/onboarding/intro'); + } + + return ; +} diff --git a/app/admin/onboarding/page.tsx b/app/admin/onboarding/page.tsx new file mode 100644 index 0000000..004f5c1 --- /dev/null +++ b/app/admin/onboarding/page.tsx @@ -0,0 +1,5 @@ +import { OperationalOnboardingPage } from './onboarding-page'; + +export default async function AdminOnboardingIndexPage() { + return ; +} diff --git a/app/admin/setup/setup-page.tsx b/app/admin/setup/setup-page.tsx index 2ededbe..eca17ec 100644 --- a/app/admin/setup/setup-page.tsx +++ b/app/admin/setup/setup-page.tsx @@ -30,6 +30,7 @@ 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'; +import { deriveOperationalOnboardingSections } from '@/lib/server/operational-onboarding'; const basicUnits = [ 'g', @@ -420,6 +421,7 @@ export async function AdminSetupPage({ const hasImportFeedback = params?.saved === 'import' && params?.importedEntity; const setupSection = activeSection; + const derivedOperationalOnboarding = deriveOperationalOnboardingSections(data); const sectionNavItems: Array<{ key: SetupSection; label: string }> = [ { key: 'onboarding', label: 'Onboarding' }, { key: 'business', label: t('setup.sections.businessSetup') }, @@ -437,7 +439,7 @@ export async function AdminSetupPage({ 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; + const onboardingSectionStatus = onboardingSectionKey ? derivedOperationalOnboarding[onboardingSectionKey]?.status ?? 'not_started' : undefined; return (
@@ -524,13 +526,9 @@ export async function AdminSetupPage({
+
-
- - - -
) : null}
@@ -538,7 +536,7 @@ export async function AdminSetupPage({ ) : null} {activeSection === 'onboarding' ? ( - + ) : null} {activeSection === 'business' ? ( diff --git a/app/globals.css b/app/globals.css index 60a4ea8..8bb512a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2434,3 +2434,101 @@ button:disabled, justify-content: flex-start; } } + + +.demo-workspace-banner { + background: color-mix(in srgb, var(--info-soft) 74%, var(--surface)); + border-bottom-color: color-mix(in srgb, var(--info-border, var(--border)) 55%, var(--border)); + color: var(--info); +} + +.onboarding-wizard-hero { + margin-bottom: var(--space-2); +} + +.onboarding-wizard-progress { + gap: var(--space-3); +} + +.onboarding-wizard-layout { + align-items: start; +} + +.onboarding-focus-panel { + border-style: solid; +} + +.onboarding-stepper-panel { + position: sticky; + top: 5rem; +} + +.onboarding-stepper { + display: grid; + gap: var(--space-2); + padding: 0; + margin: 0; + list-style: none; +} + +.onboarding-stepper-item a { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.2rem var(--space-2); + align-items: center; + padding: 0.65rem 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: inherit; + text-decoration: none; +} + +.onboarding-stepper-item a:hover, +.onboarding-stepper-item a:focus-visible { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); +} + +.onboarding-stepper-marker { + grid-row: span 2; + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + border: 2px solid var(--border-strong); + background: var(--surface); +} + +.onboarding-stepper-item small { + color: var(--muted); +} + +.onboarding-stepper-item.is-current a { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); + background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface)); +} + +.onboarding-stepper-item.is-ready .onboarding-stepper-marker { + background: var(--success); + border-color: var(--success); +} + +.onboarding-stepper-item.is-skipped .onboarding-stepper-marker { + background: var(--warning); + border-color: var(--warning); +} + +.onboarding-stepper-item.is-in_progress .onboarding-stepper-marker { + background: var(--info); + border-color: var(--info); +} + +.onboarding-summary-grid { + align-items: start; +} + +@media (max-width: 900px) { + .onboarding-stepper-panel { + position: static; + } +} diff --git a/app/page.tsx b/app/page.tsx index e10246d..d15d2b3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -113,7 +113,7 @@ export default async function HomePage() { {currentUser ? {currentUser.displayName} · {t(`roles.${currentUser.role}.label`)} : null} {t(`nav.${recommendedWorkspace}`)} · {t('home.recommendedFirst')} {runtimeModeLabel} - {gatewayState.demoModeActive ? Demo data active : null} + {gatewayState.demoModeActive ? Demo workspace : null}
diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 3ea757c..d693f09 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -29,7 +29,12 @@ export async function AppShell({ children }: { children?: ReactNode }) {
{showNonProductionBanner ? (
- {runtimeModeLabel} · non-production data only + {runtimeModeLabel} · do not use production data +
+ ) : null} + {data.instance.demoModeActive ? ( +
+ Demo workspace · resettable data
) : null}
{children}
@@ -41,7 +46,12 @@ export async function AppShell({ children }: { children?: ReactNode }) {
{showNonProductionBanner ? (
- {runtimeModeLabel} · non-production data only + {runtimeModeLabel} · do not use production data +
+ ) : null} + {data.instance.demoModeActive ? ( +
+ Demo workspace · resettable data
) : null}
diff --git a/components/setup/operational-onboarding-checklist.tsx b/components/setup/operational-onboarding-checklist.tsx index fd0cac6..0a1453c 100644 --- a/components/setup/operational-onboarding-checklist.tsx +++ b/components/setup/operational-onboarding-checklist.tsx @@ -1,106 +1,47 @@ import Link from 'next/link'; import type { OperationalOnboardingSectionKey, OperationalOnboardingSections, OnboardingSectionStatus } from '@/lib/domain/types'; import { updateOperationalOnboardingSectionAction } from '@/lib/server/actions'; +import { operationalOnboardingChapters } from '@/lib/server/operational-onboarding'; -type SectionConfig = { - key: OperationalOnboardingSectionKey; - title: string; - description: string; - configureHref: string; - importPlaceholder?: string; - minimum?: boolean; -}; +type Translator = (key: string, values?: Record) => string; -const sectionConfigs: SectionConfig[] = [ - { - key: 'business', - title: 'Business profile', - description: 'Business identity, contact details, hours, and fulfillment notes.', - configureHref: '/admin/setup/business', - minimum: true, - }, - { - key: 'team', - title: 'Team and roles', - description: 'People who sign in, practical roles, active state, and default workspaces.', - configureHref: '/admin/setup/users', - importPlaceholder: 'Team CSV later', - }, - { - key: 'shifts', - title: 'Shifts', - description: 'Working rhythm, handoff expectations, and shift ownership.', - configureHref: '/admin/setup/business', - }, - { - key: 'customers', - title: 'Customers', - description: 'Repeat customers, contact details, delivery notes, and preferences.', - configureHref: '/admin/setup/customers', - importPlaceholder: 'Customer CSV later', - minimum: true, - }, - { - key: 'products', - title: 'Products', - description: 'Sellable product names, units, categories, variants, and active state.', - configureHref: '/admin/setup/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/recipes', - }, - { - key: 'delivery', - title: 'Delivery and destinations', - description: 'Pickup, counter, delivery stops, couriers, and dispatch notes.', - configureHref: '/admin/setup/business', - }, - { - 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 getStatusLabel(t: Translator, status: OnboardingSectionStatus) { + return t(`onboarding.status.${status}`); } 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 }) { +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 +export function OperationalOnboardingChecklist({ + sections, + t, +}: { + sections?: OperationalOnboardingSections; + t: Translator; +}) { + const readyCount = operationalOnboardingChapters.filter((section) => getSectionStatus(sections, section.key) === 'ready').length; + const skippedCount = operationalOnboardingChapters.filter((section) => getSectionStatus(sections, section.key) === 'skipped').length; + const minimumReady = operationalOnboardingChapters .filter((section) => section.minimum) .every((section) => ['ready', 'skipped'].includes(getSectionStatus(sections, section.key))); @@ -108,37 +49,38 @@ export function OperationalOnboardingChecklist({ sections }: { sections?: Operat
-

Operational onboarding

-

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

+

{t('onboarding.overview.title')}

+

{t('onboarding.overview.description')}

- {readyCount}/{sectionConfigs.length} ready + {readyCount}/{operationalOnboardingChapters.length} {t('onboarding.summary.readyShort')} +
+ +
+ {t('onboarding.overview.resumeWizard')}
    - {sectionConfigs.map((section) => { + {operationalOnboardingChapters.map((section) => { const status = getSectionStatus(sections, section.key); return (
  • - {section.title} - {section.description} + {t(section.titleKey)} + {t(section.purposeKey)}
    - {getStatusLabel(status)} + {getStatusLabel(t, status)}
    - Configure - {section.importPlaceholder ? ( - + {t('onboarding.actions.openWizardStep')} + {t('onboarding.actions.openSetup')} + {section.skippable ? ( + status === 'skipped' ? ( + + ) : ( + + ) ) : null} - {status === 'skipped' ? ( - - ) : ( - - )} - {status !== 'ready' ? : null}
  • ); @@ -148,14 +90,12 @@ export function OperationalOnboardingChecklist({ sections }: { sections?: Operat
    -

    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. -

    +

    {t('onboarding.summary.launchTitle')}

    +

    {t('onboarding.summary.launchHelp')}

    - {minimumReady ? 'Viable' : 'Needs decisions'} + {minimumReady ? t('onboarding.summary.viable') : t('onboarding.summary.needsDecisions')}
    -

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

    +

    {t('onboarding.summary.skippedCount', { count: skippedCount })}

); diff --git a/lib/i18n/dictionaries/en.ts b/lib/i18n/dictionaries/en.ts index 3d25497..aa34a8c 100644 --- a/lib/i18n/dictionaries/en.ts +++ b/lib/i18n/dictionaries/en.ts @@ -1526,6 +1526,107 @@ const en = { password: 'Password', }, }, + + onboarding: { + intro: { + eyebrow: 'Guided setup', + title: 'Start operating with the minimum useful setup', + body: 'SKOSS will collect only the information needed to begin real work. You can finish sections now, skip them for later, and return without losing progress.', + minimumTitle: 'Minimum first', + minimumBody: 'Begin with the records that make order capture and daily work usable.', + resumeTitle: 'Resume later', + resumeBody: 'Leaving setup does not discard progress. The next incomplete section stays available.', + skipTitle: 'Skipped is not complete', + skipBody: 'A skipped section is intentionally deferred and remains visible in the setup overview.', + expandTitle: 'Grow from real work', + expandBody: 'Start with small lists and refine customers, products, recipes, and delivery details later.', + }, + overview: { + title: 'Operational setup overview', + description: 'Administrative status for setup sections. Use the guided setup for first-run work; use this page later to review what is ready, skipped, or incomplete.', + resumeWizard: 'Resume guided setup', + }, + progress: { + label: 'Setup progress', + intro: 'Before chapter 1', + chapterOf: 'Chapter {{current}} of {{total}}', + steps: 'Setup chapters', + nextRecommended: 'Next recommended chapter: {{chapter}}', + }, + status: { + current: 'Current', + not_started: 'Not started', + in_progress: 'In progress', + ready: 'Ready', + skipped: 'Skipped for now', + }, + actions: { + start: 'Start setup', + setupOverview: 'Go to setup overview', + openWizardStep: 'Open step', + openSetup: 'Open setup page', + configure: 'Configure this section', + back: 'Back', + saveContinue: 'Save and continue', + skip: 'Skip for now', + return: 'Return', + exit: 'Exit and resume later', + beginOperating: 'Begin operating', + resumeNext: 'Resume next section', + }, + chapters: { + business: { title: 'Business profile', purpose: 'Confirm the business identity and basic workspace preferences.' }, + team: { title: 'Team and roles', purpose: 'Add the people who need sign-in access and practical workspace defaults.' }, + shifts: { title: 'Shifts', purpose: 'Capture the working rhythm and handoff expectations when they are known.' }, + customers: { title: 'Customers', purpose: 'Add at least one usable customer or intentionally defer the customer list.' }, + products: { title: 'Products', purpose: 'Add at least one active sellable product for order capture.' }, + recipes: { title: 'Recipes', purpose: 'Add recipe data when production detail is ready, or defer it.' }, + delivery: { title: 'Delivery and destinations', purpose: 'Add pickup, delivery, or destination information when real patterns exist.' }, + orders: { title: 'Initial orders', purpose: 'Create the first real order to prove order capture and downstream work.' }, + }, + chapter: { + focusTitle: 'Current task', + focus: { + business: 'Review the workspace name, language, operating mode, and appearance settings.', + team: 'Create or update team users. One owner/admin already exists from activation.', + shifts: 'Shift setup is still lightweight; use this step to decide whether to defer it.', + customers: 'Add a real repeat customer, or skip this until customer records are useful.', + products: 'Add a basic active product name and unit. Variants and costing can wait.', + recipes: 'Add recipe data only if the kitchen is ready to maintain it.', + delivery: 'Add destinations from real pickup or delivery patterns, not from guesses.', + initialOrders: 'Create one real order with the information the team has now.', + }, + }, + metrics: { + business: 'Workspace: {{value}}', + team: '{{value}} active users', + customers: '{{value}} active customers', + products: '{{value}} active products', + recipes: '{{value}} active recipes', + delivery: '{{value}} active destinations', + shifts: '{{value}} shift logs', + initialOrders: '{{value}} real orders', + }, + limitations: { + shifts: 'Dedicated shift setup is not fully modeled yet, so readiness is derived from existing shift logs or explicit deferral.', + delivery: 'Destination setup is still lightweight, so readiness is derived from active destinations or real order fulfillment details.', + }, + summary: { + eyebrow: 'Setup summary', + title: 'Launch readiness', + body: 'Ready sections came from real data. Skipped sections are deferred, not completed.', + readyShort: 'ready', + launchTitle: 'Minimum launch readiness', + launchHelp: 'Business, customers, products, and initial orders should be ready or intentionally skipped before launch.', + viable: 'Viable', + needsDecisions: 'Needs decisions', + skippedCount: 'Skipped sections: {{count}}', + readySections: 'Ready sections', + skippedSections: 'Skipped sections', + incompleteSections: 'Incomplete sections', + none: 'None', + }, + }, activity: { homeTitle: 'Recent activity', homeHelp: 'A lightweight view of what changed across core workflows.', diff --git a/lib/i18n/dictionaries/es.ts b/lib/i18n/dictionaries/es.ts index ffc7732..5dd7d97 100644 --- a/lib/i18n/dictionaries/es.ts +++ b/lib/i18n/dictionaries/es.ts @@ -1541,6 +1541,108 @@ const es = { password: 'Contraseña', }, }, + + + onboarding: { + intro: { + eyebrow: 'Configuración guiada', + title: 'Empieza a operar con la información mínima útil', + body: 'SKOSS recopilará solo lo necesario para comenzar trabajo real. Puedes terminar secciones ahora, saltarlas para después y volver sin perder avance.', + minimumTitle: 'Primero lo mínimo', + minimumBody: 'Comienza con los registros que hacen útil la captura de pedidos y el trabajo diario.', + resumeTitle: 'Reanuda después', + resumeBody: 'Salir de la configuración no descarta el avance. La siguiente sección incompleta queda disponible.', + skipTitle: 'Saltar no es completar', + skipBody: 'Una sección saltada queda diferida intencionalmente y sigue visible en el resumen de configuración.', + expandTitle: 'Crece desde el trabajo real', + expandBody: 'Empieza con listas pequeñas y después afina clientes, productos, recetas y detalles de entrega.', + }, + overview: { + title: 'Resumen de configuración operativa', + description: 'Estado administrativo de las secciones de configuración. Usa la configuración guiada para el primer recorrido; usa esta página después para revisar qué está listo, saltado o incompleto.', + resumeWizard: 'Reanudar configuración guiada', + }, + progress: { + label: 'Avance de configuración', + intro: 'Antes del capítulo 1', + chapterOf: 'Capítulo {{current}} de {{total}}', + steps: 'Capítulos de configuración', + nextRecommended: 'Siguiente capítulo recomendado: {{chapter}}', + }, + status: { + current: 'Actual', + not_started: 'No iniciado', + in_progress: 'En progreso', + ready: 'Listo', + skipped: 'Saltado por ahora', + }, + actions: { + start: 'Empezar configuración', + setupOverview: 'Ir al resumen de configuración', + openWizardStep: 'Abrir paso', + openSetup: 'Abrir página de configuración', + configure: 'Configurar esta sección', + back: 'Atrás', + saveContinue: 'Guardar y continuar', + skip: 'Saltar por ahora', + return: 'Volver', + exit: 'Salir y reanudar después', + beginOperating: 'Empezar a operar', + resumeNext: 'Reanudar siguiente sección', + }, + chapters: { + business: { title: 'Perfil del negocio', purpose: 'Confirma la identidad del negocio y preferencias básicas del espacio.' }, + team: { title: 'Equipo y roles', purpose: 'Agrega las personas que necesitan acceso y espacios de trabajo prácticos.' }, + shifts: { title: 'Turnos', purpose: 'Registra el ritmo de trabajo y expectativas de relevo cuando se conozcan.' }, + customers: { title: 'Clientes', purpose: 'Agrega al menos un cliente usable o difiere intencionalmente la lista.' }, + products: { title: 'Productos', purpose: 'Agrega al menos un producto activo para capturar pedidos.' }, + recipes: { title: 'Recetas', purpose: 'Agrega datos de recetas cuando producción esté lista, o difiérelos.' }, + delivery: { title: 'Entrega y destinos', purpose: 'Agrega información de retiro, entrega o destinos cuando existan patrones reales.' }, + orders: { title: 'Pedidos iniciales', purpose: 'Crea el primer pedido real para probar captura y trabajo posterior.' }, + }, + chapter: { + focusTitle: 'Tarea actual', + focus: { + business: 'Revisa nombre del espacio, idioma, modo operativo y apariencia.', + team: 'Crea o actualiza usuarios del equipo. Ya existe un propietario/admin desde la activación.', + shifts: 'La configuración de turnos aún es ligera; usa este paso para decidir si se difiere.', + customers: 'Agrega un cliente repetido real, o salta esto hasta que los registros de clientes sean útiles.', + products: 'Agrega nombre y unidad de un producto activo básico. Variantes y costos pueden esperar.', + recipes: 'Agrega recetas solo si la cocina está lista para mantenerlas.', + delivery: 'Agrega destinos desde patrones reales de retiro o entrega, no desde suposiciones.', + initialOrders: 'Crea un pedido real con la información disponible ahora.', + }, + }, + metrics: { + business: 'Espacio: {{value}}', + team: '{{value}} usuarios activos', + customers: '{{value}} clientes activos', + products: '{{value}} productos activos', + recipes: '{{value}} recetas activas', + delivery: '{{value}} destinos activos', + shifts: '{{value}} registros de turno', + initialOrders: '{{value}} pedidos reales', + }, + limitations: { + shifts: 'La configuración dedicada de turnos aún no está completamente modelada, así que la preparación se deriva de registros de turno existentes o de una decisión explícita de diferir.', + delivery: 'La configuración de destinos aún es ligera, así que la preparación se deriva de destinos activos o detalles reales de cumplimiento en pedidos.', + }, + summary: { + eyebrow: 'Resumen de configuración', + title: 'Preparación para iniciar', + body: 'Las secciones listas provienen de datos reales. Las secciones saltadas están diferidas, no completadas.', + readyShort: 'listas', + launchTitle: 'Preparación mínima de inicio', + launchHelp: 'Negocio, clientes, productos y pedidos iniciales deben estar listos o saltados intencionalmente antes de iniciar.', + viable: 'Viable', + needsDecisions: 'Faltan decisiones', + skippedCount: 'Secciones saltadas: {{count}}', + readySections: 'Secciones listas', + skippedSections: 'Secciones saltadas', + incompleteSections: 'Secciones incompletas', + none: 'Ninguna', + }, + }, activity: { homeTitle: 'Actividad reciente', homeHelp: 'Una vista ligera de lo que cambió en los flujos clave.', diff --git a/lib/i18n/dictionaries/pt.ts b/lib/i18n/dictionaries/pt.ts index 1e46ee7..58a751d 100644 --- a/lib/i18n/dictionaries/pt.ts +++ b/lib/i18n/dictionaries/pt.ts @@ -1539,6 +1539,109 @@ const pt = { password: 'Senha', }, }, + + + + onboarding: { + intro: { + eyebrow: 'Configuração guiada', + title: 'Comece a operar com a informação mínima útil', + body: 'SKOSS vai coletar apenas o necessário para começar trabalho real. Você pode concluir seções agora, pulá-las para depois e voltar sem perder progresso.', + minimumTitle: 'Primeiro o mínimo', + minimumBody: 'Comece com os registros que tornam a captura de pedidos e o trabalho diário úteis.', + resumeTitle: 'Retome depois', + resumeBody: 'Sair da configuração não descarta o progresso. A próxima seção incompleta continua disponível.', + skipTitle: 'Pular não é concluir', + skipBody: 'Uma seção pulada fica intencionalmente adiada e continua visível no resumo de configuração.', + expandTitle: 'Cresça a partir do trabalho real', + expandBody: 'Comece com listas pequenas e refine clientes, produtos, receitas e detalhes de entrega depois.', + }, + overview: { + title: 'Resumo de configuração operacional', + description: 'Estado administrativo das seções de configuração. Use a configuração guiada no primeiro percurso; use esta página depois para revisar o que está pronto, pulado ou incompleto.', + resumeWizard: 'Retomar configuração guiada', + }, + progress: { + label: 'Progresso da configuração', + intro: 'Antes do capítulo 1', + chapterOf: 'Capítulo {{current}} de {{total}}', + steps: 'Capítulos de configuração', + nextRecommended: 'Próximo capítulo recomendado: {{chapter}}', + }, + status: { + current: 'Atual', + not_started: 'Não iniciado', + in_progress: 'Em andamento', + ready: 'Pronto', + skipped: 'Pulado por enquanto', + }, + actions: { + start: 'Começar configuração', + setupOverview: 'Ir ao resumo de configuração', + openWizardStep: 'Abrir etapa', + openSetup: 'Abrir página de configuração', + configure: 'Configurar esta seção', + back: 'Voltar', + saveContinue: 'Salvar e continuar', + skip: 'Pular por enquanto', + return: 'Retornar', + exit: 'Sair e retomar depois', + beginOperating: 'Começar a operar', + resumeNext: 'Retomar próxima seção', + }, + chapters: { + business: { title: 'Perfil do negócio', purpose: 'Confirme a identidade do negócio e as preferências básicas do espaço.' }, + team: { title: 'Equipe e papéis', purpose: 'Adicione as pessoas que precisam de acesso e padrões práticos de trabalho.' }, + shifts: { title: 'Turnos', purpose: 'Registre o ritmo de trabalho e as expectativas de passagem quando forem conhecidas.' }, + customers: { title: 'Clientes', purpose: 'Adicione pelo menos um cliente utilizável ou adie intencionalmente a lista.' }, + products: { title: 'Produtos', purpose: 'Adicione pelo menos um produto ativo para captura de pedidos.' }, + recipes: { title: 'Receitas', purpose: 'Adicione dados de receitas quando a produção estiver pronta, ou adie.' }, + delivery: { title: 'Entrega e destinos', purpose: 'Adicione informações de retirada, entrega ou destinos quando houver padrões reais.' }, + orders: { title: 'Pedidos iniciais', purpose: 'Crie o primeiro pedido real para validar captura e trabalho posterior.' }, + }, + chapter: { + focusTitle: 'Tarefa atual', + focus: { + business: 'Revise nome do espaço, idioma, modo operacional e aparência.', + team: 'Crie ou atualize usuários da equipe. Um proprietário/admin já existe desde a ativação.', + shifts: 'A configuração de turnos ainda é leve; use esta etapa para decidir se será adiada.', + customers: 'Adicione um cliente recorrente real, ou pule isto até que registros de clientes sejam úteis.', + products: 'Adicione nome e unidade de um produto ativo básico. Variantes e custos podem esperar.', + recipes: 'Adicione receitas apenas se a cozinha estiver pronta para mantê-las.', + delivery: 'Adicione destinos a partir de padrões reais de retirada ou entrega, não de suposições.', + initialOrders: 'Crie um pedido real com as informações disponíveis agora.', + }, + }, + metrics: { + business: 'Espaço: {{value}}', + team: '{{value}} usuários ativos', + customers: '{{value}} clientes ativos', + products: '{{value}} produtos ativos', + recipes: '{{value}} receitas ativas', + delivery: '{{value}} destinos ativos', + shifts: '{{value}} registros de turno', + initialOrders: '{{value}} pedidos reais', + }, + limitations: { + shifts: 'A configuração dedicada de turnos ainda não está totalmente modelada, então a prontidão é derivada de registros de turno existentes ou de adiamento explícito.', + delivery: 'A configuração de destinos ainda é leve, então a prontidão é derivada de destinos ativos ou de detalhes reais de atendimento nos pedidos.', + }, + summary: { + eyebrow: 'Resumo da configuração', + title: 'Prontidão para iniciar', + body: 'As seções prontas vêm de dados reais. As seções puladas estão adiadas, não concluídas.', + readyShort: 'prontas', + launchTitle: 'Prontidão mínima para iniciar', + launchHelp: 'Negócio, clientes, produtos e pedidos iniciais devem estar prontos ou intencionalmente pulados antes de iniciar.', + viable: 'Viável', + needsDecisions: 'Faltam decisões', + skippedCount: 'Seções puladas: {{count}}', + readySections: 'Seções prontas', + skippedSections: 'Seções puladas', + incompleteSections: 'Seções incompletas', + none: 'Nenhuma', + }, + }, activity: { homeTitle: 'Atividade recente', homeHelp: 'Uma visão leve do que mudou nos fluxos principais.', diff --git a/lib/server/actions/admin-core.ts b/lib/server/actions/admin-core.ts index 5b9b41e..79f51ae 100644 --- a/lib/server/actions/admin-core.ts +++ b/lib/server/actions/admin-core.ts @@ -38,6 +38,7 @@ import { moduleRegistry } from '@/lib/modules'; import { getCurrentUserContext } from '@/lib/server/auth'; import { isNonProductionMode } from '@/lib/server/runtime-mode'; import { withDerivedTeamOnboarding } from '@/lib/server/instance-state'; +import { deriveOperationalOnboardingSections, operationalOnboardingChapters } from '@/lib/server/operational-onboarding'; import type { ActivityEntry, AppData, OnboardingSectionStatus, OperationalOnboardingSectionKey, OperationalOnboardingSections, User } from '@/lib/domain/types'; function quoteLabel(label: string) { @@ -69,17 +70,8 @@ function wouldRemoveLastOwnerAdmin(existing: User, nextUser: User, users: User[] return !users.some((user) => user.id !== existing.id && user.active && hasOwnerAdminRole(user)); } -const operationalOnboardingSections: OperationalOnboardingSectionKey[] = [ - 'business', - 'team', - 'customers', - 'products', - 'recipes', - 'delivery', - 'shifts', - 'initialOrders', -]; -const operationalOnboardingStatuses: OnboardingSectionStatus[] = ['not_started', 'in_progress', 'ready', 'skipped']; +const operationalOnboardingSections = operationalOnboardingChapters.map((chapter) => chapter.key); +const operationalOnboardingStatuses: OnboardingSectionStatus[] = ['not_started', 'in_progress', 'skipped']; function buildFallbackOperationalOnboarding(): OperationalOnboardingSections { return { @@ -102,9 +94,10 @@ async function getActorUserId(data: AppData) { export async function updateOperationalOnboardingSectionAction(formData: FormData) { const section = String(formData.get('section') ?? '') as OperationalOnboardingSectionKey; const status = String(formData.get('status') ?? '') as OnboardingSectionStatus; + const redirectTo = resolveRedirectTo(formData, '/admin/setup/onboarding'); if (!operationalOnboardingSections.includes(section) || !operationalOnboardingStatuses.includes(status)) { - redirect('/admin/setup/onboarding?error=' + encodeURIComponent('Choose a valid onboarding section status.')); + redirect(redirectTo + '?error=' + encodeURIComponent('Choose a valid onboarding section status.')); } const now = new Date().toISOString(); @@ -121,7 +114,41 @@ export async function updateOperationalOnboardingSectionAction(formData: FormDat }); revalidateAllWorkspaces(); - redirect('/admin/setup/onboarding?saved=onboarding-section'); + revalidatePath('/admin/onboarding'); + redirect(redirectTo); +} + + +export async function continueOperationalOnboardingSectionAction(formData: FormData) { + const section = String(formData.get('section') ?? '') as OperationalOnboardingSectionKey; + const redirectTo = resolveRedirectTo(formData, '/admin/onboarding'); + + if (!operationalOnboardingSections.includes(section)) { + redirect(redirectTo + '?error=' + encodeURIComponent('Choose a valid onboarding section.')); + } + + const context = await readPersistence(); + const derived = deriveOperationalOnboardingSections(context.raw); + const currentStatus = derived[section].status; + + if (currentStatus === 'not_started' || currentStatus === 'in_progress') { + const now = new Date().toISOString(); + await persistence.write(({ instance }) => { + const current = instance.getInstanceState(); + instance.updateInstanceState({ + ...current, + operationalOnboarding: { + ...buildFallbackOperationalOnboarding(), + ...(current.operationalOnboarding ?? {}), + [section]: { status: 'in_progress', updatedAt: now }, + }, + }); + }); + } + + revalidateAllWorkspaces(); + revalidatePath('/admin/onboarding'); + redirect(redirectTo); } function revalidateAllWorkspaces() { diff --git a/lib/server/actions/entry-bootstrap.ts b/lib/server/actions/entry-bootstrap.ts index 2a94c2a..2a59d16 100644 --- a/lib/server/actions/entry-bootstrap.ts +++ b/lib/server/actions/entry-bootstrap.ts @@ -13,6 +13,7 @@ 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'; +import { getOperationalOnboardingProgress, getOperationalOnboardingRoute } from '@/lib/server/operational-onboarding'; const supportedThemes = ['light', 'dark', 'system'] as const; const supportedOperatingModes = ['pickup', 'delivery', 'mixed'] as const; @@ -463,7 +464,7 @@ export async function saveBootstrapStepAction(formData: FormData) { raw.activities = data.activities; }); revalidateAllWorkspaces(); - redirect('/login?redirectTo=/admin/setup/onboarding'); + redirect('/login?redirectTo=/admin/onboarding/intro'); } await persistence.write(({ instance, users }) => { @@ -575,9 +576,19 @@ export async function loginAction(formData: FormData) { redirect('/entry'); } - if (gatewayState.onboardingIncomplete && (user.roles?.includes('owner_admin') || user.roles?.includes('shift_lead') || user.role === 'owner_admin' || user.role === 'shift_lead')) { + const canManageOnboarding = user.roles?.includes('owner_admin') || user.roles?.includes('shift_lead') || user.role === 'owner_admin' || user.role === 'shift_lead'; + + if (gatewayState.onboardingIncomplete && canManageOnboarding) { revalidateAllWorkspaces(); - redirect('/admin/setup/onboarding'); + redirect('/admin/onboarding/intro'); + } + + if (!gatewayState.demoModeActive && canManageOnboarding && (redirectTo === '/' || redirectTo === '/admin/setup/onboarding')) { + const progress = getOperationalOnboardingProgress(data); + if (!progress.minimumReady || progress.incomplete.length > 0) { + revalidateAllWorkspaces(); + redirect(getOperationalOnboardingRoute(data)); + } } revalidateAllWorkspaces(); diff --git a/lib/server/operational-onboarding.ts b/lib/server/operational-onboarding.ts new file mode 100644 index 0000000..90d8a1b --- /dev/null +++ b/lib/server/operational-onboarding.ts @@ -0,0 +1,237 @@ +import type { + AppData, + OnboardingSectionStatus, + OperationalOnboardingSectionKey, + OperationalOnboardingSections, + User, +} from '@/lib/domain/types'; +import { buildDefaultOperationalOnboarding, deriveTeamOnboardingStatus } from '@/lib/server/instance-state'; + +export type OperationalOnboardingChapter = { + key: OperationalOnboardingSectionKey; + slug: string; + titleKey: string; + purposeKey: string; + setupHref: string; + minimum: boolean; + skippable: boolean; +}; + +export const operationalOnboardingChapters: OperationalOnboardingChapter[] = [ + { + key: 'business', + slug: 'business', + titleKey: 'onboarding.chapters.business.title', + purposeKey: 'onboarding.chapters.business.purpose', + setupHref: '/preferences', + minimum: true, + skippable: false, + }, + { + key: 'team', + slug: 'team', + titleKey: 'onboarding.chapters.team.title', + purposeKey: 'onboarding.chapters.team.purpose', + setupHref: '/admin/setup/users', + minimum: false, + skippable: true, + }, + { + key: 'shifts', + slug: 'shifts', + titleKey: 'onboarding.chapters.shifts.title', + purposeKey: 'onboarding.chapters.shifts.purpose', + setupHref: '/admin/setup/business', + minimum: false, + skippable: true, + }, + { + key: 'customers', + slug: 'customers', + titleKey: 'onboarding.chapters.customers.title', + purposeKey: 'onboarding.chapters.customers.purpose', + setupHref: '/customers#new-customer', + minimum: true, + skippable: true, + }, + { + key: 'products', + slug: 'products', + titleKey: 'onboarding.chapters.products.title', + purposeKey: 'onboarding.chapters.products.purpose', + setupHref: '/admin/setup/products', + minimum: true, + skippable: true, + }, + { + key: 'recipes', + slug: 'recipes', + titleKey: 'onboarding.chapters.recipes.title', + purposeKey: 'onboarding.chapters.recipes.purpose', + setupHref: '/admin/setup/recipes', + minimum: false, + skippable: true, + }, + { + key: 'delivery', + slug: 'delivery', + titleKey: 'onboarding.chapters.delivery.title', + purposeKey: 'onboarding.chapters.delivery.purpose', + setupHref: '/admin/setup/business', + minimum: false, + skippable: true, + }, + { + key: 'initialOrders', + slug: 'orders', + titleKey: 'onboarding.chapters.orders.title', + purposeKey: 'onboarding.chapters.orders.purpose', + setupHref: '/orders/new', + minimum: true, + skippable: true, + }, +]; + +const chapterByKey = new Map(operationalOnboardingChapters.map((chapter) => [chapter.key, chapter])); +const chapterBySlug = new Map(operationalOnboardingChapters.map((chapter) => [chapter.slug, chapter])); + +export function getOperationalOnboardingChapterBySlug(slug: string) { + return chapterBySlug.get(slug) ?? null; +} + +export function getOperationalOnboardingChapterByKey(key: OperationalOnboardingSectionKey) { + return chapterByKey.get(key) ?? null; +} + +function hasOwnerAdminRole(user: Pick) { + return user.role === 'owner_admin' || user.roles?.includes('owner_admin'); +} + +function hasUsableBusinessProfile(data: AppData) { + return Boolean(data.workspace.name.trim()) && data.instance.onboardingProgress.workspaceBasics; +} + +function hasUsableCustomer(data: AppData) { + return data.customers.some((customer) => customer.active && customer.displayName.trim()); +} + +function hasActiveProduct(data: AppData) { + return data.products.some((product) => product.active && product.name.trim()); +} + +function hasRecipeData(data: AppData) { + return data.recipes.some((recipe) => recipe.active && recipe.productId); +} + +function hasDeliveryData(data: AppData) { + return data.destinations.some((destination) => destination.active && destination.name.trim()) + || data.orders.some((order) => Boolean(order.destinationLabel?.trim()) || order.fulfillmentType !== 'standard'); +} + +function hasShiftData(data: AppData) { + return data.instance.onboardingProgress.shifts || data.shiftLogs.length > 0; +} + +function hasRealInitialOrder(data: AppData) { + return data.orders.some((order) => order.source !== 'generated' && order.status !== 'cancelled'); +} + +function hasStartedSection(data: AppData, key: OperationalOnboardingSectionKey, existing?: OnboardingSectionStatus) { + if (existing === 'in_progress') { + return true; + } + + if (key === 'business') return data.instance.onboardingProgress.workspaceBasics || Boolean(data.workspace.name.trim()); + if (key === 'team') return data.users.length > 1; + if (key === 'customers') return data.customers.length > 0; + if (key === 'products') return data.products.length > 0; + if (key === 'recipes') return data.recipes.length > 0; + if (key === 'delivery') return data.destinations.length > 0 || data.orders.some((order) => order.fulfillmentType !== 'standard'); + if (key === 'shifts') return data.shiftLogs.length > 0 || data.instance.onboardingProgress.shifts; + if (key === 'initialOrders') return data.orders.length > 0; + return false; +} + +function deriveReadyStatus(data: AppData, key: OperationalOnboardingSectionKey): OnboardingSectionStatus { + if (key === 'business') return hasUsableBusinessProfile(data) ? 'ready' : 'not_started'; + if (key === 'team') { + const activeUsers = data.users.filter((user) => user.active); + const hasOwner = activeUsers.some(hasOwnerAdminRole); + return hasOwner ? deriveTeamOnboardingStatus(data.users) : 'not_started'; + } + if (key === 'customers') return hasUsableCustomer(data) ? 'ready' : 'not_started'; + if (key === 'products') return hasActiveProduct(data) ? 'ready' : 'not_started'; + if (key === 'recipes') return hasRecipeData(data) ? 'ready' : 'not_started'; + if (key === 'delivery') return hasDeliveryData(data) ? 'ready' : 'not_started'; + if (key === 'shifts') return hasShiftData(data) ? 'ready' : 'not_started'; + if (key === 'initialOrders') return hasRealInitialOrder(data) ? 'ready' : 'not_started'; + return 'not_started'; +} + +export function deriveOperationalOnboardingSections(data: AppData): OperationalOnboardingSections { + const existing = { + ...buildDefaultOperationalOnboarding(), + ...(data.instance.operationalOnboarding ?? {}), + }; + const derived = { ...existing }; + + for (const chapter of operationalOnboardingChapters) { + const current = existing[chapter.key]; + if (current.status === 'skipped') { + derived[chapter.key] = current; + continue; + } + + const readyStatus = deriveReadyStatus(data, chapter.key); + derived[chapter.key] = { + ...current, + status: readyStatus === 'ready' + ? 'ready' + : hasStartedSection(data, chapter.key, current.status) + ? 'in_progress' + : 'not_started', + }; + } + + return derived; +} + +export function getNextOperationalOnboardingChapter(data: AppData) { + const sections = deriveOperationalOnboardingSections(data); + return operationalOnboardingChapters.find((chapter) => { + const status = sections[chapter.key].status; + return status === 'not_started' || status === 'in_progress'; + }) ?? null; +} + +export function getOperationalOnboardingRoute(data: AppData) { + const nextChapter = getNextOperationalOnboardingChapter(data); + return nextChapter ? `/admin/onboarding/${nextChapter.slug}` : '/admin/onboarding/summary'; +} + +export function getOperationalOnboardingProgress(data: AppData) { + const sections = deriveOperationalOnboardingSections(data); + const ready = operationalOnboardingChapters.filter((chapter) => sections[chapter.key].status === 'ready'); + const skipped = operationalOnboardingChapters.filter((chapter) => sections[chapter.key].status === 'skipped'); + const incomplete = operationalOnboardingChapters.filter((chapter) => { + const status = sections[chapter.key].status; + return status === 'not_started' || status === 'in_progress'; + }); + const minimumReady = operationalOnboardingChapters + .filter((chapter) => chapter.minimum) + .every((chapter) => { + const status = sections[chapter.key].status; + return status === 'ready' || status === 'skipped'; + }); + + return { + sections, + ready, + skipped, + incomplete, + minimumReady, + readyCount: ready.length, + totalCount: operationalOnboardingChapters.length, + nextChapter: incomplete[0] ?? null, + }; +} diff --git a/lib/server/runtime-mode.ts b/lib/server/runtime-mode.ts index 179cede..683bb66 100644 --- a/lib/server/runtime-mode.ts +++ b/lib/server/runtime-mode.ts @@ -20,11 +20,11 @@ export function getRuntimeModeLabel() { const mode = getRuntimeMode(); if (mode === 'pilot') { - return 'Internal Pilot Mode'; + return 'Internal pilot environment'; } if (mode === 'demo') { - return 'Demo / Developer Mode'; + return 'Local development environment'; } return 'Production'; diff --git a/tests/state-integrity.test.ts b/tests/state-integrity.test.ts index b55f842..077bddb 100644 --- a/tests/state-integrity.test.ts +++ b/tests/state-integrity.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import type { AppData, User } from '@/lib/domain/types'; +import type { AppData, Customer, Destination, Order, Product, Recipe, User } from '@/lib/domain/types'; import { buildUserRecord, normalizeUserForm } from '@/lib/server/demo-data'; import { hashPassword, verifyPassword } from '@/lib/server/passwords'; import { @@ -8,6 +8,12 @@ import { deriveTeamOnboardingStatus, withDerivedTeamOnboarding, } from '@/lib/server/instance-state'; +import { + deriveOperationalOnboardingSections, + getOperationalOnboardingProgress, + getOperationalOnboardingRoute, +} from '@/lib/server/operational-onboarding'; +import { getRuntimeModeLabel } from '@/lib/server/runtime-mode'; const now = '2026-06-17T00:00:00.000Z'; @@ -97,3 +103,113 @@ assert.equal(reset.instance.demoModeActive, false); assert.equal(reset.users.length, 0); assert.equal(reset.customers.length, 0); assert.equal(reset.session.currentUserId, undefined); + + +const customer: Customer = { + id: 'customer-real', + displayName: 'Real Customer', + active: true, + createdAt: now, + updatedAt: now, +}; + +const product: Product = { + id: 'product-real', + name: 'Sourdough loaf', + defaultUnit: 'piece', + active: true, + variants: [], +}; + +const destination: Destination = { + id: 'destination-real', + name: 'Front counter', + kind: 'pickup', + active: true, +}; + +const recipe: Recipe = { + id: 'recipe-real', + title: 'Sourdough loaf', + productId: product.id, + active: true, + lines: [], + createdAt: now, + updatedAt: now, +}; + +const order: Order = { + id: 'order-real', + source: 'manual', + status: 'active', + fulfillmentType: 'pickup', + customerId: customer.id, + customerLabel: customer.displayName, + destinationLabel: destination.name, + dueDate: '2026-06-18', + productionDate: '2026-06-18', + createdAt: now, + updatedAt: now, + changedInKitchen: false, + visibleOnProductionBoard: true, + generatedFromTemplate: false, + templateEdited: false, + lines: [{ + id: 'line-real', + lineType: 'product_variant', + productLabel: product.name, + quantity: 2, + completedQuantity: 0, + unit: 'piece', + lineStatus: 'pending', + }], +}; + +const started = appData([owner]); +started.preferences.onboardingCompleted = true; +started.instance.onboardingProgress.workspaceBasics = true; +started.customers = [{ ...customer, active: false }]; +started.products = []; +const startedSections = deriveOperationalOnboardingSections(started); +assert.equal(startedSections.business.status, 'ready'); +assert.equal(startedSections.customers.status, 'in_progress'); +assert.equal(startedSections.products.status, 'not_started'); +assert.equal(getOperationalOnboardingRoute(started), '/admin/onboarding/customers'); + +const skipped = appData([owner]); +skipped.instance.onboardingProgress.workspaceBasics = true; +skipped.instance.operationalOnboarding = { + ...buildDefaultOperationalOnboarding(), + products: { status: 'skipped', updatedAt: now }, +}; +skipped.products = [product]; +const skippedSections = deriveOperationalOnboardingSections(skipped); +assert.equal(skippedSections.products.status, 'skipped'); + +const ready = appData([owner, secondUser]); +ready.instance.onboardingProgress.workspaceBasics = true; +ready.customers = [customer]; +ready.products = [product]; +ready.recipes = [recipe]; +ready.destinations = [destination]; +ready.orders = [order]; +ready.shiftLogs = [{ + id: 'shift-real', + productionDate: '2026-06-18', + shiftKey: 'morning', + status: 'open', + summary: 'Morning setup started.', + openItems: [], + handoffNotes: '', + updatedAt: now, + shiftNotes: [], +}]; +const readyProgress = getOperationalOnboardingProgress(ready); +assert.equal(readyProgress.minimumReady, true); +assert.equal(readyProgress.incomplete.length, 0); +assert.equal(getOperationalOnboardingRoute(ready), '/admin/onboarding/summary'); + +const previousRuntimeMode = process.env.SKOSS_RUNTIME_MODE; +process.env.SKOSS_RUNTIME_MODE = 'demo'; +assert.equal(getRuntimeModeLabel(), 'Local development environment'); +process.env.SKOSS_RUNTIME_MODE = previousRuntimeMode;