diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:36:14 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:36:14 +0000 |
| commit | 92eda21e45d902663052575aaa4c4f80bfa2faea (patch) | |
| tree | 8483702edf82932d4359a597a854fa8e1b48e94b /components | |
| parent | f0213de0d2fb5fcb931b3ddaddcbb6581cab5d28 (diff) | |
(대표님) 벤더 문서 변경사항, data-table 변경, sync 변경
Diffstat (limited to 'components')
| -rw-r--r-- | components/data-table/data-table-filter-list.tsx | 4 | ||||
| -rw-r--r-- | components/data-table/data-table-group-list.tsx | 7 | ||||
| -rw-r--r-- | components/data-table/data-table-pin-left.tsx | 7 | ||||
| -rw-r--r-- | components/data-table/data-table-pin-right.tsx | 9 | ||||
| -rw-r--r-- | components/data-table/data-table-sort-list.tsx | 8 | ||||
| -rw-r--r-- | components/data-table/data-table-view-options.tsx | 8 | ||||
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 95 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 310 | ||||
| -rw-r--r-- | components/layout/GroupedMenuRender.tsx | 30 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 54 | ||||
| -rw-r--r-- | components/layout/MobileMenu.tsx | 30 | ||||
| -rw-r--r-- | components/ship-vendor-document/add-attachment-dialog.tsx | 5 | ||||
| -rw-r--r-- | components/ship-vendor-document/new-revision-dialog.tsx | 5 |
13 files changed, 328 insertions, 244 deletions
diff --git a/components/data-table/data-table-filter-list.tsx b/components/data-table/data-table-filter-list.tsx index 3efa02ed..6088e912 100644 --- a/components/data-table/data-table-filter-list.tsx +++ b/components/data-table/data-table-filter-list.tsx @@ -580,7 +580,7 @@ export function DataTableFilterList<TData>({ {/* 텍스트는 모바일에서 숨기고, sm 이상에서만 보임 */} <span className="hidden sm:inline"> - {t("Filters")} + {t("tableToolBar.filters")} </span> {filters.length > 0 && ( @@ -603,7 +603,7 @@ export function DataTableFilterList<TData>({ )} > {filters.length > 0 ? ( - <h4 className="font-medium leading-none"> {t("Filters")}</h4> + <h4 className="font-medium leading-none"> {t("tableToolBar.filters")}</h4> ) : ( <div className="flex flex-col gap-1"> <h4 className="font-medium leading-none">{t("nofilters")}</h4> diff --git a/components/data-table/data-table-group-list.tsx b/components/data-table/data-table-group-list.tsx index fcae9a79..213b429f 100644 --- a/components/data-table/data-table-group-list.tsx +++ b/components/data-table/data-table-group-list.tsx @@ -26,6 +26,8 @@ import { SortableItem, SortableDragHandle, } from "@/components/ui/sortable" +import { useTranslation } from '@/i18n/client' +import { useParams, usePathname } from "next/navigation"; interface DataTableGroupListProps<TData> { /** TanStack Table 인스턴스 (grouping을 이미 사용할 수 있어야 함) */ @@ -42,6 +44,9 @@ export function DataTableGroupList<TData>({ shallow, }: DataTableGroupListProps<TData>) { const id = React.useId() + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng); // ------------------------------------------------------ // 1) 초기 그룹핑 상태 + URL Query State 동기화 @@ -156,7 +161,7 @@ export function DataTableGroupList<TData>({ aria-controls={`${id}-group-dialog`} > <Layers className="size-3" aria-hidden="true" /> - <span className="hidden sm:inline">그룹</span> + <span className="hidden sm:inline">{t("tableToolBar.group")}</span> {uniqueGrouping.length > 0 && ( <Badge variant="secondary" diff --git a/components/data-table/data-table-pin-left.tsx b/components/data-table/data-table-pin-left.tsx index 27116774..aed86844 100644 --- a/components/data-table/data-table-pin-left.tsx +++ b/components/data-table/data-table-pin-left.tsx @@ -20,6 +20,8 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { useTranslation } from '@/i18n/client' +import { useParams, usePathname } from "next/navigation"; /** * Helper function to check if a column is a parent column (has subcolumns) @@ -92,6 +94,9 @@ const AUTO_PIN_LEFT_COLUMNS = ['select'] export function PinLeftButton<TData>({ table }: { table: Table<TData> }) { const [open, setOpen] = React.useState(false) const triggerRef = React.useRef<HTMLButtonElement>(null) + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng); // Try to auto-pin select and action columns if they exist React.useEffect(() => { @@ -180,7 +185,7 @@ export function PinLeftButton<TData>({ table }: { table: Table<TData> }) { <MoveLeft className="size-4" /> <span className="hidden sm:inline"> - 왼쪽 고정 + {t("tableToolBar.leftPin")} </span> </Button> </PopoverTrigger> diff --git a/components/data-table/data-table-pin-right.tsx b/components/data-table/data-table-pin-right.tsx index 133740e1..1555985b 100644 --- a/components/data-table/data-table-pin-right.tsx +++ b/components/data-table/data-table-pin-right.tsx @@ -20,7 +20,8 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover" - +import { useTranslation } from '@/i18n/client' +import { useParams, usePathname } from "next/navigation"; /** * Helper function to check if a column is a parent column (has subcolumns) */ @@ -92,7 +93,9 @@ const AUTO_PIN_RIGHT_COLUMNS = ['actions', "action"] export function PinRightButton<TData>({ table }: { table: Table<TData> }) { const [open, setOpen] = React.useState(false) const triggerRef = React.useRef<HTMLButtonElement>(null) - + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng); // Try to auto-pin actions columns if they exist React.useEffect(() => { AUTO_PIN_RIGHT_COLUMNS.forEach((columnId) => { @@ -179,7 +182,7 @@ export function PinRightButton<TData>({ table }: { table: Table<TData> }) { <MoveRight className="size-4" /> <span className="hidden sm:inline"> - 오른 고정 + {t("tableToolBar.rigthPin")} </span> </Button> </PopoverTrigger> diff --git a/components/data-table/data-table-sort-list.tsx b/components/data-table/data-table-sort-list.tsx index c752f2f4..21926f34 100644 --- a/components/data-table/data-table-sort-list.tsx +++ b/components/data-table/data-table-sort-list.tsx @@ -47,6 +47,8 @@ import { SortableDragHandle, SortableItem, } from "@/components/ui/sortable" +import { useTranslation } from '@/i18n/client' +import { useParams, usePathname } from "next/navigation"; interface DataTableSortListProps<TData> { table: Table<TData> @@ -63,6 +65,10 @@ export function DataTableSortList<TData>({ }: DataTableSortListProps<TData>) { renderCount++; + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng); + const id = React.useId() const initialSorting = (table.initialState.sorting ?? @@ -186,7 +192,7 @@ export function DataTableSortList<TData>({ <ArrowDownUp className="size-3" aria-hidden="true" /> <span className="hidden sm:inline"> - 정렬 + {t("tableToolBar.sort")} </span> {uniqueSorting.length > 0 && ( diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx index c4167b47..422e3065 100644 --- a/components/data-table/data-table-view-options.tsx +++ b/components/data-table/data-table-view-options.tsx @@ -37,6 +37,8 @@ import { SortableItem, SortableDragHandle, } from "@/components/ui/sortable" +import { useTranslation } from '@/i18n/client' +import { useParams, usePathname } from "next/navigation"; /** @@ -68,6 +70,10 @@ export function DataTableViewOptions<TData>({ }: DataTableViewOptionsProps<TData>) { const triggerRef = React.useRef<HTMLButtonElement>(null) + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng); + // 1) Identify columns that can be hidden const hideableCols = React.useMemo(() => { return table @@ -128,7 +134,7 @@ export function DataTableViewOptions<TData>({ className="gap-2" > <Settings2 className="size-4" /> - <span className="hidden sm:inline">보기</span> + <span className="hidden sm:inline">{t("tableToolBar.view")}</span> </Button> </PopoverTrigger> 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 +} diff --git a/components/layout/GroupedMenuRender.tsx b/components/layout/GroupedMenuRender.tsx index 9006c85d..b56135eb 100644 --- a/components/layout/GroupedMenuRender.tsx +++ b/components/layout/GroupedMenuRender.tsx @@ -13,10 +13,11 @@ type GroupedMenuItems = { interface GroupedMenuRendererProps { items: MenuItem[]; lng: string; - activeMenus?: Record<string, boolean>; // 활성 메뉴 상태 추가 + activeMenus?: Record<string, boolean>; + t: (key: string) => string; // 번역 함수 추가 } -const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRendererProps) => { +const GroupedMenuRenderer = ({ items, lng, activeMenus = {}, t }: GroupedMenuRendererProps) => { // 활성 메뉴만 필터링 (activeMenus가 빈 객체면 모든 메뉴 표시) const filteredItems = Object.keys(activeMenus).length > 0 ? filterActiveAdditionalMenus(items, activeMenus) @@ -25,11 +26,14 @@ const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRender // 그룹별로 아이템 분류 const groupItems = (items: MenuItem[]): GroupedMenuItems => { return items.reduce((groups, item) => { - const group = item.group || 'default'; - if (!groups[group]) { - groups[group] = []; + // groupKey가 있으면 번역된 그룹명 사용, 없으면 'default' + const groupKey = item.groupKey || 'default'; + const groupName = item.groupKey ? t(item.groupKey) : 'default'; + + if (!groups[groupName]) { + groups[groupName] = []; } - groups[group].push(item); + groups[groupName].push(item); return groups; }, {} as GroupedMenuItems); }; @@ -42,7 +46,7 @@ const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRender return ( <div className="p-4 w-[600px]"> <p className="text-sm text-muted-foreground text-center py-8"> - 사용 가능한 메뉴가 없습니다. + {t('common.no_available_menus')} </p> </div> ); @@ -53,7 +57,7 @@ const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRender {groups.map((groupName, index) => { // 빈 그룹은 건너뛰기 if (groupedItems[groupName].length === 0) return null; - + return ( <div key={groupName} className={cn("mb-4", index < groups.length - 1 && "pb-2 border-b border-border/30")}> {groupName !== 'default' && ( @@ -61,7 +65,7 @@ const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRender )} <div className="grid grid-cols-2 gap-3"> {groupedItems[groupName].map((item) => ( - <MenuListItem key={item.title} item={item} lng={lng} /> + <MenuListItem key={item.titleKey} item={item} lng={lng} t={t} /> ))} </div> </div> @@ -71,7 +75,7 @@ const GroupedMenuRenderer = ({ items, lng, activeMenus = {} }: GroupedMenuRender ); }; -const MenuListItem = ({ item, lng }: { item: MenuItem; lng: string }) => { +const MenuListItem = ({ item, lng, t }: { item: MenuItem; lng: string; t: (key: string) => string }) => { return ( <NavigationMenuLink asChild> <Link @@ -84,10 +88,10 @@ const MenuListItem = ({ item, lng }: { item: MenuItem; lng: string }) => { )} > <div className="space-y-1"> - <div className="text-sm font-medium leading-none">{item.title}</div> - {item.description && ( + <div className="text-sm font-medium leading-none">{t(item.titleKey)}</div> + {item.descriptionKey && ( <p className="line-clamp-2 text-xs leading-snug text-muted-foreground"> - {item.description} + {t(item.descriptionKey)} </p> )} </div> diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index 0b04c0c3..0e9e2abe 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -36,7 +36,8 @@ import { MenuSection, MenuItem, mainNavVendor, - additionalNavVendor + additionalNavVendor, + domainBrandingKeys } from "@/config/menuConfig"; import { MobileMenu } from "./MobileMenu"; import { CommandMenu } from "./command-menu"; @@ -44,6 +45,7 @@ import { useSession, signOut } from "next-auth/react"; import GroupedMenuRenderer from "./GroupedMenuRender"; import { useActiveMenus, filterActiveMenus, filterActiveAdditionalMenus } from "@/hooks/use-active-menus"; import { NotificationDropdown } from "./NotificationDropdown"; +import { useTranslation } from '@/i18n/client' export function Header() { const params = useParams(); @@ -51,8 +53,8 @@ export function Header() { const pathname = usePathname(); const { data: session } = useSession(); const { activeMenus, isLoading } = useActiveMenus(); + const { t } = useTranslation(lng, 'menu'); - console.log(session) const userName = session?.user?.name || ""; const domain = session?.user?.domain || ""; @@ -74,7 +76,7 @@ export function Header() { main: mainNavVendor, additional: additionalNavVendor, logoHref: `/${lng}/partners`, - brandName: "eVCP Partners", + brandNameKey: domainBrandingKeys.partners, basePath: `/${lng}/partners` }; } @@ -84,7 +86,7 @@ export function Header() { main: procurementNav, additional: additional2Nav, logoHref: `/${lng}/procurement`, - brandName: "eVCP 구매관리", + brandNameKey: domainBrandingKeys.procurement, basePath: `/${lng}/procurement` }; } @@ -94,7 +96,7 @@ export function Header() { main: salesNav, additional: additional2Nav, logoHref: `/${lng}/sales`, - brandName: "eVCP 기술영업", + brandNameKey: domainBrandingKeys.sales, basePath: `/${lng}/sales` }; } @@ -104,7 +106,7 @@ export function Header() { main: engineeringNav, additional: additional2Nav, logoHref: `/${lng}/engineering`, - brandName: "eVCP 설계관리", + brandNameKey: domainBrandingKeys.engineering, basePath: `/${lng}/engineering` }; } @@ -114,12 +116,12 @@ export function Header() { main: mainNav, additional: additionalNav, logoHref: `/${lng}/evcp`, - brandName: "eVCP 삼성중공업", + brandNameKey: domainBrandingKeys.evcp, basePath: `/${lng}/evcp` }; }; - const { main: originalMain, additional: originalAdditional, logoHref, brandName, basePath } = getDomainConfig(pathname); + const { main: originalMain, additional: originalAdditional, logoHref, brandNameKey, basePath } = getDomainConfig(pathname); // 활성 메뉴만 필터링 (로딩 중이거나 에러 시에는 모든 메뉴 표시) const main = isLoading ? originalMain : filterActiveMenus(originalMain, activeMenus); @@ -150,10 +152,10 @@ export function Header() { d="M3.75 9h16.5m-16.5 6.75h16.5" /> </svg> - <span className="sr-only">Toggle Menu</span> + <span className="sr-only">{t('menu.toggle_menu')}</span> </Button> - {/* 로고 영역 - 도메인별 브랜딩 */} + {/* 로고 영역 - 도메인별 브랜딩 (번역된) */} <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> <Link href={logoHref} className="flex items-center gap-2"> <Image @@ -164,20 +166,20 @@ export function Header() { height={20} /> <span className="hidden font-bold lg:inline-block"> - {brandName} + {t(brandNameKey)} </span> </Link> </div> - {/* 네비게이션 메뉴 - 도메인별 활성화된 메뉴만 표시 */} + {/* 네비게이션 메뉴 - 도메인별 활성화된 메뉴만 표시 (번역된) */} <div className="hidden md:block flex-1 min-w-0"> <NavigationMenu className="relative z-50"> <div className="w-full overflow-x-auto pb-1"> <NavigationMenuList className="flex-nowrap w-max"> {main.map((section: MenuSection) => ( - <NavigationMenuItem key={section.title}> + <NavigationMenuItem key={section.titleKey}> <NavigationMenuTrigger className="px-2 xl:px-3 text-sm whitespace-nowrap"> - {section.title} + {t(section.titleKey)} </NavigationMenuTrigger> {/* 그룹핑이 필요한 메뉴의 경우 GroupedMenuRenderer 사용 */} @@ -187,6 +189,7 @@ export function Header() { items={section.items} lng={lng} activeMenus={activeMenus} + t={t} /> </NavigationMenuContent> ) : ( @@ -194,11 +197,11 @@ export function Header() { <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] "> {section.items.map((item) => ( <ListItem - key={item.title} - title={item.title} + key={item.titleKey} + title={t(item.titleKey)} href={`/${lng}${item.href}`} > - {item.description} + {item.descriptionKey && t(item.descriptionKey)} </ListItem> ))} </ul> @@ -207,9 +210,9 @@ export function Header() { </NavigationMenuItem> ))} - {/* 추가 네비게이션 항목 - 도메인별 활성화된 것만 */} + {/* 추가 네비게이션 항목 - 도메인별 활성화된 것만 (번역된) */} {additional.map((item) => ( - <NavigationMenuItem key={item.title}> + <NavigationMenuItem key={item.titleKey}> <Link href={`/${lng}${item.href}`} legacyBehavior passHref> <NavigationMenuLink className={cn( @@ -217,7 +220,7 @@ export function Header() { "px-2 xl:px-3 text-sm whitespace-nowrap" )} > - {item.title} + {t(item.titleKey)} </NavigationMenuLink> </Link> </NavigationMenuItem> @@ -233,14 +236,14 @@ export function Header() { <div className="hidden md:block md:w-auto"> <CommandMenu /> </div> - <Button variant="ghost" size="icon" className="md:hidden" aria-label="Search"> + <Button variant="ghost" size="icon" className="md:hidden" aria-label={t('common.search')}> <SearchIcon className="h-5 w-5" /> </Button> {/* 알림 버튼 */} <NotificationDropdown /> - {/* 사용자 메뉴 */} + {/* 사용자 메뉴 (번역된) */} <DropdownMenu> <DropdownMenuTrigger asChild> <Avatar className="cursor-pointer h-8 w-8"> @@ -251,14 +254,14 @@ export function Header() { </Avatar> </DropdownMenuTrigger> <DropdownMenuContent className="w-48" align="end"> - <DropdownMenuLabel>My Account</DropdownMenuLabel> + <DropdownMenuLabel>{t('user.my_account')}</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem asChild> - <Link href={`${basePath}/settings`}>Settings</Link> + <Link href={`${basePath}/settings`}>{t('user.settings')}</Link> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/${domain}` })}> - Logout + {t('user.logout')} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -274,6 +277,7 @@ export function Header() { activeMenus={activeMenus} domainMain={originalMain} domainAdditional={originalAdditional} + t={t} /> )} </header> diff --git a/components/layout/MobileMenu.tsx b/components/layout/MobileMenu.tsx index dc02d2e3..6cced283 100644 --- a/components/layout/MobileMenu.tsx +++ b/components/layout/MobileMenu.tsx @@ -17,17 +17,19 @@ interface MobileMenuProps { activeMenus?: Record<string, boolean>; domainMain?: MenuSection[]; // 헤더에서 계산된 도메인별 메인 메뉴 domainAdditional?: MenuItem[]; // 헤더에서 계산된 도메인별 추가 메뉴 + t: (key: string) => string; // 번역 함수 추가 } export function MobileMenu({ - lng, - onClose, + lng, + onClose, activeMenus = {}, domainMain = [], - domainAdditional = [] + domainAdditional = [], + t }: MobileMenuProps) { const router = useRouter(); - + const handleLinkClick = (href: string) => { router.push(href); onClose(); @@ -37,7 +39,7 @@ export function MobileMenu({ const main = Object.keys(activeMenus).length > 0 ? filterActiveMenus(domainMain, activeMenus) : domainMain; - + const additional = Object.keys(activeMenus).length > 0 ? filterActiveAdditionalMenus(domainAdditional, activeMenus) : domainAdditional; @@ -55,25 +57,25 @@ export function MobileMenu({ {main.map((section: MenuSection) => ( // 섹션에 아이템이 있는 경우에만 표시 section.items.length > 0 && ( - <li key={section.title}> - <h3 className="text-md font-medium">{section.title}</h3> + <li key={section.titleKey}> + <h3 className="text-md font-medium">{t(section.titleKey)}</h3> <ul className="mt-2 space-y-2"> {section.items.map((item: MenuItem) => ( - <li key={item.title}> + <li key={item.titleKey}> <Link href={`/${lng}${item.href}`} className="text-indigo-600" onClick={() => handleLinkClick(item.href)} > - {item.title} + {t(item.titleKey)} {item.label && ( <span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs text-[#000000]"> {item.label} </span> )} </Link> - {item.description && ( - <p className="text-xs text-gray-500">{item.description}</p> + {item.descriptionKey && ( + <p className="text-xs text-gray-500">{t(item.descriptionKey)}</p> )} </li> ))} @@ -81,16 +83,16 @@ export function MobileMenu({ </li> ) ))} - + {/* 추가 네비게이션 항목 - 도메인별 활성화된 메뉴만 표시 */} {additional.map((item: MenuItem) => ( - <li key={item.title}> + <li key={item.titleKey}> <Link href={`/${lng}${item.href}`} className="block text-sm text-indigo-600" onClick={() => handleLinkClick(`/${lng}${item.href}`)} > - {item.title} + {t(item.titleKey)} </Link> </li> ))} diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx index 8357c0b6..fa6f73f1 100644 --- a/components/ship-vendor-document/add-attachment-dialog.tsx +++ b/components/ship-vendor-document/add-attachment-dialog.tsx @@ -49,7 +49,10 @@ const ACCEPTED_FILE_TYPES = [ 'image/gif', 'text/plain', 'application/zip', - 'application/x-zip-compressed' + 'application/x-zip-compressed', + // Presentations (added) + 'application/vnd.ms-powerpoint', // .ppt + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx ] const attachmentUploadSchema = z.object({ diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx index 8ca6d533..68c7a946 100644 --- a/components/ship-vendor-document/new-revision-dialog.tsx +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -61,7 +61,10 @@ const ACCEPTED_FILE_TYPES = [ 'image/gif', 'text/plain', 'application/zip', - 'application/x-zip-compressed' + 'application/x-zip-compressed', + // Presentations (added) + 'application/vnd.ms-powerpoint', // .ppt + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx ] // drawingKind에 따른 동적 스키마 생성 |
