task-d.7.11

This commit is contained in:
phaichayon
2026-07-03 14:46:42 +07:00
parent 9c8d6a0d6d
commit 15c56efc8b
20 changed files with 1790 additions and 5983 deletions

View File

@@ -0,0 +1,349 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Icons } from '@/components/icons';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import type { CsvPreviewResult, ImportReport } from '@/features/setup/api/types';
interface BootstrapSetupProps {
tokenRequired: boolean;
requiredFiles: string[];
optionalFiles: string[];
}
interface ApiErrorPayload {
message?: string;
details?: {
missingFiles?: string[];
};
}
function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
return Boolean(value && typeof value === 'object');
}
function getFileMap(files: File[]) {
return new Map(files.map((file) => [file.name.toLowerCase(), file]));
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return 'Request failed.';
}
async function readJsonResponse<T>(response: Response): Promise<T> {
const payload = (await response.json().catch(() => ({}))) as unknown;
if (!response.ok) {
const message = isApiErrorPayload(payload) && payload.message ? payload.message : 'Request failed.';
throw new Error(message);
}
return payload as T;
}
function appendFiles(formData: FormData, files: File[]) {
files.forEach((file) => {
formData.append('files', file, file.name);
});
}
function StatusBadge({ status }: { status: 'PASS' | 'WARNING' | 'FAIL' }) {
const variant = status === 'FAIL' ? 'destructive' : status === 'WARNING' ? 'secondary' : 'default';
return <Badge variant={variant}>{status}</Badge>;
}
export function BootstrapSetup({ tokenRequired, requiredFiles, optionalFiles }: BootstrapSetupProps) {
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [bootstrapToken, setBootstrapToken] = useState('');
const [preview, setPreview] = useState<CsvPreviewResult | null>(null);
const [commitReport, setCommitReport] = useState<ImportReport | null>(null);
const [isPreviewing, setIsPreviewing] = useState(false);
const [isCommitting, setIsCommitting] = useState(false);
const [isCompleting, setIsCompleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileMap = useMemo(() => getFileMap(files), [files]);
const missingRequiredFiles = requiredFiles.filter((fileName) => !fileMap.has(fileName.toLowerCase()));
const selectedOptionalCount = optionalFiles.filter((fileName) => fileMap.has(fileName.toLowerCase())).length;
const canPreview = missingRequiredFiles.length === 0 && files.length > 0 && (!tokenRequired || bootstrapToken.length > 0);
const canCommit = Boolean(preview?.previewHash) && preview?.summary.error === 0 && canPreview;
const canComplete = Boolean(commitReport && commitReport.status !== 'FAIL');
function handleFiles(fileList: FileList | null) {
setFiles(Array.from(fileList ?? []));
setPreview(null);
setCommitReport(null);
setError(null);
}
async function runPreview() {
setIsPreviewing(true);
setError(null);
try {
const formData = new FormData();
formData.set('bootstrapToken', bootstrapToken);
appendFiles(formData, files);
const response = await fetch('/api/setup/bootstrap/preview', {
method: 'POST',
body: formData
});
const result = await readJsonResponse<CsvPreviewResult>(response);
setPreview(result);
} catch (requestError) {
setError(getErrorMessage(requestError));
} finally {
setIsPreviewing(false);
}
}
async function runCommit() {
if (!preview?.previewHash) return;
setIsCommitting(true);
setError(null);
try {
const formData = new FormData();
formData.set('bootstrapToken', bootstrapToken);
formData.set('previewHash', preview.previewHash);
appendFiles(formData, files);
const response = await fetch('/api/setup/bootstrap/commit', {
method: 'POST',
body: formData
});
const result = await readJsonResponse<ImportReport>(response);
setCommitReport(result);
} catch (requestError) {
setError(getErrorMessage(requestError));
} finally {
setIsCommitting(false);
}
}
async function completeSetup() {
setIsCompleting(true);
setError(null);
try {
const response = await fetch('/api/setup/bootstrap/complete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ bootstrapToken })
});
await readJsonResponse(response);
router.replace('/auth/sign-in');
} catch (requestError) {
setError(getErrorMessage(requestError));
} finally {
setIsCompleting(false);
}
}
return (
<main className='min-h-screen bg-background px-4 py-8 md:px-8'>
<div className='mx-auto flex w-full max-w-6xl flex-col gap-6'>
<div className='space-y-2'>
<Badge variant='secondary'>First-run bootstrap</Badge>
<h1 className='text-3xl font-semibold tracking-normal'>ALLA OS Setup</h1>
<p className='max-w-3xl text-sm text-muted-foreground'>
Upload the official setup CSV files, validate them, commit the initial organization data,
then sign in with the administrator account from users.csv.
</p>
</div>
<Alert>
<Icons.lock className='h-4 w-4' />
<AlertTitle>Public only before initialization</AlertTitle>
<AlertDescription>
This bootstrap page is available only until setup is completed. After completion, setup
maintenance moves to the authenticated dashboard administration page.
</AlertDescription>
</Alert>
{error ? (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Setup request failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className='grid gap-6 lg:grid-cols-[1fr_360px]'>
<section className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>CSV Upload</CardTitle>
<CardDescription>Required foundation files must be selected before preview.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{tokenRequired ? (
<Input
type='password'
value={bootstrapToken}
onChange={(event) => setBootstrapToken(event.target.value)}
placeholder='Bootstrap setup token'
aria-label='Bootstrap setup token'
/>
) : null}
<Input type='file' multiple accept='.csv,text/csv' onChange={(event) => handleFiles(event.target.files)} />
<div className='grid gap-2 sm:grid-cols-2'>
{files.map((file) => (
<div key={`${file.name}-${file.size}-${file.lastModified}`} className='rounded-md border p-3 text-sm'>
<div className='font-medium'>{file.name}</div>
<div className='text-muted-foreground'>{file.size} bytes</div>
</div>
))}
</div>
{missingRequiredFiles.length > 0 ? (
<Alert>
<Icons.info className='h-4 w-4' />
<AlertTitle>Required files missing</AlertTitle>
<AlertDescription>{missingRequiredFiles.join(', ')}</AlertDescription>
</Alert>
) : null}
<div className='flex flex-wrap gap-2'>
<Button type='button' onClick={runPreview} disabled={!canPreview || isPreviewing}>
{isPreviewing ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.upload className='mr-2 h-4 w-4' />
)}
Preview CSV
</Button>
<Button type='button' variant='outline' onClick={runCommit} disabled={!canCommit || isCommitting}>
{isCommitting ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.check className='mr-2 h-4 w-4' />
)}
Setup System
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preview Result</CardTitle>
<CardDescription>Commit is enabled only when preview has no blocking errors.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{preview ? (
<>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='min-w-0'>
<div className='font-medium'>Preview hash</div>
<div className='break-all text-sm text-muted-foreground'>{preview.previewHash}</div>
</div>
<StatusBadge status={preview.summary.error > 0 ? 'FAIL' : preview.summary.warning > 0 ? 'WARNING' : 'PASS'} />
</div>
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Files' value={preview.summary.files} />
<Metric label='Rows' value={preview.summary.rows} />
<Metric label='Create' value={preview.summary.create} />
<Metric label='Warning' value={preview.summary.warning} />
<Metric label='Error' value={preview.summary.error} />
</div>
</>
) : (
<EmptyPanel message='Preview has not run yet.' />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Completion</CardTitle>
<CardDescription>Finish setup after data commit succeeds.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{commitReport ? (
<div className='grid gap-2 sm:grid-cols-5'>
<Metric label='Imported' value={commitReport.summary.imported} />
<Metric label='Updated' value={commitReport.summary.updated} />
<Metric label='Skipped' value={commitReport.summary.skipped} />
<Metric label='Warnings' value={commitReport.summary.warnings} />
<Metric label='Errors' value={commitReport.summary.errors} />
</div>
) : (
<EmptyPanel message='Commit has not run yet.' />
)}
<Button type='button' onClick={completeSetup} disabled={!canComplete || isCompleting}>
{isCompleting ? (
<Icons.spinner className='mr-2 h-4 w-4 animate-spin' />
) : (
<Icons.arrowRight className='mr-2 h-4 w-4' />
)}
Complete and go to login
</Button>
</CardContent>
</Card>
</section>
<aside className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Required Template Checklist</CardTitle>
<CardDescription>Foundation, document sequence, and approval setup.</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
{requiredFiles.map((fileName) => (
<TemplateChecklistItem key={fileName} fileName={fileName} selected={fileMap.has(fileName.toLowerCase())} />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Business CSV</CardTitle>
<CardDescription>Optional files selected: {selectedOptionalCount}</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
{optionalFiles.map((fileName) => (
<TemplateChecklistItem key={fileName} fileName={fileName} selected={fileMap.has(fileName.toLowerCase())} />
))}
</CardContent>
</Card>
</aside>
</div>
</div>
</main>
);
}
function TemplateChecklistItem({ fileName, selected }: { fileName: string; selected: boolean }) {
return (
<div className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
<span className='min-w-0 truncate'>{fileName}</span>
<Badge variant={selected ? 'default' : 'secondary'}>{selected ? 'Selected' : 'Missing'}</Badge>
</div>
);
}
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className='rounded-md border p-3'>
<div className='text-xs font-medium uppercase text-muted-foreground'>{label}</div>
<div className='text-lg font-semibold'>{value}</div>
</div>
);
}
function EmptyPanel({ message }: { message: string }) {
return <div className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>{message}</div>;
}

View File

@@ -0,0 +1,163 @@
import 'server-only';
import { count, eq } from 'drizzle-orm';
import { memberships, organizations, setupRuns, users } from '@/db/schema';
import { db } from '@/lib/db';
import type { ImportReport } from './csv/import-report';
import type { CsvPreviewFileInput, CsvPreviewResult } from './csv/types';
import type { SetupStepStatus } from './setup-state.service';
export const BOOTSTRAP_ACTOR_ID = 'bootstrap-setup';
export const BOOTSTRAP_REQUIRED_TEMPLATE_FILES = [
'users.csv',
'organizations.csv',
'memberships.csv',
'master-options.csv',
'branches.csv',
'product-types.csv',
'document-sequences.csv',
'approval-workflows.csv',
'approval-steps.csv',
'approval-matrix.csv'
] as const;
export const BOOTSTRAP_OPTIONAL_TEMPLATE_FILES = [
'customers.csv',
'contacts.csv',
'leads.csv',
'opportunities.csv',
'quotations.csv',
'quotation-items.csv',
'quotation-parties.csv',
'quotation-topics.csv',
'quotation-topic-items.csv'
] as const;
export class BootstrapSetupError extends Error {
status: number;
details?: Record<string, unknown>;
constructor(message: string, status = 400, details?: Record<string, unknown>) {
super(message);
this.name = 'BootstrapSetupError';
this.status = status;
this.details = details;
}
}
export interface BootstrapSetupStatus {
initialized: boolean;
tokenRequired: boolean;
checks: {
completedSetupRun: boolean;
superAdminUser: boolean;
organization: boolean;
adminMembership: boolean;
};
requiredFiles: string[];
optionalFiles: string[];
}
async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
const [row] = await query;
return Number(row?.value ?? 0) > 0;
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
const [completedSetupRun, superAdminUser, organization, adminMembership] = await Promise.all([
hasRows(
db
.select({ value: count() })
.from(setupRuns)
.where(eq(setupRuns.status, 'completed'))
),
hasRows(
db
.select({ value: count() })
.from(users)
.where(eq(users.systemRole, 'super_admin'))
),
hasRows(db.select({ value: count() }).from(organizations)),
hasRows(
db
.select({ value: count() })
.from(memberships)
.where(eq(memberships.role, 'admin'))
)
]);
const checks = {
completedSetupRun,
superAdminUser,
organization,
adminMembership
};
return {
initialized: Object.values(checks).every(Boolean),
tokenRequired: Boolean(process.env.BOOTSTRAP_SETUP_TOKEN?.trim()),
checks,
requiredFiles: [...BOOTSTRAP_REQUIRED_TEMPLATE_FILES],
optionalFiles: [...BOOTSTRAP_OPTIONAL_TEMPLATE_FILES]
};
}
export async function assertBootstrapSetupAvailable() {
const status = await getBootstrapSetupStatus();
if (status.initialized) {
throw new BootstrapSetupError('Bootstrap setup is no longer available after setup completion.', 403, {
checks: status.checks
});
}
return status;
}
export function assertBootstrapToken(token: string | null | undefined) {
const expectedToken = process.env.BOOTSTRAP_SETUP_TOKEN?.trim();
if (!expectedToken) return;
if (!token || token !== expectedToken) {
throw new BootstrapSetupError('Bootstrap setup token is invalid.', 403);
}
}
export function getMissingBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
const uploaded = new Set(files.map((file) => file.fileName.trim().toLowerCase()));
return BOOTSTRAP_REQUIRED_TEMPLATE_FILES.filter((fileName) => !uploaded.has(fileName));
}
export function assertBootstrapRequiredFiles(files: CsvPreviewFileInput[]) {
const missingFiles = getMissingBootstrapRequiredFiles(files);
if (missingFiles.length > 0) {
throw new BootstrapSetupError('Upload all required setup CSV files before running bootstrap setup.', 400, {
missingFiles
});
}
}
export function setupStepStatusFromReportStatus(status: 'PASS' | 'WARNING' | 'FAIL'): SetupStepStatus {
if (status === 'PASS') return 'passed';
if (status === 'WARNING') return 'warning';
return 'failed';
}
export function setupStepStatusFromImportReport(report: ImportReport): SetupStepStatus {
if (report.status === 'FAIL') return 'failed';
if (report.status === 'WARNING') return 'warning';
return 'passed';
}
export function setupStepStatusFromPreview(preview: CsvPreviewResult): SetupStepStatus {
if (preview.summary.error > 0 || preview.files.some((file) => file.status === 'FAIL')) return 'failed';
if (preview.summary.warning > 0 || preview.files.some((file) => file.status === 'WARNING')) return 'warning';
return 'passed';
}
export function toSerializableRecord(value: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}