260321:1700 Correct Coresspondence / Doing RFA

This commit is contained in:
admin
2026-03-21 17:00:41 +07:00
parent dcf55f4d08
commit 03d16cfd64
57 changed files with 1923 additions and 663 deletions
@@ -37,17 +37,20 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { Organization } from "@/types/organization";
import { getApiErrorMessage } from "@/types/api-error";
export default function UsersPage() {
const [search, setSearch] = useState("");
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const { data: users, isLoading } = useUsers({
const { data: users, isLoading, isError, error } = useUsers({
search: search || undefined,
primaryOrganizationId: selectedOrgId ?? undefined,
});
const { data: organizations = [] } = useOrganizations();
const userList = Array.isArray(users) ? users : [];
const organizationList = Array.isArray(organizations) ? organizations : [];
const deleteMutation = useDeleteUser();
const [dialogOpen, setDialogOpen] = useState(false);
@@ -94,8 +97,12 @@ export default function UsersPage() {
header: "Organization",
cell: ({ row }) => {
const orgId = row.original.primaryOrganizationId;
const org = (organizations as Organization[]).find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
return org ? org.organizationCode : "-";
if (!orgId) {
return "All Organizations";
}
const org = organizationList.find((o) => (o.id ?? o.uuid) === orgId?.toString() || o.uuid === orgId?.toString());
return org ? org.organizationCode : "All Organizations";
},
},
{
@@ -185,7 +192,7 @@ export default function UsersPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Organizations</SelectItem>
{Array.isArray(organizations) && (organizations as Organization[]).map((org) => (
{organizationList.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName}
</SelectItem>
@@ -195,6 +202,12 @@ export default function UsersPage() {
</div>
</div>
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{getApiErrorMessage(error, "Failed to load users")}
</div>
)}
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
@@ -204,7 +217,7 @@ export default function UsersPage() {
))}
</div>
) : (
<DataTable columns={columns} data={users || []} />
<DataTable columns={columns} data={userList} />
)}
<UserDialog
@@ -29,7 +29,7 @@ export default function EditTemplatePage() {
const { data: disciplines = [] } = useDisciplines(contractId);
const selectedProjectName =
projects.find((p: { id: number; projectName: string }) => p.id === projectId)?.projectName ||
projects.find((p: { id?: number; uuid?: string; projectCode: string; projectName: string }) => p.id === projectId)?.projectName ||
'LCBP3';
useEffect(() => {
@@ -10,6 +10,7 @@ import { Workflow } from '@/types/workflow';
export default function WorkflowsPage() {
const { data: workflows = [], isLoading: loading, error } = useWorkflowDefinitions();
const workflowList = Array.isArray(workflows) ? workflows : [];
return (
<div className="p-6 space-y-6">
@@ -34,13 +35,13 @@ export default function WorkflowsPage() {
<div className="text-center py-12 text-destructive border rounded-lg border-dashed border-destructive/50 bg-destructive/10">
Failed to load workflows. Please try again later.
</div>
) : workflows.length === 0 ? (
) : workflowList.length === 0 ? (
<div className="text-center py-12 text-muted-foreground border rounded-lg border-dashed">
No workflow definitions found. Click &quot;New Workflow&quot; to create one.
</div>
) : (
<div className="grid gap-4">
{workflows.map((workflow: Workflow) => (
{workflowList.map((workflow: Workflow) => (
<Card key={workflow.workflowId} className="p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
@@ -17,10 +17,12 @@ import { format } from "date-fns";
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getApiErrorMessage } from "@/types/api-error";
export default function MigrationErrorsPage() {
const [items, setItems] = useState<MigrationErrorItem[]>([]);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
fetchData();
@@ -29,10 +31,12 @@ export default function MigrationErrorsPage() {
const fetchData = async () => {
try {
setLoading(true);
setErrorMessage(null);
const res = await migrationService.getErrors({ limit: 100 });
setItems(res.items);
} catch (error) {
// Failed to fetch errors - loading state handles display
setItems(Array.isArray(res.items) ? res.items : []);
} catch (error: unknown) {
setItems([]);
setErrorMessage(getApiErrorMessage(error, "Failed to load errors"));
} finally {
setLoading(false);
}
@@ -59,6 +63,11 @@ export default function MigrationErrorsPage() {
<CardTitle>Error Audit Log</CardTitle>
</CardHeader>
<CardContent>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{errorMessage}
</div>
)}
{loading ? (
<div className="py-10 text-center">Loading errors...</div>
) : items.length === 0 ? (
@@ -20,6 +20,7 @@ import { format } from "date-fns";
import { EyeIcon, FileXIcon, CheckSquareIcon } from "lucide-react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getApiErrorMessage } from "@/types/api-error";
export default function MigrationReviewQueuePage() {
const [items, setItems] = useState<MigrationReviewQueueItem[]>([]);
@@ -27,6 +28,7 @@ export default function MigrationReviewQueuePage() {
const [submitting, setSubmitting] = useState(false);
const [statusFilter, setStatusFilter] = useState<string>("PENDING");
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
fetchData();
@@ -35,14 +37,16 @@ export default function MigrationReviewQueuePage() {
const fetchData = async () => {
try {
setLoading(true);
setErrorMessage(null);
const res = await migrationService.getReviewQueue({
status: statusFilter === "ALL" ? undefined : (statusFilter as MigrationReviewStatus),
limit: 50,
});
setItems(res.items);
setItems(Array.isArray(res.items) ? res.items : []);
setSelectedIds([]); // reset selection on fetch
} catch (error) {
// Failed to fetch queue - loading state handles display
} catch (error: unknown) {
setItems([]);
setErrorMessage(getApiErrorMessage(error, "Failed to load queue"));
} finally {
setLoading(false);
}
@@ -148,6 +152,11 @@ export default function MigrationReviewQueuePage() {
<CardTitle>Queue Items - {statusFilter}</CardTitle>
</CardHeader>
<CardContent>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{errorMessage}
</div>
)}
{loading ? (
<div className="py-10 text-center">Loading queue...</div>
) : items.length === 0 ? (
@@ -1,12 +1,13 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { sessionService } from '@/lib/services/session.service';
import { Session, sessionService } from '@/lib/services/session.service';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from 'sonner';
import { Loader2, Trash2, Monitor, Smartphone } from 'lucide-react';
import { Loader2, Trash2, Monitor } from 'lucide-react';
import { format } from 'date-fns';
import { getApiErrorMessage } from '@/types/api-error';
export default function SessionManagementPage() {
const queryClient = useQueryClient();
@@ -15,10 +16,11 @@ export default function SessionManagementPage() {
data: sessions,
isLoading,
error,
} = useQuery({
} = useQuery<Session[]>({
queryKey: ['sessions'],
queryFn: sessionService.getActiveSessions,
});
const sessionList = Array.isArray(sessions) ? sessions : [];
const revokeMutation = useMutation({
mutationFn: sessionService.revokeSession,
@@ -26,8 +28,10 @@ export default function SessionManagementPage() {
toast.success('Session revoked successfully');
queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
onError: (error) => {
toast.error('Failed to revoke session');
onError: (mutationError: unknown) => {
toast.error('Failed to revoke session', {
description: getApiErrorMessage(mutationError, 'Unknown error'),
});
},
});
@@ -46,7 +50,7 @@ export default function SessionManagementPage() {
}
if (error) {
return <div className="p-8 text-center text-red-500">Failed to load sessions. Please try again.</div>;
return <div className="p-8 text-center text-red-500">{getApiErrorMessage(error, 'Failed to load sessions. Please try again.')}</div>;
}
return (
@@ -67,7 +71,7 @@ export default function SessionManagementPage() {
</TableRow>
</TableHeader>
<TableBody>
{sessions?.map((session: any) => (
{sessionList.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="flex flex-col">
@@ -94,7 +98,7 @@ export default function SessionManagementPage() {
variant="destructive"
size="sm"
className="h-8"
onClick={() => handleRevoke(Number(session.id))}
onClick={() => handleRevoke(session.id)}
disabled={revokeMutation.isPending}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
@@ -103,7 +107,7 @@ export default function SessionManagementPage() {
</TableCell>
</TableRow>
))}
{(!sessions || sessions.length === 0) && (
{sessionList.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-muted-foreground">
No active sessions found.
@@ -34,16 +34,32 @@ interface RbacMatrixProps {
rolePermissions: Record<number, number[]>; // roleId -> permissionIds[]
}
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const securityService = {
getRoles: async (): Promise<Role[]> => {
const response = await apiClient.get("/users/roles");
const data = response.data?.data || response.data;
return Array.isArray(data) ? data : [];
return extractArrayData<Role>(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 : [];
return extractArrayData<Permission>(response.data);
},
updateRolePermissions: async (roleId: number, permissionIds: number[]) => {
// This endpoint might not exist as a bulk update, usually it's per role
@@ -98,6 +114,8 @@ export function RbacMatrix() {
};
const hasChanges = Object.keys(pendingChanges).length > 0;
const roleList = Array.isArray(roles) ? roles : [];
const permissionList = Array.isArray(permissions) ? permissions : [];
if (rolesLoading || permsLoading) {
return (
@@ -125,7 +143,7 @@ export function RbacMatrix() {
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">Permission</TableHead>
{roles.map((role) => (
{roleList.map((role) => (
<TableHead key={role.roleId} className="text-center min-w-[100px]">
{role.roleName}
</TableHead>
@@ -133,13 +151,13 @@ export function RbacMatrix() {
</TableRow>
</TableHeader>
<TableBody>
{permissions.map((perm) => (
{permissionList.map((perm) => (
<TableRow key={perm.permissionId}>
<TableCell className="font-medium">
<div>{perm.permissionName}</div>
<div className="text-xs text-muted-foreground">{perm.description}</div>
</TableCell>
{roles.map((role) => {
{roleList.map((role) => {
// Assume role.permissions is populated
const currentRolePerms = role.permissions?.map((p) => p.permissionId) || [];
const activePerms = pendingChanges[role.roleId] || currentRolePerms;
+11 -5
View File
@@ -26,6 +26,8 @@ import {
} from "@/components/ui/select";
import { Eye, EyeOff } from "lucide-react";
const ALL_ORGANIZATIONS_VALUE = "all";
// Update schema to include confirmPassword
const userSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
@@ -92,7 +94,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
isActive: true,
roleIds: [],
lineId: "",
primaryOrganizationId: undefined,
primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
password: "",
confirmPassword: ""
},
@@ -107,7 +109,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
lastName: user.lastName,
isActive: user.isActive,
lineId: user.lineId || "",
primaryOrganizationId: user.primaryOrganizationId?.toString(),
primaryOrganizationId: user.primaryOrganizationId?.toString() || ALL_ORGANIZATIONS_VALUE,
roleIds: user.roles?.map((r: { roleId: number }) => r.roleId) || [],
password: "",
confirmPassword: ""
@@ -120,7 +122,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
lastName: "",
isActive: true,
lineId: "",
primaryOrganizationId: undefined,
primaryOrganizationId: ALL_ORGANIZATIONS_VALUE,
roleIds: [],
password: "",
confirmPassword: ""
@@ -148,6 +150,9 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
const payload = { ...data };
delete payload.confirmPassword; // Don't send to API
if (!payload.password) delete payload.password; // Don't send empty password on edit
if (payload.primaryOrganizationId === ALL_ORGANIZATIONS_VALUE) {
delete payload.primaryOrganizationId;
}
if (user) {
updateUser.mutate(
@@ -231,7 +236,7 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<div>
<Label>Primary Organization</Label>
<Select
value={watch("primaryOrganizationId") ?? undefined}
value={watch("primaryOrganizationId") || ALL_ORGANIZATIONS_VALUE}
onValueChange={(val) =>
setValue("primaryOrganizationId", val)
}
@@ -240,7 +245,8 @@ export function UserDialog({ open, onOpenChange, user }: UserDialogProps) {
<SelectValue placeholder="Select Organization" />
</SelectTrigger>
<SelectContent>
{organizations?.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
<SelectItem value={ALL_ORGANIZATIONS_VALUE}>All Organizations</SelectItem>
{Array.isArray(organizations) && organizations.map((org: { uuid: string; organizationCode: string; organizationName: string }) => (
<SelectItem
key={org.uuid}
value={org.uuid}
+116 -25
View File
@@ -23,6 +23,7 @@ import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines }
import { CreateCorrespondenceDto } from "@/types/dto/correspondence/create-correspondence.dto";
import { useState, useEffect } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service";
import { numberingApi } from "@/lib/api/numbering";
// Updated Zod Schema with all required fields
const correspondenceSchema = z.object({
@@ -34,6 +35,9 @@ const correspondenceSchema = z.object({
body: z.string().optional(),
remarks: z.string().optional(),
dueDate: z.string().optional(), // ISO Date string
documentDate: z.string().optional(),
issuedDate: z.string().optional(),
receivedDate: z.string().optional(),
fromOrganizationId: z.string().min(1, "Please select From Organization"),
toOrganizationId: z.string().min(1, "Please select To Organization"),
importance: z.enum(["NORMAL", "HIGH", "URGENT"]),
@@ -42,21 +46,62 @@ const correspondenceSchema = z.object({
type FormData = z.infer<typeof correspondenceSchema>;
type ProjectOption = {
uuid?: string;
id?: number;
projectName: string;
projectCode: string;
};
type CorrespondenceTypeOption = {
id: number;
typeName: string;
typeCode: string;
};
type DisciplineOption = {
id: number;
disciplineCode: string;
codeNameEn?: string;
};
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, uuid?: string }) {
const router = useRouter();
const createMutation = useCreateCorrespondence();
const updateMutation = useUpdateCorrespondence();
// Fetch master data for dropdowns
const { data: projects, isLoading: isLoadingProjects } = useProjects();
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations();
const { data: correspondenceTypes, isLoading: isLoadingTypes } = useCorrespondenceTypes();
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines();
const { data: correspondenceTypesData, isLoading: isLoadingTypes } = useCorrespondenceTypes();
const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines();
const projects = extractArrayData<ProjectOption>(projectsData);
const organizationOptions = extractArrayData<Organization>(organizations);
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
const disciplines = extractArrayData<DisciplineOption>(disciplinesData);
// Extract initial values if editing
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
const defaultValues: Partial<FormData> = {
projectId: initialData?.projectId ? String(initialData.projectId) : undefined,
projectId: initialData?.project?.uuid || (initialData?.projectId ? String(initialData.projectId) : undefined),
documentTypeId: initialData?.correspondenceTypeId || undefined,
disciplineId: initialData?.disciplineId || undefined,
subject: currentRev?.subject || currentRev?.title || "",
@@ -64,6 +109,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
body: currentRev?.body || "",
remarks: currentRev?.remarks || "",
dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined,
documentDate: currentRev?.documentDate ? new Date(currentRev.documentDate).toISOString().split('T')[0] : undefined,
issuedDate: currentRev?.issuedDate ? new Date(currentRev.issuedDate).toISOString().split('T')[0] : undefined,
receivedDate: currentRev?.receivedDate ? new Date(currentRev.receivedDate).toISOString().split('T')[0] : undefined,
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
// Map initial recipient (TO) - Simplified for now
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
@@ -79,7 +127,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(correspondenceSchema),
// @ts-ignore: Zod version mismatch in monorepo
resolver: zodResolver(correspondenceSchema) as any,
defaultValues: defaultValues as FormData,
});
@@ -100,6 +149,9 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
body: data.body,
remarks: data.remarks,
dueDate: data.dueDate ? new Date(data.dueDate).toISOString() : undefined,
documentDate: data.documentDate ? new Date(data.documentDate).toISOString() : undefined,
issuedDate: data.issuedDate ? new Date(data.issuedDate).toISOString() : undefined,
receivedDate: data.receivedDate ? new Date(data.receivedDate).toISOString() : undefined,
originatorId: data.fromOrganizationId,
recipients: [
{ organizationId: data.toOrganizationId, type: 'TO' }
@@ -135,19 +187,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
const fetchPreview = async () => {
try {
const res = await correspondenceService.previewNumber({
const res = await numberingApi.previewNumber({
projectId,
typeId: documentTypeId,
correspondenceTypeId: documentTypeId,
disciplineId,
originatorId: fromOrgId,
// Map recipients structure matching backend expectation
recipients: [{ organizationId: toOrgId, type: 'TO' }],
// Add date just to be safe, though service uses 'now'
dueDate: new Date().toISOString(),
// [Fix] Subject is required by DTO validation, send placeholder if empty
subject: watch('subject') || "Preview Subject"
originatorOrganizationId: fromOrgId,
recipientOrganizationId: toOrgId
});
setPreview(res);
setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault });
} catch (err) {
setPreview(null);
}
@@ -219,8 +266,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger>
<SelectContent>
{(projects || []).map((p: any) => (
<SelectItem key={p.id} value={String(p.id)}>
{projects.map((p) => (
<SelectItem key={p.uuid || String(p.id)} value={p.uuid || String(p.id)}>
{p.projectName} ({p.projectCode})
</SelectItem>
))}
@@ -243,7 +290,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingTypes ? "Loading..." : "Select Type"} />
</SelectTrigger>
<SelectContent>
{(correspondenceTypes || []).map((t: any) => (
{correspondenceTypes.map((t) => (
<SelectItem key={t.id} value={String(t.id)}>
{t.typeName} ({t.typeCode})
</SelectItem>
@@ -267,7 +314,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline (Optional)"} />
</SelectTrigger>
<SelectContent>
{(disciplines || []).map((d: any) => (
{disciplines.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.codeNameEn || d.disciplineCode}
</SelectItem>
@@ -297,11 +344,47 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
/>
</div>
{/* Remarks & Due Date */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Date Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" />
<Label htmlFor="documentDate">Document Date</Label>
<Input
id="documentDate"
type="date"
{...register("documentDate")}
onChange={(e) => {
const val = e.target.value;
setValue("documentDate", val, { shouldValidate: true, shouldDirty: true });
if (val) {
setValue("issuedDate", val, { shouldValidate: true, shouldDirty: true });
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuedDate">Issued Date</Label>
<Input id="issuedDate" type="date" {...register("issuedDate")} />
</div>
<div className="space-y-2">
<Label htmlFor="receivedDate">Received Date</Label>
<Input
id="receivedDate"
type="date"
{...register("receivedDate")}
onChange={(e) => {
const val = e.target.value;
setValue("receivedDate", val, { shouldValidate: true, shouldDirty: true });
if (val) {
const d = new Date(val);
d.setDate(d.getDate() + 7);
setValue("dueDate", d.toISOString().split('T')[0], { shouldValidate: true, shouldDirty: true });
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dueDate">Due Date</Label>
@@ -309,6 +392,14 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
</div>
</div>
{/* Remarks */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="remarks">Remarks</Label>
<Input id="remarks" {...register("remarks")} placeholder="Optional remarks" />
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description (Internal Note)</Label>
@@ -333,7 +424,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger>
<SelectContent>
{(organizations || []).map((org: Organization) => (
{organizationOptions.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationName} ({org.organizationCode})
</SelectItem>
@@ -356,7 +447,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger>
<SelectContent>
{(organizations || []).map((org: Organization) => (
{organizationOptions.map((org) => (
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationName} ({org.organizationCode})
</SelectItem>
@@ -56,6 +56,7 @@ export function TemplateEditor({
}: TemplateEditorProps) {
const [format, setFormat] = useState(template?.formatTemplate || '');
const [typeId, setTypeId] = useState<string>(template?.correspondenceTypeId?.toString() || '');
const [disciplineId, setDisciplineId] = useState<string>(template?.disciplineId?.toString() || '0');
const [reset, setReset] = useState(template?.resetSequenceYearly ?? true);
const [preview, setPreview] = useState('');
@@ -89,6 +90,7 @@ export function TemplateEditor({
...template,
projectId: projectId,
correspondenceTypeId: typeId && typeId !== '__default__' ? Number(typeId) : null,
disciplineId: Number(disciplineId),
formatTemplate: format,
resetSequenceYearly: reset,
});
@@ -139,6 +141,22 @@ export function TemplateEditor({
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Discipline (Optional)</Label>
<Select value={disciplineId} onValueChange={setDisciplineId}>
<SelectTrigger>
<SelectValue placeholder="All Disciplines" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">All Disciplines</SelectItem>
{disciplines.map((d: any) => (
<SelectItem key={d.id} value={d.id.toString()}>
{d.disciplineCode} - {d.codeNameEn || d.codeNameTh}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Reset Rule</Label>
<div className="flex items-center h-10">
@@ -11,6 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { NumberingTemplate, numberingApi } from '@/lib/api/numbering';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import {
Select,
@@ -49,7 +50,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
disciplineId: "",
year: new Date().getFullYear(),
});
const [generatedNumber, setGeneratedNumber] = useState('');
const [testResult, setTestResult] = useState<{ number: string; isDefault?: boolean } | null>(null);
const [loading, setLoading] = useState(false);
// Master Data Hooks
@@ -66,18 +67,28 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
const handleGenerate = async () => {
if (!template) return;
setLoading(true);
setTestResult(null);
try {
const result = await numberingApi.previewNumber({
const payload = {
projectId: projectId,
originatorOrganizationId: testData.originatorId || "0",
recipientOrganizationId: testData.recipientId || "0",
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
disciplineId: parseInt(testData.disciplineId || "0"),
year: testData.year
};
console.log("TemplateTester: Sending payload:", payload);
const result = await numberingApi.previewNumber(payload);
console.log("TemplateTester: Received result:", result);
setTestResult({
number: result.previewNumber,
isDefault: result.isDefault
});
setGeneratedNumber(result.previewNumber);
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } }; message?: string };
setGeneratedNumber(`Error: ${err.response?.data?.message || err.message || "Unknown error"}`);
} catch (error: any) {
console.error("Test Preview Error:", error);
const errMsg = error?.response?.data?.message || error?.message || "Unknown error";
setTestResult({ number: `Error: ${errMsg}`, isDefault: false });
} finally {
setLoading(false);
}
@@ -196,12 +207,24 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
Generate Test Number
</Button>
{generatedNumber && (
<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>
{testResult && (
<Card className={`p-4 mt-4 border text-center ${(testResult.number || '').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'}`}>
<div className="flex justify-between items-center mb-1">
<p className="text-sm text-muted-foreground">{(testResult.number || '').startsWith('Error:') ? 'Generation Failed:' : 'Generated Number:'}</p>
{testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">Default Template</Badge>
)}
{!testResult.isDefault && !(testResult.number || '').startsWith('Error:') && (
<Badge variant="outline" className="text-[10px] h-4 px-1 border-green-300 text-green-700 bg-green-50">Specific Template</Badge>
)}
</div>
<div className={`text-2xl font-mono font-bold ${(testResult.number || '').startsWith('Error:') ? 'text-red-700' : 'text-green-700 dark:text-green-400'}`}>
{testResult.number || (
<div className="text-xs font-mono text-left bg-slate-100 p-2 overflow-auto max-h-48 border rounded text-foreground">
Empty Result. Raw: {JSON.stringify(testResult, null, 2)}
</div>
)}
</div>
</Card>
)}
+72 -60
View File
@@ -1,5 +1,6 @@
"use client";
import type { RFA, RFAItem } 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";
@@ -9,39 +10,48 @@ import Link from "next/link";
import { useState } from "react";
import { Label } from "@/components/ui/label";
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: RFADetailData;
data: RFA;
}
export function RFADetail({ data }: RFADetailProps) {
const router = useRouter();
const [actionState, setActionState] = useState<"approve" | "reject" | null>(null);
const [comments, setComments] = useState("");
const processMutation = useProcessRFA();
const currentRevision = data.revisions.find((revision) => revision.isCurrent) ?? data.revisions[0];
const currentItems = currentRevision?.items ?? [];
const currentStatus = currentRevision?.statusCode?.statusName || currentRevision?.statusCode?.statusCode || "Unknown";
const createdAt = data.correspondence?.createdAt || currentRevision?.createdAt;
const getDrawingNumber = (item: RFAItem) =>
item.shopDrawingRevision?.shopDrawing?.drawingNumber ||
item.asBuiltDrawingRevision?.asBuiltDrawing?.drawingNumber ||
"-";
const getRevisionLabel = (item: RFAItem) => {
if (item.shopDrawingRevision?.revisionLabel) {
return item.shopDrawingRevision.revisionLabel;
}
if (item.shopDrawingRevision?.revisionNumber !== undefined) {
return String(item.shopDrawingRevision.revisionNumber);
}
if (item.asBuiltDrawingRevision?.revisionLabel) {
return item.asBuiltDrawingRevision.revisionLabel;
}
if (item.asBuiltDrawingRevision?.revisionNumber !== undefined) {
return String(item.asBuiltDrawingRevision.revisionNumber);
}
return "-";
};
const getRevisionTitle = (item: RFAItem) =>
item.shopDrawingRevision?.title || item.asBuiltDrawingRevision?.title || "-";
const handleProcess = () => {
if (!actionState) return;
@@ -77,14 +87,16 @@ export function RFADetail({ data }: RFADetailProps) {
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">{data.rfaNumber}</h1>
<p className="text-muted-foreground">
Created on {format(new Date(data.createdAt), "dd MMM yyyy HH:mm")}
</p>
<h1 className="text-2xl font-bold">{data.correspondence?.correspondenceNumber || "RFA"}</h1>
{createdAt && (
<p className="text-muted-foreground">
Created on {format(new Date(createdAt), "dd MMM yyyy HH:mm")}
</p>
)}
</div>
</div>
{data.status === "PENDING" && (
{currentStatus === "PENDING" && (
<div className="flex gap-2">
<Button
variant="outline"
@@ -144,15 +156,15 @@ export function RFADetail({ data }: RFADetailProps) {
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<CardTitle className="text-xl">{data.subject}</CardTitle>
<StatusBadge status={data.status} />
<CardTitle className="text-xl">{currentRevision?.subject || "Untitled RFA"}</CardTitle>
<StatusBadge status={currentStatus} />
</div>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-gray-700 whitespace-pre-wrap">
{data.description || "No description provided."}
{currentRevision?.description || "No description provided."}
</p>
</div>
@@ -160,32 +172,32 @@ export function RFADetail({ data }: RFADetailProps) {
<div>
<h3 className="font-semibold mb-3">RFA Items</h3>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">Item No.</th>
<th className="px-4 py-3 text-left font-medium">Description</th>
<th className="px-4 py-3 text-right font-medium">Qty</th>
<th className="px-4 py-3 text-left font-medium">Unit</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{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>
<td className="px-4 py-3 text-right">{item.quantity}</td>
<td className="px-4 py-3 text-muted-foreground">{item.unit}</td>
<td className="px-4 py-3">
<StatusBadge status={item.status || "PENDING"} className="text-[10px] px-2 py-0.5 h-5" />
</td>
{currentItems.length === 0 ? (
<p className="text-sm text-muted-foreground">No drawing items linked to this RFA.</p>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">Type</th>
<th className="px-4 py-3 text-left font-medium">Drawing No.</th>
<th className="px-4 py-3 text-left font-medium">Revision</th>
<th className="px-4 py-3 text-left font-medium">Title</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody className="divide-y">
{currentItems.map((item) => (
<tr key={item.id}>
<td className="px-4 py-3 font-medium">{item.itemType}</td>
<td className="px-4 py-3">{getDrawingNumber(item)}</td>
<td className="px-4 py-3">{getRevisionLabel(item)}</td>
<td className="px-4 py-3 text-muted-foreground">{getRevisionTitle(item)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</CardContent>
</Card>
@@ -199,15 +211,15 @@ export function RFADetail({ data }: RFADetailProps) {
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Contract</p>
<p className="font-medium mt-1">{data.contractName}</p>
<p className="text-sm font-medium text-muted-foreground">Project</p>
<p className="font-medium mt-1">{data.correspondence?.project?.projectName || "-"}</p>
</div>
<hr className="my-4 border-t" />
<div>
<p className="text-sm font-medium text-muted-foreground">Discipline</p>
<p className="font-medium mt-1">{data.disciplineName}</p>
<p className="font-medium mt-1">{data.discipline?.name || data.discipline?.code || "-"}</p>
</div>
</CardContent>
</Card>
+506 -122
View File
@@ -1,14 +1,15 @@
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { useForm, type SubmitErrorHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import {
Select,
SelectContent,
@@ -18,18 +19,14 @@ import {
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import { useCreateRFA } from "@/hooks/use-rfa";
import { useDisciplines, useContracts } from "@/hooks/use-master-data";
import { useDrawings } from "@/hooks/use-drawing";
import { useDisciplines, useContracts, useOrganizations } from "@/hooks/use-master-data";
import { useCorrespondenceTypes, useRfaTypes } from "@/hooks/use-reference-data";
import { useProjects } from "@/hooks/use-projects";
import { CreateRfaDto } from "@/types/dto/rfa/rfa.dto";
import { useState, useEffect } from "react";
import { useState, useEffect, type FormEvent } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service";
const rfaItemSchema = z.object({
itemNo: z.string().min(1, "Item No is required"),
description: z.string().min(3, "Description is required"),
quantity: z.number().min(0, "Quantity must be positive"),
unit: z.string().min(1, "Unit is required"),
});
const rfaSchema = z.object({
projectId: z.string().min(1, "Project is required"), // ADR-019: UUID
contractId: z.string().min(1, "Contract is required"),
@@ -41,25 +38,137 @@ const rfaSchema = z.object({
remarks: z.string().optional(),
toOrganizationId: z.string().min(1, "Please select To Organization"),
dueDate: z.string().optional(),
shopDrawingRevisionIds: z.array(z.number()).optional(),
items: z.array(rfaItemSchema).min(1, "At least one item is required"),
shopDrawingRevisionIds: z.array(z.string()).optional(),
asBuiltDrawingRevisionIds: z.array(z.string()).optional(),
});
type RFAFormData = z.infer<typeof rfaSchema>;
type ProjectOption = {
uuid?: string;
id?: number;
projectName?: string;
projectCode?: string;
};
type ContractOption = {
uuid?: string;
id?: number;
contractName?: string;
name?: string;
contractCode?: string;
};
type DisciplineOption = {
id: number;
disciplineCode: string;
codeNameEn?: string;
codeNameTh?: string;
};
type RfaTypeOption = {
id: number;
typeCode?: string;
typeName?: string;
typeNameEn?: string;
typeNameTh?: string;
};
type CorrespondenceTypeOption = {
id: number;
typeCode?: string;
typeName?: string;
};
type OrganizationOption = {
uuid?: string;
id?: number;
organizationCode?: string;
organizationName?: string;
};
type SelectableDrawingOption = {
uuid?: string;
drawingNumber?: string;
title?: string;
legacyDrawingNumber?: string;
currentRevisionUuid?: string;
currentRevision?: {
uuid?: string;
revisionLabel?: string;
revisionNumber?: number | string;
title?: string;
legacyDrawingNumber?: string;
};
};
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const dedupeByKey = <T,>(items: T[], getKey: (item: T) => string | number | undefined): T[] => {
const seen = new Set<string | number>();
return items.filter((item) => {
const key = getKey(item);
if (key === undefined || key === "" || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
const getOptionValue = (value?: string | number): string | undefined => {
if (value === undefined || value === null || value === "") {
return undefined;
}
return String(value);
};
export function RFAForm() {
const router = useRouter();
const createMutation = useCreateRFA();
// ADR-019: Dynamic project selection
const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
const projects = projectsData?.data || projectsData || [];
const projects = dedupeByKey(
extractArrayData<ProjectOption>(projectsData),
(project) => project.uuid ?? project.id
);
const { data: organizationsData, isLoading: isLoadingOrganizations } = useOrganizations({ isActive: true });
const organizations = dedupeByKey(
extractArrayData<OrganizationOption>(organizationsData),
(organization) => organization.uuid ?? organization.id
);
const { data: correspondenceTypesData } = useCorrespondenceTypes();
const correspondenceTypes = extractArrayData<CorrespondenceTypeOption>(correspondenceTypesData);
const rfaCorrespondenceType = correspondenceTypes.find(
(type) => type.typeCode?.toUpperCase() === "RFA"
);
const {
register,
control,
handleSubmit,
setValue,
setError,
clearErrors,
watch,
formState: { errors },
} = useForm<RFAFormData>({
@@ -76,26 +185,89 @@ export function RFAForm() {
toOrganizationId: "",
dueDate: "",
shopDrawingRevisionIds: [],
items: [{ itemNo: "1", description: "", quantity: 0, unit: "" }],
asBuiltDrawingRevisionIds: [],
},
});
const selectedProjectId = watch("projectId");
const { data: contracts, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
const { data: contractsData, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
const contracts = dedupeByKey(
extractArrayData<ContractOption>(contractsData),
(contract) => contract.uuid ?? contract.id
);
const selectedContractId = watch("contractId");
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
const disciplines = dedupeByKey(extractArrayData<DisciplineOption>(disciplinesData), (discipline) => discipline.id);
const { data: rfaTypesData, isLoading: isLoadingRfaTypes } = useRfaTypes(selectedContractId);
const rfaTypes = dedupeByKey(extractArrayData<RfaTypeOption>(rfaTypesData), (rfaType) => rfaType.id);
const [shopDrawingSearch, setShopDrawingSearch] = useState("");
const [shopDrawingPage, setShopDrawingPage] = useState(1);
const { data: shopDrawingsData, isLoading: isLoadingShopDrawings } = useDrawings("SHOP", {
projectUuid: selectedProjectId || "",
search: shopDrawingSearch,
page: shopDrawingPage,
limit: 10,
});
const shopDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(shopDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
);
const [asBuiltDrawingSearch, setAsBuiltDrawingSearch] = useState("");
const [asBuiltDrawingPage, setAsBuiltDrawingPage] = useState(1);
const { data: asBuiltDrawingsData, isLoading: isLoadingAsBuiltDrawings } = useDrawings("AS_BUILT", {
projectUuid: selectedProjectId || "",
search: asBuiltDrawingSearch,
page: asBuiltDrawingPage,
limit: 10,
});
const asBuiltDrawings = dedupeByKey(
extractArrayData<SelectableDrawingOption>(asBuiltDrawingsData),
(drawing) => drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid ?? drawing.uuid
);
const selectedDisciplineId = watch("disciplineId");
// Watch fields for preview
const rfaTypeId = watch("rfaTypeId");
const disciplineId = watch("disciplineId");
const toOrganizationId = watch("toOrganizationId");
const selectedShopDrawingRevisionIds = watch("shopDrawingRevisionIds") ?? [];
const selectedAsBuiltDrawingRevisionIds = watch("asBuiltDrawingRevisionIds") ?? [];
const selectedRfaType = rfaTypes.find((rfaType) => rfaType.id === rfaTypeId);
const selectedRfaTypeCode = selectedRfaType?.typeCode?.toUpperCase();
const requiresShopDrawings = selectedRfaTypeCode === "DDW" || selectedRfaTypeCode === "SDW";
const requiresAsBuiltDrawings = selectedRfaTypeCode === "ADW";
useEffect(() => {
// Reset page and search when project changes
setShopDrawingPage(1);
setShopDrawingSearch("");
setAsBuiltDrawingPage(1);
setAsBuiltDrawingSearch("");
if (requiresShopDrawings) {
setValue("asBuiltDrawingRevisionIds", []);
clearErrors("asBuiltDrawingRevisionIds");
return;
}
if (requiresAsBuiltDrawings) {
setValue("shopDrawingRevisionIds", []);
clearErrors("shopDrawingRevisionIds");
return;
}
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
clearErrors("shopDrawingRevisionIds");
clearErrors("asBuiltDrawingRevisionIds");
}, [requiresShopDrawings, requiresAsBuiltDrawings, selectedProjectId, setValue, clearErrors]);
// -- Preview Logic --
const [preview, setPreview] = useState<{ number: string; isDefaultTemplate: boolean } | null>(null);
useEffect(() => {
if (!rfaTypeId || !disciplineId || !toOrganizationId) {
if (!selectedProjectId || !rfaCorrespondenceType?.id || !rfaTypeId || !disciplineId || !toOrganizationId) {
setPreview(null);
return;
}
@@ -104,11 +276,10 @@ export function RFAForm() {
try {
const res = await correspondenceService.previewNumber({
projectId: selectedProjectId,
typeId: rfaTypeId, // RfaTypeId acts as TypeId
typeId: rfaCorrespondenceType.id,
disciplineId,
// RFA uses 'TO' organization as recipient
recipients: [{ organizationId: toOrganizationId, type: 'TO' }],
dueDate: new Date().toISOString()
subject: watch("subject") || "Preview Subject"
});
setPreview(res);
} catch (err) {
@@ -118,17 +289,32 @@ export function RFAForm() {
const timer = setTimeout(fetchPreview, 500);
return () => clearTimeout(timer);
}, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId]);
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
}, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId, rfaCorrespondenceType?.id, watch]);
const onSubmit = (data: RFAFormData) => {
if (requiresShopDrawings && data.shopDrawingRevisionIds?.length === 0) {
setError("shopDrawingRevisionIds", {
type: "manual",
message: "Please select at least one Shop Drawing Revision",
});
return;
}
if (requiresAsBuiltDrawings && data.asBuiltDrawingRevisionIds?.length === 0) {
setError("asBuiltDrawingRevisionIds", {
type: "manual",
message: "Please select at least one As-Built Drawing Revision",
});
return;
}
clearErrors("shopDrawingRevisionIds");
clearErrors("asBuiltDrawingRevisionIds");
const payload: CreateRfaDto = {
...data,
// ADR-019: projectId is already a UUID string from the form
shopDrawingRevisionIds: requiresShopDrawings ? data.shopDrawingRevisionIds : undefined,
asBuiltDrawingRevisionIds: requiresAsBuiltDrawings ? data.asBuiltDrawingRevisionIds : undefined,
};
createMutation.mutate(payload, {
onSuccess: () => {
@@ -137,9 +323,14 @@ export function RFAForm() {
});
};
const onInvalidSubmit: SubmitErrorHandler<RFAFormData> = () => undefined;
const submitForm = handleSubmit(onSubmit, onInvalidSubmit);
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
void submitForm(event).catch(() => undefined);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl space-y-6">
{/* Preview Section */}
<form onSubmit={handleFormSubmit} className="max-w-4xl space-y-6">
{preview && (
<Card className="p-4 bg-muted border-l-4 border-l-primary">
<p className="text-sm text-muted-foreground mb-1">Document Number Preview</p>
@@ -154,7 +345,6 @@ export function RFAForm() {
</Card>
)}
{/* Basic Info */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">RFA Information</h3>
@@ -184,13 +374,17 @@ export function RFAForm() {
<Input id="description" {...register("description")} placeholder="Enter key description" />
</div>
{/* ADR-019: Project selector */}
<div>
<Label>Project *</Label>
<Select
value={selectedProjectId || undefined}
onValueChange={(val) => {
setValue("projectId", val);
setValue("contractId", ""); // Reset contract when project changes
setValue("contractId", "");
setValue("disciplineId", 0);
setValue("rfaTypeId", 0);
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
}}
disabled={isLoadingProjects}
>
@@ -198,11 +392,19 @@ export function RFAForm() {
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger>
<SelectContent>
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{projects.map((p) => {
const projectValue = getOptionValue(p.uuid ?? p.id);
if (!projectValue) {
return null;
}
return (
<SelectItem key={projectValue} value={projectValue}>
{p.projectName || p.projectCode}
</SelectItem>
))}
);
})}
</SelectContent>
</Select>
{errors.projectId && (
@@ -214,18 +416,33 @@ export function RFAForm() {
<div>
<Label>Contract *</Label>
<Select
onValueChange={(val) => setValue("contractId", val)}
value={selectedContractId || undefined}
onValueChange={(val) => {
setValue("contractId", val);
setValue("disciplineId", 0);
setValue("rfaTypeId", 0);
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
}}
disabled={!selectedProjectId || isLoadingContracts}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} />
</SelectTrigger>
<SelectContent>
{contracts?.map((c: { uuid: string; contractName?: string; name?: string; contractCode?: string }) => (
<SelectItem key={c.uuid} value={c.uuid}>
{contracts.map((c) => {
const contractValue = getOptionValue(c.uuid ?? c.id);
if (!contractValue) {
return null;
}
return (
<SelectItem key={contractValue} value={contractValue}>
{c.contractName || c.name || c.contractCode}
</SelectItem>
))}
);
})}
</SelectContent>
</Select>
{errors.contractId && (
@@ -236,6 +453,7 @@ export function RFAForm() {
<div>
<Label>Discipline *</Label>
<Select
value={selectedDisciplineId > 0 ? String(selectedDisciplineId) : undefined}
onValueChange={(val) => setValue("disciplineId", Number(val))}
disabled={!selectedContractId || isLoadingDisciplines}
>
@@ -243,12 +461,12 @@ export function RFAForm() {
<SelectValue placeholder={isLoadingDisciplines ? "Loading..." : "Select Discipline"} />
</SelectTrigger>
<SelectContent>
{disciplines?.map((d: { id: number; disciplineCode: string; codeNameEn?: string; codeNameTh?: string }) => (
{disciplines.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.codeNameEn || d.codeNameTh || d.disciplineCode} ({d.disciplineCode})
{`${d.codeNameEn || d.codeNameTh || d.disciplineCode} (${d.disciplineCode})`}
</SelectItem>
))}
{!isLoadingDisciplines && !disciplines?.length && (
{!isLoadingDisciplines && disciplines.length === 0 && (
<SelectItem value="0" disabled>No disciplines found</SelectItem>
)}
</SelectContent>
@@ -258,96 +476,262 @@ export function RFAForm() {
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>RFA Type *</Label>
<Select
value={rfaTypeId > 0 ? String(rfaTypeId) : undefined}
onValueChange={(val) => {
setValue("rfaTypeId", Number(val));
setValue("shopDrawingRevisionIds", []);
setValue("asBuiltDrawingRevisionIds", []);
}}
disabled={!selectedContractId || isLoadingRfaTypes}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingRfaTypes ? "Loading..." : "Select RFA Type"} />
</SelectTrigger>
<SelectContent>
{rfaTypes.map((rfaType) => (
<SelectItem key={rfaType.id} value={String(rfaType.id)}>
{`${rfaType.typeCode || "RFA"} - ${rfaType.typeName || rfaType.typeNameEn || rfaType.typeNameTh || "Unnamed Type"}`}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.rfaTypeId && (
<p className="text-sm text-destructive mt-1">{errors.rfaTypeId.message}</p>
)}
</div>
<div>
<Label>To Organization *</Label>
<Select
value={toOrganizationId || undefined}
onValueChange={(val) => setValue("toOrganizationId", val)}
disabled={isLoadingOrganizations}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingOrganizations ? "Loading..." : "Select To Organization"} />
</SelectTrigger>
<SelectContent>
{organizations.map((organization) => {
const organizationValue = getOptionValue(organization.uuid ?? organization.id);
if (!organizationValue) {
return null;
}
return (
<SelectItem key={organizationValue} value={organizationValue}>
{`${organization.organizationCode || "ORG"} - ${organization.organizationName || "Unnamed Organization"}`}
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.toOrganizationId && (
<p className="text-sm text-destructive mt-1">{errors.toOrganizationId.message}</p>
)}
</div>
</div>
</div>
</Card>
{/* RFA Items */}
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">RFA Items</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
itemNo: (fields.length + 1).toString(),
description: "",
quantity: 0,
unit: "",
})
}
>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
</div>
{(requiresShopDrawings || requiresAsBuiltDrawings) && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">New Item</h3>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{requiresShopDrawings
? "RFA Type นี้ต้องอ้างอิง Shop Drawing Revision อย่างน้อย 1 รายการ"
: "RFA Type นี้ต้องอ้างอิง As-Built Drawing Revision อย่างน้อย 1 รายการ"}
</p>
<div className="space-y-4">
{fields.map((field, index) => (
<Card key={field.id} className="p-4 bg-muted/20">
<div className="flex justify-between items-start mb-3">
<h4 className="font-medium text-sm">Item #{index + 1}</h4>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
{requiresShopDrawings && (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<Input
placeholder="ค้นหาตาม Drawing Number..."
value={shopDrawingSearch}
onChange={(e) => {
setShopDrawingSearch(e.target.value);
setShopDrawingPage(1);
}}
className="max-w-xs"
/>
</div>
{isLoadingShopDrawings && (
<p className="text-sm text-muted-foreground">Loading Shop Drawings...</p>
)}
{!isLoadingShopDrawings && shopDrawings.length === 0 && (
<p className="text-sm text-muted-foreground">No Shop Drawings found for the selected project.</p>
)}
<div className="grid grid-cols-1 gap-3">
{shopDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid;
if (!revisionUuid) {
return null;
}
return (
<label
key={revisionUuid}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={selectedShopDrawingRevisionIds.includes(revisionUuid)}
onCheckedChange={(checked) => {
const nextValues = checked === true
? [...selectedShopDrawingRevisionIds, revisionUuid]
: selectedShopDrawingRevisionIds.filter((value) => value !== revisionUuid);
setValue("shopDrawingRevisionIds", nextValues, { shouldDirty: true, shouldValidate: true });
clearErrors("shopDrawingRevisionIds");
}}
/>
<div className="space-y-1">
<p className="font-medium">{drawing.drawingNumber || "Unnamed Shop Drawing"}</p>
<p className="text-sm text-muted-foreground">{drawing.currentRevision?.title || drawing.title || "Untitled Revision"}</p>
<p className="text-xs text-muted-foreground">
Revision {drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || "-"}
{drawing.currentRevision?.legacyDrawingNumber ? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}` : ""}
</p>
</div>
</label>
);
})}
</div>
{shopDrawingsData?.meta && shopDrawingsData.meta.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-xs text-muted-foreground">
Page {shopDrawingPage} of {shopDrawingsData.meta.totalPages}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={shopDrawingPage === 1 || isLoadingShopDrawings}
onClick={() => setShopDrawingPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={shopDrawingPage >= shopDrawingsData.meta.totalPages || isLoadingShopDrawings}
onClick={() => setShopDrawingPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
{errors.shopDrawingRevisionIds && (
<p className="text-sm text-destructive mt-2">{errors.shopDrawingRevisionIds.message}</p>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
<div className="md:col-span-2">
<Label className="text-xs">Item No.</Label>
<Input {...register(`items.${index}.itemNo`)} placeholder="1.1" />
{errors.items?.[index]?.itemNo && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.itemNo?.message}</p>
)}
</div>
<div className="md:col-span-6">
<Label className="text-xs">Description *</Label>
<Input {...register(`items.${index}.description`)} placeholder="Item description" />
{errors.items?.[index]?.description && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.description?.message}</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-xs">Quantity</Label>
{requiresAsBuiltDrawings && (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<Input
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
placeholder="ค้นหาตาม Drawing Number..."
value={asBuiltDrawingSearch}
onChange={(e) => {
setAsBuiltDrawingSearch(e.target.value);
setAsBuiltDrawingPage(1);
}}
className="max-w-xs"
/>
{errors.items?.[index]?.quantity && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.quantity?.message}</p>
)}
</div>
<div className="md:col-span-2">
<Label className="text-xs">Unit</Label>
<Input {...register(`items.${index}.unit`)} placeholder="pcs, m3" />
{errors.items?.[index]?.unit && (
<p className="text-xs text-destructive mt-1">{errors.items[index]?.unit?.message}</p>
)}
{isLoadingAsBuiltDrawings && (
<p className="text-sm text-muted-foreground">Loading As-Built Drawings...</p>
)}
{!isLoadingAsBuiltDrawings && asBuiltDrawings.length === 0 && (
<p className="text-sm text-muted-foreground">No As-Built Drawings found for the selected project.</p>
)}
<div className="grid grid-cols-1 gap-3">
{asBuiltDrawings.map((drawing) => {
const revisionUuid = drawing.currentRevisionUuid ?? drawing.currentRevision?.uuid;
if (!revisionUuid) {
return null;
}
return (
<label
key={revisionUuid}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<Checkbox
checked={selectedAsBuiltDrawingRevisionIds.includes(revisionUuid)}
onCheckedChange={(checked) => {
const nextValues = checked === true
? [...selectedAsBuiltDrawingRevisionIds, revisionUuid]
: selectedAsBuiltDrawingRevisionIds.filter((value) => value !== revisionUuid);
setValue("asBuiltDrawingRevisionIds", nextValues, { shouldDirty: true, shouldValidate: true });
clearErrors("asBuiltDrawingRevisionIds");
}}
/>
<div className="space-y-1">
<p className="font-medium">{drawing.drawingNumber || "Unnamed As-Built Drawing"}</p>
<p className="text-sm text-muted-foreground">{drawing.currentRevision?.title || drawing.title || "Untitled Revision"}</p>
<p className="text-xs text-muted-foreground">
Revision {drawing.currentRevision?.revisionLabel || drawing.currentRevision?.revisionNumber || "-"}
{drawing.currentRevision?.legacyDrawingNumber ? ` • Legacy ${drawing.currentRevision.legacyDrawingNumber}` : ""}
</p>
</div>
</label>
);
})}
</div>
{asBuiltDrawingsData?.meta && asBuiltDrawingsData.meta.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-xs text-muted-foreground">
Page {asBuiltDrawingPage} of {asBuiltDrawingsData.meta.totalPages}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={asBuiltDrawingPage === 1 || isLoadingAsBuiltDrawings}
onClick={() => setAsBuiltDrawingPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={asBuiltDrawingPage >= asBuiltDrawingsData.meta.totalPages || isLoadingAsBuiltDrawings}
onClick={() => setAsBuiltDrawingPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
{errors.asBuiltDrawingRevisionIds && (
<p className="text-sm text-destructive mt-2">{errors.asBuiltDrawingRevisionIds.message}</p>
)}
</div>
</Card>
))}
</div>
)}
</div>
</Card>
)}
{errors.items?.root && (
<p className="text-sm text-destructive mt-2">
{errors.items.root.message}
</p>
)}
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
+4 -2
View File
@@ -74,8 +74,10 @@ export function RFAList({ data }: RFAListProps) {
const handleViewFile = (e: React.MouseEvent) => {
e.preventDefault();
// Logic to find first attachment: Check items -> shopDrawingRevision -> attachments
const firstAttachment = item.revisions?.[0]?.items?.[0]?.shopDrawingRevision?.attachments?.[0];
const firstItem = item.revisions?.[0]?.items?.[0];
const firstAttachment =
firstItem?.shopDrawingRevision?.attachments?.[0] ||
firstItem?.asBuiltDrawingRevision?.attachments?.[0];
if (firstAttachment?.url) {
window.open(firstAttachment.url, '_blank');
} else {
+188 -77
View File
@@ -20,6 +20,52 @@ import 'reactflow/dist/style.css';
import { Button } from '@/components/ui/button';
import { Plus, Download, Save, Layout } from 'lucide-react';
interface WorkflowStateNodeData {
label?: string;
name?: string;
role?: string;
type?: string;
}
interface RawTransitionShape {
to?: string;
target?: string;
require?: {
role?: string | string[];
};
}
interface RawStateShape {
id?: string;
name: string;
type?: string;
role?: string;
initial?: boolean;
terminal?: boolean;
on?: Record<string, RawTransitionShape>;
}
interface CompiledTransitionShape {
to?: string;
target?: string;
requirements?: {
roles?: string[];
};
}
interface CompiledStateShape {
initial?: boolean;
terminal?: boolean;
transitions?: Record<string, CompiledTransitionShape>;
}
interface ParsedDslShape {
workflow?: string;
initialState?: string;
states?: RawStateShape[] | Record<string, CompiledStateShape>;
dslDefinition?: string;
}
// Define custom node styles (simplified for now)
const nodeStyle = {
padding: '10px 20px',
@@ -55,75 +101,145 @@ const initialNodes: Node[] = [
interface VisualWorkflowBuilderProps {
initialNodes?: Node[];
initialEdges?: Edge[];
dslString?: string; // New prop
dslString?: string;
onSave?: (nodes: Node[], edges: Edge[]) => void;
onDslChange?: (dsl: string) => void;
}
const createNode = (
name: string,
yOffset: number,
options?: {
isCondition?: boolean;
isStart?: boolean;
isEnd?: boolean;
role?: string;
type?: string;
}
): Node<WorkflowStateNodeData> => {
const isCondition = options?.isCondition === true;
const isStart = options?.isStart === true;
const isEnd = options?.isEnd === true;
let nodeType: Node['type'] = 'default';
let style = { ...nodeStyle };
if (isStart) {
nodeType = 'input';
style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
} else if (isEnd) {
nodeType = 'output';
style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
} else if (isCondition) {
style = conditionNodeStyle;
}
return {
id: name,
type: nodeType,
data: {
label: isStart || isEnd ? name : `${name}\n(${options?.role || 'No Role'})`,
name,
role: options?.role,
type: options?.type || (isStart ? 'START' : isEnd ? 'END' : 'TASK')
},
position: { x: 250, y: yOffset },
style
};
};
const createEdge = (source: string, target: string, label: string): Edge => ({
id: `e-${source}-${label}-${target}`,
source,
target,
label,
markerEnd: { type: MarkerType.ArrowClosed }
});
function parseDSL(dsl: string): { nodes: Node[], edges: Edge[] } {
const nodes: Node[] = [];
const edges: Edge[] = [];
let yOffset = 50;
const nodes: Node[] = [];
const edges: Edge[] = [];
let yOffset = 50;
try {
const parsedDsl = JSON.parse(dsl);
const states = parsedDsl.states || [];
try {
const parsedDsl = JSON.parse(dsl) as ParsedDslShape;
states.forEach((state: { id?: string, name: string, type?: string, role?: string, initial?: boolean, terminal?: boolean, on?: Record<string, { to: string }> }) => {
const isCondition = state.type === 'CONDITION';
const isStart = state.initial === true || state.type === 'START';
const isEnd = state.terminal === true || state.type === 'END';
let nodeType = 'default';
let style = { ...nodeStyle };
if (isStart) {
nodeType = 'input';
style = { ...nodeStyle, background: '#10b981', color: 'white', border: 'none' };
} else if (isEnd) {
nodeType = 'output';
style = { ...nodeStyle, background: '#ef4444', color: 'white', border: 'none' };
} else if (isCondition) {
style = conditionNodeStyle;
}
nodes.push({
id: state.name || state.id || `node-${Date.now()}`,
type: nodeType,
data: {
label: isStart || isEnd ? state.name : `${state.name}\n(${state.role || 'No Role'})`,
name: state.name,
role: state.role,
type: state.type || (isStart ? 'START' : isEnd ? 'END' : 'TASK')
},
position: { x: 250, y: yOffset },
style: style
});
if (state.on) {
const transitions = state.on;
Object.keys(transitions).forEach((eventName) => {
const trans = transitions[eventName];
if (trans && trans.to) {
edges.push({
id: `e-${state.name || state.id || 'node'}-${trans.to}`,
source: state.name || state.id || 'node',
target: trans.to,
label: eventName,
markerEnd: { type: MarkerType.ArrowClosed }
});
}
});
}
yOffset += 120;
});
} catch (e) {
// Failed to parse DSL as JSON - nodes/edges remain empty
if (typeof parsedDsl.dslDefinition === 'string') {
return parseDSL(parsedDsl.dslDefinition);
}
return { nodes, edges };
if (Array.isArray(parsedDsl.states)) {
parsedDsl.states.forEach((state) => {
const stateName = state.name || state.id || `node-${Date.now()}`;
const role =
state.role ||
(Array.isArray(state.on?.SUBMIT?.require?.role)
? state.on?.SUBMIT?.require?.role.join(', ')
: state.on?.SUBMIT?.require?.role);
const isCondition = state.type === 'CONDITION';
const isStart = state.initial === true || state.type === 'START';
const isEnd = state.terminal === true || state.type === 'END';
nodes.push(
createNode(stateName, yOffset, {
isCondition,
isStart,
isEnd,
role,
type: state.type
})
);
if (state.on) {
Object.entries(state.on).forEach(([eventName, transition]) => {
const target = transition?.to || transition?.target;
if (target) {
edges.push(createEdge(stateName, target, eventName));
}
});
}
yOffset += 120;
});
return { nodes, edges };
}
if (parsedDsl.states && typeof parsedDsl.states === 'object') {
Object.entries(parsedDsl.states).forEach(([stateName, state]) => {
const roles = state.transitions
? Object.values(state.transitions)
.flatMap((transition) => transition.requirements?.roles || [])
.filter((role, index, array) => array.indexOf(role) === index)
: [];
const isStart = parsedDsl.initialState === stateName || state.initial === true;
const isEnd = state.terminal === true;
nodes.push(
createNode(stateName, yOffset, {
isStart,
isEnd,
role: roles.join(', ')
})
);
if (state.transitions) {
Object.entries(state.transitions).forEach(([eventName, transition]) => {
const target = transition?.to || transition?.target;
if (target) {
edges.push(createEdge(stateName, target, eventName));
}
});
}
yOffset += 120;
});
}
} catch (e) {
// Failed to parse DSL as JSON - nodes/edges remain empty
}
return { nodes, edges };
}
function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: propEdges, dslString, onSave, onDslChange }: VisualWorkflowBuilderProps) {
@@ -135,14 +251,11 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
useEffect(() => {
if (dslString) {
const { nodes: newNodes, edges: newEdges } = parseDSL(dslString);
if (newNodes.length > 0) {
setNodes(newNodes);
setEdges(newEdges);
// Fit view after update
setTimeout(() => fitView(), 100);
}
setNodes(newNodes.length > 0 ? newNodes : propNodes || initialNodes);
setEdges(newNodes.length > 0 ? newEdges : propEdges || []);
setTimeout(() => fitView(), 100);
}
}, [dslString, setNodes, setEdges, fitView]);
}, [dslString, fitView, propEdges, propNodes, setEdges, setNodes]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, markerEnd: { type: MarkerType.ArrowClosed } }, eds)),
@@ -153,7 +266,7 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
const id = `${type}-${Date.now()}`;
const nodeType = type === 'condition' ? 'CONDITION' : type === 'end' ? 'END' : type === 'start' ? 'START' : 'TASK';
const newNode: Node = {
const newNode: Node<WorkflowStateNodeData> = {
id,
position: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
data: { label: label, name: label, role: 'User', type: nodeType },
@@ -179,7 +292,6 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
// Generate JSON DSL
const generateDSL = () => {
let hasStart = false;
const states = nodes.map(n => {
const outgoingEdges = edges.filter(e => e.source === n.id);
const onConfig: Record<string, { to: string }> = {};
@@ -191,22 +303,21 @@ function VisualWorkflowBuilderContent({ initialNodes: propNodes, initialEdges: p
const isStartNode = n.type === 'input';
const isEndNode = n.type === 'output';
if (isStartNode) hasStart = true;
const nodeData = n.data as WorkflowStateNodeData;
const stateObj: { name: string; type?: string; role?: string; initial?: boolean; terminal?: boolean; on?: Record<string, { to: string }> } = {
name: n.data.name || n.data.label.split('\n')[0],
name: nodeData.name || nodeData.label?.split('\n')[0] || n.id,
};
if (n.data.type && n.data.type !== 'START' && n.data.type !== 'END' && n.data.type !== 'TASK') {
stateObj.type = n.data.type;
if (nodeData.type && nodeData.type !== 'START' && nodeData.type !== 'END' && nodeData.type !== 'TASK') {
stateObj.type = nodeData.type;
}
if (n.data.role && !isStartNode && !isEndNode) {
stateObj.role = n.data.role;
if (nodeData.role && !isStartNode && !isEndNode) {
stateObj.role = nodeData.role;
}
if (isStartNode && !hasStart) {
if (isStartNode) {
stateObj.initial = true;
}
if (isEndNode) {
+6
View File
@@ -23,6 +23,9 @@ export const drawingKeys = {
// --- Queries ---
export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
const shouldEnable =
'projectUuid' in params ? Boolean(params.projectUuid) : true;
return useQuery({
queryKey: drawingKeys.list(type, params),
queryFn: async () => {
@@ -50,6 +53,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
type: 'SHOP',
title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber,
currentRevisionUuid: d.currentRevision?.uuid,
legacyDrawingNumber: d.currentRevision?.legacyDrawingNumber,
}));
// Re-wrap to preserve meta
@@ -65,6 +69,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
type: 'AS_BUILT',
title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber,
currentRevisionUuid: d.currentRevision?.uuid,
}));
// Re-wrap to preserve meta
response = { ...response, data: mappedData };
@@ -72,6 +77,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
}
return response;
},
enabled: shouldEnable,
placeholderData: (previousData) => previousData,
});
}
+2 -2
View File
@@ -6,13 +6,13 @@ import type { CreateCorrespondenceTypeDto, UpdateCorrespondenceTypeDto } from '@
export const referenceDataKeys = {
all: ['reference-data'] as const,
rfaTypes: (contractId?: number) => [...referenceDataKeys.all, 'rfaTypes', contractId] as const,
rfaTypes: (contractId?: number | string) => [...referenceDataKeys.all, 'rfaTypes', contractId] as const,
disciplines: (contractId?: number) => [...referenceDataKeys.all, 'disciplines', contractId] as const,
correspondenceTypes: () => [...referenceDataKeys.all, 'correspondenceTypes'] as const,
};
// --- RFA Types ---
export const useRfaTypes = (contractId?: number) => {
export const useRfaTypes = (contractId?: number | string) => {
return useQuery({
queryKey: referenceDataKeys.rfaTypes(contractId),
queryFn: () => masterDataService.getRfaTypes(contractId),
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '@/lib/services/user.service';
import { CreateUserDto, UpdateUserDto, SearchUserDto } from '@/types/user';
import { CreateUserDto, UpdateUserDto, SearchUserDto, Role } from '@/types/user';
import { toast } from 'sonner';
import { getApiErrorMessage } from '@/types/api-error';
@@ -18,7 +18,7 @@ export function useUsers(params?: SearchUserDto) {
}
export function useRoles() {
return useQuery({
return useQuery<Role[]>({
queryKey: ['roles'],
queryFn: () => userService.getRoles(),
});
+3 -2
View File
@@ -6,6 +6,7 @@ import {
EvaluateWorkflowDto,
GetAvailableActionsDto,
} from '@/types/dto/workflow-engine/workflow-engine.dto';
import { Workflow } from '@/types/workflow';
export const workflowKeys = {
all: ['workflows'] as const,
@@ -14,14 +15,14 @@ export const workflowKeys = {
};
export const useWorkflowDefinitions = () => {
return useQuery({
return useQuery<Workflow[]>({
queryKey: workflowKeys.definitions(),
queryFn: () => workflowEngineService.getDefinitions(),
});
};
export const useWorkflowDefinition = (id: string | number) => {
return useQuery({
return useQuery<Workflow>({
queryKey: workflowKeys.definition(id),
queryFn: () => workflowEngineService.getDefinitionById(id),
enabled: !!id,
+47 -6
View File
@@ -24,8 +24,10 @@ export interface NumberingTemplate {
uuid?: string;
};
formatTemplate: string;
disciplineId: number;
description?: string;
resetSequenceYearly: boolean; // Controls yearly counter reset
isActive: number;
createdAt?: string;
updatedAt?: string;
}
@@ -38,8 +40,10 @@ export interface SaveTemplateDto {
projectId: number | string;
correspondenceTypeId: number | null;
formatTemplate: string;
disciplineId?: number;
description?: string;
resetSequenceYearly?: boolean;
isActive?: number;
}
/**
@@ -151,10 +155,22 @@ export const numberingApi = {
/**
* Save (create or update) a template
*/
saveTemplate: async (dto: Partial<NumberingTemplate>): Promise<NumberingTemplate> => {
saveTemplate: async (dto: SaveTemplateDto): Promise<NumberingTemplate> => {
// Clean the DTO to avoid sending nested objects that might confuse TypeORM or violate constraints
const cleanDto: any = {
id: dto.id,
projectId: dto.projectId,
correspondenceTypeId: dto.correspondenceTypeId,
disciplineId: dto.disciplineId || 0,
formatTemplate: dto.formatTemplate,
description: dto.description,
resetSequenceYearly: dto.resetSequenceYearly,
isActive: dto.isActive ?? 1,
};
const res = await apiClient.post<any>(
'/admin/document-numbering/templates',
dto
cleanDto
);
return res.data.data || res.data;
},
@@ -281,13 +297,38 @@ export const numberingApi = {
subTypeId?: number;
rfaTypeId?: number;
recipientOrganizationId?: number | string;
}): Promise<{ previewNumber: string; nextSequence: number }> => {
const res = await apiClient.post<{ data: { previewNumber: string; nextSequence: number } }>(
}): Promise<{ previewNumber: string; nextSequence: number; isDefault: boolean }> => {
const res = await apiClient.post<any>(
'/document-numbering/preview',
ctx
);
// Backend wraps response in { data: { ... }, message: "Success" }
return res.data.data || res.data;
// Explicit debug log for frontend developers to see in browser console
console.log("[numberingApi.previewNumber] Raw Response Data:", res.data);
const body = res.data;
console.log("[numberingApi.previewNumber] Full Body:", body);
// Drill down to find the actual data object
let data = body;
let depth = 0;
while (data && typeof data === 'object' && !data.previewNumber && !data.number && data.data && depth < 3) {
data = data.data;
depth++;
}
console.log(`[numberingApi.previewNumber] Unwrapped at depth ${depth}:`, data);
// Final extraction
const previewNumber = data?.previewNumber || data?.number || (typeof data === 'string' ? data : '');
const nextSequence = data?.nextSequence ?? data?.sequence ?? 0;
const isDefault = data?.isDefault === true;
return {
previewNumber: previewNumber || JSON.stringify(body), // Fallback to body string if all else fails
nextSequence: nextSequence,
isDefault: isDefault
};
},
/**
+61 -5
View File
@@ -23,6 +23,59 @@ function getJwtExpiry(token: string): number {
}
}
interface TokenPayload {
access_token: string;
refresh_token?: string;
}
interface LoginPayload extends TokenPayload {
user: {
user_id: number;
username: string;
email?: string;
firstName?: string;
lastName?: string;
role?: string;
primaryOrganizationId?: number;
};
}
function unwrapApiResponse(value: unknown): unknown {
let current = value;
for (let i = 0; i < 5; i += 1) {
if (!current || typeof current !== "object") {
return current;
}
const record = current as Record<string, unknown>;
if (typeof record.access_token === "string") {
return current;
}
if (!("data" in record)) {
return current;
}
current = record.data;
}
return current;
}
function isTokenPayload(value: unknown): value is TokenPayload {
return !!value && typeof value === "object" && typeof (value as Record<string, unknown>).access_token === "string";
}
function isLoginPayload(value: unknown): value is LoginPayload {
if (!isTokenPayload(value)) {
return false;
}
const user = (value as unknown as { user?: unknown }).user;
return !!user && typeof user === "object" && typeof (user as Record<string, unknown>).username === "string";
}
async function refreshAccessToken(token: JWT) {
try {
const response = await fetch(`${baseUrl}/auth/refresh`, {
@@ -38,7 +91,11 @@ async function refreshAccessToken(token: JWT) {
throw refreshedTokens;
}
const data = refreshedTokens.data || refreshedTokens;
const data = unwrapApiResponse(refreshedTokens);
if (!isTokenPayload(data)) {
throw new Error("Invalid refresh response format");
}
return {
...token,
@@ -100,10 +157,9 @@ export const {
}
const data = await res.json();
// Handling both { data: { ... } } and direct { ... } response formats
const backendData = data.data || data;
const backendData = unwrapApiResponse(data);
if (!backendData || !backendData.access_token) {
if (!isLoginPayload(backendData)) {
console.error("[AUTH] Login failed: Invalid response format from backend (missing access_token)");
return null;
}
@@ -112,7 +168,7 @@ export const {
return {
id: backendData.user.user_id.toString(),
name: `${backendData.user.firstName} ${backendData.user.lastName}`,
name: `${backendData.user.firstName ?? ""} ${backendData.user.lastName ?? ""}`.trim(),
email: backendData.user.email,
username: backendData.user.username,
role: backendData.user.role || "User",
+26 -8
View File
@@ -15,6 +15,24 @@ import {
SearchOrganizationDto,
} from "@/types/dto/organization/organization.dto";
const extractArrayData = <T>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
export const masterDataService = {
// --- Tags Management ---
@@ -77,7 +95,7 @@ export const masterDataService = {
return response.data;
},
/** แก้ไองค์กร */
/** แก้ไองค์กร */
updateOrganization: async (uuid: string, data: UpdateOrganizationDto) => {
const response = await apiClient.put(`/organizations/${uuid}`, data);
return response.data;
@@ -97,7 +115,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/disciplines", {
params: { contractId }
});
return response.data.data || response.data;
return extractArrayData(response.data);
},
/** สร้างสาขางานใหม่ */
@@ -119,7 +137,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/sub-types", {
params: { contractId, correspondenceTypeId: typeId }
});
return response.data.data || response.data;
return extractArrayData(response.data);
},
/** สร้างประเภทย่อยใหม่ */
@@ -135,7 +153,7 @@ export const masterDataService = {
const response = await apiClient.get("/master/rfa-types", {
params: { contractId }
});
return response.data.data || response.data;
return extractArrayData(response.data);
},
/** สร้างประเภท RFA ใหม่ */
@@ -156,7 +174,7 @@ export const masterDataService = {
// --- Correspondence Types Management ---
getCorrespondenceTypes: async () => {
const response = await apiClient.get("/master/correspondence-types");
return response.data.data || response.data;
return extractArrayData(response.data);
},
createCorrespondenceType: async (data: CreateCorrespondenceTypeDto) => {
@@ -191,18 +209,18 @@ export const masterDataService = {
const response = await apiClient.get("/drawings/contract/categories", {
params: { projectId }
});
return response.data.data || response.data;
return extractArrayData(response.data);
},
getShopMainCategories: async (projectId: number) => {
const response = await apiClient.get("/drawings/shop/main-categories", { params: { projectId } });
return response.data.data || response.data;
return extractArrayData(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;
return extractArrayData(response.data);
}
};
+65 -3
View File
@@ -7,6 +7,68 @@ import {
CommitBatchDto,
} from '@/types/migration';
interface WrappedData {
data?: unknown;
}
const extractNestedData = <T,>(value: unknown): T => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return current as T;
}
current = (current as WrappedData).data;
}
return current as T;
};
const normalizePaginatedResponse = <T,>(value: unknown): PaginatedResponse<T> => {
const extracted = extractNestedData<unknown>(value);
if (!extracted || typeof extracted !== 'object') {
return {
items: [],
total: 0,
page: 1,
limit: 0,
totalPages: 0,
};
}
const response = extracted as Partial<PaginatedResponse<T>> & { data?: unknown };
if (Array.isArray(response.items)) {
return {
items: response.items,
total: response.total ?? response.items.length,
page: response.page ?? 1,
limit: response.limit ?? response.items.length,
totalPages: response.totalPages ?? 1,
};
}
if (Array.isArray(response.data)) {
return {
items: response.data as T[],
total: response.total ?? response.data.length,
page: response.page ?? 1,
limit: response.limit ?? response.data.length,
totalPages: response.totalPages ?? 1,
};
}
return {
items: [],
total: 0,
page: 1,
limit: 0,
totalPages: 0,
};
};
export const migrationService = {
getReviewQueue: async (params: {
page?: number;
@@ -14,12 +76,12 @@ export const migrationService = {
status?: MigrationReviewStatus;
}): Promise<PaginatedResponse<MigrationReviewQueueItem>> => {
const { data } = await api.get('/migration/queue', { params });
return data?.data || data;
return normalizePaginatedResponse<MigrationReviewQueueItem>(data);
},
getQueueItem: async (id: number): Promise<MigrationReviewQueueItem> => {
const { data } = await api.get(`/migration/queue/${id}`);
return data?.data || data;
return extractNestedData<MigrationReviewQueueItem>(data);
},
getErrors: async (params: {
@@ -27,7 +89,7 @@ export const migrationService = {
limit?: number;
}): Promise<PaginatedResponse<MigrationErrorItem>> => {
const { data } = await api.get('/migration/errors', { params });
return data?.data || data;
return normalizePaginatedResponse<MigrationErrorItem>(data);
},
approveQueueItem: async (id: number, payload: any, idempotencyKey: string) => {
+27 -4
View File
@@ -1,7 +1,7 @@
import apiClient from '@/lib/api/client';
export interface Session {
id: string; // tokenId
id: number;
userId: number;
user: {
username: string;
@@ -14,10 +14,33 @@ export interface Session {
isCurrent: boolean;
}
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== 'object' || !('data' in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const transformSession = (session: Session | (Omit<Session, 'id'> & { id: string | number })): Session => ({
...session,
id: typeof session.id === 'number' ? session.id : Number(session.id),
});
export const sessionService = {
getActiveSessions: async () => {
const response = await apiClient.get<Session[] | { data: Session[] }>('/auth/sessions');
return (response.data as { data: Session[] }).data ?? response.data;
getActiveSessions: async (): Promise<Session[]> => {
const response = await apiClient.get<Session[] | { data: Session[] } | { data: { data: Session[] } }>('/auth/sessions');
return extractArrayData<Session | (Omit<Session, 'id'> & { id: string | number })>(response.data).map(transformSession);
},
revokeSession: async (sessionId: number) => {
+22 -18
View File
@@ -1,5 +1,5 @@
import apiClient from "@/lib/api/client";
import { CreateUserDto, UpdateUserDto, SearchUserDto, User } from "@/types/user";
import { CreateUserDto, UpdateUserDto, SearchUserDto, User, Role } from "@/types/user";
/** Raw API user shape (before transform) */
interface RawUser {
@@ -9,6 +9,24 @@ interface RawUser {
[key: string]: unknown;
}
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== "object" || !("data" in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const transformUser = (user: RawUser): User => {
return {
...(user as unknown as User),
@@ -24,26 +42,12 @@ type UserListResponse = User[] | { data: User[] | { data: User[] } };
export const userService = {
getAll: async (params?: SearchUserDto) => {
const response = await apiClient.get<UserListResponse>("/users", { params });
// Handle both paginated and non-paginated responses
let rawData: RawUser[] | unknown = response.data;
if (rawData && !Array.isArray(rawData) && 'data' in (rawData as object)) {
rawData = (rawData as { data: unknown }).data;
}
if (rawData && !Array.isArray(rawData) && typeof rawData === 'object' && 'data' in (rawData as object)) {
rawData = (rawData as { data: unknown }).data;
}
if (!Array.isArray(rawData)) return [];
return (rawData as RawUser[]).map(transformUser);
return extractArrayData<RawUser>(response.data).map(transformUser);
},
getRoles: async () => {
getRoles: async (): Promise<Role[]> => {
const response = await apiClient.get<{ data: unknown } | unknown>("/users/roles");
if (response.data && typeof response.data === 'object' && 'data' in (response.data as object)) {
return (response.data as { data: unknown }).data;
}
return response.data;
return extractArrayData<Role>(response.data);
},
getByUuid: async (uuid: string) => {
+104 -11
View File
@@ -7,18 +7,112 @@ import {
GetAvailableActionsDto,
} from '@/types/dto/workflow-engine/workflow-engine.dto';
import { Workflow } from '@/types/workflow';
import { Workflow, WorkflowType } from '@/types/workflow';
const mapWorkflow = (backendObj: any): Workflow => {
interface WorkflowResponseShape {
data?: unknown;
}
interface WorkflowDslShape {
workflowName?: string;
description?: string;
dslDefinition?: string;
workflow?: string;
states?: unknown;
}
interface BackendWorkflowShape {
id?: string | number;
workflow_code?: string;
description?: string;
version?: number;
is_active?: boolean;
dsl?: string | WorkflowDslShape;
compiled?: {
states?: Record<string, unknown>;
};
updated_at?: string;
}
const extractArrayData = <T,>(value: unknown): T[] => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (Array.isArray(current)) {
return current as T[];
}
if (!current || typeof current !== 'object' || !('data' in current)) {
return [];
}
current = (current as { data?: unknown }).data;
}
return Array.isArray(current) ? (current as T[]) : [];
};
const extractNestedData = <T,>(value: unknown): T => {
let current: unknown = value;
for (let i = 0; i < 5; i += 1) {
if (!current || typeof current !== 'object' || !('data' in current)) {
return current as T;
}
current = (current as WorkflowResponseShape).data;
}
return current as T;
};
const extractDslDefinition = (dsl: BackendWorkflowShape['dsl']): string => {
if (typeof dsl === 'string') {
return dsl;
}
if (!dsl || typeof dsl !== 'object') {
return '';
}
if (typeof dsl.dslDefinition === 'string') {
return dsl.dslDefinition;
}
return JSON.stringify(dsl, null, 2);
};
const normalizeWorkflowType = (workflowCode?: string): WorkflowType => {
const normalizedCode = workflowCode?.toUpperCase() ?? '';
if (normalizedCode.includes('RFA')) {
return 'RFA';
}
if (normalizedCode.includes('DRAWING')) {
return 'DRAWING';
}
return 'CORRESPONDENCE';
};
const mapWorkflow = (backendObj: BackendWorkflowShape): Workflow => {
if (!backendObj) throw new Error('Workflow not found');
return {
workflowId: backendObj.id,
workflowName: backendObj.dsl?.workflowName || backendObj.workflow_code,
description: backendObj.description || backendObj.dsl?.description || '',
workflowType: backendObj.workflow_code?.toUpperCase() || backendObj.workflow_code,
workflowId: backendObj.id ?? backendObj.workflow_code ?? '',
workflowName:
(typeof backendObj.dsl === 'object' ? backendObj.dsl?.workflowName : undefined) ||
(typeof backendObj.dsl === 'object' ? backendObj.dsl?.workflow : undefined) ||
backendObj.workflow_code ||
'',
description:
backendObj.description ||
(typeof backendObj.dsl === 'object' ? backendObj.dsl?.description : undefined) ||
'',
workflowType: normalizeWorkflowType(backendObj.workflow_code),
version: backendObj.version || 1,
isActive: backendObj.is_active,
dslDefinition: typeof backendObj.dsl === 'string' ? backendObj.dsl : backendObj.dsl?.dslDefinition || JSON.stringify(backendObj.dsl, null, 2),
isActive: backendObj.is_active ?? false,
dslDefinition: extractDslDefinition(backendObj.dsl),
stepCount: backendObj.compiled?.states ? Object.keys(backendObj.compiled.states).length : 0,
updatedAt: backendObj.updated_at || new Date().toISOString(),
};
@@ -53,8 +147,7 @@ export const workflowEngineService = {
*/
getDefinitions: async (): Promise<Workflow[]> => {
const response = await apiClient.get('/workflow-engine/definitions');
const data = response.data?.data || response.data;
return Array.isArray(data) ? data.map(mapWorkflow) : data;
return extractArrayData<BackendWorkflowShape>(response.data).map((workflow) => mapWorkflow(workflow));
},
/**
@@ -63,7 +156,7 @@ export const workflowEngineService = {
*/
getDefinitionById: async (id: string | number): Promise<Workflow> => {
const response = await apiClient.get(`/workflow-engine/definitions/${id}`);
const data = response.data?.data || response.data;
const data = extractNestedData<BackendWorkflowShape>(response.data);
return mapWorkflow(data);
},
@@ -28,6 +28,15 @@ export interface CreateCorrespondenceDto {
/** กำหนดวันตอบกลับ (ISO Date String) */
dueDate?: string;
/** วันที่เอกสาร (ISO Date String) */
documentDate?: string;
/** วันที่ออกเอกสาร (ISO Date String) */
issuedDate?: string;
/** วันที่รับเอกสาร (ISO Date String) */
receivedDate?: string;
/** ข้อมูล JSON เฉพาะประเภท (เช่น RFI question, RFA details) */
details?: Record<string, unknown>;
+4 -5
View File
@@ -1,5 +1,4 @@
// File: src/types/dto/rfa/rfa.dto.ts
import type { RFAItem } from '@/types/rfa';
// --- Create ---
export interface CreateRfaDto {
@@ -36,11 +35,11 @@ export interface CreateRfaDto {
/** กำหนดวันตอบกลับ (ISO Date String) */
dueDate?: string;
/** รายการ ID ของ Shop Drawings ที่แนบมา (ถ้ามี) */
shopDrawingRevisionIds?: number[];
/** รายการ ID หรือ UUID ของ Shop Drawing Revisions ที่แนบมา (ถ้ามี) */
shopDrawingRevisionIds?: Array<number | string>;
/** รายการ Items ของ RFA */
items?: RFAItem[];
/** รายการ ID หรือ UUID ของ As-Built Drawing Revisions ที่แนบมา (ถ้ามี) */
asBuiltDrawingRevisionIds?: Array<number | string>;
}
// --- Update (Partial) ---
+29 -15
View File
@@ -1,10 +1,30 @@
export interface RFAItem {
id?: number;
itemNo: string;
description: string;
quantity: number;
unit: string;
status?: "PENDING" | "APPROVED" | "REJECTED";
itemType: "SHOP" | "AS_BUILT";
shopDrawingRevision?: {
uuid?: string;
revisionLabel?: string;
revisionNumber?: number;
title?: string;
legacyDrawingNumber?: string;
attachments?: { id?: number; url?: string; name?: string }[];
shopDrawing?: {
uuid?: string;
drawingNumber?: string;
};
};
asBuiltDrawingRevision?: {
uuid?: string;
revisionLabel?: string;
revisionNumber?: number;
title?: string;
legacyDrawingNumber?: string;
attachments?: { id?: number; url?: string; name?: string }[];
asBuiltDrawing?: {
uuid?: string;
drawingNumber?: string;
};
};
}
export interface RFA {
@@ -17,17 +37,11 @@ export interface RFA {
id: number;
revisionNumber: number;
subject: string;
description?: string;
isCurrent: boolean;
createdAt?: string;
statusCode?: { statusCode: string; statusName: string };
items?: {
shopDrawingRevision?: {
id: number;
revisionLabel: string;
shopDrawing?: { drawingType?: { hasNumber: boolean } }; // Mock structure
attachments?: { id: number; url: string; name: string }[]
}
}[];
items?: RFAItem[];
}[];
discipline?: {
id: number;
@@ -66,6 +80,6 @@ export interface CreateRFADto {
description?: string;
documentDate?: string;
details?: Record<string, unknown>;
shopDrawingRevisionIds?: number[];
items?: RFAItem[];
shopDrawingRevisionIds?: Array<number | string>;
asBuiltDrawingRevisionIds?: Array<number | string>;
}