'use client'; import { useForm, Resolver } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; import { FileUploadZone } from '@/components/custom/file-upload-zone'; import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { useCreateCorrespondence, useUpdateCorrespondence } from '@/hooks/use-correspondence'; import { Organization } from '@/types/organization'; import { useOrganizations, useProjects, useCorrespondenceTypes, useDisciplines, useContracts } from '@/hooks/use-master-data'; import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-correspondence.dto'; import { useState, useEffect, useRef, useMemo } from 'react'; import { numberingApi } from '@/lib/api/numbering'; import { filesApi } from '@/lib/api/files'; import { toast } from 'sonner'; // Updated Zod Schema with all required fields const correspondenceSchema = z.object({ projectId: z.string().min(1, 'Please select a Project'), // [FIX v1.8.1] contractId optional เพราะ correspondences ไม่มี contract_id โดยตรง // จะ auto-populate จาก discipline เฉพาะ UI เท่านั้น contractId: z.string().optional(), documentTypeId: z.number().min(1, 'Please select a Document Type'), disciplineId: z.number().optional(), subject: z.string().min(5, 'Subject must be at least 5 characters'), description: z.string().optional(), 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'), ccOrganizationIds: z.array(z.string()).optional(), // CC organizations support importance: z.enum(['NORMAL', 'HIGH', 'URGENT']), attachments: z.array(z.instanceof(File)).optional(), }); type FormData = z.infer; type ProjectOption = { publicId: string; projectName: string; projectCode: string; }; type ContractOption = { publicId?: string; contractName?: string; contractCode?: string; }; type CorrespondenceTypeOption = { id: number; typeName: string; typeCode: string; }; interface DisciplineOption { id: number; disciplineCode: string; codeNameEn?: string; // [FIX v1.8.1] บาง API ส่ง contract relation มาด้วย → ใช้ resolve contractId contract?: { publicId?: string; contractName?: string; contractCode?: string; }; } interface InitialCorrespondenceData { projectId?: number | string; project?: { publicId?: string }; contract?: { publicId?: string }; correspondenceTypeId?: number; type?: { id?: number; publicId?: string }; disciplineId?: number; discipline?: { id?: number; publicId?: string; contract?: { publicId?: string }; }; revisions?: Array<{ publicId?: string; isCurrent?: boolean; subject?: string; title?: string; description?: string; body?: string; remarks?: string; dueDate?: string; documentDate?: string; issuedDate?: string; receivedDate?: string; details?: { importance: 'NORMAL' | 'HIGH' | 'URGENT' }; }>; originatorId?: number; originator?: { publicId?: string }; recipients?: Array<{ recipientType: string; recipientOrganizationId: number; recipientOrganization?: { publicId?: string }; }>; correspondenceNumber?: string; } const extractArrayData = (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 normalizePublicId = (value: unknown): string | undefined => { if (typeof value !== 'string') { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const normalizeUuid = (value: unknown): string | undefined => { const normalized = normalizePublicId(value); return normalized ? normalized.toLowerCase() : undefined; }; export function CorrespondenceForm({ initialData, uuid, selectedRevisionId, }: { initialData?: InitialCorrespondenceData; uuid?: string; selectedRevisionId?: string; }) { const router = useRouter(); const createMutation = useCreateCorrespondence(); const updateMutation = useUpdateCorrespondence(); // Fetch master data for dropdowns const { data: projectsData, isLoading: isLoadingProjects } = useProjects(); const { data: organizations, isLoading: isLoadingOrgs } = useOrganizations(); const { data: correspondenceTypesData, isLoading: isLoadingTypes } = useCorrespondenceTypes(); const projects = (projectsData as ProjectOption[]) ?? []; const organizationOptions = extractArrayData(organizations); const correspondenceTypes = extractArrayData(correspondenceTypesData); // Extract initial values if editing const normalizedSelectedRevisionId = normalizeUuid(selectedRevisionId); const selectedRevision = normalizedSelectedRevisionId ? initialData?.revisions?.find((r) => normalizeUuid(r.publicId) === normalizedSelectedRevisionId) : undefined; const defaultValues = useMemo>(() => { const currentRevision = selectedRevision || initialData?.revisions?.find((r) => r.isCurrent) || initialData?.revisions?.[0]; const initialToRecipient = initialData?.recipients?.find((r) => r.recipientType === 'TO'); const initialCcRecipientIds = initialData?.recipients ?.filter((r) => r.recipientType === 'CC') .map((r) => normalizePublicId(r.recipientOrganization?.publicId)) .filter((value): value is string => Boolean(value)) ?? []; return { projectId: normalizePublicId(initialData?.project?.publicId) ?? normalizePublicId((initialData as Record)?.projectId), contractId: normalizePublicId(initialData?.contract?.publicId) ?? normalizePublicId(initialData?.discipline?.contract?.publicId) ?? normalizePublicId((initialData as Record)?.contractId as string), documentTypeId: initialData?.type?.id ?? initialData?.correspondenceTypeId, disciplineId: initialData?.discipline?.id ?? initialData?.disciplineId, subject: currentRevision?.subject || currentRevision?.title || '', description: currentRevision?.description || '', body: currentRevision?.body || '', remarks: currentRevision?.remarks || '', dueDate: currentRevision?.dueDate ? new Date(currentRevision.dueDate).toISOString().split('T')[0] : undefined, documentDate: currentRevision?.documentDate ? new Date(currentRevision.documentDate).toISOString().split('T')[0] : undefined, issuedDate: currentRevision?.issuedDate ? new Date(currentRevision.issuedDate).toISOString().split('T')[0] : undefined, receivedDate: currentRevision?.receivedDate ? new Date(currentRevision.receivedDate).toISOString().split('T')[0] : undefined, fromOrganizationId: normalizePublicId(initialData?.originator?.publicId) ?? normalizePublicId((initialData as Record)?.originatorId as string), toOrganizationId: normalizePublicId(initialToRecipient?.recipientOrganization?.publicId), ccOrganizationIds: initialCcRecipientIds, importance: currentRevision?.details?.importance || 'NORMAL', } as Partial; }, [initialData, selectedRevision]); const { register, handleSubmit, setValue, watch, reset, formState: { errors }, } = useForm({ resolver: zodResolver(correspondenceSchema) as Resolver, defaultValues: defaultValues as FormData, }); // Watch for dynamic updates of initialData to ensure form correctly populates useEffect(() => { if (initialData) { reset(defaultValues as FormData); } }, [initialData, selectedRevisionId, reset, defaultValues]); // Watch for controlled inputs const projectId = watch('projectId'); const contractId = watch('contractId'); const documentTypeId = watch('documentTypeId'); const disciplineId = watch('disciplineId'); const fromOrgId = watch('fromOrganizationId'); const toOrgId = watch('toOrganizationId'); // Fetch contracts based on selected project const { data: contractsData, isLoading: isLoadingContracts } = useContracts(projectId); const contracts = extractArrayData(contractsData); // Fetch disciplines based on selected contract const { data: disciplinesData, isLoading: isLoadingDisciplines } = useDisciplines(contractId); const disciplines = extractArrayData(disciplinesData); const didInitProjectReset = useRef(false); const didInitContractReset = useRef(false); // Reset dependent fields when project changes useEffect(() => { if (!didInitProjectReset.current) { didInitProjectReset.current = true; return; } if (projectId) { setValue('contractId', ''); setValue('disciplineId', undefined); } }, [projectId, setValue]); // Reset discipline when contract changes useEffect(() => { if (!didInitContractReset.current) { didInitContractReset.current = true; return; } if (contractId) { setValue('disciplineId', undefined); } }, [contractId, setValue]); // [FIX v1.8.1] Auto-populate contractId จาก discipline เมื่อ edit mode // disciplines API ส่ง contract relation มาด้วย ถ้ามี discipline useEffect(() => { if (!initialData?.disciplineId) return; if (disciplines.length === 0) return; // ถ้ามี contractId ใน form แล้ว ไม่ต้อง override const currentContractId = watch('contractId'); if (currentContractId) return; const matched = disciplines.find((d) => d.id === initialData.disciplineId); if (matched?.contract?.publicId) { setValue('contractId', matched.contract.publicId); } }, [initialData?.disciplineId, disciplines, setValue, watch]); const [isUploading, setIsUploading] = useState(false); const onSubmit = async (data: FormData) => { // Build recipients array with TO and CC const recipients = [ { organizationId: data.toOrganizationId, type: 'TO' as const }, ...(data.ccOrganizationIds?.map(orgId => ({ organizationId: orgId, type: 'CC' as const })) || []) ]; // Phase 1: Upload attachments to temp storage let attachmentTempIds: string[] | undefined; const validFiles = (data.attachments || []).filter((f): f is File => f instanceof File && !('validationError' in f && (f as { validationError?: string }).validationError)); if (validFiles.length > 0) { setIsUploading(true); try { const uploaded = await filesApi.uploadMany(validFiles); attachmentTempIds = uploaded.map((u) => u.tempId); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to upload attachments'; toast.error('Attachment upload failed', { description: message, }); setIsUploading(false); return; } setIsUploading(false); } const payload: CreateCorrespondenceDto = { projectId: data.projectId, typeId: data.documentTypeId, disciplineId: data.disciplineId, subject: data.subject, description: data.description, 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, attachmentTempIds, recipients, details: { importance: data.importance, }, }; if (uuid && initialData) { updateMutation.mutate( { uuid, data: payload }, { onSuccess: () => router.push(`/correspondences/${uuid}`) } ); } else { createMutation.mutate(payload, { onSuccess: () => router.push('/correspondences'), }); } }; const isPending = createMutation.isPending || updateMutation.isPending || isUploading; // -- Preview Logic -- const [preview, setPreview] = useState<{ number: string; isDefaultTemplate: boolean } | null>(null); useEffect(() => { if (!projectId || !documentTypeId || !fromOrgId || !toOrgId) { setPreview(null); return; } const fetchPreview = async () => { // Don't preview or change number in edit mode if (uuid) { setPreview(null); return; } try { const res = await numberingApi.previewNumber({ projectId, correspondenceTypeId: documentTypeId, disciplineId, originatorOrganizationId: fromOrgId, recipientOrganizationId: toOrgId, }); setPreview({ number: res.previewNumber, isDefaultTemplate: res.isDefault }); } catch (_err) { setPreview(null); } }; const timer = setTimeout(fetchPreview, 500); return () => clearTimeout(timer); }, [projectId, documentTypeId, disciplineId, fromOrgId, toOrgId]); return (
{/* Existing Document Number (Read Only) */} {initialData?.correspondenceNumber && (
)} {/* Preview Section - Only for New Documents */} {preview && !uuid && (

Document Number Preview

{preview.number} {preview.isDefaultTemplate && ( Default Template )}
)} {/* Document Metadata Section */}
{/* Project Dropdown */}
{errors.projectId &&

{errors.projectId.message}

}
{/* Contract Dropdown */}
{errors.contractId &&

{errors.contractId.message}

}
{/* Document Type Dropdown */}
{errors.documentTypeId &&

{errors.documentTypeId.message}

}
{/* Discipline Dropdown (Optional) */}
{/* Organizations */}
{errors.fromOrganizationId &&

{errors.fromOrganizationId.message}

}
{errors.toOrganizationId &&

{errors.toOrganizationId.message}

}
{organizationOptions .filter(org => org.publicId !== toOrgId) // Exclude TO organization .map((org) => (
{ const currentCC = watch('ccOrganizationIds') || []; if (checked) { setValue('ccOrganizationIds', [...currentCC, org.publicId]); } else { setValue('ccOrganizationIds', currentCC.filter(id => id !== org.publicId)); } }} />
))}

Select organizations to receive a copy of this correspondence

{/* Subject */}
{errors.subject &&

{errors.subject.message}

}
{/* Body */}