From 944dbc031dcac9b9258258cc7b36d0d5ccea9416 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Fri, 3 Jul 2026 16:09:54 +0700 Subject: [PATCH] task-d.7.12 --- alla-theme.css | 181 +++ ...-d712-step-by-step-bootstrap-csv-import.md | 65 + onvalla-theme.css | 173 +++ plans/task-d.7.12.md | 355 +++++ src/app/api/setup/bootstrap/commit/route.ts | 19 +- src/app/api/setup/bootstrap/complete/route.ts | 25 +- src/app/api/setup/bootstrap/preview/route.ts | 29 +- src/components/themes/theme.config.ts | 62 +- src/features/setup/api/queries.ts | 8 + src/features/setup/api/service.ts | 50 + src/features/setup/api/types.ts | 22 + src/features/setup/api/use-csv-commit.ts | 26 +- src/features/setup/api/use-setup-run.ts | 11 +- .../setup/components/setup-wizard.tsx | 1144 ++++++----------- .../setup/server/csv/dependency-resolver.ts | 4 +- src/features/setup/server/csv/validator.ts | 2 +- .../setup/server/setup-bootstrap.service.ts | 141 +- .../setup/server/setup-state.service.ts | 41 +- src/proxy.ts | 1 + src/styles/theme.css | 1 + src/styles/themes/alla.css | 117 ++ 21 files changed, 1638 insertions(+), 839 deletions(-) create mode 100644 alla-theme.css create mode 100644 docs/implementation/task-d712-step-by-step-bootstrap-csv-import.md create mode 100644 onvalla-theme.css create mode 100644 plans/task-d.7.12.md create mode 100644 src/styles/themes/alla.css diff --git a/alla-theme.css b/alla-theme.css new file mode 100644 index 0000000..15d26de --- /dev/null +++ b/alla-theme.css @@ -0,0 +1,181 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.9911 0 0); + --foreground: oklch(0.2046 0 0); + --card: oklch(0.9911 0 0); + --card-foreground: oklch(0.2046 0 0); + --popover: oklch(0.9911 0 0); + --popover-foreground: oklch(0.4386 0 0); + --primary: oklch(0.4799 0.0804 155.8197); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.9940 0 0); + --secondary-foreground: oklch(0.2046 0 0); + --muted: oklch(0.9461 0 0); + --muted-foreground: oklch(0.2435 0 0); + --accent: oklch(0.9461 0 0); + --accent-foreground: oklch(0.2435 0 0); + --destructive: oklch(0.5523 0.1927 32.7272); + --destructive-foreground: oklch(0.9934 0.0032 17.2118); + --border: oklch(0.9037 0 0); + --input: oklch(0.9731 0 0); + --ring: oklch(0.5568 0.1355 155.8120); + --chart-1: oklch(0.8348 0.1302 160.9080); + --chart-2: oklch(0.6231 0.1880 259.8145); + --chart-3: oklch(0.6056 0.2189 292.7172); + --chart-4: oklch(0.7686 0.1647 70.0804); + --chart-5: oklch(0.6959 0.1491 162.4796); + --sidebar: oklch(0.9911 0 0); + --sidebar-foreground: oklch(0.5452 0 0); + --sidebar-primary: oklch(0.5568 0.1355 155.8120); + --sidebar-primary-foreground: oklch(0.2626 0.0147 166.4589); + --sidebar-accent: oklch(0.9461 0 0); + --sidebar-accent-foreground: oklch(0.2435 0 0); + --sidebar-border: oklch(0.9037 0 0); + --sidebar-ring: oklch(0.5568 0.1355 155.8120); + --font-sans: Anek Kannada, ui-sans-serif, sans-serif, system-ui; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 1px; + --shadow-blur: 5.5px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: #000000; + --shadow-2xs: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0.025em; + --spacing: 0.25rem; +} + +.dark { + --background: oklch(0.1822 0 0); + --foreground: oklch(0.9288 0.0126 255.5078); + --card: oklch(0.2046 0 0); + --card-foreground: oklch(0.9288 0.0126 255.5078); + --popover: oklch(0.2603 0 0); + --popover-foreground: oklch(0.7348 0 0); + --primary: oklch(0.5568 0.1355 155.8120); + --primary-foreground: oklch(0.9213 0.0135 167.1556); + --secondary: oklch(0.2603 0 0); + --secondary-foreground: oklch(0.9851 0 0); + --muted: oklch(0.2393 0 0); + --muted-foreground: oklch(0.7122 0 0); + --accent: oklch(0.3132 0 0); + --accent-foreground: oklch(0.9851 0 0); + --destructive: oklch(0.3123 0.0852 29.7877); + --destructive-foreground: oklch(0.9368 0.0045 34.3092); + --border: oklch(0.2809 0 0); + --input: oklch(0.2603 0 0); + --ring: oklch(0.5568 0.1355 155.8120); + --chart-1: oklch(0.8003 0.1821 151.7110); + --chart-2: oklch(0.7137 0.1434 254.6240); + --chart-3: oklch(0.7090 0.1592 293.5412); + --chart-4: oklch(0.8369 0.1644 84.4286); + --chart-5: oklch(0.7845 0.1325 181.9120); + --sidebar: oklch(0.1822 0 0); + --sidebar-foreground: oklch(0.6301 0 0); + --sidebar-primary: oklch(0.5568 0.1355 155.8120); + --sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556); + --sidebar-accent: oklch(0.3132 0 0); + --sidebar-accent-foreground: oklch(0.9851 0 0); + --sidebar-border: oklch(0.2809 0 0); + --sidebar-ring: oklch(0.5568 0.1355 155.8120); + --font-sans: Anek Kannada, ui-sans-serif, sans-serif, system-ui; + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + --font-mono: monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 1px; + --shadow-blur: 5.5px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: #000000; + --shadow-2xs: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 1px 5.5px 0px hsl(0 0% 0% / 0.25); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); + + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-normal: var(--tracking-normal); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); + } +} \ No newline at end of file diff --git a/docs/implementation/task-d712-step-by-step-bootstrap-csv-import.md b/docs/implementation/task-d712-step-by-step-bootstrap-csv-import.md new file mode 100644 index 0000000..104ab09 --- /dev/null +++ b/docs/implementation/task-d712-step-by-step-bootstrap-csv-import.md @@ -0,0 +1,65 @@ +# Task D.7.12 Step-by-Step First-Run Required CSV Import + +Date: 2026-07-03 +Status: implemented + +## Review Completed + +- Reviewed `AGENTS.md` task governance from the active repository instructions. +- Reviewed `docs/standards/task-contract-template.md`. +- Reviewed `docs/standards/task-catalog.md`. +- Reviewed `docs/standards/project-foundations.md`. +- Reviewed `docs/standards/architecture-rules.md`. +- Reviewed `docs/standards/ui-ux-rules.md`. +- Reviewed `docs/standards/task-review-checklist.md`. +- Reviewed setup lineage notes: + - `docs/implementation/task-d7-initial-system-setup-audit.md` + - `docs/implementation/task-d76-dashboard-setup-wizard-ui.md` + - `docs/implementation/task-d78-seed-manifest-setup-state-persistence.md` + - `docs/setup/setup-wizard-blueprint.md` + +## Decision + +The required CSV order is: + +1. `users.csv` +2. `organizations.csv` +3. `memberships.csv` +4. `crm-role-assignments.csv` +5. `master-options.csv` +6. `branches.csv` +7. `product-types.csv` +8. `document-sequences.csv` +9. `approval-workflows.csv` +10. `approval-steps.csv` +11. `approval-matrix.csv` + +This intentionally uses `users.csv` before `organizations.csv` because `organizations.csv` +resolves `created_by_email` against existing users. This follows the recommendation in +`plans/task-d.7.12.md` and avoids introducing a temporary bootstrap actor into the +organization import data path. + +## Scope Delivered + +- Added server-owned bootstrap required step metadata in `setup-bootstrap.service.ts`. +- Added `crm-role-assignments.csv` to the required bootstrap file list. +- Changed public bootstrap preview and commit APIs to accept one expected CSV file per step. +- Persisted each required CSV result to its own `setup_run_steps.step_key`. +- Updated bootstrap completion to require all required step keys to be `passed` or `warning`. +- Added bootstrap step dependency staleness rules in setup run persistence. +- Reworked the Administration Setup Wizard UI into a left-stepper, single-step import flow. +- Kept optional business CSV imports separate from system-required steps. + +## Reuse + +- Reused existing CSV preview and commit engine. +- Reused existing `/api/setup/bootstrap/preview`, `/api/setup/bootstrap/commit`, and + `/api/setup/bootstrap/complete` route groups. +- Reused existing `setup_runs` and `setup_run_steps` persistence. +- Reused setup template metadata from `setup/templates/templates.json`. +- Reused shadcn/ui dashboard primitives and existing icon registry. + +## Verification + +- `npm run typecheck -- --pretty false` passed. + diff --git a/onvalla-theme.css b/onvalla-theme.css new file mode 100644 index 0000000..61b3164 --- /dev/null +++ b/onvalla-theme.css @@ -0,0 +1,173 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.9816 0.0017 247.8390); + --foreground: oklch(0.2277 0.0534 259.0168); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.2277 0.0534 259.0168); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.2277 0.0534 259.0168); + --primary: oklch(0.3863 0.0858 321.9713); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.6340 0.2116 31.7828); + --secondary-foreground: oklch(1.0000 0 0); + --muted: oklch(0.9275 0.0017 247.8404); + --muted-foreground: oklch(0.5751 0.0259 285.5573); + --accent: oklch(0.6340 0.2116 31.7828); + --accent-foreground: oklch(1.0000 0 0); + --destructive: oklch(0.6340 0.2116 31.7828); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.5751 0.0259 285.5573); + --input: oklch(0.9275 0.0017 247.8404); + --ring: oklch(0.3863 0.0858 321.9713); + --chart-1: oklch(0.3863 0.0858 321.9713); + --chart-2: oklch(0.6340 0.2116 31.7828); + --chart-3: oklch(0.2277 0.0534 259.0168); + --chart-4: oklch(0.5751 0.0259 285.5573); + --chart-5: oklch(0.2185 0.0702 317.5316); + --sidebar: oklch(1.0000 0 0); + --sidebar-foreground: oklch(0.2277 0.0534 259.0168); + --sidebar-primary: oklch(0.3863 0.0858 321.9713); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.9275 0.0017 247.8404); + --sidebar-accent-foreground: oklch(0.2277 0.0534 259.0168); + --sidebar-border: oklch(0.9275 0.0017 247.8404); + --sidebar-ring: oklch(0.3863 0.0858 321.9713); + --font-sans: Inter, sans-serif; + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 2px; + --shadow-blur: 10px; + --shadow-spread: 0px; + --shadow-opacity: 0.1; + --shadow-color: #000000; + --shadow-2xs: 0px 2px 10px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0px 2px 10px 0px hsl(0 0% 0% / 0.05); + --shadow-sm: 0px 2px 10px 0px hsl(0 0% 0% / 0.10), 0px 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow: 0px 2px 10px 0px hsl(0 0% 0% / 0.10), 0px 1px 2px -1px hsl(0 0% 0% / 0.10); + --shadow-md: 0px 2px 10px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10); + --shadow-lg: 0px 2px 10px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10); + --shadow-xl: 0px 2px 10px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10); + --shadow-2xl: 0px 2px 10px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: oklch(0.2277 0.0534 259.0168); + --foreground: oklch(0.9275 0.0017 247.8404); + --card: oklch(0.2185 0.0702 317.5316); + --card-foreground: oklch(1.0000 0 0); + --popover: oklch(0.2185 0.0702 317.5316); + --popover-foreground: oklch(1.0000 0 0); + --primary: oklch(0.3863 0.0858 321.9713); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.6340 0.2116 31.7828); + --secondary-foreground: oklch(1.0000 0 0); + --muted: oklch(0.5751 0.0259 285.5573); + --muted-foreground: oklch(0.9275 0.0017 247.8404); + --accent: oklch(0.6340 0.2116 31.7828); + --accent-foreground: oklch(1.0000 0 0); + --destructive: oklch(0.6340 0.2116 31.7828); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.3863 0.0858 321.9713); + --input: oklch(0.2277 0.0534 259.0168); + --ring: oklch(0.6340 0.2116 31.7828); + --chart-1: oklch(0.3863 0.0858 321.9713); + --chart-2: oklch(0.6340 0.2116 31.7828); + --chart-3: oklch(0.5751 0.0259 285.5573); + --chart-4: oklch(0.2185 0.0702 317.5316); + --chart-5: oklch(1.0000 0 0); + --sidebar: oklch(0.2185 0.0702 317.5316); + --sidebar-foreground: oklch(0.9275 0.0017 247.8404); + --sidebar-primary: oklch(0.6340 0.2116 31.7828); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.3863 0.0858 321.9713); + --sidebar-accent-foreground: oklch(1.0000 0 0); + --sidebar-border: oklch(0.3863 0.0858 321.9713); + --sidebar-ring: oklch(0.6340 0.2116 31.7828); + --font-sans: Inter, sans-serif; + --font-serif: Georgia, serif; + --font-mono: JetBrains Mono, monospace; + --radius: 0.5rem; + --shadow-x: 0px; + --shadow-y: 4px; + --shadow-blur: 12px; + --shadow-spread: 0px; + --shadow-opacity: 0.3; + --shadow-color: #000000; + --shadow-2xs: 0px 4px 12px 0px hsl(0 0% 0% / 0.15); + --shadow-xs: 0px 4px 12px 0px hsl(0 0% 0% / 0.15); + --shadow-sm: 0px 4px 12px 0px hsl(0 0% 0% / 0.30), 0px 1px 2px -1px hsl(0 0% 0% / 0.30); + --shadow: 0px 4px 12px 0px hsl(0 0% 0% / 0.30), 0px 1px 2px -1px hsl(0 0% 0% / 0.30); + --shadow-md: 0px 4px 12px 0px hsl(0 0% 0% / 0.30), 0px 2px 4px -1px hsl(0 0% 0% / 0.30); + --shadow-lg: 0px 4px 12px 0px hsl(0 0% 0% / 0.30), 0px 4px 6px -1px hsl(0 0% 0% / 0.30); + --shadow-xl: 0px 4px 12px 0px hsl(0 0% 0% / 0.30), 0px 8px 10px -1px hsl(0 0% 0% / 0.30); + --shadow-2xl: 0px 4px 12px 0px hsl(0 0% 0% / 0.75); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/plans/task-d.7.12.md b/plans/task-d.7.12.md new file mode 100644 index 0000000..150daad --- /dev/null +++ b/plans/task-d.7.12.md @@ -0,0 +1,355 @@ +# Task D.7.12 – Step-by-Step First-Run Required CSV Import + +## Objective + +Improve First-run Bootstrap Setup so required system CSV files are imported step-by-step instead of uploading all files at once. + +The goal is to guide the user through each required setup dependency in the correct order: + +Step 1 organizations.csv +Step 2 users.csv +Step 3 memberships.csv +Step 4 crm-role-assignments.csv +Step 5 master-options.csv +Step 6 branches.csv +Step 7 product-types.csv +Step 8 document-sequences.csv +Step 9 approval-workflows.csv +Step 10 approval-steps.csv +Step 11 approval-matrix.csv + +After all required steps pass, optional business CSV import can be shown separately. + +--- + +## Desired UX + +Route: + +/administration/setup + +Show First-run Bootstrap Wizard. + +Each step has: + +- CSV file name +- purpose +- required columns +- upload area +- preview button +- run/import button +- result summary +- PASS / WARNING / FAIL status +- next button + +User flow: + +1. Upload organizations.csv +2. Preview +3. Run Import +4. Step marked PASS +5. Move to users.csv +6. Repeat until all system-required CSV files are complete +7. Show final verification +8. Mark setup completed +9. Redirect to login + +--- + +## Required Import Steps + +Use exact dependency order: + +1. organizations.csv +2. users.csv +3. memberships.csv +4. crm-role-assignments.csv +5. master-options.csv +6. branches.csv +7. product-types.csv +8. document-sequences.csv +9. approval-workflows.csv +10. approval-steps.csv +11. approval-matrix.csv + +Important: + +If current import engine requires users before organizations because organizations.created_by_email resolves to users, support one of these approaches: + +Option A: +Use temporary bootstrap actor for organizations.csv. + +Option B: +Change order to users.csv first. + +Decision must be documented before implementation. + +Recommended decision: + +Step 1 users.csv +Step 2 organizations.csv +Step 3 memberships.csv + +because organizations.csv currently requires created_by_email. + +--- + +## System Required Group + +Define a bootstrap required group: + +system_required + +Files: + +- users.csv +- organizations.csv +- memberships.csv +- crm-role-assignments.csv +- master-options.csv +- branches.csv +- product-types.csv +- document-sequences.csv +- approval-workflows.csv +- approval-steps.csv +- approval-matrix.csv + +Business optional group: + +business_optional + +Files: + +- customers.csv +- contacts.csv +- contact-shares.csv +- leads.csv +- opportunities.csv +- opportunity-parties.csv +- quotations.csv +- quotation-items.csv +- quotation-parties.csv +- quotation-topics.csv +- quotation-topic-items.csv + +--- + +## Implementation Scope + +Update First-run Bootstrap UI: + +src/app/administration/setup/page.tsx + +or bootstrap setup component under: + +src/features/setup/components/bootstrap-setup-wizard.tsx + +Add step configuration: + +src/features/setup/bootstrap/bootstrap-required-steps.ts + +Each step defines: + +- stepKey +- fileName +- title +- description +- required +- dependsOn +- template metadata +- status +- previewHash +- import report + +--- + +## API Reuse + +Reuse existing APIs: + +POST /api/setup/bootstrap/preview +POST /api/setup/bootstrap/commit + +Each step submits one CSV file at a time. + +Do not create new import logic. + +Commit flow per step: + +1. Upload one file +2. Preview +3. Store previewHash for that step +4. Commit same file with previewHash +5. Persist step result +6. Move to next step + +--- + +## Persistence + +Persist step status in setup_run_steps. + +Step keys: + +bootstrap_users +bootstrap_organizations +bootstrap_memberships +bootstrap_crm_role_assignments +bootstrap_master_options +bootstrap_branches +bootstrap_product_types +bootstrap_document_sequences +bootstrap_approval_workflows +bootstrap_approval_steps +bootstrap_approval_matrix + +Each step stores safe output only. + +No raw CSV contents. + +--- + +## Dependency Rules + +A step is locked until all dependsOn steps pass. + +Example: + +memberships.csv depends on: + +- users.csv +- organizations.csv + +crm-role-assignments.csv depends on: + +- users.csv +- organizations.csv +- memberships.csv +- master-options.csv +- branches.csv +- product-types.csv + +document-sequences.csv depends on: + +- organizations.csv +- branches.csv +- product-types.csv + +approval-steps.csv depends on: + +- approval-workflows.csv + +approval-matrix.csv depends on: + +- approval-workflows.csv +- branches.csv +- product-types.csv + +--- + +## UI Requirements + +Show left stepper: + +System Required + +- Users +- Organizations +- Memberships +- CRM Roles +- Master Options +- Branches +- Product Types +- Document Sequences +- Approval Workflows +- Approval Steps +- Approval Matrix + +Optional Business Data + +- Customers +- Contacts +- Leads +- Opportunities +- Quotations + +Each step panel shows: + +- What this file does +- Required filename +- Required headers +- Sample template reference +- Upload control +- Preview result +- Import result +- Errors and suggested fixes + +--- + +## Completion Rule + +Setup can complete only when all system_required steps are PASS or WARNING acknowledged. + +Then run: + +POST /api/setup/bootstrap/complete + +Completion must verify: + +- super_admin exists +- organization exists +- membership exists +- CRM role assignment exists +- branch exists +- product type exists +- document sequence exists +- approval workflow/matrix exists + +After success: + +redirect to /auth/sign-in + +--- + +## Optional Business Import + +After system_required completion, show optional step: + +Import Business Data + +Allow either: + +- step-by-step business import +or +- batch upload all business CSV + +This can be deferred if needed. + +--- + +## Out Of Scope + +- Changing CSV template format +- Rewriting import engine +- Dashboard setup wizard redesign +- Reset/reseed +- Plugin runtime changes +- Business import advanced UI +- Production public setup after completion + +--- + +## Acceptance Criteria + +- First-run setup imports required CSV files step-by-step. +- Each step accepts only the expected CSV filename. +- Each step has Preview and Run buttons. +- Import cannot run without successful previewHash. +- Next step is locked until dependencies pass. +- Step status persists across refresh. +- System cannot complete until all required steps pass. +- Existing batch import engine remains unchanged. +- No raw CSV contents are persisted. +- Setup completion redirects to login. +- After completion, public bootstrap setup is blocked. +- Typecheck passes. diff --git a/src/app/api/setup/bootstrap/commit/route.ts b/src/app/api/setup/bootstrap/commit/route.ts index ba0f03d..cde1be5 100644 --- a/src/app/api/setup/bootstrap/commit/route.ts +++ b/src/app/api/setup/bootstrap/commit/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { commitSetupCsvImport } from '@/features/setup/server/csv-commit.service'; import { createCsvImportSeedManifest } from '@/features/setup/server/seed-manifest.service'; import { - assertBootstrapRequiredFiles, + assertBootstrapStepFiles, assertBootstrapSetupAvailable, assertBootstrapToken, BOOTSTRAP_ACTOR_ID, @@ -13,7 +13,13 @@ import { import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service'; function isFile(value: FormDataEntryValue): value is File { - return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value; + return ( + typeof value === 'object' && + 'arrayBuffer' in value && + 'name' in value && + typeof value.name === 'string' && + value.name.trim().length > 0 + ); } function parseBoolean(value: FormDataEntryValue | null): boolean { @@ -28,6 +34,7 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? '')); + const stepKey = String(formData.get('stepKey') ?? ''); const previewHash = String(formData.get('previewHash') ?? ''); const dryRun = parseBoolean(formData.get('dryRun')); const uploadedFiles = Array.from(formData.values()).filter(isFile); @@ -43,7 +50,7 @@ export async function POST(request: NextRequest) { })) ); - assertBootstrapRequiredFiles(files); + const step = assertBootstrapStepFiles(stepKey, files); await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); const report = await commitSetupCsvImport({ @@ -58,7 +65,7 @@ export async function POST(request: NextRequest) { } await saveSetupStep({ - stepKey: 'csv_commit', + stepKey: step.stepKey, status: setupStepStatusFromImportReport(report), inputHash: report.committedHash, output: toSerializableRecord(report), @@ -72,6 +79,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: error.message, details: error.details }, { status: error.status }); } + if (error instanceof TypeError && /multipart\/form-data|application\/x-www-form-urlencoded/i.test(error.message)) { + return NextResponse.json({ message: 'Upload setup CSV files using multipart/form-data.' }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable commit bootstrap setup CSV import' }, { status: 500 }); } } diff --git a/src/app/api/setup/bootstrap/complete/route.ts b/src/app/api/setup/bootstrap/complete/route.ts index 974892d..f00e286 100644 --- a/src/app/api/setup/bootstrap/complete/route.ts +++ b/src/app/api/setup/bootstrap/complete/route.ts @@ -3,6 +3,7 @@ import { assertBootstrapSetupAvailable, assertBootstrapToken, BOOTSTRAP_ACTOR_ID, + BOOTSTRAP_REQUIRED_STEPS, BootstrapSetupError, getBootstrapSetupStatus } from '@/features/setup/server/setup-bootstrap.service'; @@ -16,15 +17,23 @@ export async function POST(request: NextRequest) { assertBootstrapToken(body.bootstrapToken ?? request.headers.get('x-bootstrap-setup-token') ?? ''); const currentRun = await getCurrentSetupRun(); - const commitStep = currentRun.steps.find((step) => step.stepKey === 'csv_commit'); - const hasSuccessfulCommit = - currentRun.run?.status === 'csv_imported' || - currentRun.run?.status === 'verified' || - commitStep?.status === 'passed' || - commitStep?.status === 'warning'; + const completedStepKeys = new Set( + currentRun.steps + .filter((step) => step.status === 'passed' || step.status === 'warning') + .map((step) => step.stepKey) + ); + const missingSteps = BOOTSTRAP_REQUIRED_STEPS.filter( + (step) => !completedStepKeys.has(step.stepKey) + ).map((step) => step.stepKey); - if (!hasSuccessfulCommit) { - return NextResponse.json({ message: 'Commit bootstrap setup data before completing setup.' }, { status: 409 }); + if (missingSteps.length > 0) { + return NextResponse.json( + { + message: 'Complete all required bootstrap CSV steps before completing setup.', + missingSteps + }, + { status: 409 } + ); } const status = await getBootstrapSetupStatus(); diff --git a/src/app/api/setup/bootstrap/preview/route.ts b/src/app/api/setup/bootstrap/preview/route.ts index 0f82795..97d5484 100644 --- a/src/app/api/setup/bootstrap/preview/route.ts +++ b/src/app/api/setup/bootstrap/preview/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { previewSetupCsvImport } from '@/features/setup/server/csv-preview.service'; import { - assertBootstrapRequiredFiles, + assertBootstrapStepFiles, assertBootstrapSetupAvailable, assertBootstrapToken, BOOTSTRAP_ACTOR_ID, @@ -12,7 +12,13 @@ import { import { saveSetupStep, startSetupRun } from '@/features/setup/server/setup-state.service'; function isFile(value: FormDataEntryValue): value is File { - return typeof value === 'object' && 'arrayBuffer' in value && 'name' in value; + return ( + typeof value === 'object' && + 'arrayBuffer' in value && + 'name' in value && + typeof value.name === 'string' && + value.name.trim().length > 0 + ); } export async function POST(request: NextRequest) { @@ -22,6 +28,7 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); assertBootstrapToken(String(formData.get('bootstrapToken') ?? request.headers.get('x-bootstrap-setup-token') ?? '')); + const stepKey = String(formData.get('stepKey') ?? ''); const uploadedFiles = Array.from(formData.values()).filter(isFile); if (uploadedFiles.length === 0) { @@ -35,13 +42,13 @@ export async function POST(request: NextRequest) { })) ); - assertBootstrapRequiredFiles(files); + const step = assertBootstrapStepFiles(stepKey, files); await startSetupRun({ actorId: BOOTSTRAP_ACTOR_ID, mode: 'production', targetOrganizationId: null }); const preview = await previewSetupCsvImport(files); await saveSetupStep({ - stepKey: 'csv_preview', + stepKey: step.stepKey, status: setupStepStatusFromPreview(preview), inputHash: preview.previewHash, output: toSerializableRecord(preview), @@ -49,12 +56,24 @@ export async function POST(request: NextRequest) { warnings: preview.files.flatMap((file) => file.warnings) }); - return NextResponse.json(preview, { status: preview.summary.error > 0 ? 400 : 200 }); + return NextResponse.json(preview); } catch (error) { if (error instanceof BootstrapSetupError) { return NextResponse.json({ message: error.message, details: error.details }, { status: error.status }); } + if (error instanceof TypeError && /multipart\/form-data|application\/x-www-form-urlencoded/i.test(error.message)) { + return NextResponse.json({ message: 'Upload setup CSV files using multipart/form-data.' }, { status: 400 }); + } + + console.error('Bootstrap preview failed'); + if (error instanceof Error) { + for (const line of (error.stack ?? error.message).split('\n').slice(0, 12)) { + console.error(line); + } + } else { + console.error(error); + } return NextResponse.json({ message: 'Unable preview bootstrap setup CSV import' }, { status: 500 }); } } diff --git a/src/components/themes/theme.config.ts b/src/components/themes/theme.config.ts index 802bafb..e21f5cf 100644 --- a/src/components/themes/theme.config.ts +++ b/src/components/themes/theme.config.ts @@ -1,52 +1,20 @@ /** - * Default theme that loads when no user preference is set - * Change this value to set a different default theme + * Default theme loads when no user preference is set. + * Change value to set a different default theme. */ -export const DEFAULT_THEME = 'mongodb'; +export const DEFAULT_THEME = 'alla'; export const THEMES = [ - { - name: 'MongoDB', - value: 'mongodb' - }, - { - name: 'Claude', - value: 'claude' - }, - { - name: 'Neobrutualism', - value: 'neobrutualism' - }, - { - name: 'Supabase', - value: 'supabase' - }, - { - name: 'Vercel', - value: 'vercel' - }, - { - name: 'Mono', - value: 'mono' - }, - { - name: 'Notebook', - value: 'notebook' - }, - { - name: 'Light Green', - value: 'light-green' - }, - { - name: 'Zen', - value: 'zen' - }, - { - name: 'Astro Vista', - value: 'astro-vista' - }, - { - name: 'WhatsApp', - value: 'whatsapp' - } + { name: 'MongoDB', value: 'mongodb' }, + { name: 'Claude', value: 'claude' }, + { name: 'Neobrutualism', value: 'neobrutualism' }, + { name: 'Supabase', value: 'supabase' }, + { name: 'Vercel', value: 'vercel' }, + { name: 'Mono', value: 'mono' }, + { name: 'Notebook', value: 'notebook' }, + { name: 'Light Green', value: 'light-green' }, + { name: 'Zen', value: 'zen' }, + { name: 'Astro Vista', value: 'astro-vista' }, + { name: 'WhatsApp', value: 'whatsapp' }, + { name: 'ALLA', value: 'alla' } ]; diff --git a/src/features/setup/api/queries.ts b/src/features/setup/api/queries.ts index a782616..406cef0 100644 --- a/src/features/setup/api/queries.ts +++ b/src/features/setup/api/queries.ts @@ -1,5 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; import { + getBootstrapSetupStatus, getCurrentSetupRun, getSetupProfiles, getSetupReadiness, @@ -14,6 +15,7 @@ export const setupKeys = { readiness: (profileId?: string) => [...setupKeys.all, 'readiness', profileId ?? 'crm_demo'] as const, templates: (profileId?: string) => [...setupKeys.all, 'templates', profileId ?? 'crm_demo'] as const, profiles: (profileId?: string) => [...setupKeys.all, 'profiles', profileId ?? 'crm_demo'] as const, + bootstrapStatus: () => [...setupKeys.all, 'bootstrap', 'status'] as const, run: () => [...setupKeys.all, 'run'] as const, report: () => [...setupKeys.all, 'run', 'report'] as const, verification: (payload: SetupVerificationPayload = {}) => @@ -38,6 +40,12 @@ export const setupProfilesQueryOptions = (profileId?: string) => queryFn: () => getSetupProfiles(profileId) }); +export const bootstrapSetupStatusQueryOptions = () => + queryOptions({ + queryKey: setupKeys.bootstrapStatus(), + queryFn: () => getBootstrapSetupStatus() + }); + export const setupRunQueryOptions = () => queryOptions({ queryKey: setupKeys.run(), diff --git a/src/features/setup/api/service.ts b/src/features/setup/api/service.ts index ad9bc84..3396f97 100644 --- a/src/features/setup/api/service.ts +++ b/src/features/setup/api/service.ts @@ -1,6 +1,7 @@ import { ApiClientError, apiClient } from '@/lib/api-client'; import type { CsvPreviewResult, + BootstrapSetupStatusResponse, ImportReport, SaveSetupStepPayload, SetupProfilesResponse, @@ -34,6 +35,10 @@ export async function getSetupProfiles(profileId?: string): Promise(withProfile('/setup/profiles', profileId)); } +export async function getBootstrapSetupStatus(): Promise { + return apiClient('/setup/bootstrap/status'); +} + export async function getCurrentSetupRun(): Promise { return apiClient('/setup/run/current'); } @@ -102,6 +107,23 @@ export async function previewSetupCsvFiles(files: File[]): Promise { + const formData = buildCsvFormData([input.file]); + formData.set('stepKey', input.stepKey); + if (input.bootstrapToken) formData.set('bootstrapToken', input.bootstrapToken); + + return readUploadResponse( + await fetch('/api/setup/bootstrap/preview', { + method: 'POST', + body: formData + }) + ); +} + export async function commitSetupCsvFiles(input: { files: File[]; previewHash: string; @@ -118,3 +140,31 @@ export async function commitSetupCsvFiles(input: { }) ); } + +export async function commitBootstrapCsvStep(input: { + stepKey: string; + file: File; + previewHash: string; + dryRun?: boolean; + bootstrapToken?: string; +}): Promise { + const formData = buildCsvFormData([input.file]); + formData.set('stepKey', input.stepKey); + formData.set('previewHash', input.previewHash); + formData.set('dryRun', String(Boolean(input.dryRun))); + if (input.bootstrapToken) formData.set('bootstrapToken', input.bootstrapToken); + + return readUploadResponse( + await fetch('/api/setup/bootstrap/commit', { + method: 'POST', + body: formData + }) + ); +} + +export async function completeBootstrapSetup(report: Record): Promise { + return apiClient('/setup/bootstrap/complete', { + method: 'POST', + body: JSON.stringify({ report }) + }); +} diff --git a/src/features/setup/api/types.ts b/src/features/setup/api/types.ts index 202f018..52e96f1 100644 --- a/src/features/setup/api/types.ts +++ b/src/features/setup/api/types.ts @@ -99,6 +99,28 @@ export interface SetupTemplatesResponse { templates: SetupTemplateMetadataItem[]; } +export interface BootstrapRequiredStepDto { + stepKey: string; + fileName: string; + title: string; + description: string; + dependsOn: string[]; +} + +export interface BootstrapSetupStatusResponse { + initialized: boolean; + tokenRequired: boolean; + checks: { + completedSetupRun: boolean; + superAdminUser: boolean; + organization: boolean; + adminMembership: boolean; + }; + requiredFiles: string[]; + optionalFiles: string[]; + requiredSteps: BootstrapRequiredStepDto[]; +} + export interface SetupProfilesResponse { success: boolean; time: string; diff --git a/src/features/setup/api/use-csv-commit.ts b/src/features/setup/api/use-csv-commit.ts index c4bb128..ae73696 100644 --- a/src/features/setup/api/use-csv-commit.ts +++ b/src/features/setup/api/use-csv-commit.ts @@ -1,7 +1,12 @@ 'use client'; import { useMutation } from '@tanstack/react-query'; -import { commitSetupCsvFiles, previewSetupCsvFiles } from './service'; +import { + commitBootstrapCsvStep, + commitSetupCsvFiles, + previewBootstrapCsvStep, + previewSetupCsvFiles +} from './service'; export function useCsvPreview() { return useMutation({ @@ -15,3 +20,22 @@ export function useCsvCommit() { commitSetupCsvFiles(input) }); } + +export function useBootstrapCsvPreview() { + return useMutation({ + mutationFn: (input: { stepKey: string; file: File; bootstrapToken?: string }) => + previewBootstrapCsvStep(input) + }); +} + +export function useBootstrapCsvCommit() { + return useMutation({ + mutationFn: (input: { + stepKey: string; + file: File; + previewHash: string; + dryRun?: boolean; + bootstrapToken?: string; + }) => commitBootstrapCsvStep(input) + }); +} diff --git a/src/features/setup/api/use-setup-run.ts b/src/features/setup/api/use-setup-run.ts index 1b4a234..0096eee 100644 --- a/src/features/setup/api/use-setup-run.ts +++ b/src/features/setup/api/use-setup-run.ts @@ -1,7 +1,7 @@ 'use client'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { completeSetupRun, saveSetupStep, startSetupRun } from './service'; +import { completeBootstrapSetup, completeSetupRun, saveSetupStep, startSetupRun } from './service'; import { setupKeys } from './queries'; import type { SaveSetupStepPayload } from './types'; @@ -40,3 +40,12 @@ export function useCompleteSetupRun() { onSuccess: invalidate }); } + +export function useCompleteBootstrapSetup() { + const invalidate = useInvalidateSetupRun(); + + return useMutation({ + mutationFn: (report: Record) => completeBootstrapSetup(report), + onSuccess: invalidate + }); +} diff --git a/src/features/setup/components/setup-wizard.tsx b/src/features/setup/components/setup-wizard.tsx index 3b8be91..78d5958 100644 --- a/src/features/setup/components/setup-wizard.tsx +++ b/src/features/setup/components/setup-wizard.tsx @@ -10,41 +10,31 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; -import { useCsvCommit, useCsvPreview } from '../api/use-csv-commit'; -import { useCompleteSetupRun, useSaveSetupStep, useStartSetupRun } from '../api/use-setup-run'; -import { useSetupVerification } from '../api/use-setup-verification'; +import { useBootstrapCsvCommit, useBootstrapCsvPreview } from '../api/use-csv-commit'; +import { useCompleteBootstrapSetup } from '../api/use-setup-run'; import { - setupProfilesQueryOptions, - setupReadinessQueryOptions, + bootstrapSetupStatusQueryOptions, setupRunQueryOptions, setupTemplatesQueryOptions } from '../api/queries'; import type { + BootstrapRequiredStepDto, CsvPreviewError, CsvPreviewFileResult, CsvPreviewResult, ImportReport, - SetupCheckResult, SetupCheckStatus, - SetupReadinessReport, - SetupReportSummary, SetupRunStepDto, SetupStepStatus, - SetupTemplateMetadataItem, - SetupWizardStepDto, - SetupVerificationReport + SetupTemplateMetadataItem } from '../api/types'; -type WizardStepKey = string; - -const WIZARD_STEPS: Array<{ key: WizardStepKey; label: string; description?: string }> = [ - { key: 'readiness', label: 'Readiness' }, - { key: 'templates', label: 'Templates' }, - { key: 'upload', label: 'CSV Upload' }, - { key: 'preview', label: 'Preview' }, - { key: 'commit', label: 'Commit' }, - { key: 'verification', label: 'Verification' }, - { key: 'finish', label: 'Finish' } +const BUSINESS_IMPORT_FILES = [ + 'customers.csv', + 'contacts.csv', + 'leads.csv', + 'opportunities.csv', + 'quotations.csv' ]; function getErrorMessage(error: unknown): string { @@ -52,19 +42,6 @@ function getErrorMessage(error: unknown): string { return 'Unable to complete setup request.'; } -function getFileFingerprint(files: File[]): string { - return files - .map((file) => `${file.name}:${file.size}:${file.lastModified}`) - .toSorted() - .join('|'); -} - -function getStatusVariant(status: SetupCheckStatus): 'default' | 'secondary' | 'destructive' { - if (status === 'FAIL') return 'destructive'; - if (status === 'WARNING') return 'secondary'; - return 'default'; -} - function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus | null { if (!preview) return null; if (preview.summary.error > 0 || preview.files.some((file) => file.status === 'FAIL')) return 'FAIL'; @@ -74,39 +51,24 @@ function getPreviewStatus(preview: CsvPreviewResult | null): SetupCheckStatus | return 'PASS'; } -function getStepStatusFromApiStatus(status: SetupCheckStatus): SetupStepStatus { - if (status === 'PASS') return 'passed'; - if (status === 'WARNING') return 'warning'; - return 'failed'; +function isStepComplete(step?: SetupRunStepDto): boolean { + return step?.status === 'passed' || step?.status === 'warning'; } -function toRecord(value: unknown): Record { - return JSON.parse(JSON.stringify(value)) as Record; +function setupStatusFromReport(report: ImportReport): SetupStepStatus { + if (report.status === 'FAIL') return 'failed'; + if (report.status === 'WARNING') return 'warning'; + return 'passed'; } -function groupChecks(checks: SetupCheckResult[]): Array<[string, SetupCheckResult[]]> { - const groups = new Map(); - checks.forEach((check) => { - const current = groups.get(check.area) ?? []; - current.push(check); - groups.set(check.area, current); - }); - return Array.from(groups.entries()); +function statusBadgeVariant(status: SetupStepStatus | SetupCheckStatus | 'locked' | 'not_started') { + if (status === 'FAIL' || status === 'failed') return 'destructive'; + if (status === 'PASS' || status === 'passed') return 'default'; + return 'secondary'; } -function StatusBadge({ status }: { status: SetupCheckStatus }) { - return {status}; -} - -function SummaryPills({ summary }: { summary: SetupReportSummary }) { - return ( -
- - - - -
- ); +function StepStatusBadge({ status }: { status: SetupStepStatus | SetupCheckStatus | 'locked' | 'not_started' }) { + return {status}; } function Metric({ label, value }: { label: string; value: number | string }) { @@ -118,48 +80,6 @@ function Metric({ label, value }: { label: string; value: number | string }) { ); } -function ReportCheckList({ checks }: { checks: SetupCheckResult[] }) { - if (checks.length === 0) { - return ; - } - - return ( -
- {groupChecks(checks).map(([area, areaChecks]) => ( -
-
{area}
-
- {areaChecks.map((check) => ( -
-
- -
-
-
{check.name}
-
{check.message}
- {check.suggestedFix ? ( -
- Suggested fix: {check.suggestedFix} -
- ) : null} -
-
- ))} -
-
- ))} -
- ); -} - -function EmptyState({ message }: { message: string }) { - return ( -
- {message} -
- ); -} - function ErrorAlert({ title = 'Request failed', message }: { title?: string; message: string }) { return ( @@ -170,61 +90,16 @@ function ErrorAlert({ title = 'Request failed', message }: { title?: string; mes ); } -function PermissionHint({ error }: { error: unknown }) { - const message = getErrorMessage(error); - if (!/unauthorized|forbidden|super_admin|access/i.test(message)) return null; - - return ( - - - Super admin access required - - Setup APIs are restricted to super admins. Sign in with an authorized account to continue. - - - ); -} - -function TemplateList({ templates }: { templates: SetupTemplateMetadataItem[] }) { - if (templates.length === 0) return ; - - return ( -
-
- Order - File - Seed type - Status -
-
- {templates.map((template) => ( -
- {template.importOrder} -
-
{template.file}
-
{template.description}
-
- {template.seedType} - - {template.supported ? 'Supported' : 'Deferred'} - -
- ))} -
-
- ); -} - function CsvErrors({ errors }: { errors: CsvPreviewError[] }) { if (errors.length === 0) return null; return (
{errors.slice(0, 8).map((error, index) => ( -
+
{error.code}
{error.message}
@@ -233,66 +108,53 @@ function CsvErrors({ errors }: { errors: CsvPreviewError[] }) {
))} {errors.length > 8 ? ( -
- Showing first 8 of {errors.length} issues. -
+
Showing first 8 of {errors.length} issues.
) : null}
); } -function PreviewFileTable({ files }: { files: CsvPreviewFileResult[] }) { - if (files.length === 0) return ; - +function PreviewFilePanel({ file }: { file: CsvPreviewFileResult }) { return ( -
- {files.map((file) => ( -
-
-
-
{file.fileName}
-
Template: {file.template}
-
- -
-
- - - - - -
-
- -
+
+
+
+
{file.fileName}
+
Template: {file.template}
- ))} + +
+
+ + + + + +
+
+ +
); } -function CommitReportPanel({ - report, - persistedStep -}: { - report: ImportReport | null; - persistedStep?: SetupRunStepDto; -}) { - if (!report) return ; +function ImportReportPanel({ report }: { report: ImportReport | null }) { + if (!report) { + return ( +
+ Import has not run for this step yet. +
+ ); + } return (
-
Commit report
-
- {report.dryRun ? 'Dry run only' : 'Database commit'} generated {report.generatedAt} -
-
-
- - +
Import result
+
Committed hash: {report.committedHash}
+
@@ -306,640 +168,392 @@ function CommitReportPanel({ ); } -function VerificationPanel({ - report, - persistedStep -}: { - report: SetupVerificationReport | null; - persistedStep?: SetupRunStepDto; -}) { - if (!report) return ; - - return ( -
-
-
-
{report.message}
-
Organization: {report.organizationId ?? 'Not selected'}
-
-
- - -
-
- - -
- ); -} - function Stepper({ - currentStep, - steps + steps, + currentStepKey, + stepByKey, + onSelect }: { - currentStep: WizardStepKey; - steps: Array<{ key: WizardStepKey; label: string; description?: string }>; + steps: BootstrapRequiredStepDto[]; + currentStepKey: string; + stepByKey: Map; + onSelect: (stepKey: string) => void; }) { - const currentIndex = steps.findIndex((step) => step.key === currentStep); - return ( -
- {steps.map((step, index) => ( -
-
Step {index + 1}
-
{step.label}
+ + + System Required + Import required setup CSV files in dependency order. + + + {steps.map((step, index) => { + const persisted = stepByKey.get(step.stepKey); + const locked = step.dependsOn.some((dependency) => !isStepComplete(stepByKey.get(dependency))); + return ( + + ); + })} + +
+
Optional Business Data
+
+ {BUSINESS_IMPORT_FILES.map((fileName) => ( +
+ {fileName.replace('.csv', '')} + later +
+ ))} +
- ))} -
- ); -} - -function PersistedStepBadge({ step }: { step?: SetupRunStepDto }) { - if (!step) return not_started; - - return ( - - {step.status} - + + ); } export function SetupWizard() { - const [currentStep, setCurrentStep] = useState('readiness'); - const [selectedProfileId, setSelectedProfileId] = useState('crm_demo'); - const [files, setFiles] = useState([]); + const [currentStepKey, setCurrentStepKey] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); const [preview, setPreview] = useState(null); - const [previewFingerprint, setPreviewFingerprint] = useState(''); - const [commitReport, setCommitReport] = useState(null); + const [importReport, setImportReport] = useState(null); const [warningAcknowledged, setWarningAcknowledged] = useState(false); - const [dryRun, setDryRun] = useState(false); - const profilesQuery = useQuery(setupProfilesQueryOptions(selectedProfileId)); - const readinessQuery = useQuery(setupReadinessQueryOptions(selectedProfileId)); - const templatesQuery = useQuery(setupTemplatesQueryOptions(selectedProfileId)); + const bootstrapStatusQuery = useQuery(bootstrapSetupStatusQueryOptions()); + const templatesQuery = useQuery(setupTemplatesQueryOptions('production')); const setupRunQuery = useQuery(setupRunQueryOptions()); - const previewMutation = useCsvPreview(); - const commitMutation = useCsvCommit(); - const verificationMutation = useSetupVerification(); - const startRunMutation = useStartSetupRun(); - const saveStepMutation = useSaveSetupStep(); - const completeRunMutation = useCompleteSetupRun(); + const previewMutation = useBootstrapCsvPreview(); + const commitMutation = useBootstrapCsvCommit(); + const completeMutation = useCompleteBootstrapSetup(); - const persistedStepByKey = useMemo(() => { + const steps = bootstrapStatusQuery.data?.requiredSteps ?? []; + const stepByKey = useMemo(() => { const entries = setupRunQuery.data?.steps.map((step) => [step.stepKey, step] as const) ?? []; return new Map(entries); }, [setupRunQuery.data?.steps]); - const profileWizardSteps = useMemo(() => { - const registrySteps = profilesQuery.data?.wizardSteps.map((step: SetupWizardStepDto) => ({ - key: step.key, - label: step.title, - description: step.description - })); - - return registrySteps?.length ? registrySteps : WIZARD_STEPS; - }, [profilesQuery.data?.wizardSteps]); - - const fileFingerprint = useMemo(() => getFileFingerprint(files), [files]); - const filesChangedAfterPreview = Boolean(preview) && fileFingerprint !== previewFingerprint; + const templateByFile = useMemo(() => { + const entries = templatesQuery.data?.templates.map((template) => [template.file, template] as const) ?? []; + return new Map(entries); + }, [templatesQuery.data?.templates]); + const currentStep = steps.find((step) => step.stepKey === currentStepKey) ?? steps[0] ?? null; + const currentTemplate = currentStep ? templateByFile.get(currentStep.fileName) : null; + const currentPersistedStep = currentStep ? stepByKey.get(currentStep.stepKey) : undefined; const previewStatus = getPreviewStatus(preview); - const commitBlocked = + const dependenciesComplete = + currentStep?.dependsOn.every((dependency) => isStepComplete(stepByKey.get(dependency))) ?? false; + const allRequiredComplete = steps.every((step) => isStepComplete(stepByKey.get(step.stepKey))); + const selectedFileMatches = selectedFile?.name.toLowerCase() === currentStep?.fileName.toLowerCase(); + const importBlocked = + !currentStep || + !selectedFile || !preview || !preview.previewHash || - filesChangedAfterPreview || + !selectedFileMatches || previewStatus === 'FAIL' || - (previewStatus === 'WARNING' && !warningAcknowledged) || - files.length === 0; + (previewStatus === 'WARNING' && !warningAcknowledged); - const selectedTemplateNames = new Set(files.map((file) => file.name)); - const missingRecommendedTemplates = - templatesQuery.data?.templates.filter( - (template) => template.supported && !selectedTemplateNames.has(template.file) - ) ?? []; - - function handleFiles(nextFiles: FileList | null) { - const selectedFiles = Array.from(nextFiles ?? []); - setFiles(selectedFiles); + function selectStep(stepKey: string) { + setCurrentStepKey(stepKey); + setSelectedFile(null); setPreview(null); - setPreviewFingerprint(''); - setCommitReport(null); + setImportReport(null); setWarningAcknowledged(false); } - async function saveStep(input: { - stepKey: string; - status: SetupStepStatus; - inputHash?: string | null; - output?: Record | null; - errors?: unknown[] | null; - warnings?: unknown[] | null; - }) { - if (!setupRunQuery.data?.active || !setupRunQuery.data.run) return; - - await saveStepMutation.mutateAsync({ - setupRunId: setupRunQuery.data.run.id, - ...input - }); - } - - async function runReadiness() { - const result = await readinessQuery.refetch(); - if (!result.data) return; - - await saveStep({ - stepKey: 'readiness', - status: getStepStatusFromApiStatus(result.data.status), - inputHash: `readiness:${result.data.status}:${result.data.summary.fail}:${result.data.summary.warning}`, - output: toRecord(result.data), - errors: result.data.checks.filter((check) => check.status === 'FAIL'), - warnings: result.data.checks.filter((check) => check.status === 'WARNING') - }); + function handleFile(fileList: FileList | null) { + setSelectedFile(fileList?.[0] ?? null); + setPreview(null); + setImportReport(null); + setWarningAcknowledged(false); } async function runPreview() { - const result = await previewMutation.mutateAsync(files); + if (!currentStep || !selectedFile) return; + const result = await previewMutation.mutateAsync({ + stepKey: currentStep.stepKey, + file: selectedFile + }); setPreview(result); - setPreviewFingerprint(fileFingerprint); - setCommitReport(null); - setWarningAcknowledged(false); - setCurrentStep('preview'); - const status = getPreviewStatus(result) ?? 'FAIL'; - await saveStep({ - stepKey: 'csv_preview', - status: getStepStatusFromApiStatus(status), - inputHash: result.previewHash, - output: toRecord(result), - errors: result.files.flatMap((file) => file.errors), - warnings: result.files.flatMap((file) => file.warnings) - }); + setImportReport(null); + await setupRunQuery.refetch(); } - async function runCommit() { - if (!preview?.previewHash) return; - const report = await commitMutation.mutateAsync({ - files, - previewHash: preview.previewHash, - dryRun + async function runImport() { + if (!currentStep || !selectedFile || !preview?.previewHash) return; + const result = await commitMutation.mutateAsync({ + stepKey: currentStep.stepKey, + file: selectedFile, + previewHash: preview.previewHash }); - setCommitReport(report); - await saveStep({ - stepKey: 'csv_commit', - status: getStepStatusFromApiStatus(report.status), - inputHash: report.committedHash, - output: toRecord(report), - errors: report.errors, - warnings: report.warnings - }); - } + setImportReport(result); + await setupRunQuery.refetch(); - async function runVerification() { - const report = await verificationMutation.mutateAsync({ profileId: selectedProfileId }); - setCurrentStep('verification'); - await saveStep({ - stepKey: 'verification', - status: getStepStatusFromApiStatus(report.status), - inputHash: `verification:${report.status}:${report.summary.fail}:${report.summary.warning}`, - output: toRecord(report), - errors: report.checks.filter((check) => check.status === 'FAIL'), - warnings: report.checks.filter((check) => check.status === 'WARNING') - }); - return report; + if (setupStatusFromReport(result) !== 'failed') { + const currentIndex = steps.findIndex((step) => step.stepKey === currentStep.stepKey); + const nextStep = steps[currentIndex + 1]; + if (nextStep) selectStep(nextStep.stepKey); + } } async function completeSetup() { - await completeRunMutation.mutateAsync({ - readiness: readinessQuery.data?.status ?? persistedStepByKey.get('readiness')?.status ?? 'not_started', - preview: previewStatus ?? persistedStepByKey.get('csv_preview')?.status ?? 'not_started', - commit: commitReport?.status ?? persistedStepByKey.get('csv_commit')?.status ?? 'not_started', - verification: - verificationMutation.data?.status ?? persistedStepByKey.get('verification')?.status ?? 'not_started' + await completeMutation.mutateAsync({ + source: 'step-by-step-bootstrap', + requiredStepKeys: steps.map((step) => step.stepKey) }); + window.location.assign('/auth/sign-in'); + } + + if (bootstrapStatusQuery.isLoading || templatesQuery.isLoading || setupRunQuery.isLoading) { + return ( +
+ Loading first-run setup wizard... +
+ ); + } + + if (bootstrapStatusQuery.isError) { + return ; + } + + if (!currentStep) { + return ( + + + No required setup steps + The setup API did not return any required bootstrap steps. + + ); } return ( -
- +
+ - - - Session-only wizard - - This setup wizard does not persist progress yet. Persistent resume and setup runs arrive in - D.7.8. - - +
+ + + Import order decision + + Users are imported before organizations because organizations.csv resolves created_by_email + against existing users. + + - - - Installation profile - - Profiles are resolved through the setup plugin registry. Future modules can add profiles - without editing the wizard. - - - - {profilesQuery.isError ? ( - - ) : null} -
- -
- {profilesQuery.data?.selectedProfile?.description ?? - 'Loading installation profile definitions...'} -
-
-
- {(profilesQuery.data?.plugins ?? []).map((plugin) => ( -
-
{plugin.displayName}
-
v{plugin.version}
-
{plugin.description}
-
- ))} -
-
-
+ {bootstrapStatusQuery.data?.initialized ? ( + + + Setup already initialized + Public bootstrap setup is blocked after completion. + + ) : null} - - - Setup run - Resume persisted setup progress across browser refreshes. - - - {setupRunQuery.isError ? ( - - - Persistence unavailable - - The wizard can continue in this browser session, but setup progress could not be - loaded. - - - ) : null} - {setupRunQuery.data?.run ? ( -
+ + +
-
- {setupRunQuery.data.active ? 'Active setup run' : 'Latest completed setup run'} -
-
- Status: {setupRunQuery.data.run.status} · Started: {setupRunQuery.data.run.startedAt} -
+ {currentStep.title} + {currentStep.description}
- - {setupRunQuery.data.completed ? 'completed' : 'resumable'} - +
- ) : ( - - )} -
- - - - -
- - -
+ + + {!dependenciesComplete ? ( + + + Step locked + + Complete dependencies first: {currentStep.dependsOn.join(', ')}. + + + ) : null} - - -
-
- Step 1: System Readiness - Run non-destructive checks before importing setup CSV files. +
+
+
Required filename
+
{currentStep.fileName}
+
+
+
Purpose
+
{currentTemplate?.description ?? currentStep.description}
+
+
+ + + +
+
Upload
+ handleFile(event.target.files)} + /> + {selectedFile ? ( +
+ {selectedFile.name} + {selectedFile.size} bytes +
+ ) : null} + {selectedFile && !selectedFileMatches ? ( + + + Wrong file selected + Upload {currentStep.fileName} for this step. + + ) : null} +
+ +
+ + +
+ + {previewMutation.isError ? : null} + {commitMutation.isError ? : null} + +
+
+
Preview result
+ {previewStatus ? : null} +
+ {preview ? ( + <> +
+ + + + + + +
+ {previewStatus === 'WARNING' ? ( +
+ setWarningAcknowledged(checked === true)} + /> + +
+ ) : null} + {preview.files.map((file) => ( + + ))} + + ) : ( +
+ Run Preview to validate this CSV before import. +
+ )} +
+ + + + + + + + Final Verification + Setup can complete only after all system-required steps pass. + + +
+ isStepComplete(stepByKey.get(step.stepKey))).length}/${steps.length}`} + /> + +
-
- - - {readinessQuery.isError ? ( - <> - - - - ) : null} - {readinessQuery.isLoading ? : null} - {readinessQuery.data ? ( - setCurrentStep('templates')} - /> - ) : null} - - - - - - Step 2: Template Pack - - Review the registered CSV templates. Local template files live under setup/templates. - - - - {templatesQuery.isError ? ( - <> - - - - ) : null} - {templatesQuery.isLoading ? : null} - {templatesQuery.data ? : null} - - - - - - - Step 3: CSV Upload - Select one or more CSV files. Files upload only when Preview is clicked. - - - handleFiles(event.target.files)} - /> - {files.length > 0 ? ( -
-
Selected files
-
- {files.map((file) => ( -
-
{file.name}
-
{file.size.toLocaleString()} bytes
-
- ))} -
-
- ) : ( - - )} - {missingRecommendedTemplates.length > 0 ? ( - - - Recommended templates not selected - - Missing files are informational only: {missingRecommendedTemplates.slice(0, 6).map((template) => template.file).join(', ')} - {missingRecommendedTemplates.length > 6 ? '...' : ''} - - - ) : null} - {filesChangedAfterPreview ? ( - - - Files changed after preview - Run Preview again before committing. - - ) : null} - - {previewMutation.isError ? : null} -
-
- - - - Step 4: CSV Preview - Review grouped file and row validation before commit. - - - {preview ? ( - <> -
-
-
Preview hash
-
{preview.previewHash}
-
-
- - {previewStatus ? : null} -
-
-
- - - - - - -
- {previewStatus === 'WARNING' ? ( -
- setWarningAcknowledged(checked === true)} - /> - -
- ) : null} - - - ) : ( - - )} -
-
- - - - Step 5: CSV Commit - Commit uses the latest previewHash and the currently selected files. - - -
- setDryRun(checked === true)} - /> - -
- {previewStatus === 'FAIL' ? ( - - - Commit blocked - Preview contains FAIL results. Fix CSV files and preview again. - - ) : null} - {filesChangedAfterPreview ? ( - - - Re-preview required - The selected files changed after preview. - - ) : null} - - {commitMutation.isError ? : null} - -
-
- - - - Step 6: Verification - Run setup verification checks after preview or commit. - - - - {verificationMutation.isError ? ( - <> - - - - ) : null} - - - - - - - Step 7: Finish - Summary of this session. Progress is not persisted until D.7.8. - - -
- - - - -
- {setupRunQuery.data?.manifests.length ? ( -
-
Seed manifests
-
- {setupRunQuery.data.manifests.map((manifest) => ( -
- - {manifest.name}@{manifest.version} - - {manifest.status} -
- ))} -
-
- ) : null} - -
-
+ {completeMutation.isError ? : null} + + +
); } -function ReadinessPanel({ - report, - persistedStep, - onContinue -}: { - report: SetupReadinessReport; - persistedStep?: SetupRunStepDto; - onContinue: () => void; -}) { +function TemplateColumns({ template }: { template?: SetupTemplateMetadataItem }) { + if (!template) { + return ( +
+ Template metadata is not available for this file. +
+ ); + } + return ( -
-
-
-
{report.message}
-
Checked at {report.time}
-
-
- - +
+
+
Required headers
+
+ {template.requiredColumns.map((column) => ( + + {column} + + ))}
- - - +
+
Sample template
+ + setup/templates/{template.file} + +
); } diff --git a/src/features/setup/server/csv/dependency-resolver.ts b/src/features/setup/server/csv/dependency-resolver.ts index 6e05ed8..0c7b600 100644 --- a/src/features/setup/server/csv/dependency-resolver.ts +++ b/src/features/setup/server/csv/dependency-resolver.ts @@ -883,11 +883,11 @@ export function buildCsvPreviewBatchIndex(files: CsvParsedFile[]): CsvPreviewBat files.forEach((file) => { file.rows.forEach((row) => { const values = row.values; - const organizationCode = normalizeOrganizationCode(values.organization_code); + const organizationCode = values.organization_code ? normalizeOrganizationCode(values.organization_code) : ''; switch (file.template.file) { case 'organizations.csv': - index.organizations.add(normalizeOrganizationCode(values.organization_code)); + if (values.organization_code) index.organizations.add(normalizeOrganizationCode(values.organization_code)); break; case 'users.csv': index.users.add(normalizeEmail(values.email)); diff --git a/src/features/setup/server/csv/validator.ts b/src/features/setup/server/csv/validator.ts index fc1450a..6b4bb8f 100644 --- a/src/features/setup/server/csv/validator.ts +++ b/src/features/setup/server/csv/validator.ts @@ -27,7 +27,7 @@ function issue( } function organizationCode(values: Record): string { - return normalizeOrganizationCode(values.organization_code); + return values.organization_code ? normalizeOrganizationCode(values.organization_code) : ''; } function businessKey(file: CsvParsedFile, values: Record): string { diff --git a/src/features/setup/server/setup-bootstrap.service.ts b/src/features/setup/server/setup-bootstrap.service.ts index e81b3ba..89ee856 100644 --- a/src/features/setup/server/setup-bootstrap.service.ts +++ b/src/features/setup/server/setup-bootstrap.service.ts @@ -13,6 +13,7 @@ export const BOOTSTRAP_REQUIRED_TEMPLATE_FILES = [ 'users.csv', 'organizations.csv', 'memberships.csv', + 'crm-role-assignments.csv', 'master-options.csv', 'branches.csv', 'product-types.csv', @@ -34,6 +35,96 @@ export const BOOTSTRAP_OPTIONAL_TEMPLATE_FILES = [ 'quotation-topic-items.csv' ] as const; +export const BOOTSTRAP_REQUIRED_STEPS = [ + { + stepKey: 'bootstrap_users', + fileName: 'users.csv', + title: 'Users', + description: 'Create the setup administrator and required app users.', + dependsOn: [] + }, + { + stepKey: 'bootstrap_organizations', + fileName: 'organizations.csv', + title: 'Organizations', + description: 'Create tenant organization records after users exist.', + dependsOn: ['bootstrap_users'] + }, + { + stepKey: 'bootstrap_memberships', + fileName: 'memberships.csv', + title: 'Memberships', + description: 'Connect users to organizations with app-level roles.', + dependsOn: ['bootstrap_users', 'bootstrap_organizations'] + }, + { + stepKey: 'bootstrap_crm_role_assignments', + fileName: 'crm-role-assignments.csv', + title: 'CRM Roles', + description: 'Assign CRM role profiles and branch/product scopes.', + dependsOn: ['bootstrap_users', 'bootstrap_organizations', 'bootstrap_memberships'] + }, + { + stepKey: 'bootstrap_master_options', + fileName: 'master-options.csv', + title: 'Master Options', + description: 'Seed governed CRM option categories and labels.', + dependsOn: ['bootstrap_users', 'bootstrap_organizations', 'bootstrap_memberships'] + }, + { + stepKey: 'bootstrap_branches', + fileName: 'branches.csv', + title: 'Branches', + description: 'Create branch options used for CRM scope and numbering.', + dependsOn: [ + 'bootstrap_users', + 'bootstrap_organizations', + 'bootstrap_memberships', + 'bootstrap_master_options' + ] + }, + { + stepKey: 'bootstrap_product_types', + fileName: 'product-types.csv', + title: 'Product Types', + description: 'Create product type options used for CRM scope and numbering.', + dependsOn: [ + 'bootstrap_users', + 'bootstrap_organizations', + 'bootstrap_memberships', + 'bootstrap_master_options' + ] + }, + { + stepKey: 'bootstrap_document_sequences', + fileName: 'document-sequences.csv', + title: 'Document Sequences', + description: 'Configure document numbering for CRM entities.', + dependsOn: ['bootstrap_organizations', 'bootstrap_branches', 'bootstrap_product_types'] + }, + { + stepKey: 'bootstrap_approval_workflows', + fileName: 'approval-workflows.csv', + title: 'Approval Workflows', + description: 'Create approval workflow headers.', + dependsOn: ['bootstrap_organizations'] + }, + { + stepKey: 'bootstrap_approval_steps', + fileName: 'approval-steps.csv', + title: 'Approval Steps', + description: 'Create ordered approval steps for each workflow.', + dependsOn: ['bootstrap_approval_workflows'] + }, + { + stepKey: 'bootstrap_approval_matrix', + fileName: 'approval-matrix.csv', + title: 'Approval Matrix', + description: 'Create approval routing rules by scope and amount.', + dependsOn: ['bootstrap_approval_workflows', 'bootstrap_branches', 'bootstrap_product_types'] + } +] as const; + export class BootstrapSetupError extends Error { status: number; details?: Record; @@ -57,11 +148,16 @@ export interface BootstrapSetupStatus { }; requiredFiles: string[]; optionalFiles: string[]; + requiredSteps: typeof BOOTSTRAP_REQUIRED_STEPS; } async function hasRows(query: Promise>) { - const [row] = await query; - return Number(row?.value ?? 0) > 0; + try { + const [row] = await query; + return Number(row?.value ?? 0) > 0; + } catch { + return false; + } } export async function getBootstrapSetupStatus(): Promise { @@ -99,7 +195,8 @@ export async function getBootstrapSetupStatus(): Promise { tokenRequired: Boolean(process.env.BOOTSTRAP_SETUP_TOKEN?.trim()), checks, requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES], - optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES] + optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES], + requiredSteps: [...BOOTSTRAP_REQUIRED_STEPS] }; } @@ -126,7 +223,12 @@ export function assertBootstrapToken(token: string | null | undefined) { } export function getMissingBootstrapRequiredFiles(files: CsvPreviewFileInput[]) { - const uploaded = new Set(files.map((file) => file.fileName.trim().toLowerCase())); + const uploaded = new Set( + files + .map((file) => file.fileName) + .filter((fileName): fileName is string => typeof fileName === 'string') + .map((fileName) => fileName.trim().toLowerCase()) + ); return BOOTSTRAP_REQUIRED_TEMPLATE_FILES.filter((fileName) => !uploaded.has(fileName)); } @@ -140,6 +242,37 @@ export function assertBootstrapRequiredFiles(files: CsvPreviewFileInput[]) { } } +export function assertBootstrapStepFiles(stepKey: string, files: CsvPreviewFileInput[]) { + const step = BOOTSTRAP_REQUIRED_STEPS.find((candidate) => candidate.stepKey === stepKey); + + if (!step) { + throw new BootstrapSetupError('Unknown bootstrap setup step.', 400, { + stepKey, + allowedStepKeys: BOOTSTRAP_REQUIRED_STEPS.map((candidate) => candidate.stepKey) + }); + } + + if (files.length !== 1) { + throw new BootstrapSetupError('Upload exactly one CSV file for this setup step.', 400, { + stepKey, + expectedFileName: step.fileName, + receivedFiles: files.map((file) => file.fileName) + }); + } + + const [file] = files; + + if (file.fileName.toLowerCase() !== step.fileName.toLowerCase()) { + throw new BootstrapSetupError('Uploaded CSV file does not match the current setup step.', 400, { + stepKey, + expectedFileName: step.fileName, + receivedFileName: file.fileName + }); + } + + return step; +} + export function setupStepStatusFromReportStatus(status: 'PASS' | 'WARNING' | 'FAIL'): SetupStepStatus { if (status === 'PASS') return 'passed'; if (status === 'WARNING') return 'warning'; diff --git a/src/features/setup/server/setup-state.service.ts b/src/features/setup/server/setup-state.service.ts index ffb5ecd..e6e9e6a 100644 --- a/src/features/setup/server/setup-state.service.ts +++ b/src/features/setup/server/setup-state.service.ts @@ -77,7 +77,46 @@ const STALE_DEPENDENCIES: Record = { sequences: ['verification'], approval: ['csv_preview', 'csv_commit', 'verification'], csv_preview: ['csv_commit'], - csv_commit: ['verification'] + csv_commit: ['verification'], + bootstrap_users: [ + 'bootstrap_organizations', + 'bootstrap_memberships', + 'bootstrap_crm_role_assignments', + 'bootstrap_master_options', + 'bootstrap_branches', + 'bootstrap_product_types', + 'bootstrap_document_sequences', + 'bootstrap_approval_workflows', + 'bootstrap_approval_steps', + 'bootstrap_approval_matrix' + ], + bootstrap_organizations: [ + 'bootstrap_memberships', + 'bootstrap_crm_role_assignments', + 'bootstrap_master_options', + 'bootstrap_branches', + 'bootstrap_product_types', + 'bootstrap_document_sequences', + 'bootstrap_approval_workflows', + 'bootstrap_approval_steps', + 'bootstrap_approval_matrix' + ], + bootstrap_memberships: [ + 'bootstrap_crm_role_assignments', + 'bootstrap_master_options', + 'bootstrap_branches', + 'bootstrap_product_types', + 'bootstrap_document_sequences' + ], + bootstrap_master_options: [ + 'bootstrap_branches', + 'bootstrap_product_types', + 'bootstrap_document_sequences', + 'bootstrap_approval_matrix' + ], + bootstrap_branches: ['bootstrap_document_sequences', 'bootstrap_approval_matrix'], + bootstrap_product_types: ['bootstrap_document_sequences', 'bootstrap_approval_matrix'], + bootstrap_approval_workflows: ['bootstrap_approval_steps', 'bootstrap_approval_matrix'] }; function now() { diff --git a/src/proxy.ts b/src/proxy.ts index e6425e5..0224387 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -12,6 +12,7 @@ interface BootstrapStatusResponse { function isBootstrapAllowedPath(pathname: string) { return ( pathname === '/administration/setup' || + pathname.startsWith('/api/auth/') || pathname.startsWith('/api/setup/bootstrap/') || pathname === '/api/setup/templates' ); diff --git a/src/styles/theme.css b/src/styles/theme.css index ae71657..01141b4 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -10,6 +10,7 @@ @import './themes/zen.css'; @import './themes/astro-vista.css'; @import './themes/whatsapp.css'; +@import './themes/alla.css'; body { @apply overscroll-none; diff --git a/src/styles/themes/alla.css b/src/styles/themes/alla.css new file mode 100644 index 0000000..f1f71e6 --- /dev/null +++ b/src/styles/themes/alla.css @@ -0,0 +1,117 @@ +[data-theme='alla'] { + --background: oklch(0.9911 0 0); + --foreground: oklch(0.2046 0 0); + --card: oklch(0.9911 0 0); + --card-foreground: oklch(0.2046 0 0); + --popover: oklch(0.9911 0 0); + --popover-foreground: oklch(0.4386 0 0); + --primary: oklch(0.4799 0.0804 155.8197); + --primary-foreground: oklch(1 0 0); + --secondary: oklch(0.994 0 0); + --secondary-foreground: oklch(0.2046 0 0); + --muted: oklch(0.9461 0 0); + --muted-foreground: oklch(0.2435 0 0); + --accent: oklch(0.9461 0 0); + --accent-foreground: oklch(0.2435 0 0); + --destructive: oklch(0.5523 0.1927 32.7272); + --destructive-foreground: oklch(0.9934 0.0032 17.2118); + --border: oklch(0.9037 0 0); + --input: oklch(0.9731 0 0); + --ring: oklch(0.5568 0.1355 155.812); + --chart-1: oklch(0.8348 0.1302 160.908); + --chart-2: oklch(0.6231 0.188 259.8145); + --chart-3: oklch(0.6056 0.2189 292.7172); + --chart-4: oklch(0.7686 0.1647 70.0804); + --chart-5: oklch(0.6959 0.1491 162.4796); + --sidebar: oklch(0.9911 0 0); + --sidebar-foreground: oklch(0.5452 0 0); + --sidebar-primary: oklch(0.5568 0.1355 155.812); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.9461 0 0); + --sidebar-accent-foreground: oklch(0.2435 0 0); + --sidebar-border: oklch(0.9037 0 0); + --sidebar-ring: oklch(0.5568 0.1355 155.812); + --font-sans: Anek Kannada, ui-sans-serif, sans-serif, system-ui; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: monospace; + --radius: 0.5rem; +} + +[data-theme='alla'].dark { + --background: oklch(0.1822 0 0); + --foreground: oklch(0.9851 0 0); + --card: oklch(0.2046 0 0); + --card-foreground: oklch(0.9851 0 0); + --popover: oklch(0.2046 0 0); + --popover-foreground: oklch(0.9851 0 0); + --primary: oklch(0.5568 0.1355 155.812); + --primary-foreground: oklch(0.9213 0.0135 167.1556); + --secondary: oklch(0.2603 0 0); + --secondary-foreground: oklch(0.9851 0 0); + --muted: oklch(0.2603 0 0); + --muted-foreground: oklch(0.7122 0 0); + --accent: oklch(0.3132 0 0); + --accent-foreground: oklch(0.9851 0 0); + --destructive: oklch(0.3123 0.0852 29.7877); + --destructive-foreground: oklch(0.9368 0.0045 34.3092); + --border: oklch(0.2809 0 0); + --input: oklch(0.2603 0 0); + --ring: oklch(0.5568 0.1355 155.812); + --chart-1: oklch(0.8003 0.1821 151.711); + --chart-2: oklch(0.7137 0.1434 254.624); + --chart-3: oklch(0.709 0.1592 293.5412); + --chart-4: oklch(0.8369 0.1644 84.4286); + --chart-5: oklch(0.7845 0.1325 181.912); + --sidebar: oklch(0.1822 0 0); + --sidebar-foreground: oklch(0.6301 0 0); + --sidebar-primary: oklch(0.5568 0.1355 155.812); + --sidebar-primary-foreground: oklch(0.9213 0.0135 167.1556); + --sidebar-accent: oklch(0.3132 0 0); + --sidebar-accent-foreground: oklch(0.9851 0 0); + --sidebar-border: oklch(0.2809 0 0); + --sidebar-ring: oklch(0.5568 0.1355 155.812); +} + +[data-theme='alla'] { + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + } +}