summaryrefslogtreecommitdiff
path: root/components/form-data-plant/update-form-sheet.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-23 10:10:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-23 10:10:21 +0000
commitf7f5069a2209cfa39b65f492f32270a5f554bed0 (patch)
tree933c731ec2cb7d8bc62219a0aeed45a5e97d5f15 /components/form-data-plant/update-form-sheet.tsx
parentd49ad5dee1e5a504e1321f6db802b647497ee9ff (diff)
(대표님) EDP 해양 관련 개발 사항들
Diffstat (limited to 'components/form-data-plant/update-form-sheet.tsx')
-rw-r--r--components/form-data-plant/update-form-sheet.tsx445
1 files changed, 445 insertions, 0 deletions
diff --git a/components/form-data-plant/update-form-sheet.tsx b/components/form-data-plant/update-form-sheet.tsx
new file mode 100644
index 00000000..bd75d8f3
--- /dev/null
+++ b/components/form-data-plant/update-form-sheet.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import * as React from "react";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { Check, ChevronsUpDown, Loader, LockIcon } from "lucide-react";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Form,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover";
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command";
+
+import { DataTableColumnJSON } from "./form-data-table-columns";
+import { updateFormDataInDB } from "@/lib/forms-plant/services";
+import { cn } from "@/lib/utils";
+import { useParams } from "next/navigation";
+import { useTranslation } from "@/i18n/client";
+
+/** =============================================================
+ * 🔄 UpdateTagSheet with grouped fields by `head` property
+ * -----------------------------------------------------------
+ * - Consecutive columns that share the same `head` value will be
+ * rendered under a section title (the head itself).
+ * - Columns without a head still appear normally.
+ *
+ * NOTE: Only rendering logic is touched – all validation,
+ * read‑only checks, and mutation logic stay the same.
+ * ============================================================*/
+
+interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ columns: DataTableColumnJSON[];
+ rowData: Record<string, any> | null;
+ formCode: string;
+ contractItemId: number;
+ editableFieldsMap?: Map<string, string[]>; // 새로 추가
+ /** 업데이트 성공 시 호출될 콜백 */
+ onUpdateSuccess?: (updatedValues: Record<string, any>) => void;
+}
+
+export function UpdateTagSheet({
+ open,
+ onOpenChange,
+ columns,
+ rowData,
+ formCode,
+ contractItemId,
+ editableFieldsMap = new Map(),
+ onUpdateSuccess,
+ ...props
+}: UpdateTagSheetProps) {
+ // ───────────────────────────────────────────────────────────────
+ // hooks & helpers
+ // ───────────────────────────────────────────────────────────────
+ const [isPending, startTransition] = React.useTransition();
+ const router = useRouter();
+
+ const params = useParams();
+ const lng = (params?.lng as string) || "ko";
+ const { t } = useTranslation(lng, "engineering");
+
+ /* ----------------------------------------------------------------
+ * 1️⃣ Editable‑field helpers (unchanged)
+ * --------------------------------------------------------------*/
+ const editableFields = React.useMemo(() => {
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return [] as string[];
+ }
+ return editableFieldsMap.get(rowData.TAG_NO) || [];
+ }, [rowData?.TAG_NO, editableFieldsMap]);
+
+ const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => {
+ if (column.shi === "OUT" || column.shi === null) return false; // SHI‑only
+ if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false;
+ if (column.key === "status") return false;
+ return editableFields.includes(column.key);
+ // return true
+ }, [editableFields]);
+
+ const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]);
+
+ const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => {
+ if (column.shi === "OUT" || column.shi === null) return t("updateTagSheet.readOnlyReasons.shiOnly");
+ if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") {
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return t("updateTagSheet.readOnlyReasons.noEditableFields");
+ }
+ if (!editableFields.includes(column.key)) {
+ return t("updateTagSheet.readOnlyReasons.notEditableForTag");
+ }
+ }
+ return t("updateTagSheet.readOnlyReasons.readOnly");
+ }, [rowData?.TAG_NO, editableFieldsMap, editableFields, t]);
+
+ /* ----------------------------------------------------------------
+ * 2️⃣ Zod dynamic schema & form state (unchanged)
+ * --------------------------------------------------------------*/
+ const dynamicSchema = React.useMemo(() => {
+ const shape: Record<string, z.ZodTypeAny> = {};
+ for (const col of columns) {
+ if (col.type === "NUMBER") {
+ shape[col.key] = z
+ .union([z.coerce.number(), z.nan()])
+ .transform((val) => (isNaN(val as number) ? undefined : val))
+ .optional();
+ } else {
+ shape[col.key] = z.string().optional();
+ }
+ }
+ return z.object(shape);
+ }, [columns]);
+
+ const form = useForm({
+ resolver: zodResolver(dynamicSchema),
+ defaultValues: React.useMemo(() => {
+ if (!rowData) return {};
+ return columns.reduce<Record<string, any>>((acc, col) => {
+ acc[col.key] = rowData[col.key] ?? "";
+ return acc;
+ }, {});
+ }, [rowData, columns]),
+ });
+
+ React.useEffect(() => {
+ if (!rowData) {
+ form.reset({});
+ return;
+ }
+ const defaults: Record<string, any> = {};
+ columns.forEach((col) => {
+ defaults[col.key] = rowData[col.key] ?? "";
+ });
+ form.reset(defaults);
+ }, [rowData, columns, form]);
+
+ /* ----------------------------------------------------------------
+ * 3️⃣ Grouping logic – figure out consecutive columns that share
+ * the same `head` value. This mirrors `groupColumnsByHead` that
+ * you already use for the table view.
+ * --------------------------------------------------------------*/
+ const groupedColumns = React.useMemo(() => {
+ // Ensure original ordering by `seq` where present
+ const sorted = [...columns].sort((a, b) => {
+ const seqA = a.seq ?? 999999;
+ const seqB = b.seq ?? 999999;
+ return seqA - seqB;
+ });
+
+ const groups: { head: string | null; cols: DataTableColumnJSON[] }[] = [];
+ let i = 0;
+ while (i < sorted.length) {
+ const curr = sorted[i];
+ const head = curr.head?.trim() || null;
+ if (!head) {
+ groups.push({ head: null, cols: [curr] });
+ i += 1;
+ continue;
+ }
+
+ // Collect consecutive columns with the same head
+ const cols: DataTableColumnJSON[] = [curr];
+ let j = i + 1;
+ while (j < sorted.length && sorted[j].head?.trim() === head) {
+ cols.push(sorted[j]);
+ j += 1;
+ }
+ groups.push({ head, cols });
+ i = j;
+ }
+ return groups;
+ }, [columns]);
+
+ /* ----------------------------------------------------------------
+ * 4️⃣ Submission handler (unchanged)
+ * --------------------------------------------------------------*/
+ async function onSubmit(values: Record<string, any>) {
+ startTransition(async () => {
+ try {
+ // Restore read‑only fields to their original value before saving
+ const finalValues: Record<string, any> = { ...values };
+ columns.forEach((col) => {
+ if (isFieldReadOnly(col)) {
+ finalValues[col.key] = rowData?.[col.key] ?? "";
+ }
+ });
+
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ finalValues,
+ );
+
+ if (!success) {
+ toast.error(message);
+ return;
+ }
+
+ toast.success(t("updateTagSheet.messages.updateSuccess"));
+
+ const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO };
+ onUpdateSuccess?.(updatedData);
+ router.refresh();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Error updating form data:", error);
+ toast.error(t("updateTagSheet.messages.updateError"));
+ }
+ });
+ }
+
+ /* ----------------------------------------------------------------
+ * 5️⃣ UI
+ * --------------------------------------------------------------*/
+ const editableFieldCount = React.useMemo(() => columns.filter(isFieldEditable).length, [columns, isFieldEditable]);
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange} {...props}>
+ <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col">
+ <SheetHeader className="text-left">
+ <SheetTitle>
+ {t("updateTagSheet.title")} – {rowData?.TAG_NO || t("updateTagSheet.unknownTag")}
+ </SheetTitle>
+ <SheetDescription>
+ {t("updateTagSheet.description")}
+ <LockIcon className="inline h-3 w-3 mx-1" />
+ {t("updateTagSheet.readOnlyIndicator")}
+ <br />
+ <span className="text-sm text-green-600">
+ {t("updateTagSheet.editableFieldsCount", {
+ editableCount: editableFieldCount,
+ totalCount: columns.length
+ })}
+ </span>
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* ────────────────────────────────────────────── */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ {/* Scroll wrapper */}
+ <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4">
+ {/* ------------------------------------------------------------------
+ * Render groups
+ * ----------------------------------------------------------------*/}
+ {groupedColumns.map(({ head, cols }) => (
+ <div key={head ?? cols[0].key} className="flex flex-col gap-4 pt-2">
+ {head && (
+ <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide pl-1">
+ {head}
+ </h3>
+ )}
+
+ {/* Fields inside the group */}
+ {cols.map((col) => {
+ const isReadOnly = isFieldReadOnly(col);
+ const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : "";
+ return (
+ <FormField
+ key={col.key}
+ control={form.control}
+ name={col.key}
+ render={({ field }) => {
+ // ——————————————— Number ————————————————
+ if (col.type === "NUMBER") {
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.displayLabel || col.label}
+ {isReadOnly && (
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
+ )}
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ readOnly={isReadOnly}
+ onChange={(e) => {
+ const num = parseFloat(e.target.value);
+ field.onChange(isNaN(num) ? "" : num);
+ }}
+ value={field.value ?? ""}
+ className={cn(
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
+ )}
+ />
+ </FormControl>
+ {isReadOnly && (
+ <FormDescription className="text-xs text-gray-500">
+ {readOnlyReason}
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ );
+ }
+
+ // ——————————————— List ————————————————
+ if (col.type === "LIST") {
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.label}
+ {isReadOnly && (
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
+ )}
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ disabled={isReadOnly}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground",
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
+ )}
+ >
+ {field.value ?
+ col.options?.find((o) => o === field.value) :
+ t("updateTagSheet.selectOption")
+ }
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput placeholder={t("updateTagSheet.searchOptions")} />
+ <CommandEmpty>{t("updateTagSheet.noOptionFound")}</CommandEmpty>
+ <CommandList>
+ <CommandGroup>
+ {col.options?.map((opt) => (
+ <CommandItem key={opt} value={opt} onSelect={() => field.onChange(opt)}>
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ field.value === opt ? "opacity-100" : "opacity-0",
+ )}
+ />
+ {opt}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ {isReadOnly && (
+ <FormDescription className="text-xs text-gray-500">
+ {readOnlyReason}
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ );
+ }
+
+ // ——————————————— String / default ————————————
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.label}
+ {isReadOnly && (
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
+ )}
+ </FormLabel>
+ <FormControl>
+ <Input
+ readOnly={isReadOnly}
+ {...field}
+ className={cn(
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
+ )}
+ />
+ </FormControl>
+ {isReadOnly && (
+ <FormDescription className="text-xs text-gray-500">
+ {readOnlyReason}
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ );
+ })}
+ </div>
+ ))}
+ </div>
+
+ {/* Footer */}
+ <SheetFooter className="gap-2 pt-2">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {t("buttons.cancel")}
+ </Button>
+ </SheetClose>
+ <Button type="submit" disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {t("buttons.save")}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ );
+} \ No newline at end of file