Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/admin/onboarding/[chapter]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <OperationalOnboardingPage mode="intro" />;
}

if (chapter === 'summary') {
return <OperationalOnboardingPage mode="summary" />;
}

return <OperationalOnboardingPage mode="chapter" chapterSlug={chapter} />;
}
293 changes: 293 additions & 0 deletions app/admin/onboarding/onboarding-page.tsx
Original file line number Diff line number Diff line change
@@ -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, string | number>) => 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 <span className={`summary-pill onboarding-status-pill is-${status}`}>{getStatusLabel(t, status)}</span>;
}

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 (
<section className="panel page-stack onboarding-wizard-progress" aria-label={t('onboarding.progress.label')}>
<div className="table-header-row">
<div>
<p className="eyebrow">{t('onboarding.progress.label')}</p>
<h2>{chapter ? t('onboarding.progress.chapterOf', { current: index, total: totalCount }) : t('onboarding.progress.intro')}</h2>
</div>
<span className="summary-pill">{readyCount}/{totalCount} {t('onboarding.summary.readyShort')}</span>
</div>
<div className="bootstrap-progress-track" aria-hidden="true">
<span className="bootstrap-progress-value" style={{ width: `${percent}%` }} />
</div>
</section>
);
}

function ChapterStepper({
current,
sections,
t,
}: {
current?: OperationalOnboardingChapter;
sections: ReturnType<typeof getOperationalOnboardingProgress>['sections'];
t: Translator;
}) {
return (
<ol className="onboarding-stepper" aria-label={t('onboarding.progress.steps')}>
{operationalOnboardingChapters.map((chapter) => {
const status = sections[chapter.key].status;
const isCurrent = current?.key === chapter.key;
return (
<li key={chapter.key} className={`onboarding-stepper-item is-${status} ${isCurrent ? 'is-current' : ''}`}>
<Link href={{ pathname: chapterRoute(chapter) }}>
<span aria-hidden="true" className="onboarding-stepper-marker" />
<span>{t(chapter.titleKey)}</span>
<small>{isCurrent ? t('onboarding.status.current') : getStatusLabel(t, status)}</small>
</Link>
</li>
);
})}
</ol>
);
}

function SkipForm({ chapter, nextHref, t }: { chapter: OperationalOnboardingChapter; nextHref: string; t: Translator }) {
if (!chapter.skippable) return null;

return (
<form action={updateOperationalOnboardingSectionAction}>
<input type="hidden" name="section" value={chapter.key} />
<input type="hidden" name="status" value="skipped" />
<input type="hidden" name="redirectTo" value={nextHref} />
<button type="submit" className="button-ghost compact-button">{t('onboarding.actions.skip')}</button>
</form>
);
}

function ContinueForm({ chapter, nextHref, t }: { chapter: OperationalOnboardingChapter; nextHref: string; t: Translator }) {
return (
<form action={continueOperationalOnboardingSectionAction}>
<input type="hidden" name="section" value={chapter.key} />
<input type="hidden" name="redirectTo" value={nextHref} />
<button type="submit" className="button-primary compact-button">{t('onboarding.actions.saveContinue')}</button>
</form>
);
}

