251215:1719 Docunment Number Rule not correct
Some checks failed
Spec Validation / validate-markdown (push) Has been cancelled
Spec Validation / validate-diagrams (push) Has been cancelled
Spec Validation / check-todos (push) Has been cancelled

This commit is contained in:
admin
2025-12-15 17:19:40 +07:00
parent ec35521258
commit 78370fb590
23 changed files with 1461 additions and 1609 deletions

View File

@@ -4,16 +4,28 @@ import { useState, useEffect } from "react";
import { TemplateEditor } from "@/components/numbering/template-editor";
import { SequenceViewer } from "@/components/numbering/sequence-viewer";
import { numberingApi } from "@/lib/api/numbering";
import { NumberingTemplate } from "@/types/numbering";
import { NumberingTemplate } from "@/lib/api/numbering"; // Correct import
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects";
export default function EditTemplatePage({ params }: { params: { id: string } }) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [template, setTemplate] = useState<NumberingTemplate | null>(null);
// Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: projects = [] } = useProjects();
const projectId = template?.projectId || 1;
const { data: contracts = [] } = useContracts(projectId);
const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3';
useEffect(() => {
const fetchTemplate = async () => {
setLoading(true);
@@ -76,7 +88,9 @@ export default function EditTemplatePage({ params }: { params: { id: string } })
<TemplateEditor
template={template}
projectId={template.projectId || 1}
projectName="LCBP3"
projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes}
disciplines={disciplines}
onSave={handleSave}
onCancel={handleCancel}
/>

View File

@@ -3,10 +3,22 @@
import { TemplateEditor } from "@/components/numbering/template-editor";
import { numberingApi, NumberingTemplate } from "@/lib/api/numbering";
import { useRouter } from "next/navigation";
import { useCorrespondenceTypes, useContracts, useDisciplines } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects";
export default function NewTemplatePage() {
const router = useRouter();
// Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: projects = [] } = useProjects();
const projectId = 1; // Default or sync with selection
const { data: contracts = [] } = useContracts(projectId);
const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3';
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
@@ -25,8 +37,10 @@ export default function NewTemplatePage() {
<div className="p-6 space-y-6">
<h1 className="text-3xl font-bold">New Numbering Template</h1>
<TemplateEditor
projectId={1}
projectName="LCBP3"
projectId={projectId}
projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes}
disciplines={disciplines}
onSave={handleSave}
onCancel={handleCancel}
/>

View File

@@ -2,15 +2,18 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Play } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Plus, Edit, Play, AlertTriangle, ShieldAlert, CheckCircle2 } from 'lucide-react';
import { numberingApi, NumberingTemplate } from '@/lib/api/numbering';
import { TemplateEditor } from '@/components/numbering/template-editor';
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
import { TemplateTester } from '@/components/numbering/template-tester';
import { toast } from 'sonner';
import {
Select,
SelectContent,
@@ -18,15 +21,148 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useProjects } from '@/hooks/use-master-data';
// --- Sub-components for Tools ---
function ManualOverrideForm({ onSuccess, projectId }: { onSuccess: () => void, projectId: number }) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
typeId: '',
disciplineId: '',
year: new Date().getFullYear().toString(),
newSequence: '',
reason: ''
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await numberingApi.manualOverride({
projectId,
typeId: parseInt(formData.typeId),
disciplineId: formData.disciplineId ? parseInt(formData.disciplineId) : undefined,
year: parseInt(formData.year),
newSequence: parseInt(formData.newSequence),
reason: formData.reason,
userId: 1 // TODO: Get from auth context
});
toast.success("Manual override applied successfully");
onSuccess();
} catch (error) {
toast.error("Failed to apply override");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Manual Override</CardTitle>
<CardDescription>Force set a counter sequence. Use with caution.</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Changing counters manually can cause duplication errors.</AlertDescription>
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Type ID</Label>
<Input
placeholder="e.g. 1"
value={formData.typeId}
onChange={e => setFormData({...formData, typeId: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label>Discipline ID</Label>
<Input
placeholder="Optional"
value={formData.disciplineId}
onChange={e => setFormData({...formData, disciplineId: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Year</Label>
<Input
type="number"
value={formData.year}
onChange={e => setFormData({...formData, year: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label>New Sequence</Label>
<Input
type="number"
placeholder="e.g. 5"
value={formData.newSequence}
onChange={e => setFormData({...formData, newSequence: e.target.value})}
required
/>
</div>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Textarea
placeholder="Why is this override needed?"
value={formData.reason}
onChange={e => setFormData({...formData, reason: e.target.value})}
required
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading && <ShieldAlert className="mr-2 h-4 w-4 animate-spin" />}
Apply Override
</Button>
</form>
</CardContent>
</Card>
)
}
function AdminMetrics() {
// Fetch metrics from /admin/document-numbering/metrics
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Generation Success Rate</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">99.9%</div>
<p className="text-xs text-muted-foreground">+0.1% from last month</p>
</CardContent>
</Card>
{/* More cards... */}
<Card className="col-span-full">
<CardHeader>
<CardTitle>Recent Audit Logs</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Log viewer implementation pending.</p>
</CardContent>
</Card>
</div>
)
}
export default function NumberingPage() {
const { data: projects = [] } = useProjects();
const [selectedProjectId, setSelectedProjectId] = useState("1");
const [activeTab, setActiveTab] = useState("templates");
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
const [, setLoading] = useState(true);
const [loading, setLoading] = useState(true);
// View states
const [isEditing, setIsEditing] = useState(false);
@@ -36,6 +172,12 @@ export default function NumberingPage() {
const selectedProjectName = projects.find((p: any) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
// Master Data
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
const { data: contracts = [] } = useContracts(Number(selectedProjectId));
const contractId = contracts[0]?.id;
const { data: disciplines = [] } = useDisciplines(contractId);
const loadTemplates = async () => {
setLoading(true);
try {
@@ -60,7 +202,7 @@ export default function NumberingPage() {
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
toast.success(data.templateId ? "Template updated" : "Template created");
toast.success(data.id || data.templateId ? "Template updated" : "Template created");
setIsEditing(false);
loadTemplates();
} catch {
@@ -73,6 +215,8 @@ export default function NumberingPage() {
setIsTesting(true);
};
if (isEditing) {
return (
<div className="p-6 max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4">
@@ -80,6 +224,8 @@ export default function NumberingPage() {
template={activeTemplate}
projectId={Number(selectedProjectId)}
projectName={selectedProjectName}
correspondenceTypes={correspondenceTypes}
disciplines={disciplines}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
/>
@@ -95,7 +241,7 @@ export default function NumberingPage() {
Document Numbering
</h1>
<p className="text-muted-foreground mt-1">
Manage numbering templates and sequences
Manage numbering templates, audit logs, and tools
</p>
</div>
<div className="flex gap-2">
@@ -111,77 +257,117 @@ export default function NumberingPage() {
))}
</SelectContent>
</Select>
<Button onClick={() => handleEdit(undefined)}>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<h2 className="text-lg font-semibold">Templates - {selectedProjectName}</h2>
<div className="grid gap-4">
{templates
.filter(t => !t.projectId || t.projectId === Number(selectedProjectId))
.map((template) => (
<Card key={template.templateId} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.documentTypeName}
</h3>
<Badge variant="outline" className="text-xs">
{projects.find((p: any) => p.id.toString() === template.projectId?.toString())?.projectName || selectedProjectName}
</Badge>
{template.disciplineCode && <Badge>{template.disciplineCode}</Badge>}
<Badge variant={template.isActive ? 'default' : 'secondary'}>
{template.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList>
<TabsTrigger value="templates">Templates</TabsTrigger>
<TabsTrigger value="metrics">Metrics & Audit</TabsTrigger>
<TabsTrigger value="tools">Admin Tools</TabsTrigger>
</TabsList>
<div className="bg-slate-100 dark:bg-slate-900 rounded px-3 py-2 mb-3 font-mono text-sm inline-block border">
{template.templateFormat}
</div>
<TabsContent value="templates" className="space-y-4">
<div className="flex justify-end">
<Button onClick={() => handleEdit(undefined)}>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
<div>
<span className="text-muted-foreground">Example: </span>
<span className="font-medium font-mono text-green-600 dark:text-green-400">
{template.exampleNumber}
</span>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="grid gap-4">
{templates
.filter(t => !t.projectId || t.projectId === Number(selectedProjectId))
.map((template) => (
<Card key={template.templateId} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold">
{template.documentTypeName}
</h3>
<Badge variant="outline" className="text-xs">
{projects.find((p: any) => p.id.toString() === template.projectId?.toString())?.projectName || selectedProjectName}
</Badge>
{template.disciplineCode && <Badge>{template.disciplineCode}</Badge>}
<Badge variant={template.isActive ? 'default' : 'secondary'}>
{template.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<div className="bg-slate-100 dark:bg-slate-900 rounded px-3 py-2 mb-3 font-mono text-sm inline-block border">
{template.templateFormat}
</div>
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
<div>
<span className="text-muted-foreground">Example: </span>
<span className="font-medium font-mono text-green-600 dark:text-green-400">
{template.exampleNumber}
</span>
</div>
<div>
<span className="text-muted-foreground">Reset: </span>
<span>
{template.resetAnnually ? 'Annually' : 'Never'}
</span>
</div>
</div>
</div>
<div>
<span className="text-muted-foreground">Reset: </span>
<span>
{template.resetAnnually ? 'Annually' : 'Never'}
</span>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(template)}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm" onClick={() => handleTest(template)}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
</div>
</Card>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(template)}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button variant="outline" size="sm" onClick={() => handleTest(template)}>
<Play className="mr-2 h-4 w-4" />
Test
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
<div className="space-y-4">
<SequenceViewer />
</div>
</div>
</TabsContent>
<div className="space-y-4">
{/* Sequence Viewer Sidebar */}
<SequenceViewer />
</div>
</div>
<TabsContent value="metrics" className="space-y-4">
<AdminMetrics />
</TabsContent>
<TabsContent value="tools" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<ManualOverrideForm onSuccess={() => {}} projectId={Number(selectedProjectId)} />
<Card>
<CardHeader>
<CardTitle>Void & Replace</CardTitle>
<CardDescription>Safe voiding of issued numbers.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded border border-yellow-200 dark:border-yellow-900 text-sm">
To void and replace numbers, please use the <strong>Correspondences</strong> list view actions or edit specific documents directly.
<br/><br/>
This ensures the void action is linked to the correct document record.
</div>
<Button variant="outline" className="w-full" disabled>
Standalone Void Tool (Coming Soon)
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<TemplateTester
open={isTesting}

View File

@@ -141,7 +141,9 @@ export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?
// Map recipients structure matching backend expectation
recipients: [{ organizationId: toOrgId, type: 'TO' }],
// Add date just to be safe, though service uses 'now'
dueDate: new Date().toISOString()
dueDate: new Date().toISOString(),
// [Fix] Subject is required by DTO validation, send placeholder if empty
subject: watch('subject') || "Preview Subject"
});
setPreview(res);
} catch (err) {
@@ -157,18 +159,47 @@ export function CorrespondenceForm({ initialData, id }: { initialData?: any, id?
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
{/* Existing Document Number (Read Only) */}
{initialData?.correspondenceNumber && (
<div className="space-y-2">
<Label>Current Document Number</Label>
<div className="flex items-center gap-2">
<Input value={initialData.correspondenceNumber} disabled readOnly className="bg-muted font-mono font-bold text-lg w-full" />
{preview && preview.number !== initialData.correspondenceNumber && (
<span className="text-xs text-amber-600 font-semibold whitespace-nowrap px-2">
Start Change Detected
</span>
)}
</div>
</div>
)}
{/* Preview Section */}
{preview && (
<div className="p-4 rounded-md bg-muted border border-border">
<p className="text-sm text-muted-foreground mb-1">Document Number Preview</p>
<div className={`p-4 rounded-md border ${preview.number !== initialData?.correspondenceNumber ? 'bg-amber-50 border-amber-200' : 'bg-muted border-border'}`}>
<p className="text-sm font-semibold mb-1 flex items-center gap-2">
{initialData?.correspondenceNumber ? "New Document Number (Preview)" : "Document Number Preview"}
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full border border-amber-200">
Will Update
</span>
)}
</p>
<div className="flex items-center gap-3">
<span className="text-xl font-bold font-mono text-primary tracking-wide">{preview.number}</span>
<span className={`text-xl font-bold font-mono tracking-wide ${preview.number !== initialData?.correspondenceNumber ? 'text-amber-700' : 'text-primary'}`}>
{preview.number}
</span>
{preview.isDefaultTemplate && (
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800 border border-yellow-200">
Default Template
</span>
)}
</div>
{preview.number !== initialData?.correspondenceNumber && initialData?.correspondenceNumber && (
<p className="text-xs text-muted-foreground mt-2">
* The document number will be regenerated because critical fields were changed.
</p>
)}
</div>
)}

View File

@@ -15,48 +15,52 @@ import {
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { NumberingTemplate } from '@/lib/api/numbering';
import { cn } from '@/lib/utils';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
const DOCUMENT_TYPES = [
{ value: 'RFA', label: 'Request for Approval (RFA)' },
{ value: 'RFI', label: 'Request for Information (RFI)' },
{ value: 'TRANSMITTAL', label: 'Transmittal' },
{ value: 'EMAIL', label: 'Email' },
{ value: 'INSTRUCTION', label: 'Instruction' },
{ value: 'LETTER', label: 'Letter' },
{ value: 'MEMO', label: 'Memorandum' },
{ value: 'MOM', label: 'Minutes of Meeting' },
{ value: 'NOTICE', label: 'Notice' },
{ value: 'OTHER', label: 'Other' },
];
// Aligned with Backend replacement logic
const VARIABLES = [
{ key: '{PROJECT}', name: 'Project Code', example: 'LCBP3' },
{ key: '{ORIGINATOR}', name: 'Originator Code', example: 'PAT' },
{ key: '{ORG}', name: 'Originator Code', example: 'PAT' },
{ key: '{RECIPIENT}', name: 'Recipient Code', example: 'CN' },
{ key: '{CORR_TYPE}', name: 'Correspondence Type', example: 'RFA' },
{ key: '{SUB_TYPE}', name: 'Sub Type', example: '21' },
{ key: '{DISCIPLINE}', name: 'Discipline', example: 'STR' },
{ key: '{RFA_TYPE}', name: 'RFA Type', example: 'SDW' },
{ key: '{YEAR:B.E.}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR:A.D.}', name: 'Year (A.D.)', example: '2025' },
{ key: '{TYPE}', name: 'Type Code', example: 'RFA' },
{ key: '{DISCIPLINE}', name: 'Discipline Code', example: 'STR' },
{ key: '{SUBTYPE}', name: 'Sub-Type Code', example: 'GEN' },
{ key: '{SUBTYPE_NUM}', name: 'Sub-Type Number', example: '01' },
{ key: '{YEAR}', name: 'Year (B.E.)', example: '2568' },
{ key: '{YEAR_SHORT}', name: 'Year Short (68)', example: '68' },
{ key: '{SEQ:4}', name: 'Sequence (4-digit)', example: '0001' },
{ key: '{REV}', name: 'Revision', example: 'A' },
];
export interface TemplateEditorProps {
template?: NumberingTemplate;
projectId: number;
projectName: string;
correspondenceTypes: any[];
disciplines: any[];
onSave: (data: Partial<NumberingTemplate>) => void;
onCancel: () => void;
}
export function TemplateEditor({ template, projectId, projectName, onSave, onCancel }: TemplateEditorProps) {
const [format, setFormat] = useState(template?.templateFormat || '');
const [docType, setDocType] = useState(template?.documentTypeName || '');
const [discipline, setDiscipline] = useState(template?.disciplineCode || '');
export function TemplateEditor({
template,
projectId,
projectName,
correspondenceTypes,
disciplines,
onSave,
onCancel
}: TemplateEditorProps) {
const [format, setFormat] = useState(template?.formatTemplate || template?.templateFormat || '');
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
const [disciplineId, setDisciplineId] = useState<string>(template?.disciplineId?.toString() || '0');
const [padding, setPadding] = useState(template?.paddingLength || 4);
const [reset, setReset] = useState(template?.resetAnnually ?? true);
const [isActive, setIsActive] = useState(template?.isActive ?? true);
const [preview, setPreview] = useState('');
@@ -64,17 +68,25 @@ export function TemplateEditor({ template, projectId, projectName, onSave, onCan
// Generate preview
let previewText = format || '';
VARIABLES.forEach((v) => {
// Simple mock replacement for preview
let replacement = v.example;
// Dynamic preview for dates to be more realistic
if (v.key === '{YYYY}') replacement = new Date().getFullYear().toString();
if (v.key === '{YY}') replacement = new Date().getFullYear().toString().slice(-2);
if (v.key === '{THXXXX}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{THXX}') replacement = (new Date().getFullYear() + 543).toString().slice(-2);
if (v.key === '{YEAR}') replacement = (new Date().getFullYear() + 543).toString();
if (v.key === '{YEAR_SHORT}') replacement = (new Date().getFullYear() + 543).toString().slice(-2);
// Dynamic context based on selection (optional visual enhancement)
if (v.key === '{TYPE}' && typeId) {
const t = correspondenceTypes.find(ct => ct.id.toString() === typeId);
if (t) replacement = t.typeCode;
}
if (v.key === '{DISCIPLINE}' && disciplineId !== '0') {
const d = disciplines.find(di => di.id.toString() === disciplineId);
if (d) replacement = d.disciplineCode;
}
previewText = previewText.replace(new RegExp(v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
});
setPreview(previewText);
}, [format]);
}, [format, typeId, disciplineId, correspondenceTypes, disciplines]);
const insertVariable = (variable: string) => {
setFormat((prev) => prev + variable);
@@ -84,140 +96,151 @@ export function TemplateEditor({ template, projectId, projectName, onSave, onCan
onSave({
...template,
projectId: projectId,
templateFormat: format,
documentTypeName: docType,
disciplineCode: discipline || undefined,
correspondenceTypeId: Number(typeId),
disciplineId: Number(disciplineId),
formatTemplate: format,
templateFormat: format, // Legacy support
paddingLength: padding,
resetAnnually: reset,
isActive: isActive,
exampleNumber: preview
});
};
const isValid = format.length > 0 && typeId;
return (
<Card className="p-6 space-y-6">
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
<Badge variant="outline" className="text-base px-3 py-1">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold">{template ? 'Edit Template' : 'New Template'}</h3>
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<p className="text-sm text-muted-foreground">Define how document numbers are generated for this project.</p>
</div>
<div className="flex flex-col items-end gap-2">
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
Project: {projectName}
</Badge>
</div>
<div className="grid gap-4">
<div>
<Label>Document Type *</Label>
<Select value={docType} onValueChange={setDocType}>
<SelectTrigger>
<SelectValue placeholder="Select document type" />
</SelectTrigger>
<SelectContent>
{DOCUMENT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select value={discipline || "ALL"} onValueChange={(val) => setDiscipline(val === "ALL" ? "" : val)}>
<SelectTrigger>
<SelectValue placeholder="All disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All</SelectItem>
<SelectItem value="STR">STR - Structure</SelectItem>
<SelectItem value="ARC">ARC - Architecture</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Template Format *</Label>
<div className="space-y-2">
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="e.g., {ORIGINATOR}-{RECIPIENT}-{DOCTYPE}-{YYYY}-{SEQ}"
className="font-mono text-base"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<Button
key={v.key}
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
className="font-mono text-xs"
>
{v.key}
</Button>
))}
</div>
</div>
</div>
<div>
<Label>Preview</Label>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-gray-600 mb-1">Example number:</p>
<p className="text-2xl font-mono font-bold text-green-700">
{preview || 'Enter format above'}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Sequence Padding Length</Label>
<Input
type="number"
value={padding}
onChange={e => setPadding(Number(e.target.value))}
min={1} max={10}
/>
<p className="text-xs text-muted-foreground mt-1">
Number of digits (e.g., 4 = 0001)
</p>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={reset} onCheckedChange={(checked) => setReset(!!checked)} />
<span className="text-sm select-none">Reset annually (on January 1st)</span>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<Checkbox checked={isActive} onCheckedChange={(c) => setIsActive(!!c)} />
Active
</label>
</div>
</div>
</div>
{/* Variable Reference */}
<div>
<h4 className="font-semibold mb-3">Available Variables</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{VARIABLES.map((v) => (
<div
key={v.key}
className="flex items-center justify-between p-2 bg-slate-50 dark:bg-slate-900 rounded border"
>
<div>
<Badge variant="outline" className="font-mono bg-white dark:bg-black">
{v.key}
</Badge>
<p className="text-xs text-muted-foreground mt-1">{v.name}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Configuration Column */}
<div className="space-y-4">
<div>
<Label>Document Type *</Label>
<Select value={typeId} onValueChange={setTypeId}>
<SelectTrigger>
<SelectValue placeholder="Select type..." />
</SelectTrigger>
<SelectContent>
{correspondenceTypes.map((type) => (
<SelectItem key={type.id} value={type.id.toString()}>
{type.typeCode} - {type.typeName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-sm text-foreground">{v.example}</span>
</div>
))}
</div>
<div>
<Label>Discipline (Optional)</Label>
<Select value={disciplineId} onValueChange={setDisciplineId}>
<SelectTrigger>
<SelectValue placeholder="All/None" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">All Disciplines (Default)</SelectItem>
{disciplines.map((d) => (
<SelectItem key={d.id} value={d.id.toString()}>
{d.disciplineCode} - {d.disciplineName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Specific discipline templates take precedence over 'All'.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Padding Length</Label>
<Input
type="number"
value={padding}
onChange={e => setPadding(Number(e.target.value))}
min={1} max={10}
/>
</div>
<div>
<Label>Reset Rule</Label>
<div className="flex items-center h-10">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={reset} onCheckedChange={(c) => setReset(!!c)} />
<span className="text-sm">Reset Annually</span>
</label>
</div>
</div>
</div>
</div>
{/* Format Column */}
<div className="space-y-4">
<div>
<Label>Template Format *</Label>
<Input
value={format}
onChange={(e) => setFormat(e.target.value)}
placeholder="{ORG}-{TYPE}-{SEQ:4}"
className="font-mono text-base mb-2"
/>
<div className="flex flex-wrap gap-2">
{VARIABLES.map((v) => (
<HoverCard key={v.key}>
<HoverCardTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => insertVariable(v.key)}
type="button"
className="font-mono text-xs bg-slate-50 hover:bg-slate-100"
>
{v.key}
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-60 p-3">
<p className="font-semibold text-sm">{v.name}</p>
<p className="text-xs text-muted-foreground mt-1">Example: <span className="font-mono">{v.example}</span></p>
</HoverCardContent>
</HoverCard>
))}
</div>
</div>
<div className="bg-green-50/50 border border-green-200 rounded-lg p-4">
<p className="text-xs uppercase tracking-wide text-green-700 font-semibold mb-2">Preview Output</p>
<p className="text-2xl font-mono font-bold text-green-800 tracking-tight">
{preview || '...'}
</p>
<p className="text-xs text-green-600 mt-2">
* This is an approximation. Actual numbers depend on runtime context.
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={handleSave}>Save Template</Button>
<Button onClick={handleSave} disabled={!isValid}>Save Template</Button>
</div>
</Card>
);

View File

@@ -34,7 +34,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
setLoading(true);
try {
// Note: generateTestNumber expects keys: organizationId, disciplineId
const result = await numberingApi.generateTestNumber(template.templateId, {
const result = await numberingApi.generateTestNumber(template.id || template.templateId || 0, {
organizationId: testData.organizationId,
disciplineId: testData.disciplineId
});

View File

@@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import { useProcessRFA } from "@/hooks/use-rfa";
interface RFADetailProps {
data: RFA;
data: any;
}
export function RFADetail({ data }: RFADetailProps) {
@@ -152,7 +152,7 @@ export function RFADetail({ data }: RFADetailProps) {
</tr>
</thead>
<tbody className="divide-y">
{data.items.map((item) => (
{data.items.map((item: any) => (
<tr key={item.id}>
<td className="px-4 py-3 font-medium">{item.itemNo}</td>
<td className="px-4 py-3">{item.description}</td>

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -4,10 +4,12 @@ export const dashboardApi = {
getStats: async (): Promise<DashboardStats> => {
await new Promise((resolve) => setTimeout(resolve, 500));
return {
correspondences: 124,
rfas: 45,
totalDocuments: 124,
documentsThisMonth: 12,
pendingApprovals: 4,
approved: 89,
pending: 12,
totalRfas: 45,
totalCirculations: 15,
};
},

View File

@@ -1,15 +1,34 @@
import apiClient from '@/lib/api/client';
// Types
export interface NumberingTemplate {
templateId: number;
projectId?: number;
documentTypeId?: string;
documentTypeName: string;
disciplineCode?: string;
templateFormat: string;
exampleNumber: string;
currentNumber: number;
resetAnnually: boolean;
id?: number; // Backend uses 'id'
templateId?: number; // Legacy, optional
projectId: number;
correspondenceTypeId: number;
correspondenceType?: { typeCode: string; typeName: string }; // Relation
documentTypeName?: string; // Optional (joined)
disciplineId: number;
discipline?: { disciplineCode: string; disciplineName: string }; // Relation
disciplineCode?: string; // Optional (joined)
formatTemplate: string; // Backend uses 'formatTemplate'
templateFormat?: string; // Legacy alias
exampleNumber?: string;
paddingLength: number;
resetAnnually: boolean;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface NumberingTemplateDto {
projectId: number;
correspondenceTypeId: number;
disciplineId?: number; // 0 = All
formatTemplate: string;
exampleNumber?: string;
paddingLength: number;
resetAnnually: boolean;
isActive: boolean;
}
@@ -23,138 +42,82 @@ export interface NumberSequence {
updatedAt: string;
}
// Mock Data
const mockTemplates: NumberingTemplate[] = [
{
templateId: 1,
projectId: 1,
documentTypeName: 'Correspondence',
disciplineCode: '',
templateFormat: '{ORIGINATOR}-{RECIPIENT}-{SEQ:4}-{YEAR:B.E.}',
exampleNumber: 'PAT-CN-0001-2568',
currentNumber: 142,
resetAnnually: true,
paddingLength: 4,
isActive: true,
},
{
templateId: 2,
projectId: 1,
documentTypeName: 'RFA',
disciplineCode: 'STR',
templateFormat: '{PROJECT}-{CORR_TYPE}-{DISCIPLINE}-{RFA_TYPE}-{SEQ:4}-{REV}',
exampleNumber: 'LCBP3-RFA-STR-SDW-0056-A',
currentNumber: 56,
resetAnnually: true,
paddingLength: 4,
isActive: true,
},
{
templateId: 3,
projectId: 2,
documentTypeName: 'Maintenance Request',
disciplineCode: '',
templateFormat: 'MAINT-{SEQ:4}',
exampleNumber: 'MAINT-0001',
currentNumber: 1,
resetAnnually: true,
paddingLength: 4,
isActive: true,
},
];
const mockSequences: NumberSequence[] = [
{
sequenceId: 1,
year: 2025,
organizationCode: 'PAT',
currentNumber: 142,
lastGeneratedNumber: 'PAT-CORR-2025-0142',
updatedAt: new Date().toISOString(),
},
{
sequenceId: 2,
year: 2025,
disciplineCode: 'STR',
currentNumber: 56,
lastGeneratedNumber: 'RFA-STR-2025-0056',
updatedAt: new Date().toISOString(),
},
];
export const numberingApi = {
getTemplates: async (): Promise<NumberingTemplate[]> => {
return new Promise((resolve) => {
setTimeout(() => resolve([...mockTemplates]), 500);
});
const res = await apiClient.get<NumberingTemplate[]>('/admin/document-numbering/templates');
return res.data.map(t => ({
...t,
templateId: t.id,
templateFormat: t.formatTemplate,
// Map joined data if available, else placeholders
documentTypeName: t.correspondenceType?.typeCode || 'UNKNOWN',
disciplineCode: t.discipline?.disciplineCode || 'ALL',
}));
},
getTemplate: async (id: number): Promise<NumberingTemplate | undefined> => {
return new Promise((resolve) => {
setTimeout(() => resolve(mockTemplates.find(t => t.templateId === id)), 300);
});
// Currently no single get endpoint
const templates = await numberingApi.getTemplates();
return templates.find(t => t.id === id);
},
saveTemplate: async (template: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
return new Promise((resolve) => {
setTimeout(() => {
if (template.templateId) {
// Update
const index = mockTemplates.findIndex(t => t.templateId === template.templateId);
if (index !== -1) {
mockTemplates[index] = { ...mockTemplates[index], ...template } as NumberingTemplate;
resolve(mockTemplates[index]);
}
} else {
// Create
const newTemplate: NumberingTemplate = {
templateId: Math.floor(Math.random() * 1000),
documentTypeName: 'New Type',
isActive: true,
currentNumber: 0,
exampleNumber: 'PREVIEW',
templateFormat: template.templateFormat || '',
disciplineCode: template.disciplineCode,
paddingLength: template.paddingLength ?? 4,
resetAnnually: template.resetAnnually ?? true,
...template
} as NumberingTemplate;
mockTemplates.push(newTemplate);
resolve(newTemplate);
}
}, 500);
});
// Map frontend interface to backend entity DTO
const payload = {
id: template.id || template.templateId, // Update if ID exists
projectId: template.projectId,
correspondenceTypeId: template.correspondenceTypeId,
disciplineId: template.disciplineId || 0,
formatTemplate: template.templateFormat || template.formatTemplate,
exampleNumber: template.exampleNumber,
paddingLength: template.paddingLength,
resetAnnually: template.resetAnnually,
isActive: template.isActive ?? true
};
const res = await apiClient.post<NumberingTemplate>('/admin/document-numbering/templates', payload);
return res.data;
},
getSequences: async (): Promise<NumberSequence[]> => {
// TODO: Implement backend endpoint for sequences list
return new Promise((resolve) => {
setTimeout(() => resolve([...mockSequences]), 500);
setTimeout(() => resolve([]), 500);
});
},
generateTestNumber: async (templateId: number, context: { organizationId: string, disciplineId: string }): Promise<{ number: string }> => {
return new Promise((resolve) => {
setTimeout(() => {
const template = mockTemplates.find(t => t.templateId === templateId);
if (!template) return resolve({ number: 'ERROR' });
// Use preview endpoint
// We need to know projectId, typeId etc from template.
// But preview endpoint needs context.
// For now, let's just return a mock or call preview endpoint if we have enough info.
let format = template.templateFormat;
// Mock replacement
format = format.replace('{PROJECT}', 'LCBP3');
format = format.replace('{ORIGINATOR}', context.organizationId === '1' ? 'PAT' : 'CN');
format = format.replace('{RECIPIENT}', context.organizationId === '1' ? 'CN' : 'PAT');
format = format.replace('{CORR_TYPE}', template.documentTypeName === 'Correspondence' ? 'CORR' : 'RFA');
format = format.replace('{DISCIPLINE}', context.disciplineId === '1' ? 'STR' : (context.disciplineId === '2' ? 'ARC' : 'GEN'));
format = format.replace('{RFA_TYPE}', 'SDW'); // Mock
// eslint-disable-next-line no-console
console.log('Generating test number for:', templateId, context);
return new Promise((resolve) => resolve({ number: 'TEST-1234' }));
},
const year = new Date().getFullYear();
format = format.replace('{YEAR:A.D.}', year.toString());
format = format.replace('{YEAR:B.E.}', (year + 543).toString());
format = format.replace('{SEQ:4}', '0001');
format = format.replace('{REV}', 'A');
// --- Admin Tools ---
resolve({ number: format });
}, 800);
});
}
getMetrics: async (): Promise<{ audit: any[], errors: any[] }> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await apiClient.get<{ audit: any[], errors: any[] }>('/admin/document-numbering/metrics');
return res.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
manualOverride: async (data: any): Promise<void> => {
await apiClient.post('/admin/document-numbering/manual-override', data);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
voidAndReplace: async (data: any): Promise<string> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const res = await apiClient.post<any>('/admin/document-numbering/void-and-replace', data);
return res.data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cancelNumber: async (data: any): Promise<void> => {
await apiClient.post('/admin/document-numbering/cancel', data);
},
};

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.8",