task-d.7.12

This commit is contained in:
phaichayon
2026-07-03 16:09:54 +07:00
parent 15c56efc8b
commit 944dbc031d
21 changed files with 1638 additions and 839 deletions

View File

@@ -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(),

View File

@@ -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<SetupProfile
return apiClient<SetupProfilesResponse>(withProfile('/setup/profiles', profileId));
}
export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatusResponse> {
return apiClient<BootstrapSetupStatusResponse>('/setup/bootstrap/status');
}
export async function getCurrentSetupRun(): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/run/current');
}
@@ -102,6 +107,23 @@ export async function previewSetupCsvFiles(files: File[]): Promise<CsvPreviewRes
);
}
export async function previewBootstrapCsvStep(input: {
stepKey: string;
file: File;
bootstrapToken?: string;
}): Promise<CsvPreviewResult> {
const formData = buildCsvFormData([input.file]);
formData.set('stepKey', input.stepKey);
if (input.bootstrapToken) formData.set('bootstrapToken', input.bootstrapToken);
return readUploadResponse<CsvPreviewResult>(
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<ImportReport> {
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<ImportReport>(
await fetch('/api/setup/bootstrap/commit', {
method: 'POST',
body: formData
})
);
}
export async function completeBootstrapSetup(report: Record<string, unknown>): Promise<SetupRunResponse> {
return apiClient<SetupRunResponse>('/setup/bootstrap/complete', {
method: 'POST',
body: JSON.stringify({ report })
});
}

View File

@@ -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;

View File

@@ -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)
});
}

View File

@@ -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<string, unknown>) => completeBootstrapSetup(report),
onSuccess: invalidate
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -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));

View File

@@ -27,7 +27,7 @@ function issue(
}
function organizationCode(values: Record<string, string>): string {
return normalizeOrganizationCode(values.organization_code);
return values.organization_code ? normalizeOrganizationCode(values.organization_code) : '';
}
function businessKey(file: CsvParsedFile, values: Record<string, string>): string {

View File

@@ -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<string, unknown>;
@@ -57,11 +148,16 @@ export interface BootstrapSetupStatus {
};
requiredFiles: string[];
optionalFiles: string[];
requiredSteps: typeof BOOTSTRAP_REQUIRED_STEPS;
}
async function hasRows<T>(query: Promise<Array<T & { value: number }>>) {
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<BootstrapSetupStatus> {
@@ -99,7 +195,8 @@ export async function getBootstrapSetupStatus(): Promise<BootstrapSetupStatus> {
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';

View File

@@ -77,7 +77,46 @@ const STALE_DEPENDENCIES: Record<string, string[]> = {
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() {