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 ? (
- {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;