251223:1649 On going update to 1.7.0: Refoctory drawing Module & document number Module
This commit is contained in:
@@ -24,7 +24,7 @@ export default function EditTemplatePage({ params }: { params: { id: string } })
|
||||
const contractId = contracts[0]?.id;
|
||||
const { data: disciplines = [] } = useDisciplines(contractId);
|
||||
|
||||
const selectedProjectName = projects.find((p: any) => p.id === projectId)?.projectName || 'LCBP3';
|
||||
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName || 'LCBP3';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTemplate = async () => {
|
||||
@@ -46,7 +46,7 @@ export default function EditTemplatePage({ params }: { params: { id: string } })
|
||||
|
||||
const handleSave = async (data: Partial<NumberingTemplate>) => {
|
||||
try {
|
||||
await numberingApi.saveTemplate({ ...data, templateId: parseInt(params.id) });
|
||||
await numberingApi.saveTemplate({ ...data, id: parseInt(params.id) });
|
||||
router.push("/admin/numbering");
|
||||
} catch (error) {
|
||||
console.error("Failed to update template", error);
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { Plus, Edit, Play } from 'lucide-react';
|
||||
import { numberingApi, NumberingTemplate } from '@/lib/api/numbering';
|
||||
import { TemplateEditor } from '@/components/numbering/template-editor';
|
||||
import { SequenceViewer } from '@/components/numbering/sequence-viewer';
|
||||
@@ -22,136 +19,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useProjects, useCorrespondenceTypes, useContracts, useDisciplines } from '@/hooks/use-master-data';
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
// --- 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,
|
||||
correspondenceTypeId: parseInt(formData.typeId) || null,
|
||||
year: parseInt(formData.year),
|
||||
newValue: parseInt(formData.newSequence),
|
||||
});
|
||||
toast.success("Manual override applied successfully");
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
toast.error("Failed to apply override");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
import { ManualOverrideForm } from '@/components/numbering/manual-override-form';
|
||||
import { MetricsDashboard } from '@/components/numbering/metrics-dashboard';
|
||||
import { AuditLogsTable } from '@/components/numbering/audit-logs-table';
|
||||
import { VoidReplaceForm } from '@/components/numbering/void-replace-form';
|
||||
import { CancelNumberForm } from '@/components/numbering/cancel-number-form';
|
||||
import { BulkImportForm } from '@/components/numbering/bulk-import-form';
|
||||
|
||||
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();
|
||||
@@ -159,7 +35,6 @@ export default function NumberingPage() {
|
||||
const [activeTab, setActiveTab] = useState("templates");
|
||||
|
||||
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// View states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -167,7 +42,7 @@ export default function NumberingPage() {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
|
||||
|
||||
const selectedProjectName = projects.find((p: any) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
|
||||
const selectedProjectName = projects.find((p: { id: number; projectName: string }) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
|
||||
|
||||
// Master Data
|
||||
const { data: correspondenceTypes = [] } = useCorrespondenceTypes();
|
||||
@@ -176,7 +51,6 @@ export default function NumberingPage() {
|
||||
const { data: disciplines = [] } = useDisciplines(contractId);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await numberingApi.getTemplates();
|
||||
// Handle wrapped response { data: [...] } or direct array
|
||||
@@ -185,8 +59,6 @@ export default function NumberingPage() {
|
||||
} catch {
|
||||
toast.error("Failed to load templates");
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,7 +122,7 @@ export default function NumberingPage() {
|
||||
<SelectValue placeholder="Select Project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project: any) => (
|
||||
{projects.map((project: { id: number; projectCode: string; projectName: string }) => (
|
||||
<SelectItem key={project.id} value={project.id.toString()}>
|
||||
{project.projectCode} - {project.projectName}
|
||||
</SelectItem>
|
||||
@@ -337,31 +209,21 @@ export default function NumberingPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metrics" className="space-y-4">
|
||||
<AdminMetrics />
|
||||
<MetricsDashboard />
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium mb-4">Audit Logs</h3>
|
||||
<AuditLogsTable />
|
||||
</div>
|
||||
</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>
|
||||
<ManualOverrideForm projectId={Number(selectedProjectId)} />
|
||||
<VoidReplaceForm projectId={Number(selectedProjectId)} />
|
||||
<CancelNumberForm />
|
||||
<div className="md:col-span-2">
|
||||
<BulkImportForm projectId={Number(selectedProjectId)} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -25,9 +25,10 @@ export default function DrawingsPage() {
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="contract" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-[400px]">
|
||||
<TabsList className="grid w-full grid-cols-3 max-w-[600px]">
|
||||
<TabsTrigger value="contract">Contract Drawings</TabsTrigger>
|
||||
<TabsTrigger value="shop">Shop Drawings</TabsTrigger>
|
||||
<TabsTrigger value="asbuilt">As Built Drawings</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="contract" className="mt-6">
|
||||
@@ -37,6 +38,10 @@ export default function DrawingsPage() {
|
||||
<TabsContent value="shop" className="mt-6">
|
||||
<DrawingList type="SHOP" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="asbuilt" className="mt-6">
|
||||
<DrawingList type="AS_BUILT" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,11 +21,11 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber}>
|
||||
{drawing.drawingNumber}
|
||||
<h3 className="text-lg font-semibold truncate" title={drawing.drawingNumber || "No Number"}>
|
||||
{drawing.drawingNumber || "No Number"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title}>
|
||||
{drawing.title}
|
||||
<p className="text-sm text-muted-foreground truncate" title={drawing.title || "No Title"}>
|
||||
{drawing.title || "No Title"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{typeof drawing.discipline === 'object' ? drawing.discipline?.disciplineCode : drawing.discipline}</Badge>
|
||||
@@ -33,11 +33,21 @@ export function DrawingCard({ drawing }: { drawing: Drawing }) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-muted-foreground mb-3">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber}
|
||||
<span className="font-medium text-foreground">Sheet:</span> {drawing.sheetNumber || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.revision}
|
||||
<span className="font-medium text-foreground">Rev:</span> {drawing.revision || "0"}
|
||||
</div>
|
||||
{drawing.legacyDrawingNumber && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium text-foreground">Legacy:</span> {drawing.legacyDrawingNumber}
|
||||
</div>
|
||||
)}
|
||||
{drawing.volumePage !== undefined && (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Page:</span> {drawing.volumePage}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Scale:</span> {drawing.scale || "N/A"}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Drawing } from "@/types/drawing";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface DrawingListProps {
|
||||
type: "CONTRACT" | "SHOP";
|
||||
type: "CONTRACT" | "SHOP" | "AS_BUILT";
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,27 +16,73 @@ import {
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCreateDrawing } from "@/hooks/use-drawing";
|
||||
import { useDisciplines } from "@/hooks/use-master-data";
|
||||
import { useState } from "react";
|
||||
import { useContractDrawingCategories, useShopMainCategories, useShopSubCategories } from "@/hooks/use-master-data";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const drawingSchema = z.object({
|
||||
drawingType: z.enum(["CONTRACT", "SHOP"]),
|
||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||
title: z.string().min(5, "Title must be at least 5 characters"),
|
||||
disciplineId: z.number().min(1, "Discipline is required"),
|
||||
sheetNumber: z.string().min(1, "Sheet Number is required"),
|
||||
scale: z.string().optional(),
|
||||
file: z.instanceof(File, { message: "File is required" }), // In real app, might validation creation before upload
|
||||
// Base Schema
|
||||
const baseSchema = z.object({
|
||||
drawingType: z.enum(["CONTRACT", "SHOP", "AS_BUILT"]),
|
||||
projectId: z.number().default(1), // Hardcoded for now
|
||||
file: z.instanceof(File, { message: "File is required" }),
|
||||
});
|
||||
|
||||
type DrawingFormData = z.infer<typeof drawingSchema>;
|
||||
// Contract Schema
|
||||
const contractSchema = baseSchema.extend({
|
||||
drawingType: z.literal("CONTRACT"),
|
||||
contractDrawingNo: z.string().min(1, "Drawing Number is required"),
|
||||
title: z.string().min(3, "Title is required"),
|
||||
volumeId: z.string().optional(), // Select input returns string usually (changed to string for input compatibility)
|
||||
volumePage: z.string().transform(val => parseInt(val, 10)).optional(), // Input type number returns string
|
||||
mapCatId: z.string().min(1, "Category is required"),
|
||||
});
|
||||
|
||||
export function DrawingUploadForm() {
|
||||
// Shop Schema
|
||||
const shopSchema = baseSchema.extend({
|
||||
drawingType: z.literal("SHOP"),
|
||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||
mainCategoryId: z.string().min(1, "Main Category is required"),
|
||||
subCategoryId: z.string().min(1, "Sub Category is required"),
|
||||
// Revision Fields
|
||||
revisionLabel: z.string().default("0"),
|
||||
title: z.string().min(3, "Revision Title is required"),
|
||||
legacyDrawingNumber: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
// As Built Schema
|
||||
const asBuiltSchema = baseSchema.extend({
|
||||
drawingType: z.literal("AS_BUILT"),
|
||||
drawingNumber: z.string().min(1, "Drawing Number is required"),
|
||||
// Revision Fields
|
||||
revisionLabel: z.string().default("0"),
|
||||
title: z.string().optional(),
|
||||
legacyDrawingNumber: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("drawingType", [
|
||||
contractSchema,
|
||||
shopSchema,
|
||||
asBuiltSchema,
|
||||
]);
|
||||
|
||||
type DrawingFormData = z.infer<typeof formSchema>;
|
||||
|
||||
interface DrawingUploadFormProps {
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
export function DrawingUploadForm({ projectId = 1 }: DrawingUploadFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Discipline Hook
|
||||
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines();
|
||||
// Hooks
|
||||
const { data: contractCategories } = useContractDrawingCategories();
|
||||
const { data: shopMainCats } = useShopMainCategories(projectId);
|
||||
|
||||
const [selectedShopMainCat, setSelectedShopMainCat] = useState<number | undefined>();
|
||||
const { data: shopSubCats } = useShopSubCategories(projectId, selectedShopMainCat);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -45,167 +91,233 @@ export function DrawingUploadForm() {
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<DrawingFormData>({
|
||||
resolver: zodResolver(drawingSchema),
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
projectId,
|
||||
drawingType: "CONTRACT",
|
||||
}
|
||||
});
|
||||
|
||||
const drawingType = watch("drawingType");
|
||||
const createMutation = useCreateDrawing(drawingType); // Hook depends on type but defaults to undefined initially which is fine or handled
|
||||
const createMutation = useCreateDrawing(drawingType);
|
||||
|
||||
// Reset logic when type changes
|
||||
useEffect(() => {
|
||||
// Optional: clear fields or set defaults
|
||||
}, [drawingType]);
|
||||
|
||||
const onSubmit = (data: DrawingFormData) => {
|
||||
// Only proceed if createMutation is valid for the type (it should be since we watch type)
|
||||
if (!drawingType) return;
|
||||
|
||||
// Convert to FormData
|
||||
// Note: Backend might expect JSON Body or Multipart/Form-Data depending on implementation.
|
||||
// Assuming Multipart/Form-Data if file is involved, OR
|
||||
// Two-step upload: 1. Upload File -> Get URL 2. Create Record with URL.
|
||||
// The previous code assumed direct call.
|
||||
// Let's assume the service handles FormData conversion if we pass plain object or we construct here.
|
||||
// My previous assumption in implementation plan: "File upload will use FormData".
|
||||
// I should check service again. `contract-drawing.service` takes `CreateContractDrawingDto`.
|
||||
// Usually NestJS with FileUpload uses Interceptors and FormData.
|
||||
|
||||
// Creating FormData manually to be safe for file upload
|
||||
/*
|
||||
const formData = new FormData();
|
||||
formData.append('title', data.title);
|
||||
// ...
|
||||
// BUT useCreateDrawing calls service.create(data). Service uses apiClient.post(data).
|
||||
// axios handles FormData automatically if passed directly, but nested objects are tricky.
|
||||
// Let's pass the raw DTO and hope services handle it or assume Backend accepts DTO JSON and file separately?
|
||||
// Actually standard Axios with FormData:
|
||||
*/
|
||||
|
||||
// Let's try to construct FormData here as generic approach for file uploads
|
||||
// However, if I change the argument to FormData, Types might complain.
|
||||
// Let's just pass `data` and let the developer (me) ensure Service handles it correctly or modify service later if failed.
|
||||
// Wait, `contractDrawingService.create` takes `CreateContractDrawingDto`.
|
||||
// I will assume for now I pass the object. If file upload fails, I will fix service.
|
||||
|
||||
// Actually better to handle FormData logic here since we have the File object
|
||||
const formData = new FormData();
|
||||
formData.append('drawingNumber', data.drawingNumber);
|
||||
formData.append('title', data.title);
|
||||
formData.append('disciplineId', String(data.disciplineId));
|
||||
formData.append('sheetNumber', data.sheetNumber);
|
||||
if(data.scale) formData.append('scale', data.scale);
|
||||
// Common fields
|
||||
formData.append('projectId', String(data.projectId));
|
||||
formData.append('file', data.file);
|
||||
// Type specific fields if any? (Project ID?)
|
||||
// Contract/Shop might have different fields. Assuming minimal common set.
|
||||
|
||||
createMutation.mutate(data as any, { // Passing raw data or FormData? Hook awaits 'any'.
|
||||
// If I pass FormData, Axios sends it as multipart/form-data.
|
||||
// If I pass JSON, it sends as JSON (and File is empty object).
|
||||
// Since there is a File, I MUST use FormData for it to work with standard uploads.
|
||||
// But wait, the `useCreateDrawing` calls `service.create` which calls `apiClient.post`.
|
||||
// If I pass FormData to `mutate`, it goes to `service.create`.
|
||||
// So I will pass FormData but `data as any` above cast allows it.
|
||||
// BUT `data` argument in `onSubmit` is `DrawingFormData` (Object).
|
||||
// I will pass `formData` to mutate.
|
||||
// WARNING: Hooks expects correct type. I used `any` in hook definition.
|
||||
onSuccess: () => {
|
||||
router.push("/drawings");
|
||||
}
|
||||
if (data.drawingType === 'CONTRACT') {
|
||||
formData.append('contractDrawingNo', data.contractDrawingNo);
|
||||
formData.append('title', data.title);
|
||||
formData.append('mapCatId', data.mapCatId);
|
||||
if (data.volumeId) formData.append('volumeId', data.volumeId);
|
||||
if (data.volumePage) formData.append('volumePage', String(data.volumePage));
|
||||
} else if (data.drawingType === 'SHOP') {
|
||||
formData.append('drawingNumber', data.drawingNumber);
|
||||
formData.append('mainCategoryId', data.mainCategoryId);
|
||||
formData.append('subCategoryId', data.subCategoryId);
|
||||
formData.append('revisionLabel', data.revisionLabel || '0');
|
||||
formData.append('title', data.title); // Revision Title
|
||||
if (data.legacyDrawingNumber) formData.append('legacyDrawingNumber', data.legacyDrawingNumber);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
// Date default to now
|
||||
} else if (data.drawingType === 'AS_BUILT') {
|
||||
formData.append('drawingNumber', data.drawingNumber);
|
||||
formData.append('revisionLabel', data.revisionLabel || '0');
|
||||
if (data.title) formData.append('title', data.title);
|
||||
if (data.legacyDrawingNumber) formData.append('legacyDrawingNumber', data.legacyDrawingNumber);
|
||||
if (data.description) formData.append('description', data.description);
|
||||
}
|
||||
|
||||
createMutation.mutate(formData as any, {
|
||||
onSuccess: () => {
|
||||
router.push("/drawings");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Actually, to make it work with TypeScript and `mutate`, let's wrap logic
|
||||
const handleFormSubmit = (data: DrawingFormData) => {
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key === 'file') {
|
||||
formData.append(key, data.file);
|
||||
} else {
|
||||
formData.append(key, String((data as any)[key]));
|
||||
}
|
||||
});
|
||||
// Append projectId if needed (hardcoded 1 for now)
|
||||
formData.append('projectId', '1');
|
||||
|
||||
createMutation.mutate(formData as any, {
|
||||
onSuccess: () => {
|
||||
router.push("/drawings");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="max-w-2xl space-y-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-3xl space-y-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Drawing Information</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Drawing Type *</Label>
|
||||
<Select onValueChange={(v) => setValue("drawingType", v as any)}>
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setValue("drawingType", v as any);
|
||||
// Reset errors or fields if needed
|
||||
}}
|
||||
defaultValue="CONTRACT"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CONTRACT">Contract Drawing</SelectItem>
|
||||
<SelectItem value="SHOP">Shop Drawing</SelectItem>
|
||||
<SelectItem value="AS_BUILT">As Built Drawing</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.drawingType && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawingType.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="drawingNumber">Drawing Number *</Label>
|
||||
<Input id="drawingNumber" {...register("drawingNumber")} placeholder="e.g. A-101" />
|
||||
{errors.drawingNumber && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sheetNumber">Sheet Number *</Label>
|
||||
<Input id="sheetNumber" {...register("sheetNumber")} placeholder="e.g. 01" />
|
||||
{errors.sheetNumber && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.sheetNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* CONTRACT FIELDS */}
|
||||
{drawingType === 'CONTRACT' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Contract Drawing No *</Label>
|
||||
<Input {...register("contractDrawingNo")} placeholder="e.g. CD-001" />
|
||||
{(errors as any).contractDrawingNo && (
|
||||
<p className="text-sm text-destructive">{(errors as any).contractDrawingNo.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Title *</Label>
|
||||
<Input {...register("title")} placeholder="Drawing Title" />
|
||||
{(errors as any).title && (
|
||||
<p className="text-sm text-destructive">{(errors as any).title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input id="title" {...register("title")} placeholder="Drawing Title" />
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Category *</Label>
|
||||
<Select onValueChange={(v) => setValue("mapCatId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contractCategories?.map((c: any) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(errors as any).mapCatId && (
|
||||
<p className="text-sm text-destructive">{(errors as any).mapCatId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>Volume ID</Label>
|
||||
<Input {...register("volumeId")} placeholder="Vol. 1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Page No.</Label>
|
||||
<Input {...register("volumePage")} type="number" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Discipline *</Label>
|
||||
<Select
|
||||
onValueChange={(v) => setValue("disciplineId", parseInt(v))}
|
||||
disabled={isLoadingDisciplines}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplines?.map((d: any) => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>
|
||||
{d.name} ({d.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.disciplineId && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.disciplineId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="scale">Scale</Label>
|
||||
<Input id="scale" {...register("scale")} placeholder="e.g. 1:100" />
|
||||
</div>
|
||||
</div>
|
||||
{/* SHOP FIELDS */}
|
||||
{drawingType === 'SHOP' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Shop Drawing No *</Label>
|
||||
<Input {...register("drawingNumber")} placeholder="e.g. SD-101" />
|
||||
{(errors as any).drawingNumber && (
|
||||
<p className="text-sm text-destructive">{(errors as any).drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Number</Label>
|
||||
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Main Category *</Label>
|
||||
<Select onValueChange={(v) => {
|
||||
setValue("mainCategoryId", v);
|
||||
setSelectedShopMainCat(v ? parseInt(v) : undefined);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Main Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopMainCats?.map((c: any) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(errors as any).mainCategoryId && (
|
||||
<p className="text-sm text-destructive">{(errors as any).mainCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Sub Category *</Label>
|
||||
<Select onValueChange={(v) => setValue("subCategoryId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Sub Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shopSubCats?.map((c: any) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(errors as any).subCategoryId && (
|
||||
<p className="text-sm text-destructive">{(errors as any).subCategoryId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Revision Title *</Label>
|
||||
<Input {...register("title")} placeholder="Current Revision Title" />
|
||||
{(errors as any).title && (
|
||||
<p className="text-sm text-destructive">{(errors as any).title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AS BUILT FIELDS */}
|
||||
{drawingType === 'AS_BUILT' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Drawing No *</Label>
|
||||
<Input {...register("drawingNumber")} placeholder="e.g. AB-101" />
|
||||
{(errors as any).drawingNumber && (
|
||||
<p className="text-sm text-destructive">{(errors as any).drawingNumber.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Legacy Number</Label>
|
||||
<Input {...register("legacyDrawingNumber")} placeholder="Legacy No." />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Title</Label>
|
||||
<Input {...register("title")} placeholder="Title" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="file">Drawing File *</Label>
|
||||
<Input
|
||||
id="file"
|
||||
@@ -217,13 +329,11 @@ export function DrawingUploadForm() {
|
||||
if (file) setValue("file", file);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Accepted: PDF, DWG (Max 50MB)
|
||||
</p>
|
||||
{errors.file && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.file.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -231,7 +341,7 @@ export function DrawingUploadForm() {
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || !drawingType}>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Upload Drawing
|
||||
</Button>
|
||||
|
||||
69
frontend/components/numbering/audit-logs-table.tsx
Normal file
69
frontend/components/numbering/audit-logs-table.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export function AuditLogsTable() {
|
||||
const [logs, setLogs] = useState<any[]>([]); // Replace with AuditLog type
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const data = await documentNumberingService.getMetrics(); // Using metrics endpoint for now as it contains logs
|
||||
if (data && data.audit) {
|
||||
setLogs(data.audit);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch audit logs", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchLogs();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading logs...</div>;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Operation</TableHead>
|
||||
<TableHead>Number</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center">No logs found.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell>{format(new Date(log.createdAt), "yyyy-MM-dd HH:mm:ss")}</TableCell>
|
||||
<TableCell>{log.operation}</TableCell>
|
||||
<TableCell>{log.generatedNumber}</TableCell>
|
||||
<TableCell>{log.createdBy || "System"}</TableCell>
|
||||
<TableCell>{log.status}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/components/numbering/bulk-import-form.tsx
Normal file
54
frontend/components/numbering/bulk-import-form.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
|
||||
export function BulkImportForm({ projectId = 1 }: { projectId?: number }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("projectId", projectId.toString());
|
||||
|
||||
await documentNumberingService.bulkImport(formData);
|
||||
toast.success("Bulk import initiated. Check audit logs for progress.");
|
||||
setFile(null);
|
||||
} catch (error) {
|
||||
toast.error("Failed to import numbers.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border p-4 rounded-md space-y-4">
|
||||
<h3 className="text-lg font-medium">Bulk Import Numbers</h3>
|
||||
<p className="text-sm text-gray-500">Import legacy numbers via CSV to reserve them in the system.</p>
|
||||
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="csv-file">CSV File</Label>
|
||||
<Input id="csv-file" type="file" accept=".csv,.xlsx" onChange={handleFileChange} />
|
||||
</div>
|
||||
|
||||
<Button onClick={handleUpload} disabled={!file || loading}>
|
||||
{loading ? "Importing..." : "Upload & Import"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/components/numbering/cancel-number-form.tsx
Normal file
86
frontend/components/numbering/cancel-number-form.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { CancelNumberDto } from "@/types/dto/numbering.dto";
|
||||
import { useState } from "react";
|
||||
|
||||
const formSchema = z.object({
|
||||
documentNumber: z.string().min(3, "Document Number is required"),
|
||||
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||
});
|
||||
|
||||
type CancelNumberFormData = z.infer<typeof formSchema>;
|
||||
|
||||
export function CancelNumberForm() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<CancelNumberFormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
documentNumber: "",
|
||||
reason: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: CancelNumberFormData) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dto: CancelNumberDto = values;
|
||||
await documentNumberingService.cancelNumber(dto);
|
||||
toast.success("Number cancelled successfully.");
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error("Failed to cancel number. It may not exist or is already cancelled.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md">
|
||||
<h3 className="text-lg font-medium">Cancel Number</h3>
|
||||
<p className="text-sm text-gray-500">Permanently cancel a number (e.g. if generated by mistake). It cannot be reused.</p>
|
||||
|
||||
<FormField control={form.control} name="documentNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reason for cancellation..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<Button type="submit" variant="destructive" disabled={loading}>
|
||||
{loading ? "Cancelling..." : "Cancel Number"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
133
frontend/components/numbering/manual-override-form.tsx
Normal file
133
frontend/components/numbering/manual-override-form.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { ManualOverrideDto } from "@/types/dto/numbering.dto";
|
||||
import { useState } from "react";
|
||||
|
||||
const formSchema = z.object({
|
||||
projectId: z.coerce.number().min(1, "Project is required"),
|
||||
originatorOrganizationId: z.coerce.number().min(1, "Originator is required"),
|
||||
recipientOrganizationId: z.coerce.number().min(1, "Recipient is required"),
|
||||
correspondenceTypeId: z.coerce.number().min(1, "Type is required"),
|
||||
newLastNumber: z.coerce.number().min(1, "New number is required"),
|
||||
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||
resetScope: z.string().optional()
|
||||
});
|
||||
|
||||
export function ManualOverrideForm({ projectId = 1 }: { projectId?: number }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
projectId: projectId,
|
||||
originatorOrganizationId: 0,
|
||||
recipientOrganizationId: 0,
|
||||
correspondenceTypeId: 0,
|
||||
newLastNumber: 0,
|
||||
reason: "",
|
||||
resetScope: "YEAR_2025" // Example, should be dynamic or selected
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dto: ManualOverrideDto = {
|
||||
...values,
|
||||
resetScope: values.resetScope || "YEAR_" + new Date().getFullYear()
|
||||
};
|
||||
await documentNumberingService.manualOverride(dto);
|
||||
toast.success("Manual override applied successfully.");
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error("Failed to apply override.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md mt-4">
|
||||
<h3 className="text-lg font-medium">Manual Override Sequence</h3>
|
||||
<p className="text-sm text-gray-500">Careful: This updates the LAST generated number. Next number will receive +1.</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Allow simple text input for IDs for now, ideally Selects from Master Data */}
|
||||
<FormField control={form.control} name="projectId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="correspondenceTypeId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="originatorOrganizationId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Originator Org ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="recipientOrganizationId" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Recipient Org ID</FormLabel>
|
||||
<FormControl><Input type="number" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<FormField control={form.control} name="newLastNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Set Last Number To</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you set 99, the next auto-generated number will be 100.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Why are you overriding?" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Applying..." : "Apply Override"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
81
frontend/components/numbering/metrics-dashboard.tsx
Normal file
81
frontend/components/numbering/metrics-dashboard.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { NumberingMetrics } from "@/types/dto/numbering.dto";
|
||||
|
||||
export function MetricsDashboard() {
|
||||
const [metrics, setMetrics] = useState<Partial<NumberingMetrics>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMetrics() {
|
||||
try {
|
||||
const data = await documentNumberingService.getMetrics();
|
||||
setMetrics(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch metrics", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchMetrics();
|
||||
const interval = setInterval(fetchMetrics, 30000); // Poll every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading metrics...</div>;
|
||||
if (!metrics) return <div>No metrics available.</div>;
|
||||
|
||||
// Mock data mapping if real data is missing from backend stub
|
||||
const utilization = metrics.audit ? 45 : 0; // Placeholder until backend returns specific metric
|
||||
const generationRate = 120; // Placeholder
|
||||
const lockWaitP95 = 0.05; // Placeholder
|
||||
|
||||
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 Rate</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{generationRate} /Hr</div>
|
||||
<p className="text-xs text-muted-foreground">+20.1% from last hour</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Sequence Utilization</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{utilization}%</div>
|
||||
<Progress value={utilization} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-1">Average capacity used</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Lock Wait Time (P95)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{lockWaitP95}s</div>
|
||||
<p className="text-xs text-muted-foreground">Redis distributed lock latency</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Recent Errors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.errors?.length || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">In the last 24 hours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ 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,
|
||||
@@ -40,8 +39,10 @@ export interface TemplateEditorProps {
|
||||
template?: NumberingTemplate;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
correspondenceTypes: any[];
|
||||
disciplines: any[];
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
onSave: (data: Partial<NumberingTemplate>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -51,16 +52,14 @@ export function TemplateEditor({
|
||||
projectId,
|
||||
projectName,
|
||||
correspondenceTypes,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
disciplines,
|
||||
onSave,
|
||||
onCancel
|
||||
}: TemplateEditorProps) {
|
||||
const [format, setFormat] = useState(template?.formatTemplate || template?.templateFormat || '');
|
||||
const [format, setFormat] = useState(template?.formatTemplate || '');
|
||||
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 [reset, setReset] = useState(template?.resetSequenceYearly ?? true);
|
||||
|
||||
const [preview, setPreview] = useState('');
|
||||
|
||||
@@ -78,18 +77,14 @@ export function TemplateEditor({
|
||||
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, typeId, disciplineId, correspondenceTypes, disciplines]);
|
||||
}, [format, typeId, correspondenceTypes]);
|
||||
|
||||
const insertVariable = (variable: string) => {
|
||||
setFormat((prev) => prev + variable);
|
||||
setFormat((prev: string) => prev + variable);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -97,13 +92,8 @@ export function TemplateEditor({
|
||||
...template,
|
||||
projectId: projectId,
|
||||
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
|
||||
disciplineId: Number(disciplineId),
|
||||
formatTemplate: format,
|
||||
templateFormat: format, // Legacy support
|
||||
paddingLength: padding,
|
||||
resetAnnually: reset,
|
||||
isActive: isActive,
|
||||
exampleNumber: preview
|
||||
resetSequenceYearly: reset,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,9 +105,6 @@ export function TemplateEditor({
|
||||
<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>
|
||||
@@ -125,10 +112,6 @@ export function TemplateEditor({
|
||||
<Badge variant="outline" className="text-base px-3 py-1 bg-slate-50">
|
||||
Project: {projectName}
|
||||
</Badge>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<Checkbox checked={isActive} onCheckedChange={(c) => setIsActive(!!c)} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +126,8 @@ export function TemplateEditor({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (All Types)</SelectItem>
|
||||
{correspondenceTypes.map((type) => (
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{correspondenceTypes.map((type: any) => (
|
||||
<SelectItem key={type.id} value={type.id.toString()}>
|
||||
{type.typeCode} - {type.typeName}
|
||||
</SelectItem>
|
||||
@@ -155,36 +139,7 @@ export function TemplateEditor({
|
||||
</p>
|
||||
</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">
|
||||
|
||||
@@ -12,7 +12,28 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useOrganizations, useCorrespondenceTypes, useDisciplines, useContracts } from '@/hooks/use-master-data';
|
||||
import { Organization } from '@/types/organization';
|
||||
|
||||
// Local interfaces for Master Data since centralized ones are missing/fragmented
|
||||
interface CorrespondenceType {
|
||||
id: number;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
interface Discipline {
|
||||
id: number;
|
||||
disciplineCode: string;
|
||||
}
|
||||
|
||||
|
||||
interface TemplateTesterProps {
|
||||
open: boolean;
|
||||
@@ -22,23 +43,45 @@ interface TemplateTesterProps {
|
||||
|
||||
export function TemplateTester({ open, onOpenChange, template }: TemplateTesterProps) {
|
||||
const [testData, setTestData] = useState({
|
||||
organizationId: "1",
|
||||
disciplineId: "1",
|
||||
originatorId: "",
|
||||
recipientId: "",
|
||||
correspondenceTypeId: "",
|
||||
disciplineId: "",
|
||||
year: new Date().getFullYear(),
|
||||
});
|
||||
const [generatedNumber, setGeneratedNumber] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Master Data Hooks
|
||||
const projectId = template?.projectId || 1;
|
||||
const { data: organizations } = useOrganizations({ isActive: true });
|
||||
const { data: correspondenceTypes } = useCorrespondenceTypes();
|
||||
const { data: contracts } = useContracts(projectId);
|
||||
|
||||
// Use first contract ID for disciplines, fallback to 1 or undefined
|
||||
const contractId = contracts?.[0]?.id;
|
||||
const { data: disciplines } = useDisciplines(contractId);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!template) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
// Note: generateTestNumber expects keys: organizationId, disciplineId
|
||||
const result = await numberingApi.generateTestNumber(template.id ?? 0, {
|
||||
organizationId: testData.organizationId,
|
||||
disciplineId: testData.disciplineId
|
||||
const result = await numberingApi.previewNumber({
|
||||
projectId: projectId,
|
||||
originatorId: parseInt(testData.originatorId || "0"),
|
||||
recipientOrganizationId: parseInt(testData.recipientId || "0"),
|
||||
typeId: parseInt(testData.correspondenceTypeId || "0"),
|
||||
disciplineId: parseInt(testData.disciplineId || "0"),
|
||||
});
|
||||
setGeneratedNumber(result.number);
|
||||
setGeneratedNumber(result.previewNumber);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to generate test number", error);
|
||||
setGeneratedNumber("");
|
||||
// Assuming toast is available globally or we can use console for now,
|
||||
// but better to show visible error.
|
||||
// Alert is primitive but effective for 'tester' component debugging if toast not imported.
|
||||
// Actually, let's just set the error string in display if we can, or add a simple red text.
|
||||
setGeneratedNumber(`Error: ${error.response?.data?.message || error.message || "Unknown error"}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,7 +89,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test Number Generation</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -58,44 +101,109 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
|
||||
<Card className="p-6 mt-6 bg-muted/50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">Template Tester</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="md:col-span-2">
|
||||
<h4 className="text-sm font-medium mb-2">Test Parameters</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Originator */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Organization</label>
|
||||
<Input
|
||||
value={testData.organizationId}
|
||||
onChange={(e) => setTestData({...testData, organizationId: e.target.value})}
|
||||
placeholder="Org ID"
|
||||
/>
|
||||
<label className="text-xs font-medium">Originator (ORG)</label>
|
||||
<Select
|
||||
value={testData.originatorId}
|
||||
onValueChange={(val) => setTestData({...testData, originatorId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Originator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(organizations as Organization[])?.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id.toString()}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Recipient */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Discipline</label>
|
||||
<Input
|
||||
<label className="text-xs font-medium">Recipient (REC)</label>
|
||||
<Select
|
||||
value={testData.recipientId}
|
||||
onValueChange={(val) => setTestData({...testData, recipientId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Recipient" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(organizations as Organization[])?.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id.toString()}>
|
||||
{org.organizationCode} - {org.organizationName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Document Type */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Document Type (TYPE)</label>
|
||||
<Select
|
||||
value={testData.correspondenceTypeId}
|
||||
onValueChange={(val) => setTestData({...testData, correspondenceTypeId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Default (All Types)</SelectItem>
|
||||
{(correspondenceTypes as CorrespondenceType[])?.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id.toString()}>
|
||||
{type.typeCode} - {type.typeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Discipline */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium">Discipline (DIS)</label>
|
||||
<Select
|
||||
value={testData.disciplineId}
|
||||
onChange={(e) => setTestData({...testData, disciplineId: e.target.value})}
|
||||
placeholder="Disc ID"
|
||||
/>
|
||||
onValueChange={(val) => setTestData({...testData, disciplineId: val})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Discipline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">None</SelectItem>
|
||||
{(disciplines as Discipline[])?.map((disc) => (
|
||||
<SelectItem key={disc.id} value={disc.id.toString()}>
|
||||
{disc.disciplineCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Format: {template?.formatTemplate}
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
Format Preview: {template?.formatTemplate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button onClick={handleGenerate} className="w-full" disabled={loading || !template}>
|
||||
<Button onClick={handleGenerate} className="w-full mt-4" disabled={loading || !template}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Test Number
|
||||
</Button>
|
||||
|
||||
{generatedNumber && (
|
||||
<Card className="p-4 bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900 border text-center">
|
||||
<p className="text-sm text-muted-foreground mb-1">Generated Number:</p>
|
||||
<p className="text-2xl font-mono font-bold text-green-700 dark:text-green-400">
|
||||
<Card className={`p-4 mt-4 border text-center ${generatedNumber.startsWith('Error:') ? 'bg-red-50 border-red-200 text-red-700' : 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'}`}>
|
||||
<p className="text-sm text-muted-foreground mb-1">{generatedNumber.startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p>
|
||||
<p className={`text-2xl font-mono font-bold ${generatedNumber.startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}>
|
||||
{generatedNumber}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
113
frontend/components/numbering/void-replace-form.tsx
Normal file
113
frontend/components/numbering/void-replace-form.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { documentNumberingService } from "@/lib/services/document-numbering.service";
|
||||
import { VoidReplaceDto } from "@/types/dto/numbering.dto";
|
||||
import { useState } from "react";
|
||||
|
||||
const formSchema = z.object({
|
||||
documentNumber: z.string().min(3, "Document Number is required"),
|
||||
reason: z.string().min(5, "Reason must be at least 5 characters"),
|
||||
replace: z.boolean(),
|
||||
projectId: z.number()
|
||||
});
|
||||
|
||||
type VoidReplaceFormData = z.infer<typeof formSchema>;
|
||||
|
||||
export function VoidReplaceForm({ projectId = 1 }: { projectId?: number }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<VoidReplaceFormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
documentNumber: "",
|
||||
reason: "",
|
||||
replace: false,
|
||||
projectId: projectId
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dto: VoidReplaceDto = {
|
||||
...values,
|
||||
};
|
||||
await documentNumberingService.voidAndReplace(dto);
|
||||
toast.success("Number voided successfully. " + (values.replace ? "Replacement generated." : ""));
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error("Failed to void number. Check if it exists.");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-md">
|
||||
<h3 className="text-lg font-medium">Void & Replace Number</h3>
|
||||
<p className="text-sm text-gray-500">Void a generated number. Useful for skipped numbers or errors.</p>
|
||||
|
||||
<FormField control={form.control} name="documentNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Document Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. LCB3-COR-GGL-2025-0001" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField control={form.control} name="reason" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reason for voiding..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField control={form.control} name="replace" render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Generate Replacement?
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
If checked, a new number will be reserved immediately.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<Button type="submit" variant="destructive" disabled={loading}>
|
||||
{loading ? "Processing..." : "Void Number"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -114,21 +114,25 @@ describe('use-correspondence hooks', () => {
|
||||
const mockResponse = { id: 1, title: 'New Correspondence' };
|
||||
vi.mocked(correspondenceService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper, queryClient } = createTestQueryClient();
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateCorrespondence(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
title: 'New Correspondence',
|
||||
subject: 'New Correspondence',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
typeId: 1,
|
||||
originatorId: 1,
|
||||
recipients: []
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.create).toHaveBeenCalledWith({
|
||||
title: 'New Correspondence',
|
||||
subject: 'New Correspondence',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
typeId: 1,
|
||||
originatorId: 1,
|
||||
recipients: []
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence created successfully');
|
||||
});
|
||||
@@ -146,9 +150,11 @@ describe('use-correspondence hooks', () => {
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
title: '',
|
||||
subject: '',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
typeId: 1,
|
||||
originatorId: 1,
|
||||
recipients: []
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
@@ -163,7 +169,7 @@ describe('use-correspondence hooks', () => {
|
||||
|
||||
describe('useUpdateCorrespondence', () => {
|
||||
it('should update correspondence and invalidate cache', async () => {
|
||||
const mockResponse = { id: 1, title: 'Updated Correspondence' };
|
||||
const mockResponse = { id: 1, subject: 'Updated Correspondence' };
|
||||
vi.mocked(correspondenceService.update).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
@@ -172,12 +178,12 @@ describe('use-correspondence hooks', () => {
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { title: 'Updated Correspondence' },
|
||||
data: { subject: 'Updated Correspondence' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.update).toHaveBeenCalledWith(1, {
|
||||
title: 'Updated Correspondence',
|
||||
subject: 'Updated Correspondence',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence updated successfully');
|
||||
});
|
||||
@@ -210,11 +216,11 @@ describe('use-correspondence hooks', () => {
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { recipientIds: [2, 3] },
|
||||
data: { note: 'Ready for review' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.submit).toHaveBeenCalledWith(1, { recipientIds: [2, 3] });
|
||||
expect(correspondenceService.submit).toHaveBeenCalledWith(1, { note: 'Ready for review' });
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence submitted successfully');
|
||||
});
|
||||
});
|
||||
@@ -230,13 +236,13 @@ describe('use-correspondence hooks', () => {
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'approve', comment: 'LGTM' },
|
||||
data: { action: 'APPROVE', comments: 'LGTM' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.processWorkflow).toHaveBeenCalledWith(1, {
|
||||
action: 'approve',
|
||||
comment: 'LGTM',
|
||||
action: 'APPROVE',
|
||||
comments: 'LGTM',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Action completed successfully');
|
||||
});
|
||||
@@ -255,7 +261,7 @@ describe('use-correspondence hooks', () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'approve' },
|
||||
data: { action: 'APPROVE' },
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
|
||||
@@ -110,6 +110,8 @@ describe('use-rfa hooks', () => {
|
||||
await result.current.mutateAsync({
|
||||
projectId: 1,
|
||||
subject: 'Test RFA',
|
||||
rfaTypeId: 1,
|
||||
toOrganizationId: 1
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +134,8 @@ describe('use-rfa hooks', () => {
|
||||
await result.current.mutateAsync({
|
||||
projectId: 1,
|
||||
subject: '',
|
||||
rfaTypeId: 1,
|
||||
toOrganizationId: 1
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
@@ -175,13 +179,13 @@ describe('use-rfa hooks', () => {
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'approve', comment: 'Approved' },
|
||||
data: { action: 'APPROVE', comments: 'Approved' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(rfaService.processWorkflow).toHaveBeenCalledWith(1, {
|
||||
action: 'approve',
|
||||
comment: 'Approved',
|
||||
action: 'APPROVE',
|
||||
comments: 'Approved',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Workflow status updated successfully');
|
||||
});
|
||||
@@ -200,7 +204,7 @@ describe('use-rfa hooks', () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'reject' },
|
||||
data: { action: 'REJECT' },
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
||||
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
|
||||
import { asBuiltDrawingService } from '@/lib/services/asbuilt-drawing.service'; // Added
|
||||
import { SearchContractDrawingDto, CreateContractDrawingDto } from '@/types/dto/drawing/contract-drawing.dto';
|
||||
import { SearchShopDrawingDto, CreateShopDrawingDto } from '@/types/dto/drawing/shop-drawing.dto';
|
||||
import { SearchAsBuiltDrawingDto, CreateAsBuiltDrawingDto } from '@/types/dto/drawing/asbuilt-drawing.dto'; // Added
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type DrawingType = 'CONTRACT' | 'SHOP';
|
||||
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto;
|
||||
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto;
|
||||
type DrawingType = 'CONTRACT' | 'SHOP' | 'AS_BUILT'; // Added AS_BUILT
|
||||
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto | SearchAsBuiltDrawingDto;
|
||||
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto | CreateAsBuiltDrawingDto;
|
||||
|
||||
export const drawingKeys = {
|
||||
all: ['drawings'] as const,
|
||||
@@ -25,8 +27,10 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
|
||||
queryFn: async () => {
|
||||
if (type === 'CONTRACT') {
|
||||
return contractDrawingService.getAll(params as SearchContractDrawingDto);
|
||||
} else {
|
||||
} else if (type === 'SHOP') {
|
||||
return shopDrawingService.getAll(params as SearchShopDrawingDto);
|
||||
} else {
|
||||
return asBuiltDrawingService.getAll(params as SearchAsBuiltDrawingDto);
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData,
|
||||
@@ -39,8 +43,10 @@ export function useDrawing(type: DrawingType, id: number | string) {
|
||||
queryFn: async () => {
|
||||
if (type === 'CONTRACT') {
|
||||
return contractDrawingService.getById(id);
|
||||
} else {
|
||||
} else if (type === 'SHOP') {
|
||||
return shopDrawingService.getById(id);
|
||||
} else {
|
||||
return asBuiltDrawingService.getById(id);
|
||||
}
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -56,8 +62,10 @@ export function useCreateDrawing(type: DrawingType) {
|
||||
mutationFn: async (data: CreateDrawingData) => {
|
||||
if (type === 'CONTRACT') {
|
||||
return contractDrawingService.create(data as CreateContractDrawingDto);
|
||||
} else {
|
||||
} else if (type === 'SHOP') {
|
||||
return shopDrawingService.create(data as CreateShopDrawingDto);
|
||||
} else {
|
||||
return asBuiltDrawingService.create(data as CreateAsBuiltDrawingDto);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -105,3 +105,28 @@ export function useCorrespondenceTypes() {
|
||||
queryFn: () => masterDataService.getCorrespondenceTypes(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Drawing Categories Hooks ---
|
||||
|
||||
export function useContractDrawingCategories() {
|
||||
return useQuery({
|
||||
queryKey: ['contract-drawing-categories'],
|
||||
queryFn: () => masterDataService.getContractDrawingCategories(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useShopMainCategories(projectId: number) {
|
||||
return useQuery({
|
||||
queryKey: ['shop-main-categories', projectId],
|
||||
queryFn: () => masterDataService.getShopMainCategories(projectId),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useShopSubCategories(projectId: number, mainCategoryId?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['shop-sub-categories', projectId, mainCategoryId],
|
||||
queryFn: () => masterDataService.getShopSubCategories(projectId, mainCategoryId),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { correspondenceService } from '../correspondence.service';
|
||||
import apiClient from '@/lib/api/client';
|
||||
import { WorkflowActionDto } from '@/types/dto/correspondence/workflow-action.dto';
|
||||
|
||||
// apiClient is already mocked in vitest.setup.ts
|
||||
|
||||
@@ -12,7 +13,7 @@ describe('correspondenceService', () => {
|
||||
describe('getAll', () => {
|
||||
it('should call GET /correspondences with params', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ id: 1, title: 'Test' }],
|
||||
data: [{ id: 1, subject: 'Test' }],
|
||||
meta: { total: 1 },
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
@@ -39,7 +40,7 @@ describe('correspondenceService', () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('should call GET /correspondences/:id', async () => {
|
||||
const mockData = { id: 1, title: 'Test' };
|
||||
const mockData = { id: 1, subject: 'Test' };
|
||||
// Service expects response.data.data (NestJS interceptor wrapper)
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockData } });
|
||||
|
||||
@@ -62,9 +63,9 @@ describe('correspondenceService', () => {
|
||||
describe('create', () => {
|
||||
it('should call POST /correspondences with data', async () => {
|
||||
const createDto = {
|
||||
title: 'New Correspondence',
|
||||
subject: 'New Correspondence',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
typeId: 1,
|
||||
};
|
||||
const mockResponse = { id: 1, ...createDto };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
@@ -78,8 +79,8 @@ describe('correspondenceService', () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should call PUT /correspondences/:id with data', async () => {
|
||||
const updateData = { title: 'Updated Title' };
|
||||
const mockResponse = { id: 1, title: 'Updated Title' };
|
||||
const updateData = { subject: 'Updated Title' };
|
||||
const mockResponse = { id: 1, subject: 'Updated Title' };
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.update(1, updateData);
|
||||
@@ -102,7 +103,7 @@ describe('correspondenceService', () => {
|
||||
|
||||
describe('submit', () => {
|
||||
it('should call POST /correspondences/:id/submit', async () => {
|
||||
const submitDto = { recipientIds: [2, 3] };
|
||||
const submitDto = { note: 'Ready for review' };
|
||||
const mockResponse = { id: 1, status: 'submitted' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
@@ -115,7 +116,7 @@ describe('correspondenceService', () => {
|
||||
|
||||
describe('processWorkflow', () => {
|
||||
it('should call POST /correspondences/:id/workflow', async () => {
|
||||
const workflowDto = { action: 'approve', comment: 'LGTM' };
|
||||
const workflowDto: WorkflowActionDto = { action: 'APPROVE', comments: 'LGTM' };
|
||||
const mockResponse = { id: 1, status: 'approved' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
@@ -128,7 +129,7 @@ describe('correspondenceService', () => {
|
||||
|
||||
describe('addReference', () => {
|
||||
it('should call POST /correspondences/:id/references', async () => {
|
||||
const referenceDto = { referencedDocumentId: 2, referenceType: 'reply_to' };
|
||||
const referenceDto = { targetId: 2, referenceType: 'reply_to' };
|
||||
const mockResponse = { id: 1 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
@@ -144,7 +145,7 @@ describe('correspondenceService', () => {
|
||||
|
||||
describe('removeReference', () => {
|
||||
it('should call DELETE /correspondences/:id/references with body', async () => {
|
||||
const referenceDto = { referencedDocumentId: 2 };
|
||||
const referenceDto = { targetId: 2 };
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await correspondenceService.removeReference(1, referenceDto);
|
||||
|
||||
41
frontend/lib/services/asbuilt-drawing.service.ts
Normal file
41
frontend/lib/services/asbuilt-drawing.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// File: lib/services/asbuilt-drawing.service.ts
|
||||
import apiClient from "@/lib/api/client";
|
||||
import {
|
||||
CreateAsBuiltDrawingDto,
|
||||
CreateAsBuiltDrawingRevisionDto,
|
||||
SearchAsBuiltDrawingDto
|
||||
} from "@/types/dto/drawing/asbuilt-drawing.dto";
|
||||
|
||||
export const asBuiltDrawingService = {
|
||||
/**
|
||||
* Get As Built Drawings list
|
||||
*/
|
||||
getAll: async (params: SearchAsBuiltDrawingDto) => {
|
||||
const response = await apiClient.get("/drawings/asbuilt", { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get details by ID
|
||||
*/
|
||||
getById: async (id: string | number) => {
|
||||
const response = await apiClient.get(`/drawings/asbuilt/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create New As Built Drawing
|
||||
*/
|
||||
create: async (data: CreateAsBuiltDrawingDto | FormData) => {
|
||||
const response = await apiClient.post("/drawings/asbuilt", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create New Revision
|
||||
*/
|
||||
createRevision: async (id: string | number, data: CreateAsBuiltDrawingRevisionDto) => {
|
||||
const response = await apiClient.post(`/drawings/asbuilt/${id}/revisions`, data);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export const contractDrawingService = {
|
||||
/**
|
||||
* สร้างแบบสัญญาใหม่
|
||||
*/
|
||||
create: async (data: CreateContractDrawingDto) => {
|
||||
create: async (data: CreateContractDrawingDto | FormData) => {
|
||||
const response = await apiClient.post("/drawings/contract", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
46
frontend/lib/services/document-numbering.service.ts
Normal file
46
frontend/lib/services/document-numbering.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import apiClient from "@/lib/api/client";
|
||||
import {
|
||||
NumberingMetrics,
|
||||
ManualOverrideDto,
|
||||
VoidReplaceDto,
|
||||
CancelNumberDto,
|
||||
AuditQueryParams
|
||||
} from "@/types/dto/numbering.dto";
|
||||
|
||||
export const documentNumberingService = {
|
||||
// --- Admin Dashboard Metrics ---
|
||||
getMetrics: async (): Promise<NumberingMetrics> => {
|
||||
const response = await apiClient.get("/admin/document-numbering/metrics");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- Admin Tools ---
|
||||
manualOverride: async (dto: ManualOverrideDto): Promise<void> => {
|
||||
await apiClient.post("/admin/document-numbering/manual-override", dto);
|
||||
},
|
||||
|
||||
voidAndReplace: async (dto: VoidReplaceDto): Promise<any> => {
|
||||
const response = await apiClient.post("/admin/document-numbering/void-and-replace", dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancelNumber: async (dto: CancelNumberDto): Promise<void> => {
|
||||
await apiClient.post("/admin/document-numbering/cancel", dto);
|
||||
},
|
||||
|
||||
bulkImport: async (data: FormData | any[]): Promise<any> => {
|
||||
const isFormData = data instanceof FormData;
|
||||
const config = isFormData ? { headers: { "Content-Type": "multipart/form-data" } } : {};
|
||||
const response = await apiClient.post("/admin/document-numbering/bulk-import", data, config);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- Audit Logs ---
|
||||
getAuditLogs: async (params?: AuditQueryParams) => {
|
||||
// NOTE: endpoint might be merged with metrics or separate
|
||||
// Currently controller has getMetrics returning audit logs too.
|
||||
// But if we want separate pagination later:
|
||||
// return apiClient.get("/admin/document-numbering/audit", { params });
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -191,5 +191,24 @@ export const masterDataService = {
|
||||
params: { projectId, correspondenceTypeId: typeId }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- Drawing Categories ---
|
||||
|
||||
getContractDrawingCategories: async () => {
|
||||
const response = await apiClient.get("/drawings/contract/categories");
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
getShopMainCategories: async (projectId: number) => {
|
||||
const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } });
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
getShopSubCategories: async (projectId: number, mainCategoryId?: number) => {
|
||||
const response = await apiClient.get("/drawings/shop/sub-categories", {
|
||||
params: { projectId, mainCategoryId }
|
||||
});
|
||||
return response.data.data || response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const shopDrawingService = {
|
||||
/**
|
||||
* สร้าง Shop Drawing ใหม่ (พร้อม Revision 0)
|
||||
*/
|
||||
create: async (data: CreateShopDrawingDto) => {
|
||||
create: async (data: CreateShopDrawingDto | FormData) => {
|
||||
const response = await apiClient.post("/drawings/shop", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Entity Interfaces
|
||||
export interface DrawingRevision {
|
||||
revisionId: number;
|
||||
revisionNumber: string;
|
||||
title?: string; // Added
|
||||
legacyDrawingNumber?: string; // Added
|
||||
revisionDate: string;
|
||||
revisionDescription?: string;
|
||||
revisedByName: string;
|
||||
@@ -8,19 +11,55 @@ export interface DrawingRevision {
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface ContractDrawing {
|
||||
id: number;
|
||||
contractDrawingNo: string;
|
||||
title: string;
|
||||
projectId: number;
|
||||
mapCatId?: number;
|
||||
volumeId?: number;
|
||||
volumePage?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShopDrawing {
|
||||
id: number;
|
||||
drawingNumber: string;
|
||||
projectId: number;
|
||||
mainCategoryId: number;
|
||||
subCategoryId: number;
|
||||
currentRevision?: DrawingRevision;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// title removed
|
||||
}
|
||||
|
||||
export interface AsBuiltDrawing {
|
||||
id: number;
|
||||
drawingNumber: string;
|
||||
projectId: number;
|
||||
currentRevision?: DrawingRevision;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Unified Type for List
|
||||
export interface Drawing {
|
||||
drawingId: number;
|
||||
drawingNumber: string;
|
||||
title: string;
|
||||
title: string; // Display title (from current revision for Shop/AsBuilt)
|
||||
discipline?: string | { disciplineCode: string; disciplineName: string };
|
||||
type?: string;
|
||||
status?: string;
|
||||
revision?: string;
|
||||
sheetNumber?: string;
|
||||
legacyDrawingNumber?: string; // Added for display
|
||||
scale?: string;
|
||||
issueDate?: string;
|
||||
revisionCount?: number;
|
||||
revisions?: DrawingRevision[];
|
||||
volumePage?: number; // Contract only
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
38
frontend/types/dto/drawing/asbuilt-drawing.dto.ts
Normal file
38
frontend/types/dto/drawing/asbuilt-drawing.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// File: src/types/dto/drawing/asbuilt-drawing.dto.ts
|
||||
|
||||
// --- Create New As Built Drawing ---
|
||||
export interface CreateAsBuiltDrawingDto {
|
||||
projectId: number;
|
||||
drawingNumber: string;
|
||||
|
||||
// First Revision Data
|
||||
revisionLabel?: string;
|
||||
title?: string;
|
||||
legacyDrawingNumber?: string;
|
||||
revisionDate?: string; // ISO Date String
|
||||
description?: string;
|
||||
|
||||
shopDrawingRevisionIds?: number[]; // Reference to Shop Drawing Revisions
|
||||
attachmentIds?: number[];
|
||||
}
|
||||
|
||||
// --- Create New Revision ---
|
||||
export interface CreateAsBuiltDrawingRevisionDto {
|
||||
revisionLabel: string;
|
||||
title: string;
|
||||
legacyDrawingNumber?: string;
|
||||
revisionDate?: string;
|
||||
description?: string;
|
||||
|
||||
shopDrawingRevisionIds?: number[];
|
||||
attachmentIds?: number[];
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
export interface SearchAsBuiltDrawingDto {
|
||||
projectId: number;
|
||||
search?: string;
|
||||
|
||||
page?: number; // Default: 1
|
||||
pageSize?: number; // Default: 20
|
||||
}
|
||||
@@ -11,12 +11,15 @@ export interface CreateContractDrawingDto {
|
||||
/** ชื่อแบบ */
|
||||
title: string;
|
||||
|
||||
/** ID หมวดหมู่ย่อย */
|
||||
subCategoryId?: number;
|
||||
/** ID หมวดหมู่ย่อย (Mapping) */
|
||||
mapCatId?: number;
|
||||
|
||||
/** ID เล่มของแบบ */
|
||||
volumeId?: number;
|
||||
|
||||
/** เลขหน้าในเล่ม */
|
||||
volumePage?: number;
|
||||
|
||||
/** รายการ ID ของไฟล์แนบ (PDF/DWG) */
|
||||
attachmentIds?: number[];
|
||||
}
|
||||
@@ -30,9 +33,9 @@ export interface SearchContractDrawingDto {
|
||||
projectId: number;
|
||||
|
||||
volumeId?: number;
|
||||
subCategoryId?: number;
|
||||
mapCatId?: number;
|
||||
search?: string; // ค้นหาจาก Title หรือ Number
|
||||
|
||||
page?: number; // Default: 1
|
||||
pageSize?: number; // Default: 20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateShopDrawingDto {
|
||||
revisionLabel?: string;
|
||||
revisionDate?: string; // ISO Date String
|
||||
description?: string;
|
||||
legacyDrawingNumber?: string; // Legacy number for the first revision
|
||||
contractDrawingIds?: number[]; // อ้างอิงแบบสัญญา
|
||||
attachmentIds?: number[];
|
||||
}
|
||||
@@ -19,6 +20,8 @@ export interface CreateShopDrawingDto {
|
||||
// --- Create New Revision ---
|
||||
export interface CreateShopDrawingRevisionDto {
|
||||
revisionLabel: string;
|
||||
title: string; // Title per revision
|
||||
legacyDrawingNumber?: string;
|
||||
revisionDate?: string;
|
||||
description?: string;
|
||||
contractDrawingIds?: number[];
|
||||
@@ -34,4 +37,4 @@ export interface SearchShopDrawingDto {
|
||||
|
||||
page?: number; // Default: 1
|
||||
pageSize?: number; // Default: 20
|
||||
}
|
||||
}
|
||||
|
||||
35
frontend/types/dto/numbering.dto.ts
Normal file
35
frontend/types/dto/numbering.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface NumberingMetrics {
|
||||
audit: any[]; // Replace with specific AuditLog type if available
|
||||
errors: any[]; // Replace with specific ErrorLog type
|
||||
}
|
||||
|
||||
export interface ManualOverrideDto {
|
||||
projectId: number;
|
||||
originatorOrganizationId: number;
|
||||
recipientOrganizationId: number;
|
||||
correspondenceTypeId: number;
|
||||
subTypeId?: number;
|
||||
rfaTypeId?: number;
|
||||
disciplineId?: number;
|
||||
resetScope: string;
|
||||
newLastNumber: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface VoidReplaceDto {
|
||||
documentNumber: string;
|
||||
reason: string;
|
||||
replace: boolean;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
export interface CancelNumberDto {
|
||||
documentNumber: string;
|
||||
reason: string;
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
export interface AuditQueryParams {
|
||||
projectId?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -20,4 +20,5 @@ export interface SearchOrganizationDto {
|
||||
projectId?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user