This commit is contained in:
phaichayon
2026-06-29 23:05:01 +07:00
parent bdc2d9f1b4
commit 4570495a77
24 changed files with 2944 additions and 114 deletions

View File

@@ -19,6 +19,7 @@ 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,
@@ -527,7 +528,10 @@ function MappingDialog({
function TemplateDetailCard({ templateId }: { templateId: string }) {
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
const template = data.template;
const [versionDialogOpen, setVersionDialogOpen] = React.useState(false);
const [versionDialogState, setVersionDialogState] = React.useState<{
open: boolean;
version?: DocumentTemplateVersionDetail;
}>({ open: false });
const [mappingDialogState, setMappingDialogState] = React.useState<{
open: boolean;
version?: DocumentTemplateVersionDetail;
@@ -569,7 +573,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
</TabsTrigger>
))}
</TabsList>
<Button onClick={() => setVersionDialogOpen(true)}>
<Button onClick={() => setVersionDialogState({ open: true, version: undefined })}>
<Icons.add className='mr-2 h-4 w-4' />
Add Version
</Button>
@@ -617,7 +621,15 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
filePath={version.filePath}
schemaJson={version.schemaJson}
/>
<Button variant='outline' onClick={() => setVersionDialogOpen(true)}>
<Button
variant='outline'
onClick={() =>
setVersionDialogState({
open: true,
version,
})
}
>
Edit Version
</Button>
<Button
@@ -634,6 +646,11 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
</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'>
@@ -699,10 +716,12 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
</Tabs>
<VersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
open={versionDialogState.open}
onOpenChange={(open) =>
setVersionDialogState((current) => ({ ...current, open }))
}
templateId={template.id}
version={undefined}
version={versionDialogState.version}
/>
{mappingDialogState.open && mappingDialogState.version ? (
<MappingDialog

View File

@@ -0,0 +1,472 @@
'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 {
activateDocumentTemplateVersionMutation,
archiveDocumentTemplateVersionMutation,
compareDocumentTemplateVersionsMutation,
previewDocumentTemplateVersionMutation,
publishDocumentTemplateVersionMutation,
rollbackDocumentTemplateVersionMutation,
validateDocumentTemplateVersionMutation,
} from '../mutations';
import { documentTemplateVersionManagementQueryOptions } from '../queries';
import type {
DocumentTemplateVersionAuditSummary,
DocumentTemplateVersionDetail,
DocumentTemplateVersionValidationSummary,
} from '../types';
function getStatusBadgeVariant(status: 'PASS' | 'WARNING' | 'FAIL' | 'NOT_RUN') {
if (status === 'PASS') {
return 'secondary' as const;
}
if (status === 'FAIL') {
return 'destructive' as const;
}
return 'outline' as const;
}
function getLifecycleBadgeVariant(
status: DocumentTemplateVersionDetail['lifecycleStatus'],
) {
if (status === 'active') {
return 'secondary' as const;
}
if (status === 'archived') {
return 'outline' as const;
}
return 'default' as const;
}
function SummaryCard({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className='rounded-lg bg-muted/40 p-3'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='mt-1 text-sm font-medium'>{value}</div>
</div>
);
}
function IssueList({
title,
items,
}: {
title: string;
items: string[];
}) {
if (!items.length) {
return null;
}
return (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>{title}</div>
<div className='space-y-1 text-sm'>
{items.map((item) => (
<div key={item} className='text-muted-foreground break-all'>
{item}
</div>
))}
</div>
</div>
);
}
function RuntimeIssueList({
validation,
}: {
validation: DocumentTemplateVersionValidationSummary | null | undefined;
}) {
if (!validation?.runtimeIssues.length) {
return null;
}
return (
<div className='space-y-2 rounded-lg border p-3'>
<div className='text-sm font-medium'>Runtime Issues</div>
<div className='space-y-2'>
{validation.runtimeIssues.map((issue) => (
<div key={`${issue.code}-${issue.message}`} className='rounded-md bg-muted/40 p-2 text-sm'>
<div className='flex items-center gap-2'>
<Badge variant={issue.severity === 'error' ? 'destructive' : 'outline'}>
{issue.severity}
</Badge>
<span className='font-medium'>{issue.code}</span>
</div>
<div className='text-muted-foreground mt-1'>{issue.message}</div>
</div>
))}
</div>
</div>
);
}
function CompareDialog({
open,
onOpenChange,
templateId,
currentVersion,
versions,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
templateId: string;
currentVersion: DocumentTemplateVersionDetail;
versions: DocumentTemplateVersionDetail[];
}) {
const [rightVersionId, setRightVersionId] = React.useState(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
const compareMutation = useMutation({
...compareDocumentTemplateVersionsMutation,
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Compare failed'),
});
React.useEffect(() => {
if (open) {
setRightVersionId(
versions.find((item) => item.id !== currentVersion.id)?.id ?? currentVersion.id,
);
}
}, [currentVersion.id, open, versions]);
async function handleCompare() {
const result = await compareMutation.mutateAsync({
templateId,
leftVersionId: currentVersion.id,
rightVersionId,
});
const metadataCount = result.comparison.metadataChanges.length;
const placeholderCount =
result.comparison.placeholderOnlyLeft.length +
result.comparison.placeholderOnlyRight.length;
const mappingCount = result.comparison.mappingDifferences.length;
toast.success(
`Compare complete: ${metadataCount} metadata, ${placeholderCount} placeholders, ${mappingCount} mappings`,
);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Compare Versions</DialogTitle>
<DialogDescription>
Compare current version against another version in the same template family.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Base Version</div>
<div className='rounded-lg border px-3 py-2 text-sm'>{currentVersion.version}</div>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Compare With</div>
<select
className='flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm'
value={rightVersionId}
onChange={(event) => setRightVersionId(event.target.value)}
>
{versions
.filter((item) => item.id !== currentVersion.id)
.map((version) => (
<option key={version.id} value={version.id}>
{version.version}
</option>
))}
</select>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
onClick={handleCompare}
isLoading={compareMutation.isPending}
disabled={!rightVersionId || rightVersionId === currentVersion.id}
>
Compare
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function TemplateVersionManagementPanel({
templateId,
version,
versions,
}: {
templateId: string;
version: DocumentTemplateVersionDetail;
versions: DocumentTemplateVersionDetail[];
}) {
const { data } = useSuspenseQuery(
documentTemplateVersionManagementQueryOptions(version.id),
);
const managedVersion = data.version;
const [compareOpen, setCompareOpen] = React.useState(false);
const validateMutation = useMutation({
...validateDocumentTemplateVersionMutation,
onSuccess: (result) => toast.success(`Validation: ${result.validation.status}`),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Validate failed'),
});
const publishMutation = useMutation({
...publishDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Version published'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Publish failed'),
});
const activateMutation = useMutation({
...activateDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Version activated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Activate failed'),
});
const rollbackMutation = useMutation({
...rollbackDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Rollback completed'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Rollback failed'),
});
const archiveMutation = useMutation({
...archiveDocumentTemplateVersionMutation,
onSuccess: () => toast.success('Version archived'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Archive failed'),
});
const previewMutation = useMutation({
...previewDocumentTemplateVersionMutation,
onSuccess: (result) => {
const url = `data:${result.preview.contentType};base64,${result.preview.base64}`;
window.open(url, '_blank', 'noopener,noreferrer');
toast.success(`Preview ready (${result.preview.pageCount} pages)`);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Preview failed'),
});
const validation = managedVersion.validationSummary ?? null;
const audit = managedVersion.auditSummary ?? null;
function renderAuditSummary(auditSummary: DocumentTemplateVersionAuditSummary | null) {
if (!auditSummary) {
return <div className='text-muted-foreground text-sm'>No audit summary available.</div>;
}
return (
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Audit'
value={<Badge variant={getStatusBadgeVariant(auditSummary.status)}>{auditSummary.status}</Badge>}
/>
<SummaryCard
label='Visual Regression'
value={
<Badge variant={getStatusBadgeVariant(auditSummary.visualRegressionStatus)}>
{auditSummary.visualRegressionStatus}
</Badge>
}
/>
<SummaryCard
label='Audited At'
value={auditSummary.auditedAt ?? 'Not available'}
/>
<SummaryCard
label='Runtime Version'
value={auditSummary.runtimeVersion ?? 'Not set'}
/>
</div>
);
}
return (
<>
<div className='space-y-4 rounded-lg border p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='space-y-2'>
<div className='font-medium'>Version Lifecycle Management</div>
<div className='flex flex-wrap gap-2'>
<Badge variant={getLifecycleBadgeVariant(managedVersion.lifecycleStatus)}>
{managedVersion.lifecycleStatus ?? 'draft'}
</Badge>
<Badge variant={managedVersion.isActive ? 'secondary' : 'outline'}>
{managedVersion.isActive ? 'Active Runtime' : 'Inactive Runtime'}
</Badge>
{validation ? (
<Badge variant={getStatusBadgeVariant(validation.status)}>
Validation {validation.status}
</Badge>
) : null}
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => previewMutation.mutate(version.id)}
isLoading={previewMutation.isPending}
>
<Icons.post className='mr-2 h-4 w-4' />
Preview
</Button>
<Button variant='outline' onClick={() => setCompareOpen(true)}>
<Icons.share className='mr-2 h-4 w-4' />
Compare
</Button>
<Button
variant='outline'
onClick={() => validateMutation.mutate(version.id)}
isLoading={validateMutation.isPending}
>
Validate
</Button>
<Button
variant='outline'
onClick={() => publishMutation.mutate({ templateId, versionId: version.id })}
isLoading={publishMutation.isPending}
>
Publish
</Button>
<Button
onClick={() => activateMutation.mutate({ templateId, versionId: version.id })}
isLoading={activateMutation.isPending}
>
Activate
</Button>
<Button
variant='outline'
onClick={() => rollbackMutation.mutate({ templateId, versionId: version.id })}
isLoading={rollbackMutation.isPending}
>
Rollback
</Button>
<Button
variant='outline'
onClick={() => archiveMutation.mutate({ templateId, versionId: version.id })}
isLoading={archiveMutation.isPending}
>
Archive
</Button>
</div>
</div>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Runtime Version'
value={managedVersion.runtimeVersion ?? 'Not set'}
/>
<SummaryCard
label='Template Variant'
value={managedVersion.templateVariant ?? 'Not set'}
/>
<SummaryCard label='Brand' value={managedVersion.brand ?? 'Not set'} />
<SummaryCard
label='Published At'
value={managedVersion.publishedAt ?? 'Not published'}
/>
<SummaryCard
label='Activated At'
value={managedVersion.activatedAt ?? 'Not activated'}
/>
<SummaryCard
label='Archived At'
value={managedVersion.archivedAt ?? 'Not archived'}
/>
<SummaryCard
label='Previous Version'
value={managedVersion.previousVersionId ?? 'None'}
/>
<SummaryCard
label='Next Version'
value={managedVersion.nextVersionId ?? 'None'}
/>
</div>
<div className='space-y-3'>
<div className='font-medium'>Validation Summary</div>
{validation ? (
<div className='space-y-3'>
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<SummaryCard
label='Validation Status'
value={<Badge variant={getStatusBadgeVariant(validation.status)}>{validation.status}</Badge>}
/>
<SummaryCard
label='Validated At'
value={validation.validatedAt ?? 'Not validated'}
/>
<SummaryCard
label='Required Missing'
value={validation.requiredFieldsMissing.length}
/>
<SummaryCard
label='Unmapped Placeholders'
value={validation.unmappedPlaceholders.length}
/>
</div>
<IssueList
title='Required Fields Missing'
items={validation.requiredFieldsMissing}
/>
<IssueList title='Duplicate Fields' items={validation.duplicateFields} />
<IssueList
title='Unmapped Placeholders'
items={validation.unmappedPlaceholders}
/>
<RuntimeIssueList validation={validation} />
</div>
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Validation has not been run for this version yet.
</div>
)}
</div>
<div className='space-y-3'>
<div className='font-medium'>Audit Summary</div>
{renderAuditSummary(audit)}
</div>
</div>
<CompareDialog
open={compareOpen}
onOpenChange={setCompareOpen}
templateId={templateId}
currentVersion={version}
versions={versions}
/>
</>
);
}