function ChapterCounts({ chapter, data, t }: { chapter: OperationalOnboardingChapter; data: Awaited<ReturnType<typeof readAppData>>; 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 <span className="summary-pill">{t(`onboarding.metrics.${chapter.key}`, { value })}</span>;
}

function IntroScreen({ t, nextHref }: { t: Translator; nextHref: string }) {
return (
<div className="page-stack">
<section className="section-header page-hero-header onboarding-wizard-hero">
<div>
<p className="eyebrow">{t('onboarding.intro.eyebrow')}</p>
<h1>{t('onboarding.intro.title')}</h1>
<p>{t('onboarding.intro.body')}</p>
</div>
</section>

<section className="panel page-stack">
<ul className="stack-list compact-list">
<li><strong>{t('onboarding.intro.minimumTitle')}</strong><span>{t('onboarding.intro.minimumBody')}</span></li>
<li><strong>{t('onboarding.intro.resumeTitle')}</strong><span>{t('onboarding.intro.resumeBody')}</span></li>
<li><strong>{t('onboarding.intro.skipTitle')}</strong><span>{t('onboarding.intro.skipBody')}</span></li>
<li><strong>{t('onboarding.intro.expandTitle')}</strong><span>{t('onboarding.intro.expandBody')}</span></li>
</ul>
<div className="inline-action-row action-row-start">
<Link href={{ pathname: nextHref }} className="button-primary compact-button">{t('onboarding.actions.start')}</Link>
<Link href="/admin/setup/onboarding" className="button-secondary compact-button">{t('onboarding.actions.setupOverview')}</Link>
</div>
</section>
</div>
);
}

function SummaryScreen({
t,
progress,
}: {
t: Translator;
progress: ReturnType<typeof getOperationalOnboardingProgress>;
}) {
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 (
<div className="page-stack">
<ProgressHeader t={t} readyCount={progress.readyCount} totalCount={progress.totalCount} />
<section className="panel page-stack">
<div className="table-header-row">
<div>
<p className="eyebrow">{t('onboarding.summary.eyebrow')}</p>
<h1>{t('onboarding.summary.title')}</h1>
<p className="helper-text">{t('onboarding.summary.body')}</p>
</div>
<span className="summary-pill">{progress.minimumReady ? t('onboarding.summary.viable') : t('onboarding.summary.needsDecisions')}</span>
</div>
<div className="grid-three onboarding-summary-grid">
{groups.map((group) => (
<article key={group.key} className="subpanel page-stack">
<h2>{group.title}</h2>
{group.chapters.length > 0 ? (
<ul className="stack-list compact-list">
{group.chapters.map((chapter) => (
<li key={chapter.key}><strong>{t(chapter.titleKey)}</strong><span>{getStatusLabel(t, progress.sections[chapter.key].status)}</span></li>
))}
</ul>
) : <p className="empty-state">{t('onboarding.summary.none')}</p>}
</article>
))}
</div>
<div className="inline-action-row action-row-start">
<Link href="/" className="button-primary compact-button">{t('onboarding.actions.beginOperating')}</Link>
{progress.nextChapter ? <Link href={{ pathname: chapterRoute(progress.nextChapter) }} className="button-secondary compact-button">{t('onboarding.actions.resumeNext')}</Link> : null}
<Link href="/admin/setup/onboarding" className="button-ghost compact-button">{t('onboarding.actions.setupOverview')}</Link>
</div>
</section>
</div>
);
}

function ChapterScreen({
chapter,
data,
progress,
t,
}: {
chapter: OperationalOnboardingChapter;
data: Awaited<ReturnType<typeof readAppData>>;
progress: ReturnType<typeof getOperationalOnboardingProgress>;
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 (
<div className="page-stack">
<ProgressHeader chapter={chapter} t={t} readyCount={progress.readyCount} totalCount={progress.totalCount} />
<section className="grid-two setup-focused-grid onboarding-wizard-layout">
<article className="panel page-stack">
<div className="table-header-row">
<div>
<p className="eyebrow">{t('onboarding.progress.chapterOf', { current: index + 1, total: progress.totalCount })}</p>
<h1>{t(chapter.titleKey)}</h1>
<p className="helper-text">{t(chapter.purposeKey)}</p>
</div>
<StatusPill status={status} t={t} />
</div>

<div className="subpanel page-stack onboarding-focus-panel">
<div className="table-header-row">
<div>
<h2>{t('onboarding.chapter.focusTitle')}</h2>
<p className="helper-text no-margin">{t(`onboarding.chapter.focus.${chapter.key}`)}</p>
</div>
<ChapterCounts chapter={chapter} data={data} t={t} />
</div>
<Link href={{ pathname: chapter.setupHref }} className="button-secondary compact-button">{t('onboarding.actions.configure')}</Link>
{chapter.key === 'shifts' || chapter.key === 'delivery' ? (
<p className="inline-warning">{t(`onboarding.limitations.${chapter.key}`)}</p>
) : null}
</div>

<div className="onboarding-actions">
{previous ? <Link href={{ pathname: chapterRoute(previous) }} className="button-secondary compact-button">{t('onboarding.actions.back')}</Link> : <Link href="/admin/onboarding/intro" className="button-secondary compact-button">{t('onboarding.actions.back')}</Link>}
<ContinueForm chapter={chapter} nextHref={nextHref} t={t} />
<SkipForm chapter={chapter} nextHref={nextHref} t={t} />
<Link href="/" className="button-ghost compact-button">{t('onboarding.actions.exit')}</Link>
</div>
</article>
<aside className="panel page-stack onboarding-stepper-panel">
<h2>{t('onboarding.progress.steps')}</h2>
<ChapterStepper current={chapter} sections={progress.sections} t={t} />
{progress.nextChapter ? <p className="helper-text no-margin">{t('onboarding.progress.nextRecommended', { chapter: t(progress.nextChapter.titleKey) })}</p> : null}
</aside>
</section>
</div>
);
}

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 <IntroScreen t={t} nextHref={nextHref} />;
}

if (mode === 'summary') {
return <SummaryScreen t={t} progress={progress} />;
}

const chapter = chapterSlug ? getOperationalOnboardingChapterBySlug(chapterSlug) : null;
if (!chapter) {
redirect('/admin/onboarding/intro');
}

return <ChapterScreen chapter={chapter} data={data} progress={progress} t={t} />;
}
5 changes: 5 additions & 0 deletions app/admin/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { OperationalOnboardingPage } from './onboarding-page';

export default async function AdminOnboardingIndexPage() {
return <OperationalOnboardingPage mode="intro" />;
}
12 changes: 5 additions & 7 deletions app/admin/setup/setup-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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') },
Expand All @@ -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 (
<div className="page-stack">
Expand Down Expand Up @@ -524,21 +526,17 @@ export async function AdminSetupPage({
<form action={updateOperationalOnboardingSectionAction}>
<input type="hidden" name="section" value={onboardingSectionKey} />
<input type="hidden" name="status" value="skipped" />
<input type="hidden" name="redirectTo" value={`/admin/setup/${activeSection}?saved=onboarding-section`} />
<button type="submit" className="button-ghost compact-button">Skip for now</button>
</form>
<form action={updateOperationalOnboardingSectionAction}>
<input type="hidden" name="section" value={onboardingSectionKey} />
<input type="hidden" name="status" value="ready" />
<button type="submit" className="button-secondary compact-button">Mark ready</button>
</form>
</>
) : null}
</div>
</section>
) : null}

{activeSection === 'onboarding' ? (
<OperationalOnboardingChecklist sections={identitySetup.instance.operationalOnboarding} />
<OperationalOnboardingChecklist sections={derivedOperationalOnboarding} t={t} />
) : null}

{activeSection === 'business' ? (
Expand Down
Loading
Loading