summaryrefslogtreecommitdiff
path: root/components/form-data
diff options
context:
space:
mode:
Diffstat (limited to 'components/form-data')
-rw-r--r--components/form-data/form-data-table-columns.tsx95
-rw-r--r--components/form-data/update-form-sheet.tsx310
2 files changed, 224 insertions, 181 deletions
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx
index 930e113b..2a065d1b 100644
--- a/components/form-data/form-data-table-columns.tsx
+++ b/components/form-data/form-data-table-columns.tsx
@@ -103,53 +103,59 @@ function getHeaderText(col: DataTableColumnJSON): string {
}
/**
- * 컬럼들을 head 값에 따라 그룹핑하는 헬퍼 함수
+ * 컬럼들을 seq 순서대로 배치하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶는 함수
*/
function groupColumnsByHead(columns: DataTableColumnJSON[]): ColumnDef<any>[] {
- const groupedColumns: ColumnDef<any>[] = [];
- const groupMap = new Map<string, DataTableColumnJSON[]>();
- const ungroupedColumns: DataTableColumnJSON[] = [];
+ const result: ColumnDef<any>[] = [];
+ let i = 0;
- // head 값에 따라 컬럼들을 그룹핑
- columns.forEach(col => {
- if (col.head && col.head.trim()) {
- const groupKey = col.head.trim();
- if (!groupMap.has(groupKey)) {
- groupMap.set(groupKey, []);
- }
- groupMap.get(groupKey)!.push(col);
- } else {
- ungroupedColumns.push(col);
+ while (i < columns.length) {
+ const currentCol = columns[i];
+
+ // head가 없거나 빈 문자열인 경우 일반 컬럼으로 처리
+ if (!currentCol.head || !currentCol.head.trim()) {
+ result.push(createColumnDef(currentCol, false));
+ i++;
+ continue;
+ }
+
+ // 같은 head를 가진 연속된 컬럼들을 찾기
+ const groupHead = currentCol.head.trim();
+ const groupColumns: DataTableColumnJSON[] = [currentCol];
+ let j = i + 1;
+
+ while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) {
+ groupColumns.push(columns[j]);
+ j++;
}
- });
- // 그룹핑된 컬럼들 처리
- groupMap.forEach((groupColumns, groupHeader) => {
+ // 그룹에 컬럼이 하나만 있으면 일반 컬럼으로 처리
if (groupColumns.length === 1) {
- // 그룹에 컬럼이 하나만 있으면 일반 컬럼으로 처리
- ungroupedColumns.push(groupColumns[0]);
+ result.push(createColumnDef(currentCol, false));
} else {
- // 그룹 컬럼 생성
+ // 그룹 컬럼 생성 (구분선 스타일 적용)
const groupColumn: ColumnDef<any> = {
- header: groupHeader,
- columns: groupColumns.map(col => createColumnDef(col))
+ id: `group-${groupHead.replace(/\s+/g, '-')}`,
+ header: groupHead,
+ columns: groupColumns.map(col => createColumnDef(col, true)),
+ meta: {
+ isGroupColumn: true,
+ groupBorders: true, // 그룹 구분선 표시 플래그
+ }
};
- groupedColumns.push(groupColumn);
+ result.push(groupColumn);
}
- });
- // 그룹핑되지 않은 컬럼들 처리
- ungroupedColumns.forEach(col => {
- groupedColumns.push(createColumnDef(col));
- });
+ i = j; // 다음 그룹으로 이동
+ }
- return groupedColumns;
+ return result;
}
/**
* 개별 컬럼 정의를 생성하는 헬퍼 함수
*/
-function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
+function createColumnDef(col: DataTableColumnJSON, isInGroup: boolean = false): ColumnDef<any> {
return {
accessorKey: col.key,
header: ({ column }) => (
@@ -165,6 +171,8 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
paddingFactor: 1.2,
maxWidth: col.key === "TAG_NO" ? 120 : 150,
isReadOnly: col.shi === true,
+ isInGroup, // 그룹 내 컬럼인지 표시
+ groupBorders: isInGroup, // 그룹 구분선 표시 플래그
},
cell: ({ row }) => {
@@ -173,10 +181,19 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
// SHI 필드만 읽기 전용으로 처리
const isReadOnly = col.shi === true;
+ // 그룹 구분선 스타일 클래스 추가
+ const groupBorderClass = isInGroup ? "group-column-border" : "";
const readOnlyClass = isReadOnly ? "read-only-cell" : "";
- const cellStyle = isReadOnly
- ? { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' }
- : {};
+ const combinedClass = [groupBorderClass, readOnlyClass].filter(Boolean).join(" ");
+
+ const cellStyle = {
+ ...(isReadOnly && { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' }),
+ ...(isInGroup && {
+ borderLeft: '2px solid #e2e8f0',
+ borderRight: '2px solid #e2e8f0',
+ position: 'relative' as const
+ })
+ };
// 툴팁 메시지 설정 (SHI 필드만)
const tooltipMessage = isReadOnly ? "SHI 전용 필드입니다" : "";
@@ -188,7 +205,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
return (
<div
- className={readOnlyClass}
+ className={combinedClass}
style={cellStyle}
title={tooltipMessage}
>
@@ -204,7 +221,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
case "NUMBER":
return (
<div
- className={readOnlyClass}
+ className={combinedClass}
style={cellStyle}
title={tooltipMessage}
>
@@ -215,7 +232,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
case "LIST":
return (
<div
- className={readOnlyClass}
+ className={combinedClass}
style={cellStyle}
title={tooltipMessage}
>
@@ -227,7 +244,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
default:
return (
<div
- className={readOnlyClass}
+ className={combinedClass}
style={cellStyle}
title={tooltipMessage}
>
@@ -243,7 +260,7 @@ function createColumnDef(col: DataTableColumnJSON): ColumnDef<any> {
* getColumns 함수
* 1) columnsJSON 배열을 필터링 (hidden이 true가 아닌 것들만)
* 2) seq에 따라 정렬
- * 3) head 값에 따라 컬럼 그룹핑
+ * 3) seq 순서를 유지하면서 연속된 같은 head를 가진 컬럼들을 그룹으로 묶기
* 4) 체크박스 컬럼 추가
* 5) 마지막에 "Action" 칼럼 추가
*/
@@ -318,7 +335,7 @@ export function getColumns<TData extends object>({
};
columns.push(selectColumn);
- // (2) 기본 컬럼들 (head에 따라 그룹핑 처리)
+ // (2) 기본 컬럼들 (seq 순서를 유지하면서 head에 따라 그룹핑 처리)
const groupedColumns = groupColumnsByHead(visibleColumns);
columns.push(...groupedColumns);
diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx
index abc9bbf3..5666a116 100644
--- a/components/form-data/update-form-sheet.tsx
+++ b/components/form-data/update-form-sheet.tsx
@@ -32,7 +32,7 @@ import {
Popover,
PopoverTrigger,
PopoverContent,
-} from "@/components/ui/popover"
+} from "@/components/ui/popover";
import {
Command,
CommandInput,
@@ -40,14 +40,24 @@ import {
CommandGroup,
CommandItem,
CommandEmpty,
-} from "@/components/ui/command"
+} from "@/components/ui/command";
import { DataTableColumnJSON } from "./form-data-table-columns";
import { updateFormDataInDB } from "@/lib/forms/services";
import { cn } from "@/lib/utils";
-interface UpdateTagSheetProps
- extends React.ComponentPropsWithoutRef<typeof Sheet> {
+/** =============================================================
+ * 🔄 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[];
@@ -66,74 +76,57 @@ export function UpdateTagSheet({
rowData,
formCode,
contractItemId,
- editableFieldsMap = new Map(), // 기본값 설정
+ editableFieldsMap = new Map(),
onUpdateSuccess,
...props
}: UpdateTagSheetProps) {
+ // ───────────────────────────────────────────────────────────────
+ // hooks & helpers
+ // ───────────────────────────────────────────────────────────────
const [isPending, startTransition] = React.useTransition();
const router = useRouter();
- // 현재 TAG의 편집 가능한 필드 목록 가져오기
+ /* ----------------------------------------------------------------
+ * 1️⃣ Editable‑field helpers (unchanged)
+ * --------------------------------------------------------------*/
const editableFields = React.useMemo(() => {
if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
- return [];
+ return [] as string[];
}
return editableFieldsMap.get(rowData.TAG_NO) || [];
}, [rowData?.TAG_NO, editableFieldsMap]);
- // 필드가 편집 가능한지 판별하는 함수
const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => {
- // 1. SHI-only 필드는 편집 불가
- if (column.shi === true) {
- return false;
- }
-
- // 2. TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (필요에 따라 수정 가능)
- if (column.key === "TAG_NO" || column.key === "TAG_DESC") {
- return true;
- }
+ if (column.shi === true) return false; // SHI‑only
+ if (column.key === "TAG_NO" || column.key === "TAG_DESC") return true;
+ return editableFields.includes(column.key);
+ }, [editableFields]);
- //3. editableFieldsMap이 있으면 해당 리스트에 있는지 확인
- // if (rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) {
- // return editableFields.includes(column.key);
- // }
+ const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]);
- // 4. editableFieldsMap 정보가 없으면 기본적으로 편집 불가 (안전한 기본값)
- return true;
- }, []);
-
- // 읽기 전용 필드인지 판별하는 함수 (편집 가능의 반대)
- const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => {
- return !isFieldEditable(column);
- }, [isFieldEditable]);
-
- // 읽기 전용 사유를 반환하는 함수
const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => {
- if (column.shi === true) {
- return "SHI-only field (managed by SHI system)";
- }
-
+ if (column.shi) return "SHI‑only field (managed by SHI system)";
if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") {
if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
return "No editable fields information for this TAG";
}
-
if (!editableFields.includes(column.key)) {
return "Not editable for this TAG class";
}
}
-
- return "Read-only field";
+ return "Read‑only field";
}, [rowData?.TAG_NO, editableFieldsMap, editableFields]);
- // 1) zod 스키마
+ /* ----------------------------------------------------------------
+ * 2️⃣ Zod dynamic schema & form state (unchanged)
+ * --------------------------------------------------------------*/
const dynamicSchema = React.useMemo(() => {
- const shape: Record<string, z.ZodType<any>> = {};
+ 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) ? undefined : val))
+ .transform((val) => (isNaN(val as number) ? undefined : val))
.optional();
} else {
shape[col.key] = z.string().optional();
@@ -142,16 +135,14 @@ export function UpdateTagSheet({
return z.object(shape);
}, [columns]);
- // 2) form init
const form = useForm({
resolver: zodResolver(dynamicSchema),
defaultValues: React.useMemo(() => {
if (!rowData) return {};
- const defaults: Record<string, any> = {};
- for (const col of columns) {
- defaults[col.key] = rowData[col.key] ?? "";
- }
- return defaults;
+ return columns.reduce<Record<string, any>>((acc, col) => {
+ acc[col.key] = rowData[col.key] ?? "";
+ return acc;
+ }, {});
}, [rowData, columns]),
});
@@ -161,28 +152,67 @@ export function UpdateTagSheet({
return;
}
const defaults: Record<string, any> = {};
- for (const col of columns) {
+ 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 {
- // 제출 전에 읽기 전용 필드를 원본 값으로 복원
- const finalValues = { ...values };
- for (const col of columns) {
+ // 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
+ finalValues,
);
if (!success) {
@@ -190,25 +220,12 @@ export function UpdateTagSheet({
return;
}
- // Success handling
toast.success("Updated successfully!");
- // Create a merged object of original rowData and new values
- const updatedData = {
- ...rowData,
- ...finalValues,
- TAG_NO: rowData?.TAG_NO,
- };
-
- // Call the success callback
+ const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO };
onUpdateSuccess?.(updatedData);
-
- // Refresh the entire route to get fresh data
router.refresh();
-
- // Close the sheet
onOpenChange(false);
-
} catch (error) {
console.error("Error updating form data:", error);
toast.error("An unexpected error occurred while updating");
@@ -216,44 +233,57 @@ export function UpdateTagSheet({
});
}
- // 편집 가능한 필드 개수 계산
- const editableFieldCount = React.useMemo(() => {
- return columns.filter(col => isFieldEditable(col)).length;
- }, [columns, isFieldEditable]);
+ /* ----------------------------------------------------------------
+ * 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>Update Row - {rowData?.TAG_NO || 'Unknown TAG'}</SheetTitle>
+ <SheetTitle>
+ Update Row – {rowData?.TAG_NO || "Unknown TAG"}
+ </SheetTitle>
<SheetDescription>
- Modify the fields below and save changes. Fields with <LockIcon className="inline h-3 w-3" /> are read-only.
+ Modify the fields below and save changes. Fields with
+ <LockIcon className="inline h-3 w-3 mx-1" /> are read‑only.
<br />
<span className="text-sm text-green-600">
- {editableFieldCount} of {columns.length} fields are editable for this TAG.
+ {editableFieldCount} of {columns.length} fields are editable for
+ this TAG.
</span>
</SheetDescription>
</SheetHeader>
+ {/* ────────────────────────────────────────────── */}
<Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
- >
+ <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">
- <div className="flex flex-col gap-4 pt-2">
- {columns.map((col) => {
- const isReadOnly = isFieldReadOnly(col);
- const readOnlyReason = isReadOnly ? getReadOnlyReason(col) : "";
-
- return (
- <FormField
- key={col.key}
- control={form.control}
- name={col.key}
- render={({ field }) => {
- switch (col.type) {
- case "NUMBER":
+ {/* ------------------------------------------------------------------
+ * 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">
@@ -272,7 +302,8 @@ export function UpdateTagSheet({
}}
value={field.value ?? ""}
className={cn(
- isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300"
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
)}
/>
</FormControl>
@@ -284,8 +315,10 @@ export function UpdateTagSheet({
<FormMessage />
</FormItem>
);
+ }
- case "LIST":
+ // ——————————————— List ————————————————
+ if (col.type === "LIST") {
return (
<FormItem>
<FormLabel className="flex items-center">
@@ -303,12 +336,11 @@ export function UpdateTagSheet({
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
- isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300"
+ isReadOnly &&
+ "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300",
)}
>
- {field.value
- ? col.options?.find((opt) => opt === field.value)
- : "Select an option"}
+ {field.value ? col.options?.find((o) => o === field.value) : "Select an option"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -319,17 +351,11 @@ export function UpdateTagSheet({
<CommandList>
<CommandGroup>
{col.options?.map((opt) => (
- <CommandItem
- key={opt}
- value={opt}
- onSelect={() => {
- field.onChange(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"
+ field.value === opt ? "opacity-100" : "opacity-0",
)}
/>
{opt}
@@ -348,52 +374,52 @@ export function UpdateTagSheet({
<FormMessage />
</FormItem>
);
+ }
- case "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>
+ // ——————————————— String / default ————————————
+ return (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ {col.label}
{isReadOnly && (
- <FormDescription className="text-xs text-gray-500">
- {readOnlyReason}
- </FormDescription>
+ <LockIcon className="ml-1 h-3 w-3 text-gray-400" />
)}
- <FormMessage />
- </FormItem>
- );
- }
- }}
- />
- );
- })}
- </div>
+ </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">
Cancel
</Button>
</SheetClose>
-
<Button type="submit" disabled={isPending}>
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- Save
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}Save
</Button>
</SheetFooter>
</form>
@@ -401,4 +427,4 @@ export function UpdateTagSheet({
</SheetContent>
</Sheet>
);
-} \ No newline at end of file
+}