260321:1700 Correct Coresspondence / Doing RFA
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user