260320:1131 Refactor Overrall #01
Build and Deploy / deploy (push) Has been cancelled

This commit is contained in:
admin
2026-03-20 11:31:27 +07:00
parent f1b81a7d0d
commit 1d3479770b
147 changed files with 1745 additions and 1567 deletions
@@ -57,15 +57,19 @@ interface Field {
options?: { label: string; value: string | number }[];
}
interface ApiError extends Error {
response?: { data?: { message?: string } };
}
interface GenericCrudTableProps<T> {
title: string;
description?: string;
entityName: string;
queryKey: any[];
queryKey: string[];
fetchFn: () => Promise<T[] | { data: T[] }>;
createFn: (data: any) => Promise<any>;
updateFn: (id: number, data: any) => Promise<any>;
deleteFn: (id: number) => Promise<any>;
createFn: (data: Record<string, unknown>) => Promise<unknown>;
updateFn: (id: number, data: Record<string, unknown>) => Promise<unknown>;
deleteFn: (id: number) => Promise<unknown>;
columns: ColumnDef<T>[];
fields: Field[];
filters?: React.ReactNode;
@@ -95,7 +99,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
});
// ADR-019: Support both direct array or wrapped data object
const data: T[] = Array.isArray(rawData) ? rawData : (rawData as any)?.data || [];
const data: T[] = Array.isArray(rawData) ? rawData : (rawData as { data?: T[] } | undefined)?.data || [];
const createMutation = useMutation({
mutationFn: createFn,
@@ -105,13 +109,13 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
setIsDialogOpen(false);
reset();
},
onError: (error: any) => {
onError: (error: ApiError) => {
toast.error(error.response?.data?.message || `Failed to create ${entityName}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => updateFn(id, data),
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => updateFn(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
toast.success(`${entityName} updated successfully`);
@@ -119,7 +123,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
setEditingId(null);
reset();
},
onError: (error: any) => {
onError: (error: ApiError) => {
toast.error(error.response?.data?.message || `Failed to update ${entityName}`);
},
});
@@ -131,7 +135,7 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
toast.success(`${entityName} deleted successfully`);
setItemToDelete(null);
},
onError: (error: any) => {
onError: (error: ApiError) => {
toast.error(error.response?.data?.message || `Failed to delete ${entityName}`);
},
});
@@ -184,19 +188,20 @@ export function GenericCrudTable<T extends { id?: number; uuid?: string }>({
setIsDialogOpen(true);
};
const handleEdit = (item: any) => {
setEditingId(item.id);
reset(item);
const handleEdit = (item: T) => {
setEditingId(item.id as number);
reset(item as Record<string, unknown>);
// Ensure select values are strings for Shadcn Select
fields.forEach(f => {
if (f.type === 'select' && item[f.name]) {
setValue(f.name, String(item[f.name]));
const record = item as Record<string, unknown>;
if (f.type === 'select' && record[f.name]) {
setValue(f.name, String(record[f.name]));
}
});
setIsDialogOpen(true);
};
const onSubmit = (formData: any) => {
const onSubmit = (formData: Record<string, unknown>) => {
if (editingItem) {
updateMutation.mutate({ id: editingItem, data: formData });
} else {
@@ -35,13 +35,15 @@ interface RbacMatrixProps {
}
const securityService = {
getRoles: async () => {
const response = await apiClient.get<any>("/users/roles");
return response.data?.data || response.data;
getRoles: async (): Promise<Role[]> => {
const response = await apiClient.get("/users/roles");
const data = response.data?.data || response.data;
return Array.isArray(data) ? data : [];
},
getPermissions: async () => {
const response = await apiClient.get<any>("/users/permissions");
return response.data?.data || response.data;
getPermissions: async (): Promise<Permission[]> => {
const response = await apiClient.get("/users/permissions");
const data = response.data?.data || response.data;
return Array.isArray(data) ? data : [];
},
updateRolePermissions: async (roleId: number, permissionIds: number[]) => {
// This endpoint might not exist as a bulk update, usually it's per role
@@ -137,9 +139,9 @@ export function RbacMatrix() {
<div>{perm.permissionName}</div>
<div className="text-xs text-muted-foreground">{perm.description}</div>
</TableCell>
{roles.map((role: any) => {
{roles.map((role) => {
// Assume role.permissions is populated
const currentRolePerms = role.permissions?.map((p: any) => p.permissionId) || [];
const currentRolePerms = role.permissions?.map((p) => p.permissionId) || [];
const activePerms = pendingChanges[role.roleId] || currentRolePerms;
const isChecked = activePerms.includes(perm.permissionId);
+14 -4
View File
@@ -108,7 +108,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
isActive: user.isActive,
lineId: user.lineId || "",
primaryOrganizationId: user.primaryOrganizationId?.toString(),
roleIds: user.roles?.map((r: any) => r.roleId) || [],
roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [],
password: "",
confirmPassword: ""
});
@@ -158,7 +158,17 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
// Create req: Password mandatory
if (!payload.password) return; // Should allow Zod to catch or show error
createUser.mutate(payload as any, {
createUser.mutate({
username: payload.username,
email: payload.email,
firstName: payload.firstName,
lastName: payload.lastName,
password: payload.password,
isActive: payload.isActive ?? true,
lineId: payload.lineId,
primaryOrganizationId: payload.primaryOrganizationId,
roleIds: payload.roleIds ?? [],
}, {
onSuccess: () => onOpenChange(false),
});
}
@@ -230,7 +240,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<SelectValue placeholder="Select Organization" />
</SelectTrigger>
<SelectContent>
{organizations?.map((org: any) => (
{organizations?.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
<SelectItem
key={org.uuid}
value={org.uuid}
@@ -300,7 +310,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<Label className="mb-3 block">Roles</Label>
<div className="space-y-2 border p-3 rounded-md max-h-[200px] overflow-y-auto">
{Array.isArray(roles) && roles.length === 0 && <p className="text-sm text-muted-foreground">Loading roles...</p>}
{Array.isArray(roles) && roles.map((role: any) => (
{Array.isArray(roles) && roles.map((role: { roleId: number; roleName: string; description?: string }) => (
<div key={role.roleId} className="flex items-start space-x-2">
<Checkbox
id={`role-${role.roleId}`}
+19 -10
View File
@@ -15,20 +15,29 @@ export function AuthSync() {
// Map NextAuth session to AuthStore user
// Assuming session.user has the fields we need based on types/next-auth.d.ts
// cast to any or specific type if needed, as NextAuth types might need assertion
const user = session.user as any;
// Map NextAuth session user to AuthStore user type
const user = session.user as {
id?: string;
user_id?: string;
username?: string;
email?: string;
firstName?: string;
lastName?: string;
role?: string;
permissions?: string[];
};
setAuth(
{
id: user.id || user.user_id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
permissions: user.permissions // If backend/auth.ts provides this
id: user.id || user.user_id || '',
username: user.username || '',
email: user.email || '',
firstName: user.firstName || '',
lastName: user.lastName || '',
role: user.role || 'User',
permissions: user.permissions
},
session.accessToken || '' // If we store token in session
(session as { accessToken?: string }).accessToken || ''
);
} else if (status === 'unauthenticated') {
logout();
-102
View File
@@ -1,102 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, X, File } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FileUploadProps {
onFilesSelected: (files: File[]) => void;
maxFiles?: number;
accept?: string;
maxSize?: number; // bytes
}
export function FileUpload({
onFilesSelected,
maxFiles = 5,
accept = ".pdf,.doc,.docx",
maxSize = 10485760, // 10MB
}: FileUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setFiles((prev) => {
const newFiles = [...prev, ...acceptedFiles].slice(0, maxFiles);
onFilesSelected(newFiles);
return newFiles;
});
},
[maxFiles, onFilesSelected]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxFiles,
accept: accept.split(",").reduce((acc, ext) => ({ ...acc, [ext]: [] }), {}),
maxSize,
});
const removeFile = (index: number) => {
setFiles((prev) => {
const newFiles = prev.filter((_, i) => i !== index);
onFilesSelected(newFiles);
return newFiles;
});
};
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
isDragActive
? "border-primary bg-primary/5"
: "border-gray-300 hover:border-gray-400"
)}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-600">
{isDragActive
? "Drop files here"
: "Drag & drop files or click to browse"}
</p>
<p className="mt-1 text-xs text-gray-500">
Maximum {maxFiles} files, {(maxSize / 1024 / 1024).toFixed(0)}MB each
</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<File className="h-5 w-5 text-gray-500" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
@@ -18,10 +18,9 @@ export function CorrespondencesContent() {
const { data, isLoading, isError } = useCorrespondences({
page,
status,
search,
revisionStatus,
} as any);
});
if (isLoading) {
return (
@@ -24,8 +24,6 @@ export function CorrespondenceDetail({ data }: CorrespondenceDetailProps) {
if (!data) return <div>No data found</div>;
console.log("Correspondence Detail Data:", data);
// Derive Current Revision Data
const currentRevision = data.revisions?.find(r => r.isCurrent) || data.revisions?.[0];
const subject = currentRevision?.subject || "-";
+6 -6
View File
@@ -14,7 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FileUpload } from "@/components/common/file-upload";
import { FileUploadZone } from "@/components/custom/file-upload-zone";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useCreateCorrespondence, useUpdateCorrespondence } from "@/hooks/use-correspondence";
@@ -80,7 +80,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(correspondenceSchema),
defaultValues: defaultValues as any,
defaultValues: defaultValues as FormData,
});
// Watch for controlled inputs
@@ -407,10 +407,10 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
{!initialData && (
<div className="space-y-2">
<Label>Attachments</Label>
<FileUpload
onFilesSelected={(files) => setValue("attachments", files)}
maxFiles={10}
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.png"
<FileUploadZone
onFilesChanged={(files) => setValue("attachments", files)}
multiple
accept={[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".jpg", ".png"]}
/>
</div>
)}
@@ -74,7 +74,7 @@ export function FileUploadZone({
const processedFiles: FileWithMeta[] = newFiles.map((file) => {
const error = validateFile(file);
// สร้าง Object ใหม่เพื่อไม่ให้กระทบ File object เดิม
const fileWithMeta = new File([file], file.name, { type: file.type } as any) as FileWithMeta;
const fileWithMeta = new File([file], file.name, { type: file.type }) as FileWithMeta;
fileWithMeta.validationError = error;
return fileWithMeta;
});
@@ -1,125 +0,0 @@
// File: components/custom/responsive-data-table.tsx
import React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { cn } from "@/lib/utils";
/**
* Interface สำหรับ Column Definition
*/
export interface ColumnDef<T> {
key: string;
header: string;
/** ฟังก์ชันสำหรับ render cell content (optional) */
cell?: (item: T) => React.ReactNode;
/** คลาส CSS เพิ่มเติมสำหรับ cell */
className?: string;
}
/**
* Props สำหรับ ResponsiveDataTable
*/
interface ResponsiveDataTableProps<T> {
/** ข้อมูลที่จะแสดงในตาราง */
data: T[];
/** นิยามของคอลัมน์ */
columns: ColumnDef<T>[];
/** Key ที่เป็น Unique ID ของข้อมูล (เช่น 'id', 'user_id') */
keyExtractor: (item: T) => string | number;
/** ฟังก์ชันสำหรับ Render Card View บน Mobile (ถ้าไม่ใส่จะ Render แบบ Default Key-Value) */
renderMobileCard?: (item: T) => React.ReactNode;
/** ข้อความเมื่อไม่มีข้อมูล */
emptyMessage?: string;
/** คลาส CSS เพิ่มเติมสำหรับ Container */
className?: string;
}
/**
* ResponsiveDataTable Component
* * แสดงผลเป็น Table ปกติในหน้าจอขนาด md ขึ้นไป
* และแสดงผลเป็น Card List ในหน้าจอขนาดเล็กกว่า md
*/
export function ResponsiveDataTable<T>({
data,
columns,
keyExtractor,
renderMobileCard,
emptyMessage = "ไม่พบข้อมูล",
className,
}: ResponsiveDataTableProps<T>) {
if (!data || data.length === 0) {
return (
<div className="p-8 text-center text-muted-foreground border rounded-md bg-background">
{emptyMessage}
</div>
);
}
return (
<div className={cn("w-full space-y-4", className)}>
{/* --- Desktop View (Table) --- */}
<div className="hidden md:block rounded-md border bg-background">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key} className={col.className}>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={keyExtractor(item)}>
{columns.map((col) => (
<TableCell key={`${keyExtractor(item)}-${col.key}`} className={col.className}>
{col.cell ? col.cell(item) : (item as any)[col.key]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* --- Mobile View (Cards) --- */}
<div className="md:hidden space-y-4">
{data.map((item) => (
<div key={keyExtractor(item)}>
{renderMobileCard ? (
// Custom Mobile Render
renderMobileCard(item)
) : (
// Default Mobile Render (Key-Value Pairs)
<Card>
<CardHeader className="pb-2 font-semibold border-b mb-2">
# {keyExtractor(item)}
</CardHeader>
<CardContent className="space-y-2 text-sm">
{columns.map((col) => (
<div key={col.key} className="flex justify-between items-start border-b pb-1 last:border-0">
<span className="font-medium text-muted-foreground w-1/3">{col.header}:</span>
<span className="text-right w-2/3 break-words">
{col.cell ? col.cell(item) : (item as any)[col.key]}
</span>
</div>
))}
</CardContent>
</Card>
)}
</div>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function DrawingList({ type, projectUuid, filters }: DrawingListProps) {
...filters,
page: pagination.pageIndex + 1, // API is 1-based
limit: pagination.pageSize,
} as any);
} as DrawingSearchParams);
const drawings = response?.data || [];
const meta = response?.meta || { total: 0, page: 1, limit: 20, totalPages: 0 };
+35 -32
View File
@@ -1,6 +1,6 @@
"use client";
import { useForm } from "react-hook-form";
import { useForm, FieldError } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -95,12 +95,15 @@ export function DrawingUploadForm() {
watch,
formState: { errors },
} = useForm<DrawingFormData>({
resolver: zodResolver(formSchema) as any,
resolver: zodResolver(formSchema),
defaultValues: {
drawingType: "CONTRACT",
}
} as DrawingFormData
});
// Type-safe error access for discriminated union fields
const formErrors = errors as Record<string, FieldError | undefined>;
const drawingType = watch("drawingType");
const watchedProjectId = watch("projectId");
const createMutation = useCreateDrawing(drawingType);
@@ -148,7 +151,7 @@ export function DrawingUploadForm() {
if (data.description) formData.append('description', data.description);
}
createMutation.mutate(formData as any, {
createMutation.mutate(formData, {
onSuccess: () => {
router.push("/drawings");
}
@@ -191,7 +194,7 @@ export function DrawingUploadForm() {
<Label>Drawing Type *</Label>
<Select
onValueChange={(v) => {
setValue("drawingType", v as any);
setValue("drawingType", v as DrawingFormData["drawingType"]);
// Reset errors or fields if needed
}}
defaultValue="CONTRACT"
@@ -214,15 +217,15 @@ export function DrawingUploadForm() {
<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>
{formErrors.contractDrawingNo && (
<p className="text-sm text-destructive">{formErrors.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>
{formErrors.title && (
<p className="text-sm text-destructive">{formErrors.title.message}</p>
)}
</div>
</div>
@@ -235,13 +238,13 @@ export function DrawingUploadForm() {
<SelectValue placeholder="Select Category" />
</SelectTrigger>
<SelectContent>
{contractCategories?.map((c: any) => (
{contractCategories?.map((c: { id: number; catName?: string; catCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.catName || c.catCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).mapCatId && (
<p className="text-sm text-destructive">{(errors as any).mapCatId.message}</p>
{formErrors.mapCatId && (
<p className="text-sm text-destructive">{formErrors.mapCatId.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-2">
@@ -265,8 +268,8 @@ export function DrawingUploadForm() {
<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>
{formErrors.drawingNumber && (
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
)}
</div>
<div>
@@ -286,13 +289,13 @@ export function DrawingUploadForm() {
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map((c: any) => (
{shopMainCats?.map((c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.mainCategoryName || c.mainCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).mainCategoryId && (
<p className="text-sm text-destructive">{(errors as any).mainCategoryId.message}</p>
{formErrors.mainCategoryId && (
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
)}
</div>
<div>
@@ -302,13 +305,13 @@ export function DrawingUploadForm() {
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map((c: any) => (
{shopSubCats?.map((c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.subCategoryName || c.subCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).subCategoryId && (
<p className="text-sm text-destructive">{(errors as any).subCategoryId.message}</p>
{formErrors.subCategoryId && (
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
)}
</div>
</div>
@@ -316,8 +319,8 @@ export function DrawingUploadForm() {
<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>
{formErrors.title && (
<p className="text-sm text-destructive">{formErrors.title.message}</p>
)}
</div>
@@ -335,8 +338,8 @@ export function DrawingUploadForm() {
<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>
{formErrors.drawingNumber && (
<p className="text-sm text-destructive">{formErrors.drawingNumber.message}</p>
)}
</div>
<div>
@@ -356,13 +359,13 @@ export function DrawingUploadForm() {
<SelectValue placeholder="Select Main Category" />
</SelectTrigger>
<SelectContent>
{shopMainCats?.map((c: any) => (
{shopMainCats?.map((c: { id: number; mainCategoryName?: string; mainCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.mainCategoryName || c.mainCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).mainCategoryId && (
<p className="text-sm text-destructive">{(errors as any).mainCategoryId.message}</p>
{formErrors.mainCategoryId && (
<p className="text-sm text-destructive">{formErrors.mainCategoryId.message}</p>
)}
</div>
<div>
@@ -372,13 +375,13 @@ export function DrawingUploadForm() {
<SelectValue placeholder="Select Sub Category" />
</SelectTrigger>
<SelectContent>
{shopSubCats?.map((c: any) => (
{shopSubCats?.map((c: { id: number; subCategoryName?: string; subCategoryCode?: string; name?: string }) => (
<SelectItem key={c.id} value={String(c.id)}>{c.subCategoryName || c.subCategoryCode || c.name}</SelectItem>
))}
</SelectContent>
</Select>
{(errors as any).subCategoryId && (
<p className="text-sm text-destructive">{(errors as any).subCategoryId.message}</p>
{formErrors.subCategoryId && (
<p className="text-sm text-destructive">{formErrors.subCategoryId.message}</p>
)}
</div>
</div>
@@ -386,8 +389,8 @@ export function DrawingUploadForm() {
<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>
{formErrors.title && (
<p className="text-sm text-destructive">{formErrors.title.message}</p>
)}
</div>
<div>
-124
View File
@@ -1,124 +0,0 @@
// File: components/forms/file-upload.tsx
"use client";
import { useRef, useState } from "react";
import { UploadCloud, X, File, FileText, Image as ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface FileUploadProps {
onFilesChange: (files: File[]) => void;
maxFiles?: number;
maxSize?: number; // MB
accept?: string; // e.g. ".pdf,.jpg,.png"
}
export function FileUpload({ onFilesChange, maxFiles = 5, maxSize = 50, accept }: FileUploadProps) {
const [dragActive, setDragActive] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFiles(Array.from(e.dataTransfer.files));
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
handleFiles(Array.from(e.target.files));
}
};
const handleFiles = (newFiles: File[]) => {
// Validate size & type here if needed
const validFiles = newFiles.slice(0, maxFiles - files.length);
const updatedFiles = [...files, ...validFiles];
setFiles(updatedFiles);
onFilesChange(updatedFiles);
};
const removeFile = (idx: number) => {
const updatedFiles = files.filter((_, i) => i !== idx);
setFiles(updatedFiles);
onFilesChange(updatedFiles);
};
const getFileIcon = (type: string) => {
if (type.includes("image")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
if (type.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
return <File className="h-5 w-5 text-gray-500" />;
};
return (
<div className="space-y-4">
<div
className={cn(
"relative flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed transition-colors",
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:bg-muted/5"
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
ref={inputRef}
className="hidden"
type="file"
multiple
accept={accept}
onChange={handleChange}
/>
<div className="flex flex-col items-center justify-center pt-5 pb-6 text-center cursor-pointer" onClick={() => inputRef.current?.click()}>
<UploadCloud className="w-8 h-8 mb-2 text-muted-foreground" />
<p className="mb-1 text-sm text-gray-500 dark:text-gray-400">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
PDF, DWG, DOCX (Max {maxSize}MB)
</p>
</div>
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between p-2 bg-muted/40 rounded-md border text-sm">
<div className="flex items-center gap-2 overflow-hidden">
{getFileIcon(file.type)}
<span className="truncate max-w-[200px]">{file.name}</span>
<span className="text-xs text-muted-foreground">({(file.size / 1024 / 1024).toFixed(2)} MB)</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => removeFile(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
+6 -6
View File
@@ -112,14 +112,14 @@ export function Sidebar({ className }: SidebarProps) {
<div className="flex-1 overflow-y-auto py-4">
<nav className="grid gap-1 px-2">
{mainNavItems.map((item, index) => {
{mainNavItems.map((item) => {
if (item.adminOnly && !isAdmin) return null;
const isActive = pathname.startsWith(item.href);
const LinkComponent = (
<Link
key={index}
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
@@ -135,7 +135,7 @@ export function Sidebar({ className }: SidebarProps) {
if (item.permission) {
return (
<Can key={index} permission={item.permission}>
<Can key={item.href} permission={item.permission}>
{LinkComponent}
</Can>
);
@@ -186,14 +186,14 @@ export function MobileSidebar() {
</div>
<div className="flex-1 overflow-y-auto py-4 h-[calc(100vh-4rem)]">
<nav className="grid gap-1 px-2">
{mainNavItems.map((item, index) => {
{mainNavItems.map((item) => {
if (item.adminOnly && !isAdmin) return null;
const isActive = pathname.startsWith(item.href);
const LinkComponent = (
<Link
key={index}
key={item.href}
href={item.href}
onClick={() => setOpen(false)}
className={cn(
@@ -208,7 +208,7 @@ export function MobileSidebar() {
if (item.permission) {
return (
<Can key={index} permission={item.permission}>
<Can key={item.href} permission={item.permission}>
{LinkComponent}
</Can>
);
@@ -24,7 +24,7 @@ export function AuditLogsTable() {
setLogs(data.audit);
}
} catch (error) {
console.error("Failed to fetch audit logs", error);
// Failed to fetch audit logs - empty state shown
} finally {
setLoading(false);
}
@@ -7,7 +7,7 @@ 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 }) {
export function BulkImportForm({ projectId = 1 }: { projectId?: number | string }) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -30,7 +30,6 @@ export function BulkImportForm({ projectId = 1 }: { projectId?: number }) {
setFile(null);
} catch (error) {
toast.error("Failed to import numbers.");
console.error(error);
} finally {
setLoading(false);
}
@@ -29,7 +29,7 @@ export function CancelNumberForm() {
const [loading, setLoading] = useState(false);
const form = useForm<CancelNumberFormData>({
resolver: zodResolver(formSchema) as any,
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
defaultValues: {
documentNumber: "",
reason: "",
@@ -45,7 +45,6 @@ export function CancelNumberForm() {
form.reset();
} catch (error) {
toast.error("Failed to cancel number. It may not exist or is already cancelled.");
console.error(error);
} finally {
setLoading(false);
}
@@ -29,13 +29,13 @@ const formSchema = z.object({
resetScope: z.string().optional()
});
export function ManualOverrideForm({ projectId = 1 }: { projectId?: number }) {
export function ManualOverrideForm({ projectId = 1 }: { projectId?: number | string }) {
const [loading, setLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema) as any,
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
defaultValues: {
projectId: projectId,
projectId: Number(projectId),
originatorOrganizationId: 0,
recipientOrganizationId: 0,
correspondenceTypeId: 0,
@@ -57,7 +57,6 @@ export function ManualOverrideForm({ projectId = 1 }: { projectId?: number }) {
form.reset();
} catch (error) {
toast.error("Failed to apply override.");
console.error(error);
} finally {
setLoading(false);
}
@@ -16,7 +16,7 @@ export function MetricsDashboard() {
const data = await documentNumberingService.getMetrics();
setMetrics(data);
} catch (error) {
console.error("Failed to fetch metrics", error);
// Failed to fetch metrics - handled by loading state
} finally {
setLoading(false);
}
@@ -21,7 +21,7 @@ export function SequenceViewer() {
const data = Array.isArray(response) ? response : (response as { data?: NumberSequence[] })?.data ?? [];
setSequences(data);
} catch {
console.error('Failed to fetch sequences');
// Failed to fetch sequences - show empty state
setSequences([]);
} finally {
setLoading(false);
@@ -71,7 +71,7 @@ export function TemplateEditor({
// Dynamic context based on selection (optional visual enhancement)
if (v.key === '{TYPE}' && typeId) {
const t = (correspondenceTypes as any[]).find((ct: any) => ct.id?.toString() === typeId);
const t = (correspondenceTypes as { id: number; typeCode: string; typeName: string }[]).find((ct) => ct.id?.toString() === typeId);
if (t) replacement = t.typeCode;
}
@@ -53,7 +53,8 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
const [loading, setLoading] = useState(false);
// Master Data Hooks
const projectId = (template as any)?.project?.id ?? (template as any)?.project?.uuid ?? template?.projectId ?? 1;
const templateWithProject = template as (NumberingTemplate & { project?: { id?: number; uuid?: string } }) | null;
const projectId = templateWithProject?.project?.id ?? templateWithProject?.project?.uuid ?? template?.projectId ?? 1;
const { data: organizations } = useOrganizations({ isActive: true });
const { data: correspondenceTypes } = useCorrespondenceTypes();
const { data: contracts } = useContracts(projectId);
@@ -74,14 +75,9 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
disciplineId: parseInt(testData.disciplineId || "0"),
});
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"}`);
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } }; message?: string };
setGeneratedNumber(`Error: ${err.response?.data?.message || err.message || "Unknown error"}`);
} finally {
setLoading(false);
}
@@ -29,16 +29,16 @@ const formSchema = z.object({
type VoidReplaceFormData = z.infer<typeof formSchema>;
export function VoidReplaceForm({ projectId = 1 }: { projectId?: number }) {
export function VoidReplaceForm({ projectId = 1 }: { projectId?: number | string }) {
const [loading, setLoading] = useState(false);
const form = useForm<VoidReplaceFormData>({
resolver: zodResolver(formSchema) as any,
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
defaultValues: {
documentNumber: "",
reason: "",
replace: false,
projectId: projectId
projectId: Number(projectId)
},
});
@@ -53,7 +53,6 @@ export function VoidReplaceForm({ projectId = 1 }: { projectId?: number }) {
form.reset();
} catch (error) {
toast.error("Failed to void number. Check if it exists.");
console.error(error);
} finally {
setLoading(false);
}
+23 -3
View File
@@ -1,6 +1,5 @@
"use client";
import { RFA } from "@/types/rfa";
import { StatusBadge } from "@/components/common/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -13,8 +12,29 @@ import { Textarea } from "@/components/ui/textarea";
import { useRouter } from "next/navigation";
import { useProcessRFA } from "@/hooks/use-rfa";
interface RFADetailItem {
id: number;
itemNo: string;
description: string;
quantity: number;
unit: string;
status?: string;
}
interface RFADetailData {
uuid: string;
rfaNumber: string;
subject: string;
description?: string;
status: string;
createdAt: string;
contractName?: string;
disciplineName?: string;
items: RFADetailItem[];
}
interface RFADetailProps {
data: any;
data: RFADetailData;
}
export function RFADetail({ data }: RFADetailProps) {
@@ -152,7 +172,7 @@ export function RFADetail({ data }: RFADetailProps) {
</tr>
</thead>
<tbody className="divide-y">
{data.items.map((item: any) => (
{data.items.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3 font-medium">{item.itemNo}</td>
<td className="px-4 py-3">{item.description}</td>
+3 -3
View File
@@ -20,7 +20,7 @@ import { useRouter } from "next/navigation";
import { useCreateRFA } from "@/hooks/use-rfa";
import { useDisciplines, useContracts } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects";
import { CreateRFADto } from "@/types/rfa";
import { CreateRfaDto } from "@/types/dto/rfa/rfa.dto";
import { useState, useEffect } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service";
@@ -126,11 +126,11 @@ export function RFAForm() {
});
const onSubmit = (data: RFAFormData) => {
const payload: CreateRFADto = {
const payload: CreateRfaDto = {
...data,
// ADR-019: projectId is already a UUID string from the form
};
createMutation.mutate(payload as any, {
createMutation.mutate(payload, {
onSuccess: () => {
router.push("/rfas");
},
@@ -77,7 +77,7 @@ export function TransmittalForm() {
const [docOpen, setDocOpen] = useState(false);
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
resolver: zodResolver(formSchema) as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- zod 4 + @hookform/resolvers compat
defaultValues: {
projectId: "",
recipientOrganizationId: "",
+1 -1
View File
@@ -48,7 +48,7 @@ export function DSLEditor({ initialValue = '', onChange, readOnly = false }: DSL
const result = await workflowApi.validateDSL(dsl);
setValidationResult(result);
} catch (error) {
console.error("Validation error:", error);
// Validation failed - error state shown in UI
setValidationResult({ valid: false, errors: ['Validation failed due to server error'] });
} finally {
setIsValidating(false);
@@ -120,7 +120,7 @@ function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
});
} catch (e) {
console.error("Failed to parse DSL as JSON", e);
// Failed to parse DSL as JSON - nodes/edges remain empty
}
return { nodes, edges };
@@ -226,7 +226,7 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
};
const dsl = JSON.stringify(dslObj, null, 2);
console.log("Generated DSL:", dsl);
// DSL generated from visual builder
onDslChange?.(dsl);
alert("DSL Updated from Visual Builder!");
};