251216:1644 Docunment Number: Update frontend/
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-16 16:44:23 +07:00
parent 9c1e175b76
commit 95ee94997f
12 changed files with 1097 additions and 198 deletions

View File

@@ -41,12 +41,9 @@ function ManualOverrideForm({ onSuccess, projectId }: { onSuccess: () => void, p
try {
await numberingApi.manualOverride({
projectId,
typeId: parseInt(formData.typeId),
disciplineId: formData.disciplineId ? parseInt(formData.disciplineId) : undefined,
correspondenceTypeId: parseInt(formData.typeId) || null,
year: parseInt(formData.year),
newSequence: parseInt(formData.newSequence),
reason: formData.reason,
userId: 1 // TODO: Get from auth context
newValue: parseInt(formData.newSequence),
});
toast.success("Manual override applied successfully");
onSuccess();
@@ -181,10 +178,13 @@ export default function NumberingPage() {
const loadTemplates = async () => {
setLoading(true);
try {
const data = await numberingApi.getTemplates();
const response = await numberingApi.getTemplates();
// Handle wrapped response { data: [...] } or direct array
const data = Array.isArray(response) ? response : (response as { data?: NumberingTemplate[] })?.data ?? [];
setTemplates(data);
} catch {
toast.error("Failed to load templates");
setTemplates([]);
} finally {
setLoading(false);
}
@@ -202,7 +202,7 @@ export default function NumberingPage() {
const handleSave = async (data: Partial<NumberingTemplate>) => {
try {
await numberingApi.saveTemplate(data);
toast.success(data.id || data.templateId ? "Template updated" : "Template created");
toast.success(data.id ? "Template updated" : "Template created");
setIsEditing(false);
loadTemplates();
} catch {
@@ -281,37 +281,34 @@ export default function NumberingPage() {
{templates
.filter(t => !t.projectId || t.projectId === Number(selectedProjectId))
.map((template) => (
<Card key={template.templateId} className="p-6 hover:shadow-md transition-shadow">
<Card key={template.id} 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}
{template.correspondenceType?.typeName || 'Default Format'}
</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'}
{template.project?.projectCode || selectedProjectName}
</Badge>
{template.description && <Badge variant="secondary">{template.description}</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}
{template.formatTemplate}
</div>
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
<div>
<span className="text-muted-foreground">Example: </span>
<span className="text-muted-foreground">Type Code: </span>
<span className="font-medium font-mono text-green-600 dark:text-green-400">
{template.exampleNumber}
{template.correspondenceType?.typeCode || 'DEFAULT'}
</span>
</div>
<div>
<span className="text-muted-foreground">Reset: </span>
<span>
{template.resetAnnually ? 'Annually' : 'Never'}
{template.resetSequenceYearly ? 'Annually' : 'Continuous'}
</span>
</div>
</div>

View File

@@ -16,10 +16,15 @@ export function SequenceViewer() {
const fetchSequences = async () => {
setLoading(true);
try {
const data = await numberingApi.getSequences();
setSequences(data);
const response = await numberingApi.getSequences();
// Handle wrapped response { data: [...] } or direct array
const data = Array.isArray(response) ? response : (response as { data?: NumberSequence[] })?.data ?? [];
setSequences(data);
} catch {
console.error('Failed to fetch sequences');
setSequences([]);
} finally {
setLoading(false);
setLoading(false);
}
};
@@ -27,17 +32,23 @@ export function SequenceViewer() {
fetchSequences();
}, []);
const filteredSequences = sequences.filter(s =>
const filteredSequences = sequences.filter(
(s) =>
s.year.toString().includes(search) ||
s.organizationCode?.toLowerCase().includes(search.toLowerCase()) ||
s.disciplineCode?.toLowerCase().includes(search.toLowerCase())
s.projectId.toString().includes(search) ||
s.typeId.toString().includes(search)
);
return (
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Number Sequences</h3>
<Button variant="outline" size="sm" onClick={fetchSequences} disabled={loading}>
<h3 className="text-lg font-semibold">Number Counters</h3>
<Button
variant="outline"
size="sm"
onClick={fetchSequences}
disabled={loading}
>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
@@ -45,7 +56,7 @@ export function SequenceViewer() {
<div className="mb-4">
<Input
placeholder="Search by year, organization..."
placeholder="Search by year, project, type..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -53,31 +64,32 @@ export function SequenceViewer() {
<div className="space-y-2">
{filteredSequences.length === 0 && (
<div className="text-center text-muted-foreground py-4">No sequences found</div>
<div className="text-center text-muted-foreground py-4">
No sequences found
</div>
)}
{filteredSequences.map((seq) => (
{filteredSequences.map((seq, index) => (
<div
key={seq.sequenceId}
key={`${seq.projectId}-${seq.typeId}-${seq.year}-${index}`}
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-900 rounded border"
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">Year {seq.year}</span>
{seq.organizationCode && (
<Badge>{seq.organizationCode}</Badge>
)}
{seq.disciplineCode && (
<Badge variant="outline">{seq.disciplineCode}</Badge>
<Badge variant="outline">Project: {seq.projectId}</Badge>
<Badge>Type: {seq.typeId}</Badge>
{seq.disciplineId > 0 && (
<Badge variant="secondary">Disc: {seq.disciplineId}</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
<span className="text-foreground font-medium">Current: {seq.currentNumber}</span> | Last Generated:{' '}
<span className="font-mono">{seq.lastGeneratedNumber}</span>
<span className="text-foreground font-medium">
Counter: {seq.lastNumber}
</span>{' '}
| Originator: {seq.originatorId} | Recipient:{' '}
{seq.recipientOrganizationId === -1 ? 'All' : seq.recipientOrganizationId}
</div>
</div>
<div className="text-sm text-gray-500">
Updated {new Date(seq.updatedAt).toLocaleDateString()}
</div>
</div>
))}
</div>

View File

@@ -96,7 +96,7 @@ export function TemplateEditor({
onSave({
...template,
projectId: projectId,
correspondenceTypeId: Number(typeId),
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
disciplineId: Number(disciplineId),
formatTemplate: format,
templateFormat: format, // Legacy support
@@ -107,7 +107,7 @@ export function TemplateEditor({
});
};
const isValid = format.length > 0 && typeId;
const isValid = format.length > 0; // typeId is optional (null = default for all types)
return (
<Card className="p-6 space-y-6">
@@ -136,12 +136,13 @@ export function TemplateEditor({
{/* Configuration Column */}
<div className="space-y-4">
<div>
<Label>Document Type *</Label>
<Label>Document Type (Optional)</Label>
<Select value={typeId} onValueChange={setTypeId}>
<SelectTrigger>
<SelectValue placeholder="Select type..." />
<SelectValue placeholder="Default (All Types)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (All Types)</SelectItem>
{correspondenceTypes.map((type) => (
<SelectItem key={type.id} value={type.id.toString()}>
{type.typeCode} - {type.typeName}
@@ -149,6 +150,9 @@ export function TemplateEditor({
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to create a default template for this project.
</p>
</div>
<div>

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.id || template.templateId || 0, {
const result = await numberingApi.generateTestNumber(template.id ?? 0, {
organizationId: testData.organizationId,
disciplineId: testData.disciplineId
});
@@ -52,7 +52,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</DialogHeader>
<div className="text-sm text-muted-foreground mb-2">
Template: <span className="font-mono font-bold text-foreground">{template?.templateFormat}</span>
Template: <span className="font-mono font-bold text-foreground">{template?.formatTemplate}</span>
</div>
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
@@ -80,7 +80,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</div>
</div>
<p className="text-xs text-muted-foreground">
Format: {template?.templateFormat}
Format: {template?.formatTemplate}
</p>
</div>
</div>

View File

@@ -1,123 +1,334 @@
import apiClient from '@/lib/api/client';
// Types
// ============================================================
// Types - aligned with backend entities
// ============================================================
/**
* Document Number Format/Template
* Matches: backend/src/modules/document-numbering/entities/document-number-format.entity.ts
*/
export interface NumberingTemplate {
id?: number; // Backend uses 'id'
templateId?: number; // Legacy, optional
id: number;
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;
correspondenceTypeId: number | null; // null = Default Format for project
correspondenceType?: {
id: number;
typeCode: string;
typeName: string;
} | null;
project?: {
id: number;
projectCode: string;
projectName: string;
};
formatTemplate: string;
description?: string;
resetSequenceYearly: boolean; // Controls yearly counter reset
createdAt?: string;
updatedAt?: string;
}
export interface NumberingTemplateDto {
/**
* DTO for creating/updating templates
*/
export interface SaveTemplateDto {
id?: number; // If present, update; otherwise create
projectId: number;
correspondenceTypeId: number;
disciplineId?: number; // 0 = All
correspondenceTypeId: number | null;
formatTemplate: string;
exampleNumber?: string;
paddingLength: number;
resetAnnually: boolean;
isActive: boolean;
description?: string;
resetSequenceYearly?: boolean;
}
export interface NumberSequence {
sequenceId: number;
year: number;
organizationCode?: string;
disciplineCode?: string;
currentNumber: number;
lastGeneratedNumber: string;
updatedAt: string;
/**
* Document Number Audit Log
* Matches: backend/src/modules/document-numbering/entities/document-number-audit.entity.ts
*/
export interface DocumentNumberAudit {
id: number;
documentId: number;
generatedNumber: string;
counterKey: Record<string, unknown>;
templateUsed: string;
operation: 'RESERVE' | 'CONFIRM' | 'MANUAL_OVERRIDE' | 'VOID_REPLACE' | 'CANCEL';
metadata?: Record<string, unknown>;
userId: number;
ipAddress?: string;
retryCount: number;
lockWaitMs?: number;
totalDurationMs?: number;
fallbackUsed?: 'NONE' | 'DB_LOCK' | 'RETRY';
createdAt: string;
}
/**
* Document Number Error Log
*/
export interface DocumentNumberError {
id: number;
errorMessage: string;
stackTrace?: string;
context?: Record<string, unknown>;
userId?: number;
ipAddress?: string;
createdAt: string;
resolvedAt?: string;
}
/**
* Manual Override DTO
*/
export interface ManualOverrideDto {
projectId: number;
correspondenceTypeId: number | null;
year: number;
newValue: number;
}
/**
* Void and Replace DTO
*/
export interface VoidAndReplaceDto {
documentId: number;
reason: string;
}
/**
* Cancel Number DTO
*/
export interface CancelNumberDto {
documentNumber: string;
reason: string;
}
/**
* Bulk Import Item
*/
export interface BulkImportItem {
projectId: number;
correspondenceTypeId: number | null;
year: number;
lastNumber: number;
}
// ============================================================
// API Client
// ============================================================
export const numberingApi = {
// ----------------------------------------------------------
// Template Management (Admin endpoints)
// ----------------------------------------------------------
/**
* Get all templates
*/
getTemplates: async (): Promise<NumberingTemplate[]> => {
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',
}));
return res.data;
},
/**
* Get templates for a specific project
*/
getTemplatesByProject: async (projectId: number): Promise<NumberingTemplate[]> => {
const res = await apiClient.get<NumberingTemplate[]>(
`/admin/document-numbering/templates?projectId=${projectId}`
);
return res.data;
},
/**
* Get single template by ID
*/
getTemplate: async (id: number): Promise<NumberingTemplate | undefined> => {
// Currently no single get endpoint
const templates = await numberingApi.getTemplates();
return templates.find(t => t.id === id);
return templates.find((t) => t.id === id);
},
saveTemplate: async (template: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
// 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;
/**
* Save (create or update) a template
*/
saveTemplate: async (dto: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
const res = await apiClient.post<NumberingTemplate>(
'/admin/document-numbering/templates',
dto
);
return res.data;
},
getSequences: async (): Promise<NumberSequence[]> => {
// TODO: Implement backend endpoint for sequences list
return new Promise((resolve) => {
setTimeout(() => resolve([]), 500);
});
/**
* Delete a template
*/
deleteTemplate: async (id: number): Promise<void> => {
await apiClient.delete(`/admin/document-numbering/templates/${id}`);
},
generateTestNumber: async (templateId: number, context: { organizationId: string, disciplineId: string }): Promise<{ number: string }> => {
// 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.
// ----------------------------------------------------------
// Logs (Requires system.view_logs permission)
// ----------------------------------------------------------
// eslint-disable-next-line no-console
console.log('Generating test number for:', templateId, context);
return new Promise((resolve) => resolve({ number: 'TEST-1234' }));
/**
* Get audit logs
*/
getAuditLogs: async (limit = 100): Promise<DocumentNumberAudit[]> => {
const res = await apiClient.get<DocumentNumberAudit[]>(
`/document-numbering/logs/audit?limit=${limit}`
);
return res.data;
},
// --- Admin Tools ---
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;
/**
* Get error logs
*/
getErrorLogs: async (limit = 100): Promise<DocumentNumberError[]> => {
const res = await apiClient.get<DocumentNumberError[]>(
`/document-numbering/logs/errors?limit=${limit}`
);
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);
/**
* Get metrics (audit + errors combined)
*/
getMetrics: async (): Promise<{ audit: DocumentNumberAudit[]; errors: DocumentNumberError[] }> => {
const res = await apiClient.get<{ audit: DocumentNumberAudit[]; errors: DocumentNumberError[] }>(
'/admin/document-numbering/metrics'
);
return res.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;
// ----------------------------------------------------------
// Admin Tools
// ----------------------------------------------------------
/**
* Manually override/set a counter value
*/
manualOverride: async (dto: ManualOverrideDto): Promise<{ success: boolean; message: string }> => {
const res = await apiClient.post<{ success: boolean; message: string }>(
'/admin/document-numbering/manual-override',
dto
);
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);
/**
* Void a document number and generate replacement
*/
voidAndReplace: async (dto: VoidAndReplaceDto): Promise<{ newNumber: string; auditId: number }> => {
const res = await apiClient.post<{ newNumber: string; auditId: number }>(
'/admin/document-numbering/void-and-replace',
dto
);
return res.data;
},
/**
* Cancel/skip a document number
*/
cancelNumber: async (dto: CancelNumberDto): Promise<{ success: boolean }> => {
const res = await apiClient.post<{ success: boolean }>(
'/admin/document-numbering/cancel',
dto
);
return res.data;
},
/**
* Bulk import counter values
*/
bulkImport: async (items: BulkImportItem[]): Promise<{ imported: number; errors: string[] }> => {
const res = await apiClient.post<{ imported: number; errors: string[] }>(
'/admin/document-numbering/bulk-import',
items
);
return res.data;
},
/**
* Update counter sequence value (Admin only)
*/
updateCounter: async (counterId: number, sequence: number): Promise<void> => {
await apiClient.patch(`/document-numbering/counters/${counterId}`, { sequence });
},
// ----------------------------------------------------------
// Placeholder Methods (Backend not yet implemented)
// ----------------------------------------------------------
/**
* Get all counter sequences
*/
getSequences: async (projectId?: number): Promise<NumberSequence[]> => {
const url = projectId
? `/document-numbering/sequences?projectId=${projectId}`
: '/document-numbering/sequences';
const res = await apiClient.get<NumberSequence[]>(url);
return res.data;
},
/**
* Preview what a document number would look like (without generating)
*/
previewNumber: async (ctx: {
projectId: number;
originatorId: number;
typeId: number;
disciplineId?: number;
subTypeId?: number;
rfaTypeId?: number;
recipientOrganizationId?: number;
}): Promise<{ previewNumber: string; nextSequence: number }> => {
const res = await apiClient.post<{ previewNumber: string; nextSequence: number }>(
'/document-numbering/preview',
ctx
);
return res.data;
},
/**
* Generate test number - Uses preview endpoint
* @deprecated Use previewNumber instead
*/
generateTestNumber: async (
_templateId: number,
context: { organizationId: string; disciplineId: string }
): Promise<{ number: string }> => {
// Fallback mock for legacy UI - requires proper context for real use
const mockNumber = `TEST-${new Date().getFullYear()}-${String(Math.floor(Math.random() * 9999)).padStart(4, '0')}`;
console.log('Using mock generateTestNumber. Context:', context);
return { number: mockNumber };
},
};
// ============================================================
// Types for Sequences
// ============================================================
/**
* Number Sequence / Counter record
*/
export interface NumberSequence {
projectId: number;
originatorId: number;
recipientOrganizationId: number;
typeId: number;
disciplineId: number;
year: number;
lastNumber: number;
}
/**
* Preview Number Context
*/
export interface PreviewNumberContext {
projectId: number;
originatorId: number;
typeId: number;
disciplineId?: number;
subTypeId?: number;
rfaTypeId?: number;
recipientOrganizationId?: number;
}