summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/data-table/data-table-filter-list.tsx4
-rw-r--r--components/data-table/data-table-group-list.tsx7
-rw-r--r--components/data-table/data-table-pin-left.tsx7
-rw-r--r--components/data-table/data-table-pin-right.tsx9
-rw-r--r--components/data-table/data-table-sort-list.tsx8
-rw-r--r--components/data-table/data-table-view-options.tsx8
-rw-r--r--components/form-data/form-data-table-columns.tsx95
-rw-r--r--components/form-data/update-form-sheet.tsx310
-rw-r--r--components/layout/GroupedMenuRender.tsx30
-rw-r--r--components/layout/Header.tsx54
-rw-r--r--components/layout/MobileMenu.tsx30
-rw-r--r--components/ship-vendor-document/add-attachment-dialog.tsx5
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx5
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에 따른 동적 스키마 생성