858 lines
33 KiB
TypeScript
858 lines
33 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { Icons } from '@/components/icons';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { TemplateJsonSheet } from './template-json-sheet';
|
|
import { TemplateVersionManagementPanel } from './template-version-management-panel';
|
|
import {
|
|
createDocumentTemplateMappingMutation,
|
|
createDocumentTemplateMutation,
|
|
createDocumentTemplateVersionMutation,
|
|
deleteDocumentTemplateMappingMutation,
|
|
updateDocumentTemplateMappingMutation,
|
|
updateDocumentTemplateMutation,
|
|
updateDocumentTemplateVersionMutation
|
|
} from '../mutations';
|
|
import { documentTemplateByIdOptions, documentTemplatesQueryOptions } from '../queries';
|
|
import type {
|
|
DocumentTemplateDetail,
|
|
DocumentTemplateMappingMutationPayload,
|
|
DocumentTemplateMutationPayload,
|
|
DocumentTemplateVersionDetail,
|
|
DocumentTemplateVersionMutationPayload
|
|
} from '../types';
|
|
|
|
type TemplateFormState = {
|
|
documentType: string;
|
|
productType: string;
|
|
fileType: string;
|
|
templateName: string;
|
|
description: string;
|
|
isDefault: boolean;
|
|
isActive: boolean;
|
|
};
|
|
|
|
type VersionFormState = {
|
|
version: string;
|
|
filePath: string;
|
|
schemaJson: string;
|
|
previewImageUrl: string;
|
|
isActive: boolean;
|
|
};
|
|
|
|
type MappingColumnState = {
|
|
columnName: string;
|
|
sourceField: string;
|
|
columnLetter: string;
|
|
sortOrder: string;
|
|
formatMask: string;
|
|
};
|
|
|
|
type MappingFormState = {
|
|
placeholderKey: string;
|
|
sourcePath: string;
|
|
dataType: 'scalar' | 'multiline' | 'table' | 'image';
|
|
sheetName: string;
|
|
defaultValue: string;
|
|
formatMask: string;
|
|
sortOrder: string;
|
|
columns: MappingColumnState[];
|
|
};
|
|
|
|
function toTemplateState(template?: DocumentTemplateDetail): TemplateFormState {
|
|
return {
|
|
documentType: template?.documentType ?? 'quotation',
|
|
productType: template?.productType ?? 'default',
|
|
fileType: template?.fileType ?? 'pdfme',
|
|
templateName: template?.templateName ?? '',
|
|
description: template?.description ?? '',
|
|
isDefault: template?.isDefault ?? false,
|
|
isActive: template?.isActive ?? true
|
|
};
|
|
}
|
|
|
|
function toVersionState(version?: DocumentTemplateVersionDetail): VersionFormState {
|
|
return {
|
|
version: version?.version ?? '',
|
|
filePath: version?.filePath ?? '',
|
|
schemaJson: version ? JSON.stringify(version.schemaJson, null, 2) : '{}',
|
|
previewImageUrl: version?.previewImageUrl ?? '',
|
|
isActive: version?.isActive ?? true
|
|
};
|
|
}
|
|
|
|
function toMappingState(mapping?: DocumentTemplateVersionDetail['mappings'][number]): MappingFormState {
|
|
return {
|
|
placeholderKey: mapping?.placeholderKey ?? '',
|
|
sourcePath: mapping?.sourcePath ?? '',
|
|
dataType: mapping?.dataType ?? 'scalar',
|
|
sheetName: mapping?.sheetName ?? '',
|
|
defaultValue: mapping?.defaultValue ?? '',
|
|
formatMask: mapping?.formatMask ?? '',
|
|
sortOrder: String(mapping?.sortOrder ?? 0),
|
|
columns:
|
|
mapping?.columns.map((column) => ({
|
|
columnName: column.columnName,
|
|
sourceField: column.sourceField,
|
|
columnLetter: column.columnLetter ?? '',
|
|
sortOrder: String(column.sortOrder),
|
|
formatMask: column.formatMask ?? ''
|
|
})) ?? []
|
|
};
|
|
}
|
|
|
|
function buildTemplatePayload(state: TemplateFormState): DocumentTemplateMutationPayload {
|
|
return {
|
|
documentType: state.documentType,
|
|
productType: state.productType,
|
|
fileType: state.fileType,
|
|
templateName: state.templateName,
|
|
description: state.description || null,
|
|
isDefault: state.isDefault,
|
|
isActive: state.isActive
|
|
};
|
|
}
|
|
|
|
function buildVersionPayload(state: VersionFormState): DocumentTemplateVersionMutationPayload {
|
|
let schemaJson: unknown;
|
|
|
|
try {
|
|
schemaJson = JSON.parse(state.schemaJson);
|
|
} catch {
|
|
throw new Error('Schema JSON must be valid JSON');
|
|
}
|
|
|
|
return {
|
|
version: state.version,
|
|
filePath: state.filePath || null,
|
|
schemaJson,
|
|
previewImageUrl: state.previewImageUrl || null,
|
|
isActive: state.isActive
|
|
};
|
|
}
|
|
|
|
function buildMappingPayload(state: MappingFormState): DocumentTemplateMappingMutationPayload {
|
|
return {
|
|
placeholderKey: state.placeholderKey,
|
|
sourcePath: state.sourcePath,
|
|
dataType: state.dataType,
|
|
sheetName: state.sheetName || null,
|
|
defaultValue: state.defaultValue || null,
|
|
formatMask: state.formatMask || null,
|
|
sortOrder: Number(state.sortOrder || 0),
|
|
columns:
|
|
state.dataType === 'table'
|
|
? state.columns.map((column) => ({
|
|
columnName: column.columnName,
|
|
sourceField: column.sourceField,
|
|
columnLetter: column.columnLetter || null,
|
|
sortOrder: Number(column.sortOrder || 0),
|
|
formatMask: column.formatMask || null
|
|
}))
|
|
: []
|
|
};
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
children
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className='space-y-2'>
|
|
<div className='text-sm font-medium'>{label}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getTableColumnCount(version: DocumentTemplateVersionDetail) {
|
|
return version.mappings.reduce((total, mapping) => total + mapping.columns.length, 0);
|
|
}
|
|
|
|
function TemplateDialog({
|
|
open,
|
|
onOpenChange,
|
|
template
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
template?: DocumentTemplateDetail;
|
|
}) {
|
|
const [state, setState] = React.useState<TemplateFormState>(toTemplateState(template));
|
|
const createMutation = useMutation({
|
|
...createDocumentTemplateMutation,
|
|
onSuccess: () => {
|
|
toast.success('Template created');
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
|
|
});
|
|
const updateMutation = useMutation({
|
|
...updateDocumentTemplateMutation,
|
|
onSuccess: () => {
|
|
toast.success('Template updated');
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setState(toTemplateState(template));
|
|
}
|
|
}, [open, template]);
|
|
|
|
async function onSubmit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
const payload = buildTemplatePayload(state);
|
|
|
|
if (template) {
|
|
await updateMutation.mutateAsync({ id: template.id, values: payload });
|
|
return;
|
|
}
|
|
|
|
await createMutation.mutateAsync(payload);
|
|
}
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className='sm:max-w-2xl'>
|
|
<DialogHeader>
|
|
<DialogTitle>{template ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
|
<DialogDescription>Manage template metadata for CRM document generation.</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit} className='grid gap-4'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<Field label='Template Name'>
|
|
<Input value={state.templateName} onChange={(e) => setState((current) => ({ ...current, templateName: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Document Type'>
|
|
<Input value={state.documentType} onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Product Type'>
|
|
<Input value={state.productType} onChange={(e) => setState((current) => ({ ...current, productType: e.target.value }))} />
|
|
</Field>
|
|
<Field label='File Type'>
|
|
<Input value={state.fileType} onChange={(e) => setState((current) => ({ ...current, fileType: e.target.value }))} />
|
|
</Field>
|
|
</div>
|
|
<Field label='Description'>
|
|
<Textarea value={state.description} onChange={(e) => setState((current) => ({ ...current, description: e.target.value }))} />
|
|
</Field>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
|
<div>
|
|
<div className='font-medium'>Default Template</div>
|
|
<div className='text-muted-foreground text-sm'>Use this template as the default selection.</div>
|
|
</div>
|
|
<Switch checked={state.isDefault} onCheckedChange={(checked) => setState((current) => ({ ...current, isDefault: checked }))} />
|
|
</div>
|
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
|
<div>
|
|
<div className='font-medium'>Active</div>
|
|
<div className='text-muted-foreground text-sm'>Inactive templates stay visible for audit history.</div>
|
|
</div>
|
|
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type='submit' isLoading={isPending}>
|
|
{template ? 'Save Changes' : 'Create Template'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function VersionDialog({
|
|
open,
|
|
onOpenChange,
|
|
templateId,
|
|
version
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
templateId: string;
|
|
version?: DocumentTemplateVersionDetail;
|
|
}) {
|
|
const [state, setState] = React.useState<VersionFormState>(toVersionState(version));
|
|
const createMutation = useMutation({
|
|
...createDocumentTemplateVersionMutation,
|
|
onSuccess: () => {
|
|
toast.success('Version saved');
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed')
|
|
});
|
|
const updateMutation = useMutation({
|
|
...updateDocumentTemplateVersionMutation,
|
|
onSuccess: () => {
|
|
toast.success('Version updated');
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setState(toVersionState(version));
|
|
}
|
|
}, [open, version]);
|
|
|
|
async function onSubmit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
const payload = buildVersionPayload(state);
|
|
|
|
if (version) {
|
|
await updateMutation.mutateAsync({ templateId, versionId: version.id, values: payload });
|
|
return;
|
|
}
|
|
|
|
await createMutation.mutateAsync({ id: templateId, values: payload });
|
|
}
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className='sm:max-w-3xl'>
|
|
<DialogHeader>
|
|
<DialogTitle>{version ? 'Edit Version' : 'Create Version'}</DialogTitle>
|
|
<DialogDescription>Schema JSON is accepted as raw JSON in this admin release.</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit} className='grid gap-4'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<Field label='Version'>
|
|
<Input value={state.version} onChange={(e) => setState((current) => ({ ...current, version: e.target.value }))} />
|
|
</Field>
|
|
<Field label='File Path'>
|
|
<Input value={state.filePath} onChange={(e) => setState((current) => ({ ...current, filePath: e.target.value }))} />
|
|
</Field>
|
|
</div>
|
|
<Field label='Preview Image URL'>
|
|
<Input value={state.previewImageUrl} onChange={(e) => setState((current) => ({ ...current, previewImageUrl: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Schema JSON'>
|
|
<Textarea className='min-h-64 font-mono text-xs' value={state.schemaJson} onChange={(e) => setState((current) => ({ ...current, schemaJson: e.target.value }))} />
|
|
</Field>
|
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
|
<div>
|
|
<div className='font-medium'>Active Version</div>
|
|
<div className='text-muted-foreground text-sm'>Inactive versions remain stored for traceability.</div>
|
|
</div>
|
|
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type='submit' isLoading={isPending}>
|
|
{version ? 'Save Version' : 'Create Version'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function MappingDialog({
|
|
open,
|
|
onOpenChange,
|
|
templateId,
|
|
version,
|
|
mapping
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
templateId: string;
|
|
version: DocumentTemplateVersionDetail;
|
|
mapping?: DocumentTemplateVersionDetail['mappings'][number];
|
|
}) {
|
|
const [state, setState] = React.useState<MappingFormState>(toMappingState(mapping));
|
|
const createMutation = useMutation({
|
|
...createDocumentTemplateMappingMutation,
|
|
onSuccess: () => {
|
|
toast.success('Mapping saved');
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed')
|
|
});
|
|
const updateMutation = useMutation({
|
|
...updateDocumentTemplateMappingMutation,
|
|
onSuccess: () => {
|
|
toast.success('Mapping updated');
|
|
onOpenChange(false);
|
|
},
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
setState(toMappingState(mapping));
|
|
}
|
|
}, [open, mapping]);
|
|
|
|
async function onSubmit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
const payload = buildMappingPayload(state);
|
|
|
|
if (mapping) {
|
|
await updateMutation.mutateAsync({ templateId, mappingId: mapping.id, values: payload });
|
|
return;
|
|
}
|
|
|
|
await createMutation.mutateAsync({ templateId, versionId: version.id, values: payload });
|
|
}
|
|
|
|
function updateColumn(index: number, field: keyof MappingColumnState, value: string) {
|
|
setState((current) => ({
|
|
...current,
|
|
columns: current.columns.map((column, columnIndex) =>
|
|
columnIndex === index ? { ...column, [field]: value } : column
|
|
)
|
|
}));
|
|
}
|
|
|
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className='sm:max-w-4xl'>
|
|
<DialogHeader>
|
|
<DialogTitle>{mapping ? 'Edit Mapping' : 'Create Mapping'}</DialogTitle>
|
|
<DialogDescription>Table mappings can define per-column source fields and format masks.</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit} className='grid gap-4'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<Field label='Placeholder Key'>
|
|
<Input value={state.placeholderKey} onChange={(e) => setState((current) => ({ ...current, placeholderKey: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Source Path'>
|
|
<Input value={state.sourcePath} onChange={(e) => setState((current) => ({ ...current, sourcePath: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Data Type'>
|
|
<Input value={state.dataType} onChange={(e) => setState((current) => ({ ...current, dataType: e.target.value as MappingFormState['dataType'] }))} />
|
|
</Field>
|
|
<Field label='Sheet Name'>
|
|
<Input value={state.sheetName} onChange={(e) => setState((current) => ({ ...current, sheetName: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Default Value'>
|
|
<Input value={state.defaultValue} onChange={(e) => setState((current) => ({ ...current, defaultValue: e.target.value }))} />
|
|
</Field>
|
|
<Field label='Format Mask'>
|
|
<Input value={state.formatMask} onChange={(e) => setState((current) => ({ ...current, formatMask: e.target.value }))} />
|
|
</Field>
|
|
</div>
|
|
<Field label='Sort Order'>
|
|
<Input value={state.sortOrder} onChange={(e) => setState((current) => ({ ...current, sortOrder: e.target.value }))} />
|
|
</Field>
|
|
{state.dataType === 'table' ? (
|
|
<div className='space-y-3 rounded-lg border p-4'>
|
|
<div className='flex items-center justify-between'>
|
|
<div className='font-medium'>Table Columns</div>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
onClick={() =>
|
|
setState((current) => ({
|
|
...current,
|
|
columns: [
|
|
...current.columns,
|
|
{
|
|
columnName: '',
|
|
sourceField: '',
|
|
columnLetter: '',
|
|
sortOrder: String(current.columns.length),
|
|
formatMask: ''
|
|
}
|
|
]
|
|
}))
|
|
}
|
|
>
|
|
<Icons.add className='mr-2 h-4 w-4' />
|
|
Add Column
|
|
</Button>
|
|
</div>
|
|
{state.columns.map((column, index) => (
|
|
<div key={`${index}-${column.columnName}`} className='grid gap-3 md:grid-cols-5'>
|
|
<Input placeholder='Column name' value={column.columnName} onChange={(e) => updateColumn(index, 'columnName', e.target.value)} />
|
|
<Input placeholder='Source field' value={column.sourceField} onChange={(e) => updateColumn(index, 'sourceField', e.target.value)} />
|
|
<Input placeholder='Column letter' value={column.columnLetter} onChange={(e) => updateColumn(index, 'columnLetter', e.target.value)} />
|
|
<Input placeholder='Sort order' value={column.sortOrder} onChange={(e) => updateColumn(index, 'sortOrder', e.target.value)} />
|
|
<Input placeholder='Format mask' value={column.formatMask} onChange={(e) => updateColumn(index, 'formatMask', e.target.value)} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<DialogFooter>
|
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button type='submit' isLoading={isPending}>
|
|
{mapping ? 'Save Mapping' : 'Create Mapping'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function TemplateDetailCard({ templateId }: { templateId: string }) {
|
|
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
|
const template = data.template;
|
|
const [versionDialogState, setVersionDialogState] = React.useState<{
|
|
open: boolean;
|
|
version?: DocumentTemplateVersionDetail;
|
|
}>({ open: false });
|
|
const [mappingDialogState, setMappingDialogState] = React.useState<{
|
|
open: boolean;
|
|
version?: DocumentTemplateVersionDetail;
|
|
mapping?: DocumentTemplateVersionDetail['mappings'][number];
|
|
}>({ open: false });
|
|
const deleteMappingMutation = useMutation({
|
|
...deleteDocumentTemplateMappingMutation,
|
|
onSuccess: () => toast.success('Mapping deleted'),
|
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
|
});
|
|
|
|
return (
|
|
<div className='space-y-4'>
|
|
<div className='grid gap-4 md:grid-cols-4'>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='text-muted-foreground text-xs'>Document Type</div>
|
|
<div className='mt-1 font-medium'>{template.documentType}</div>
|
|
</div>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='text-muted-foreground text-xs'>Product Type</div>
|
|
<div className='mt-1 font-medium'>{template.productType}</div>
|
|
</div>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='text-muted-foreground text-xs'>Version Count</div>
|
|
<div className='mt-1 font-medium'>{template.versions.length}</div>
|
|
</div>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='text-muted-foreground text-xs'>Mapping Count</div>
|
|
<div className='mt-1 font-medium'>{template.mappingCount}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue={template.versions[0]?.id ?? 'empty'} className='space-y-4'>
|
|
<div className='flex items-center justify-between gap-4'>
|
|
<TabsList className='flex flex-wrap'>
|
|
{template.versions.map((version) => (
|
|
<TabsTrigger key={version.id} value={version.id}>
|
|
{version.version}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
<Button onClick={() => setVersionDialogState({ open: true, version: undefined })}>
|
|
<Icons.add className='mr-2 h-4 w-4' />
|
|
Add Version
|
|
</Button>
|
|
</div>
|
|
{template.versions.length === 0 ? (
|
|
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
|
No versions created yet.
|
|
</div>
|
|
) : null}
|
|
{template.versions.map((version) => (
|
|
<TabsContent key={version.id} value={version.id} className='space-y-4'>
|
|
<div className='rounded-lg border p-4'>
|
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
|
<div className='min-w-0 flex-1 space-y-3'>
|
|
<div className='font-medium'>Version {version.version}</div>
|
|
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
|
<div className='rounded-lg bg-muted/40 p-3'>
|
|
<div className='text-muted-foreground text-xs'>File Path</div>
|
|
<div className='mt-1 break-all text-sm font-medium'>
|
|
{version.filePath || 'No file path set'}
|
|
</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-3'>
|
|
<div className='text-muted-foreground text-xs'>Mappings</div>
|
|
<div className='mt-1 text-sm font-medium'>{version.mappings.length}</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-3'>
|
|
<div className='text-muted-foreground text-xs'>Table Columns</div>
|
|
<div className='mt-1 text-sm font-medium'>{getTableColumnCount(version)}</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-3'>
|
|
<div className='text-muted-foreground text-xs'>Preview Image</div>
|
|
<div className='mt-1 break-all text-sm font-medium'>
|
|
{version.previewImageUrl || '-'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<Badge variant={version.isActive ? 'secondary' : 'outline'}>
|
|
{version.isActive ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
<TemplateJsonSheet
|
|
version={version.version}
|
|
filePath={version.filePath}
|
|
schemaJson={version.schemaJson}
|
|
/>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() =>
|
|
setVersionDialogState({
|
|
open: true,
|
|
version,
|
|
})
|
|
}
|
|
>
|
|
Edit Version
|
|
</Button>
|
|
<Button
|
|
onClick={() =>
|
|
setMappingDialogState({
|
|
open: true,
|
|
version
|
|
})
|
|
}
|
|
>
|
|
<Icons.add className='mr-2 h-4 w-4' />
|
|
Add Mapping
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<TemplateVersionManagementPanel
|
|
templateId={template.id}
|
|
version={version}
|
|
versions={template.versions}
|
|
/>
|
|
<div className='space-y-3'>
|
|
{version.mappings.map((mapping) => (
|
|
<div key={mapping.id} className='rounded-lg border p-4'>
|
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
|
<div>
|
|
<div className='font-medium'>{mapping.placeholderKey}</div>
|
|
<div className='text-muted-foreground text-sm'>
|
|
{mapping.sourcePath} • {mapping.dataType}
|
|
</div>
|
|
</div>
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() =>
|
|
setMappingDialogState({
|
|
open: true,
|
|
version,
|
|
mapping
|
|
})
|
|
}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() =>
|
|
deleteMappingMutation.mutate({
|
|
templateId,
|
|
mappingId: mapping.id
|
|
})
|
|
}
|
|
isLoading={deleteMappingMutation.isPending}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{mapping.columns.length ? (
|
|
<div className='mt-3 rounded-lg border p-3'>
|
|
<div className='mb-2 text-sm font-medium'>Table Columns</div>
|
|
<div className='space-y-2 text-sm'>
|
|
{mapping.columns.map((column) => (
|
|
<div key={column.id} className='flex flex-wrap gap-2'>
|
|
<Badge variant='outline'>{column.columnName}</Badge>
|
|
<span>{column.sourceField}</span>
|
|
{column.columnLetter ? <span>({column.columnLetter})</span> : null}
|
|
{column.formatMask ? <span>• {column.formatMask}</span> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
{version.mappings.length === 0 ? (
|
|
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
|
No mappings defined for this version yet.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
|
|
<VersionDialog
|
|
open={versionDialogState.open}
|
|
onOpenChange={(open) =>
|
|
setVersionDialogState((current) => ({ ...current, open }))
|
|
}
|
|
templateId={template.id}
|
|
version={versionDialogState.version}
|
|
/>
|
|
{mappingDialogState.open && mappingDialogState.version ? (
|
|
<MappingDialog
|
|
open={mappingDialogState.open}
|
|
onOpenChange={(open) => setMappingDialogState((current) => ({ ...current, open }))}
|
|
templateId={template.id}
|
|
version={mappingDialogState.version}
|
|
mapping={mappingDialogState.mapping}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TemplateSettings() {
|
|
const { data } = useSuspenseQuery(
|
|
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
|
|
);
|
|
const [templateDialogState, setTemplateDialogState] = React.useState<{
|
|
open: boolean;
|
|
templateId?: string;
|
|
}>({ open: false });
|
|
|
|
return (
|
|
<div className='space-y-6'>
|
|
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
|
<div>
|
|
<div className='font-medium'>Template Configuration Center</div>
|
|
<div className='text-muted-foreground text-sm'>
|
|
Admins can manage metadata, versions, mappings, and JSON schema without a visual designer.
|
|
</div>
|
|
</div>
|
|
<Button onClick={() => setTemplateDialogState({ open: true })}>
|
|
<Icons.add className='mr-2 h-4 w-4' />
|
|
Create Template
|
|
</Button>
|
|
</div>
|
|
|
|
{!data.items.length ? (
|
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
|
No production document templates found.
|
|
</div>
|
|
) : (
|
|
data.items.map((template) => (
|
|
<div key={template.id} className='space-y-4 rounded-xl border p-5'>
|
|
<div className='flex flex-wrap items-start justify-between gap-4'>
|
|
<div className='space-y-1'>
|
|
<div className='text-xl font-semibold'>{template.templateName}</div>
|
|
<div className='text-muted-foreground text-sm'>
|
|
{template.description || 'No description provided.'}
|
|
</div>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{template.isDefault ? <Badge>Default</Badge> : null}
|
|
<Badge variant={template.isActive ? 'secondary' : 'outline'}>
|
|
{template.isActive ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
<Badge variant='outline'>{template.fileType}</Badge>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() => setTemplateDialogState({ open: true, templateId: template.id })}
|
|
>
|
|
Edit Metadata
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-4 md:grid-cols-5'>
|
|
<div className='rounded-lg bg-muted/40 p-4'>
|
|
<div className='text-muted-foreground text-xs'>Document Type</div>
|
|
<div className='mt-1 text-sm font-medium'>{template.documentType}</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-4'>
|
|
<div className='text-muted-foreground text-xs'>Product Type</div>
|
|
<div className='mt-1 text-sm font-medium'>{template.productType}</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-4'>
|
|
<div className='text-muted-foreground text-xs'>Latest Version</div>
|
|
<div className='mt-1 text-sm font-medium'>{template.latestVersion ?? '-'}</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-4'>
|
|
<div className='text-muted-foreground text-xs'>Version Count</div>
|
|
<div className='mt-1 text-sm font-medium'>{template.versionCount}</div>
|
|
</div>
|
|
<div className='rounded-lg bg-muted/40 p-4'>
|
|
<div className='text-muted-foreground text-xs'>Mapping Count</div>
|
|
<div className='mt-1 text-sm font-medium'>{template.mappingCount}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<React.Suspense
|
|
fallback={
|
|
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
|
Loading template detail...
|
|
</div>
|
|
}
|
|
>
|
|
<TemplateDetailCard templateId={template.id} />
|
|
</React.Suspense>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
<TemplateDialog
|
|
open={templateDialogState.open && !templateDialogState.templateId}
|
|
onOpenChange={(open) => setTemplateDialogState((current) => ({ ...current, open }))}
|
|
/>
|
|
{templateDialogState.open && templateDialogState.templateId ? (
|
|
<React.Suspense fallback={null}>
|
|
<TemplateDetailDialog
|
|
templateId={templateDialogState.templateId}
|
|
open={templateDialogState.open}
|
|
onOpenChange={(open) => setTemplateDialogState((current) => ({ ...current, open }))}
|
|
/>
|
|
</React.Suspense>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TemplateDetailDialog({
|
|
templateId,
|
|
open,
|
|
onOpenChange
|
|
}: {
|
|
templateId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
|
|
|
return <TemplateDialog open={open} onOpenChange={onOpenChange} template={data.template} />;
|
|
}
|