Compare commits

..

24 Commits

Author SHA1 Message Date
kjs 579461a6cb Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-18 10:31:04 +09:00
DDD1542 8630d82a69 Refactor RealtimePreviewDynamic and ResponsiveGridRenderer components to improve layout handling and responsiveness. Update SingleTableWithSticky and TableListComponent for better checkbox handling and styling consistency. Enhance overall user experience with refined component structures and styles. 2026-03-18 10:16:59 +09:00
DDD1542 b2a569f908 123 2026-03-18 00:05:40 +09:00
DDD1542 c63eaf8434 123123 2026-03-17 22:49:42 +09:00
DDD1542 ba8a2fec2b Refactor RealtimePreviewDynamic and ResponsiveGridRenderer components to enhance rendering logic using CSS scale for improved layout consistency. Update SplitPanelLayoutComponent to handle drag events more effectively by passing the event object. This ensures better user interaction during column dragging. 2026-03-17 22:24:47 +09:00
DDD1542 f36638e53e 22 2026-03-17 22:13:15 +09:00
DDD1542 12d4d2a8b1 11 2026-03-17 22:06:13 +09:00
DDD1542 13b2ebaf1f Refactor ColumnDetailPanel and AppLayout for improved loading state handling and UI consistency. Enhance TabBar and TableListComponent styles for better user experience. Update V2SplitPanelLayoutConfigPanel to manage button visibility based on configuration. Introduce filter chips in TableListComponent for better filter management. 2026-03-17 22:02:52 +09:00
DDD1542 b293d184bb 11 2026-03-17 21:50:37 +09:00
DDD1542 cfd7ee9fce [agent-pipeline] pipe-20260317084014-ydap round-3 2026-03-17 18:25:36 +09:00
DDD1542 ad48b22770 [agent-pipeline] rollback to d3acf391 2026-03-17 18:17:51 +09:00
DDD1542 9dc2959601 [agent-pipeline] pipe-20260317084014-ydap round-2 2026-03-17 18:17:51 +09:00
DDD1542 d3acf391a4 [agent-pipeline] pipe-20260317084014-ydap round-1 2026-03-17 18:05:10 +09:00
DDD1542 9409f1308f [agent-pipeline] pipe-20260317063830-0nfs round-3 2026-03-17 17:12:54 +09:00
DDD1542 265f46f8d4 [agent-pipeline] pipe-20260317063830-0nfs round-2 2026-03-17 16:47:12 +09:00
DDD1542 128872b766 [agent-pipeline] pipe-20260317063830-0nfs round-1 2026-03-17 16:20:24 +09:00
DDD1542 80cd95e683 [agent-pipeline] pipe-20260317054958-cypk round-6 2026-03-17 15:18:41 +09:00
DDD1542 d8a542b253 [agent-pipeline] pipe-20260317054958-cypk round-5 2026-03-17 15:13:42 +09:00
DDD1542 c55520f01c [agent-pipeline] rollback to 87a7431e 2026-03-17 15:09:53 +09:00
DDD1542 c3fae741ae [agent-pipeline] pipe-20260317054958-cypk round-4 2026-03-17 15:09:53 +09:00
DDD1542 cc51ad71da [agent-pipeline] rollback to 2b4500a9 2026-03-17 15:04:26 +09:00
DDD1542 87a7431e53 [agent-pipeline] pipe-20260317054958-cypk round-3 2026-03-17 15:04:26 +09:00
DDD1542 2b4500a999 [agent-pipeline] pipe-20260317054958-cypk round-2 2026-03-17 15:00:41 +09:00
DDD1542 4db5d73817 [agent-pipeline] pipe-20260317054958-cypk round-1 2026-03-17 14:54:45 +09:00
28 changed files with 3262 additions and 2664 deletions

View File

@ -217,10 +217,16 @@ export default function TableManagementPage() {
// 메모이제이션된 입력타입 옵션
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴)
// 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시)
const referenceTableOptions = [
{ value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") },
...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })),
...tables.map((table) => ({
value: table.tableName,
label:
table.displayName && table.displayName !== table.tableName
? `${table.displayName} (${table.tableName})`
: table.tableName,
})),
];
// 공통 코드 카테고리 목록 상태
@ -1610,6 +1616,8 @@ export default function TableManagementPage() {
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
}
tables={tables}
referenceTableColumns={referenceTableColumns}
/>
</>
)}
@ -1809,11 +1817,16 @@ export default function TableManagementPage() {
<p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => (
<Badge key={col} variant="secondary" className="font-mono text-xs">
{col}
</Badge>
))}
{pendingPkColumns.map((col) => {
const colInfo = columns.find((c) => c.columnName === col);
return (
<Badge key={col} variant="secondary" className="text-xs">
{colInfo?.displayName && colInfo.displayName !== col
? `${colInfo.displayName} (${col})`
: col}
</Badge>
);
})}
</div>
) : (
<p className="text-destructive mt-2 text-sm">PK가 </p>

View File

@ -174,6 +174,8 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
? "bg-accent/50 font-semibold"
: ""
}`}
role="button"
aria-label={`${company.company_name} ${company.company_code}`}
onClick={() => handleCompanySwitch(company.company_code)}
>
<div className="flex flex-col">

View File

@ -76,9 +76,34 @@ export function ColumnDetailPanel({
if (!column) return null;
const refTableOpts = referenceTableOptions.length
? referenceTableOptions
: [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))];
const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length
? [...referenceTableOptions]
: [
{ value: "none", label: "없음" },
...tables.map((t) => ({
value: t.tableName,
label:
t.displayName && t.displayName !== t.tableName
? `${t.displayName} (${t.tableName})`
: t.tableName,
})),
];
const noneOpt = raw.find((o) => o.value === "none");
const rest = raw.filter((o) => o.value !== "none");
rest.sort((a, b) => {
const aK = hasKorean(a.label);
const bK = hasKorean(b.label);
if (aK && !bK) return -1;
if (!aK && bK) return 1;
return a.label.localeCompare(b.label, "ko");
});
return noneOpt ? [noneOpt, ...rest] : rest;
}, [referenceTableOptions, tables]);
return (
<div className="flex h-full w-full flex-col border-l bg-card">
@ -90,7 +115,11 @@ export function ColumnDetailPanel({
{typeConf.label}
</span>
)}
<span className="truncate font-mono text-sm font-medium">{column.columnName}</span>
<span className="truncate text-sm font-medium">
{column.displayName && column.displayName !== column.columnName
? `${column.displayName} (${column.columnName})`
: column.columnName}
</span>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onClose} aria-label="닫기">
<X className="h-4 w-4" />
@ -170,23 +199,33 @@ export function ColumnDetailPanel({
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{refTableOpts.map((opt) => (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.value}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
/>
{opt.label}
</CommandItem>
))}
{refTableOpts.map((opt) => {
const hasKorean = opt.value !== "none" && opt.label !== opt.value && !opt.label.startsWith(opt.value);
return (
<CommandItem
key={opt.value}
value={`${opt.label} ${opt.value}`}
onSelect={() => {
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
setEntityTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
/>
{hasKorean ? (
<div className="flex flex-col">
<span className="font-medium">{opt.label.replace(` (${opt.value})`, "")}</span>
<span className="text-[10px] text-muted-foreground">{opt.value}</span>
</div>
) : (
opt.label
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
@ -207,7 +246,12 @@ export function ColumnDetailPanel({
className="h-9 w-full justify-between text-xs"
>
{column.referenceColumn && column.referenceColumn !== "none"
? column.referenceColumn
? (() => {
const matched = refColumns.find((c) => c.columnName === column.referenceColumn);
return matched?.displayName && matched.displayName !== column.referenceColumn
? `${matched.displayName} (${column.referenceColumn})`
: column.referenceColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 opacity-50" />
</Button>
@ -245,7 +289,14 @@ export function ColumnDetailPanel({
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)}
/>
{refCol.columnName}
{refCol.displayName && refCol.displayName !== refCol.columnName ? (
<div className="flex flex-col">
<span className="font-medium">{refCol.displayName}</span>
<span className="text-[10px] text-muted-foreground">{refCol.columnName}</span>
</div>
) : (
<span>{refCol.columnName}</span>
)}
</CommandItem>
))}
</CommandGroup>
@ -259,12 +310,20 @@ export function ColumnDetailPanel({
{/* 참조 요약 미니맵 */}
{column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
<div className="flex items-center gap-2 rounded-md bg-violet-50 px-3 py-2">
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceTable}
<span className="text-[11px] font-semibold text-violet-600">
{(() => {
const tbl = refTableOpts.find((o) => o.value === column.referenceTable);
return tbl?.label ?? column.referenceTable;
})()}
</span>
<span className="text-muted-foreground text-[10px]"></span>
<span className="font-mono text-[11px] font-semibold text-violet-600">
{column.referenceColumn}
<span className="text-[11px] font-semibold text-violet-600">
{(() => {
const col = refColumns.find((c) => c.columnName === column.referenceColumn);
return col?.displayName && col.displayName !== column.referenceColumn
? `${col.displayName} (${column.referenceColumn})`
: column.referenceColumn;
})()}
</span>
</div>
)}

View File

@ -5,8 +5,9 @@ import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types";
import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
export interface ColumnGridConstraints {
primaryKey: { columns: string[] };
@ -23,6 +24,9 @@ export interface ColumnGridProps {
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void;
/** 호버 시 한글 라벨 표시용 (Badge title) */
tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
}
function getIndexState(
@ -53,6 +57,8 @@ export function ColumnGrid({
getColumnIndexState: externalGetIndexState,
onPkToggle,
onIndexToggle,
tables,
referenceTableColumns,
}: ColumnGridProps) {
const getIdxState = useMemo(
() => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)),
@ -136,13 +142,12 @@ export function ColumnGrid({
{/* 4px 색상바 (타입별 진한 색) */}
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
{/* 라벨 + 컬럼명 */}
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{column.displayName || column.columnName}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{column.columnName}
{column.displayName && column.displayName !== column.columnName
? `${column.displayName} (${column.columnName})`
: column.columnName}
</div>
</div>
@ -150,11 +155,38 @@ export function ColumnGrid({
<div className="flex min-w-0 flex-wrap gap-1">
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && (
<>
<Badge variant="outline" className="text-xs font-normal">
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
const t = tables.find((tb) => tb.tableName === column.referenceTable);
return t?.displayName && t.displayName !== t.tableName
? `${t.displayName} (${column.referenceTable})`
: column.referenceTable;
})()
: column.referenceTable
}
>
{column.referenceTable}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge variant="outline" className="text-xs font-normal">
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[column.referenceTable]
? (() => {
const refCols = referenceTableColumns[column.referenceTable];
const c = refCols.find((rc) => rc.columnName === (column.referenceColumn ?? ""));
return c?.displayName && c.displayName !== c.columnName
? `${c.displayName} (${column.referenceColumn})`
: column.referenceColumn ?? "—";
})()
: column.referenceColumn ?? "—"
}
>
{column.referenceColumn || "—"}
</Badge>
</>

View File

@ -1,6 +1,6 @@
"use client";
import { useState, Suspense, useEffect } from "react";
import { useState, Suspense, useEffect, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@ -341,6 +341,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const currentMenus = isAdminMode ? adminMenus : userMenus;
const currentTabs = useTabStore((s) => s[s.mode].tabs);
const currentActiveTabId = useTabStore((s) => s[s.mode].activeTabId);
const activeTab = currentTabs.find((t) => t.id === currentActiveTabId);
const toggleMenu = (menuId: string) => {
const newExpanded = new Set(expandedMenus);
if (newExpanded.has(menuId)) {
@ -478,6 +482,26 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// pathname + 활성 탭 기반 활성 메뉴 판별 (탭 네비게이션에서도 사이드바 활성 표시)
const isMenuActive = useCallback(
(menu: any): boolean => {
if (pathname === menu.url) return true;
if (!activeTab) return false;
const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0");
if (activeTab.type === "admin" && activeTab.adminUrl) {
return menu.url === activeTab.adminUrl;
}
if (activeTab.type === "screen") {
if (activeTab.menuObjid != null && menuObjid === activeTab.menuObjid) return true;
if (activeTab.screenId != null && menu.screenId === activeTab.screenId) return true;
}
return false;
},
[pathname, activeTab],
);
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id);
@ -489,8 +513,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
draggable={isLeaf}
onDragStart={(e) => handleMenuDragStart(e, menu)}
className={`group flex min-h-[44px] cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 ease-in-out sm:min-h-[40px] ${
pathname === menu.url
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold"
isMenuActive(menu)
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: isExpanded
? "bg-accent/60 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
@ -518,8 +542,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)}
className={`flex min-h-[44px] cursor-pointer items-center rounded-md px-3 py-2 text-sm transition-colors duration-150 hover:cursor-pointer sm:min-h-[40px] ${
pathname === child.url
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold"
isMenuActive(child)
? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
onClick={() => handleMenuClick(child)}
@ -544,6 +568,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
// 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장
useEffect(() => {
if (!activeTab || uiMenus.length === 0) return;
const toExpand: string[] = [];
for (const menu of uiMenus) {
if (menu.hasChildren && menu.children) {
const hasActiveChild = menu.children.some((child: any) => isMenuActive(child));
if (hasActiveChild && !expandedMenus.has(menu.id)) {
toExpand.push(menu.id);
}
}
}
if (toExpand.length > 0) {
setExpandedMenus((prev) => {
const next = new Set(prev);
toExpand.forEach((id) => next.add(id));
return next;
});
}
}, [activeTab, uiMenus, isMenuActive, expandedMenus]);
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
@ -555,8 +603,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
return (
<div className="bg-background flex h-screen flex-col">
{/* 모바일 헤더 */}

View File

@ -493,8 +493,8 @@ export function TabBar() {
className={cn(
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
isActive
? "text-foreground z-10 -mb-px h-[30px] bg-white"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
? "text-primary z-10 -mb-px h-[30px] bg-primary/15 dark:bg-primary/20 border-primary/40 border-t-[3px] border-t-primary font-semibold"
: "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent",
)}
style={{
width: TAB_WIDTH,

View File

@ -478,7 +478,7 @@ const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
</PopoverContent>
</Popover>
{sourceTableName && columns.length === 0 && !loadingColumns && (
<p className="mt-1 text-[10px] text-amber-600 sm:text-xs">
<p className="mt-1 text-[10px] text-warning sm:text-xs">
</p>
)}

View File

@ -1,7 +1,6 @@
"use client";
import React from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -27,25 +26,24 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
tableName,
}) => {
return (
<Card className="border-border bg-card flex-1">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs sm:text-sm">
{part.order}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
className="text-destructive h-7 w-7 sm:h-8 sm:w-8"
disabled={isPreview}
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
</CardHeader>
<div className="config-field flex-1 rounded-[8px] border border-border bg-muted/50 px-3 py-3 sm:px-4 sm:py-4">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<Badge variant="outline" className="text-xs sm:text-sm">
{part.order}
</Badge>
<Button
variant="destructive"
size="icon"
onClick={onDelete}
className="h-7 w-7 sm:h-8 sm:w-8"
disabled={isPreview}
aria-label="규칙 삭제"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
<CardContent className="space-y-3 sm:space-y-4">
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
@ -117,7 +115,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
isPreview={isPreview}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -5,28 +5,16 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { Plus, Save, ListOrdered } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { cn } from "@/lib/utils";
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
}
interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig;
onSave?: (config: NumberingRuleConfig) => void;
@ -34,8 +22,8 @@ interface NumberingRuleDesignerProps {
maxRules?: number;
isPreview?: boolean;
className?: string;
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
currentTableName?: string;
menuObjid?: number;
}
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
@ -48,124 +36,84 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 좌측: 채번 타입 컬럼 목록 로드
const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
// 좌측: 규칙 목록 로드
useEffect(() => {
loadNumberingColumns();
loadRules();
}, []);
const loadNumberingColumns = async () => {
const loadRules = async () => {
setLoading(true);
try {
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
const response = await getNumberingRules();
if (response.success && response.data) {
setRulesList(response.data);
if (response.data.length > 0 && !selectedRuleId) {
const first = response.data[0];
setSelectedRuleId(first.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(first)));
}
}
} catch (error: any) {
console.error("채번 컬럼 목록 로드 실패:", error);
} catch (e) {
console.error("채번 규칙 목록 로드 실패:", e);
} finally {
setLoading(false);
}
};
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
const handleSelectColumn = async (tableName: string, columnName: string) => {
setSelectedColumn({ tableName, columnName });
setLoading(true);
try {
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (response.data.success && response.data.data) {
const rule = response.data.data as NumberingRuleConfig;
setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else {
// 규칙 없으면 신규 생성 모드
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
}
} catch {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
} finally {
setLoading(false);
}
const handleSelectRule = (rule: NumberingRuleConfig) => {
setSelectedRuleId(rule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(rule)));
setSelectedPartOrder(null);
};
// 테이블별로 그룹화
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
if (!acc[col.tableName]) {
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
}
acc[col.tableName].columns.push(col);
return acc;
}, {});
// 검색 필터 적용
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
if (!columnSearch) return true;
const search = columnSearch.toLowerCase();
return (
tableName.toLowerCase().includes(search) ||
group.tableLabel.toLowerCase().includes(search) ||
group.columns.some(
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
)
);
});
const handleAddNewRule = () => {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "global",
tableName: currentTableName ?? "",
columnName: "",
};
setRulesList((prev) => [...prev, newRule]);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(JSON.parse(JSON.stringify(newRule)));
setSelectedPartOrder(null);
toast.success("새 규칙이 추가되었습니다");
};
useEffect(() => {
if (currentRule) {
onChange?.(currentRule);
}
if (currentRule) onChange?.(currentRule);
}, [currentRule, onChange]);
// currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => {
if (currentRule && currentRule.parts.length > 0) {
const newSepTypes: Record<number, SeparatorType> = {};
const newCustomSeps: Record<number, string> = {};
currentRule.parts.forEach((part) => {
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
if (sep === "") {
newSepTypes[part.order] = "none";
newCustomSeps[part.order] = "";
} else {
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
const opt = SEPARATOR_OPTIONS.find(
(o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
);
if (predefinedOption) {
newSepTypes[part.order] = predefinedOption.value;
if (opt) {
newSepTypes[part.order] = opt.value;
newCustomSeps[part.order] = "";
} else {
newSepTypes[part.order] = "custom";
@ -173,54 +121,45 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}
});
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type }));
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
};
});
}
}, []);
// 개별 파트 직접 입력 구분자 변경 핸들러
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
const trimmedValue = value.slice(0, 2);
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
};
});
}, []);
const handleAddPart = useCallback(() => {
if (!currentRule) return;
if (currentRule.parts.length >= maxRules) {
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
return;
}
const newPart: NumberingRulePart = {
id: `part-${Date.now()}`,
order: currentRule.parts.length + 1,
@ -229,40 +168,33 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
autoConfig: { textValue: "CODE" },
separatorAfter: "-",
};
setCurrentRule((prev) => {
if (!prev) return null;
return { ...prev, parts: [...prev.parts, newPart] };
});
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]);
// partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) => (part.order === partOrder ? { ...part, ...updates } : part)),
parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, ...updates } : p)),
};
});
}, []);
// partOrder 기반으로 파트 삭제 (id가 null일 수 있으므로 order 사용)
const handleDeletePart = useCallback((partOrder: number) => {
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })),
parts: prev.parts
.filter((p) => p.order !== partOrder)
.map((p, i) => ({ ...p, order: i + 1 })),
};
});
setSelectedPartOrder(null);
toast.success("규칙이 삭제되었습니다");
}, []);
@ -271,246 +203,283 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
toast.error("저장할 규칙이 없습니다");
return;
}
if (currentRule.parts.length === 0) {
toast.error("최소 1개 이상의 규칙을 추가해주세요");
return;
}
setLoading(true);
try {
// 파트별 기본 autoConfig 정의
const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "" },
};
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
const partsWithDefaults = currentRule.parts.map((part) => {
if (part.generationMethod === "auto") {
const defaults = defaultAutoConfigs[part.partType] || {};
return {
...part,
autoConfig: { ...defaults, ...part.autoConfig },
};
return { ...part, autoConfig: { ...defaults, ...part.autoConfig } };
}
return part;
});
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
columnName: selectedColumn?.columnName || currentRule.columnName || "",
scopeType: "global" as const,
tableName: currentRule.tableName || currentTableName || "",
columnName: currentRule.columnName || "",
};
// 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
setCurrentRule(currentData);
const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
setCurrentRule(saved);
setRulesList((prev) => {
const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId);
if (idx >= 0) {
const next = [...prev];
next[idx] = saved;
return next;
}
return [...prev, saved];
});
setSelectedRuleId(saved.ruleId);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
}
} catch (error: any) {
showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} catch (error: unknown) {
showErrorToast("채번 규칙 저장에 실패했습니다", error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
} finally {
setLoading(false);
}
}, [currentRule, onSave, selectedColumn]);
}, [currentRule, onSave, currentTableName]);
const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null;
const globalSep = currentRule?.separator ?? "-";
const partItems = currentRule ? computePartDisplayItems(currentRule) : [];
return (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
<h2 className="text-sm font-semibold sm:text-base"> </h2>
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<div className="flex-1 space-y-1 overflow-y-auto">
{loading && numberingColumns.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p>
<div className={cn("flex h-full", className)}>
{/* 좌측: 규칙 리스트 (code-nav, 220px) */}
<div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
<div className="code-nav-head flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate text-xs font-bold"> ({rulesList.length})</span>
</div>
<Button
size="sm"
variant="default"
className="h-8 shrink-0 gap-1 text-xs font-medium"
onClick={handleAddNewRule}
disabled={isPreview || loading}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="code-nav-list flex-1 overflow-y-auto">
{loading && rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center text-xs text-muted-foreground">
...
</div>
) : filteredGroups.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
) : rulesList.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50 text-xs text-muted-foreground">
</div>
) : (
filteredGroups.map(([tableName, group]) => (
<div key={tableName} className="mb-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
<FolderTree className="h-3 w-3" />
<span>{group.tableLabel}</span>
<span className="text-muted-foreground/60">({group.columns.length})</span>
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<div
key={`${col.tableName}.${col.columnName}`}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
isSelected
? "bg-primary/10 text-primary border-primary border font-medium"
: "hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
>
{col.columnLabel}
</div>
);
})}
</div>
))
rulesList.map((rule) => {
const isSelected = selectedRuleId === rule.ruleId;
return (
<button
key={rule.ruleId}
type="button"
className={cn(
"code-nav-item flex w-full items-center gap-2 border-b border-border/50 px-3 py-2 text-left transition-colors",
isSelected
? "border-l-[3px] border-primary bg-primary/5 pl-2.5 font-bold"
: "hover:bg-accent"
)}
onClick={() => handleSelectRule(rule)}
>
<span className="rule-name min-w-0 flex-1 truncate text-xs font-semibold">
{rule.ruleName}
</span>
<span className="rule-table max-w-[70px] shrink-0 truncate text-[9px] text-muted-foreground">
{rule.tableName || "-"}
</span>
<span className="rule-parts shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[8px] font-bold text-muted-foreground">
{rule.parts?.length ?? 0}
</span>
</button>
);
})
)}
</div>
</div>
{/* 구분선 */}
<div className="bg-border h-full w-px"></div>
{/* 우측: 편집 영역 */}
<div className="flex flex-1 flex-col gap-4">
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="mb-2 text-lg font-medium text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground">
&quot;&quot;
</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
{editingRightTitle ? (
<Input
value={rightTitle}
onChange={(e) => setRightTitle(e.target.value)}
onBlur={() => setEditingRightTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingRightTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
<div className="flex flex-col gap-2 px-6 pt-4">
<Label className="text-xs font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
placeholder="예: 프로젝트 코드"
className="h-9 text-sm"
/>
</div>
<div className="space-y-3">
{/* 첫 번째 줄: 규칙명 + 미리보기 */}
<div className="flex items-center gap-3">
<div className="flex-1 space-y-2">
<Label className="text-sm font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))}
className="h-9"
placeholder="예: 프로젝트 코드"
/>
</div>
<div className="flex-1 space-y-2">
<Label className="text-sm font-medium"></Label>
<NumberingRulePreview config={currentRule} />
</div>
</div>
{/* 큰 미리보기 스트립 (code-preview-strip) */}
<div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
<NumberingRulePreview config={currentRule} variant="strip" />
</div>
<div className="flex-1 overflow-y-auto">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<span className="text-muted-foreground text-xs">
{/* 파이프라인 영역 (code-pipeline-area) */}
<div className="code-pipeline-area flex flex-col gap-3 border-b border-border px-6 py-5">
<div className="area-label flex items-center gap-1.5">
<span className="text-xs font-bold"> </span>
<span className="cnt text-xs font-medium text-muted-foreground">
{currentRule.parts.length}/{maxRules}
</span>
</div>
{currentRule.parts.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs sm:text-sm"> </p>
</div>
) : (
<div className="flex flex-wrap items-stretch gap-3">
{currentRule.parts.map((part, index) => (
<React.Fragment key={`part-${part.order}-${index}`}>
<div className="flex w-[200px] flex-col">
<NumberingRuleCard
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
tableName={selectedColumn?.tableName}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> </span>
<Select
value={separatorTypes[part.order] || "-"}
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
>
<SelectTrigger className="h-6 flex-1 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[part.order] === "custom" && (
<Input
value={customSeparators[part.order] || ""}
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
className="h-6 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
<div className="code-pipeline flex flex-1 flex-wrap items-center gap-0 overflow-x-auto overflow-y-hidden pb-2">
{currentRule.parts.length === 0 ? (
<div className="flex h-24 min-w-[200px] items-center justify-center rounded-xl border-2 border-dashed border-border bg-muted/30 text-xs text-muted-foreground">
</div>
) : (
<>
{currentRule.parts.map((part, index) => {
const item = partItems.find((i) => i.order === part.order);
const sep = part.separatorAfter ?? globalSep;
const isSelected = selectedPartOrder === part.order;
const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType;
return (
<React.Fragment key={`part-${part.order}-${index}`}>
<button
type="button"
className={cn(
"pipe-segment min-w-[120px] flex-shrink-0 rounded-[10px] border-2 px-4 py-3 text-center transition-all",
part.partType === "date" && "border-warning",
part.partType === "text" && "border-primary",
part.partType === "sequence" && "border-primary",
(part.partType === "number" || part.partType === "category" || part.partType === "reference") && "border-border",
isSelected && "border-primary bg-primary/5 shadow-md ring-2 ring-primary/30"
)}
</div>
)}
</div>
</React.Fragment>
))}
</div>
)}
onClick={() => setSelectedPartOrder(part.order)}
>
<div className="seg-type text-[8px] font-bold uppercase tracking-wide text-muted-foreground">
{typeLabel}
</div>
<div className={cn("seg-value mt-1 truncate font-mono text-base font-extrabold leading-none", getPartTypeColorClass(part.partType))}>
{item?.displayValue ?? "-"}
</div>
</button>
{index < currentRule.parts.length - 1 && (
<div className="pipe-connector flex w-8 flex-shrink-0 flex-col items-center justify-center gap-0.5">
<span className="conn-line text-xs font-bold text-muted-foreground"></span>
<span className="conn-sep rounded border border-border bg-muted px-1 py-0.5 text-[8px] font-semibold text-muted-foreground">
{sep || "-"}
</span>
</div>
)}
</React.Fragment>
);
})}
<button
type="button"
className="pipe-add flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:bg-primary/5 hover:text-primary"
onClick={handleAddPart}
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
aria-label="규칙 추가"
>
<Plus className="h-5 w-5" />
</button>
</>
)}
</div>
</div>
<div className="flex gap-2">
{/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */}
{selectedPart && (
<div className="code-config-panel min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="code-config-grid grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
<NumberingRuleCard
part={selectedPart}
onUpdate={(updates) => handleUpdatePart(selectedPart.order, updates)}
onDelete={() => handleDeletePart(selectedPart.order)}
isPreview={isPreview}
tableName={currentRule.tableName ?? currentTableName}
/>
</div>
{currentRule.parts.some((p) => p.order === selectedPart.order) && (
<div className="mt-3 flex items-center gap-2">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={separatorTypes[selectedPart.order] ?? "-"}
onValueChange={(v) => handlePartSeparatorChange(selectedPart.order, v as SeparatorType)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[selectedPart.order] === "custom" && (
<Input
value={customSeparators[selectedPart.order] ?? ""}
onChange={(e) => handlePartCustomSeparatorChange(selectedPart.order, e.target.value)}
className="h-7 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
)}
</div>
)}
</div>
)}
{/* 저장 바 (code-save-bar) */}
<div className="code-save-bar flex flex-shrink-0 items-center justify-between gap-4 border-t border-border bg-muted/30 px-6 py-4">
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
{currentRule.tableName && (
<span>: {currentRule.tableName}</span>
)}
{currentRule.columnName && (
<span className="ml-2">: {currentRule.columnName}</span>
)}
<span className="ml-2">: {globalSep || "-"}</span>
{currentRule.resetPeriod && currentRule.resetPeriod !== "none" && (
<span className="ml-2">: {currentRule.resetPeriod}</span>
)}
</div>
<Button
onClick={handleAddPart}
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
variant="outline"
className="h-9 flex-1 text-sm"
onClick={handleSave}
disabled={isPreview || loading}
className="h-9 gap-2 text-sm font-medium"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={isPreview || loading} className="h-9 flex-1 text-sm">
<Save className="mr-2 h-4 w-4" />
<Save className="h-4 w-4" />
{loading ? "저장 중..." : "저장"}
</Button>
</div>

View File

@ -1,88 +1,163 @@
"use client";
import React, { useMemo } from "react";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { cn } from "@/lib/utils";
import { NumberingRuleConfig, NumberingRulePart, CodePartType } from "@/types/numbering-rule";
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
/** 파트별 표시값 + 타입 (미리보기 스트립/세그먼트용) */
export interface PartDisplayItem {
partType: CodePartType;
displayValue: string;
order: number;
}
/** config에서 파트별 표시값 배열 계산 (정렬된 parts 기준) */
export function computePartDisplayItems(config: NumberingRuleConfig): PartDisplayItem[] {
if (!config.parts || config.parts.length === 0) return [];
const sorted = [...config.parts].sort((a, b) => a.order - b.order);
const globalSep = config.separator ?? "-";
return sorted.map((part) => ({
order: part.order,
partType: part.partType,
displayValue: getPartDisplayValue(part),
}));
}
function getPartDisplayValue(part: NumberingRulePart): string {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "XXX";
}
const c = part.autoConfig || {};
switch (part.partType) {
case "sequence":
return String(c.startFrom ?? 1).padStart(c.sequenceLength ?? 3, "0");
case "number":
return String(c.numberValue ?? 0).padStart(c.numberLength ?? 4, "0");
case "date": {
const format = c.dateFormat || "YYYYMMDD";
if (c.useColumnValue && c.sourceColumnName) {
return format === "YYYY" ? "[YYYY]" : format === "YY" ? "[YY]" : format === "YYYYMM" ? "[YYYYMM]" : format === "YYMM" ? "[YYMM]" : format === "YYMMDD" ? "[YYMMDD]" : "[DATE]";
}
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
if (format === "YYYY") return String(y);
if (format === "YY") return String(y).slice(-2);
if (format === "YYYYMM") return `${y}${m}`;
if (format === "YYMM") return `${String(y).slice(-2)}${m}`;
if (format === "YYYYMMDD") return `${y}${m}${d}`;
if (format === "YYMMDD") return `${String(y).slice(-2)}${m}${d}`;
return `${y}${m}${d}`;
}
case "text":
return c.textValue || "TEXT";
default:
return "XXX";
}
}
/** 파트 타입별 미리보기용 텍스트 색상 클래스 (CSS 변수 기반) */
export function getPartTypeColorClass(partType: CodePartType): string {
switch (partType) {
case "date":
return "text-warning";
case "text":
return "text-primary";
case "sequence":
return "text-primary";
case "number":
return "text-muted-foreground";
case "category":
case "reference":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
/** 파트 타입별 점(dot) 배경 색상 (범례용) */
export function getPartTypeDotClass(partType: CodePartType): string {
switch (partType) {
case "date":
return "bg-warning";
case "text":
case "sequence":
return "bg-primary";
case "number":
case "category":
case "reference":
return "bg-muted-foreground";
default:
return "bg-foreground";
}
}
interface NumberingRulePreviewProps {
config: NumberingRuleConfig;
compact?: boolean;
/** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */
variant?: "default" | "strip";
}
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
config,
compact = false
compact = false,
variant = "default",
}) => {
const partItems = useMemo(() => computePartDisplayItems(config), [config]);
const sortedParts = useMemo(
() => (config.parts ? [...config.parts].sort((a, b) => a.order - b.order) : []),
[config.parts]
);
const generatedCode = useMemo(() => {
if (!config.parts || config.parts.length === 0) {
return "규칙을 추가해주세요";
}
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
const partValues = sortedParts.map((part) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "XXX";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
return String(startFrom).padStart(length, "0");
}
case "number": {
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
return String(value).padStart(length, "0");
}
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
switch (format) {
case "YYYY": return "[YYYY]";
case "YY": return "[YY]";
case "YYYYMM": return "[YYYYMM]";
case "YYMM": return "[YYMM]";
case "YYYYMMDD": return "[YYYYMMDD]";
case "YYMMDD": return "[YYMMDD]";
default: return "[DATE]";
}
}
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
switch (format) {
case "YYYY": return String(year);
case "YY": return String(year).slice(-2);
case "YYYYMM": return `${year}${month}`;
case "YYMM": return `${String(year).slice(-2)}${month}`;
case "YYYYMMDD": return `${year}${month}${day}`;
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
default: return `${year}${month}${day}`;
}
}
case "text":
return autoConfig.textValue || "TEXT";
default:
return "XXX";
}
});
// 파트별 개별 구분자로 결합
if (partItems.length === 0) return "규칙을 추가해주세요";
const globalSep = config.separator ?? "-";
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? globalSep;
result += sep;
partItems.forEach((item, idx) => {
result += item.displayValue;
if (idx < partItems.length - 1) {
const part = sortedParts.find((p) => p.order === item.order);
result += part?.separatorAfter ?? globalSep;
}
});
return result;
}, [config]);
}, [config.separator, partItems, sortedParts]);
if (variant === "strip") {
const globalSep = config.separator ?? "-";
return (
<div className="rounded-lg border border-border bg-gradient-to-b from-muted to-card px-4 py-4 sm:px-6 sm:py-5">
<div className="font-mono text-[22px] font-extrabold tracking-tight sm:text-[28px]">
{partItems.length === 0 ? (
<span className="text-muted-foreground"> </span>
) : (
partItems.map((item, idx) => (
<React.Fragment key={item.order}>
<span className={getPartTypeColorClass(item.partType)}>{item.displayValue}</span>
{idx < partItems.length - 1 && (
<span className="text-muted-foreground">
{sortedParts.find((p) => p.order === item.order)?.separatorAfter ?? globalSep}
</span>
)}
</React.Fragment>
))
)}
</div>
{partItems.length > 0 && (
<div className="preview-desc mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{CODE_PART_TYPE_OPTIONS.filter((opt) => partItems.some((p) => p.partType === opt.value)).map((opt) => (
<span key={opt.value} className="flex items-center gap-1.5">
<span className={cn("h-[6px] w-[6px] shrink-0 rounded-full", getPartTypeDotClass(opt.value))} />
{opt.label}
</span>
))}
</div>
)}
</div>
);
}
if (compact) {
return (

View File

@ -362,15 +362,15 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// 런타임 모드에서 컴포넌트 타입별 높이 처리
if (!isDesignMode) {
const compType = (component as any).componentType || component.componentConfig?.type || "";
// 테이블: 부모 flex 컨테이너가 높이 관리 (flex: 1)
const flexGrowTypes = [
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
const fillParentTypes = [
"table-list", "v2-table-list",
"split-panel-layout", "split-panel-layout2",
"v2-split-panel-layout", "screen-split-panel",
"v2-tab-container", "tab-container",
"tabs-widget", "v2-tabs-widget",
];
if (flexGrowTypes.some(t => compType === t)) {
if (fillParentTypes.some(t => compType === t)) {
return "100%";
}
const autoHeightTypes = [

View File

@ -23,8 +23,9 @@ function getComponentTypeId(component: ComponentData): string {
}
/**
* .
* , .
* (%) .
* 가로: 컨테이너 %
* 세로: 컨테이너 %
*/
function ProportionalRenderer({
components,
@ -47,19 +48,12 @@ function ProportionalRenderer({
}, []);
const topLevel = components.filter((c) => !c.parentId);
const ratio = containerW > 0 ? containerW / canvasWidth : 1;
const maxBottom = topLevel.reduce((max, c) => {
const bottom = c.position.y + (c.size?.height || 40);
return Math.max(max, bottom);
}, 0);
return (
<div
ref={containerRef}
data-screen-runtime="true"
className="bg-background relative w-full overflow-x-hidden"
style={{ minHeight: containerW > 0 ? `${maxBottom * ratio}px` : "200px" }}
className="bg-background relative h-full w-full overflow-hidden"
>
{containerW > 0 &&
topLevel.map((component) => {
@ -72,9 +66,9 @@ function ProportionalRenderer({
style={{
position: "absolute",
left: `${(component.position.x / canvasWidth) * 100}%`,
top: `${component.position.y * ratio}px`,
top: `${(component.position.y / canvasHeight) * 100}%`,
width: `${((component.size?.width || 100) / canvasWidth) * 100}%`,
height: `${(component.size?.height || 40) * ratio}px`,
height: `${((component.size?.height || 40) / canvasHeight) * 100}%`,
zIndex: component.position.z || 1,
}}
>

View File

@ -17,7 +17,6 @@ import {
GroupComponent,
DataTableComponent,
TableInfo,
LayoutComponent,
FileComponent,
AreaComponent,
} from "@/types/screen";
@ -47,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer";
import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import { Slider } from "@/components/ui/slider";
@ -98,6 +97,24 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
// 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더
const [columnMetaVersion, setColumnMetaVersion] = useState(0);
useEffect(() => {
if (!selectedComponent) return;
const tblName =
(selectedComponent as any).tableName ||
currentTable?.tableName ||
tables?.[0]?.tableName;
if (!tblName) return;
if (columnMetaCache[tblName]) return;
loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1));
}, [
selectedComponent?.id,
(selectedComponent as any)?.tableName,
currentTable?.tableName,
tables?.[0]?.tableName,
]);
// 🆕 전체 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
@ -211,20 +228,20 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 현재 화면의 테이블명 가져오기
const currentTableName = tables?.[0]?.tableName;
// DB input_type 가져오기 (columnMetaCache에서 최신값 조회)
const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
// DB input_type만 조회 (saved config와 분리하여 전달)
const colName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName;
const tblName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined;
const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 컴포넌트별 추가 props
const extraProps: Record<string, any> = {};
const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
const resolvedColumnName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName;
if (componentId === "v2-input" || componentId === "v2-select") {
extraProps.inputType = inputType;
extraProps.componentType = componentId;
extraProps.inputType = dbInputType;
extraProps.tableName = resolvedTableName;
extraProps.columnName = resolvedColumnName;
extraProps.screenTableName = resolvedTableName;
@ -256,7 +273,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const currentConfig = selectedComponent.componentConfig || {};
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const config = currentConfig || (definition as any).defaultProps?.componentConfig || {};
const handlePanelConfigChange = (newConfig: any) => {
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
@ -282,14 +299,14 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
onConfigChange={handlePanelConfigChange}
tables={tables}
allTables={allTables}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
columnName={
(selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName
}
inputType={(selectedComponent as any).inputType || currentConfig?.inputType}
componentType={componentType}
tableColumns={currentTable?.columns || []}
tableColumns={(currentTable as any)?.columns || []}
allComponents={allComponents}
currentComponent={selectedComponent}
menuObjid={menuObjid}
@ -323,8 +340,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
componentType={componentType}
config={selectedComponent.componentConfig || {}}
onChange={handleDynamicConfigChange}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
tableColumns={(currentTable as any)?.columns || []}
tables={tables}
menuObjid={menuObjid}
allComponents={allComponents}
@ -491,7 +508,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={group.title || area.title || ""}
value={(group as any).title || (area as any).title || ""}
onChange={(e) => handleUpdate("title", e.target.value)}
placeholder="제목"
className="h-7 text-xs"
@ -503,7 +520,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<span className="text-muted-foreground text-xs"></span>
<div className="w-[160px]">
<Input
value={area.description || ""}
value={(area as any).description || ""}
onChange={(e) => handleUpdate("description", e.target.value)}
placeholder="설명"
className="h-7 text-xs"
@ -519,9 +536,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<h4 className="text-muted-foreground py-2 text-[10px] font-semibold tracking-wider uppercase">OPTIONS</h4>
{(isInputField || widget.required !== undefined) &&
(() => {
const colName = widget.columnName || selectedComponent?.columnName;
const colName = widget.columnName || (selectedComponent as any)?.columnName;
const colMeta = colName
? currentTable?.columns?.find(
? (currentTable as any)?.columns?.find(
(c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(),
)
: null;
@ -568,7 +585,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
checked={(selectedComponent as any).hidden === true || selectedComponent.componentConfig?.hidden === true}
onCheckedChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
@ -689,7 +706,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"></span>
<Checkbox
checked={selectedComponent.style?.labelDisplay === true || selectedComponent.labelDisplay === true}
checked={selectedComponent.style?.labelDisplay === true || (selectedComponent as any).labelDisplay === true}
onCheckedChange={(checked) => {
const boolValue = checked === true;
handleUpdate("style.labelDisplay", boolValue);
@ -785,7 +802,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const webType = selectedComponent.componentConfig?.webType;
// 테이블 패널에서 드래그한 컴포넌트인지 확인
const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName);
const isFromTablePanel = !!((selectedComponent as any).tableName && (selectedComponent as any).columnName);
if (!componentId) {
return (
@ -845,8 +862,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<DynamicComponentConfigPanel
componentId={componentId}
config={selectedComponent.componentConfig || {}}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
tableColumns={(currentTable as any)?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
@ -1006,8 +1023,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
<DynamicComponentConfigPanel
componentId={widget.widgetType}
config={widget.componentConfig || {}}
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
screenTableName={(widget as any).tableName || currentTable?.tableName || currentTableName}
tableColumns={(currentTable as any)?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
@ -1023,17 +1040,17 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
return (
<div className="space-y-4">
{/* WebType 선택 (있는 경우만) */}
{widget.webType && (
{(widget as any).webType && (
<div>
<Label> </Label>
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
<Select value={(widget as any).webType} onValueChange={(value) => handleUpdate("webType", value)}>
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypes.map((wt) => (
<SelectItem key={wt.web_type} value={wt.web_type}>
{wt.web_type_name_kor || wt.web_type}
{(wt as any).web_type_name_kor || wt.web_type}
</SelectItem>
))}
</SelectContent>

View File

@ -5,10 +5,11 @@ import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface CategoryColumn {
export interface CategoryColumn {
tableName: string;
tableLabel?: string; // 테이블 라벨 추가
tableLabel?: string;
columnName: string;
columnLabel: string;
inputType: string;
@ -16,17 +17,30 @@ interface CategoryColumn {
}
interface CategoryColumnListProps {
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시)
tableName: string;
selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void;
menuObjid?: number; // 현재 메뉴 OBJID (필수)
onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void;
menuObjid?: number;
/** 대시보드 모드: 테이블 단위 네비만 표시, 선택 시 onTableSelect 호출 */
selectedTable?: string | null;
onTableSelect?: (tableName: string) => void;
/** 컬럼 로드 완료 시 부모에 전달 (Stat Strip 등 계산용) */
onColumnsLoaded?: (columns: CategoryColumn[]) => void;
}
/**
* ( )
* - ( )
*/
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) {
export function CategoryColumnList({
tableName,
selectedColumn,
onColumnSelect,
menuObjid,
selectedTable = null,
onTableSelect,
onColumnsLoaded,
}: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@ -151,8 +165,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
);
setColumns(columnsWithCount);
onColumnsLoaded?.(columnsWithCount);
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
@ -160,6 +174,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
} catch (error) {
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
setColumns([]);
onColumnsLoaded?.([]);
} finally {
setIsLoading(false);
}
@ -248,21 +263,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
}
setColumns(columnsWithCount);
onColumnsLoaded?.(columnsWithCount);
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
}
} catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error);
// 에러 시에도 tableName 기반으로 fallback
if (tableName) {
console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName);
await loadCategoryColumnsByTable();
return;
} else {
setColumns([]);
onColumnsLoaded?.([]);
}
}
setIsLoading(false);
@ -291,6 +305,72 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
);
}
// 대시보드 모드: 테이블 단위 네비만 표시
if (onTableSelect != null) {
return (
<div className="flex h-full flex-col">
<div className="border-b p-2.5">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="테이블 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="flex-1 space-y-0 overflow-y-auto">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{groupedColumns.map((group) => {
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
const isActive = selectedTable === group.tableName;
return (
<button
key={group.tableName}
type="button"
onClick={() => onTableSelect(group.tableName)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors",
isActive
? "border-l-[3px] border-primary bg-primary/5 font-bold text-primary"
: "hover:bg-muted/50",
)}
>
<div
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[5px] bg-primary/20 text-primary"
aria-hidden
>
<FolderTree className="h-3.5 w-3.5" />
</div>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{group.tableLabel || group.tableName}
</span>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-bold">
{group.columns.length}
</span>
</button>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="space-y-1">
@ -298,7 +378,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
<p className="text-muted-foreground text-xs"> </p>
</div>
{/* 검색 입력 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
@ -310,6 +389,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2"
>

View File

@ -120,7 +120,7 @@ export const CategoryValueAddDialog: React.FC<
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"

View File

@ -86,7 +86,7 @@ export const CategoryValueEditDialog: React.FC<
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="valueLabel"

View File

@ -30,6 +30,8 @@ interface CategoryValueManagerProps {
columnLabel: string;
onValueCountChange?: (count: number) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
headerRight?: React.ReactNode;
}
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
@ -38,6 +40,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
columnLabel,
onValueCountChange,
menuObjid,
headerRight,
}) => {
const { toast } = useToast();
const [values, setValues] = useState<TableCategoryValue[]>([]);
@ -284,7 +287,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
{/* 편집기 헤더: 컬럼명 + 값 수 + 비활성 토글 + 새 값 추가 + headerRight(트리·목록 세그먼트 등) */}
<div className="border-b p-4">
<div className="mb-4 flex items-center justify-between">
<div>
@ -308,11 +311,11 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
</label>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
</div>
</div>
@ -405,7 +408,6 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
value.isActive !== false
)
}
className="data-[state=checked]:bg-emerald-500"
/>
<Button

View File

@ -19,6 +19,7 @@ import {
Search,
RefreshCw,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
@ -59,6 +60,8 @@ interface CategoryValueManagerTreeProps {
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
/** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */
headerRight?: React.ReactNode;
}
// 트리 노드 컴포넌트
@ -114,13 +117,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return null;
}
// 깊이별 아이콘
// 깊이별 아이콘 (대/중분류 = Folder, 소분류 = Tag)
const getIcon = () => {
if (hasChildren) {
return isExpanded ? (
<FolderOpen className="h-4 w-4 text-amber-500" />
<FolderOpen className="text-muted-foreground h-4 w-4" />
) : (
<Folder className="h-4 w-4 text-amber-500" />
<Folder className="text-muted-foreground h-4 w-4" />
);
}
return <Tag className="h-4 w-4 text-primary" />;
@ -141,31 +144,28 @@ const TreeNode: React.FC<TreeNodeProps> = ({
};
return (
<div>
<div className="mb-px">
<div
className={cn(
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
"group flex cursor-pointer items-center gap-[5px] rounded-[6px] px-[8px] py-[5px] transition-colors",
isSelected ? "border-primary border-l-2 bg-primary/10" : "hover:bg-muted/50",
isChecked && "bg-primary/5",
"cursor-pointer",
)}
style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => onSelect(node)}
>
{/* 체크박스 */}
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
onCheck(node.valueId, checked as boolean);
}}
onClick={(e) => e.stopPropagation()}
className="mr-1"
className="mr-1 shrink-0"
/>
{/* 확장 토글 */}
<button
type="button"
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
if (hasChildren) {
@ -184,22 +184,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
)}
</button>
{/* 아이콘 */}
{getIcon()}
{/* 라벨 */}
<div className="flex flex-1 items-center gap-2">
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
<div className="flex min-w-0 flex-1 items-center gap-[5px]">
<span className={cn("truncate text-sm", node.depth === 1 && "font-medium")}>
{node.valueLabel}
</span>
<span className="bg-muted text-muted-foreground shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
{getDepthLabel()}
</span>
</div>
{/* 비활성 표시 */}
{!node.isActive && (
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-[10px]"></span>
<span className="bg-destructive/5 text-destructive shrink-0 rounded-[4px] px-1.5 py-0.5 text-[8px] font-bold">
</span>
)}
{/* 액션 버튼 */}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{canAddChild && (
<Button
variant="ghost"
@ -272,6 +274,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
columnName,
columnLabel,
onValueCountChange,
headerRight,
}) => {
// 상태
const [tree, setTree] = useState<CategoryValue[]>([]);
@ -634,10 +637,13 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
{/* 편집기 헤더: 컬럼명 + 값 수 Badge + 비활성/전체펼침/대분류추가 + headerRight(트리·목록 세그먼트 등) */}
<div className="mb-3 flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{columnLabel} </h3>
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-bold">
{countAllValues(tree)}
</Badge>
{checkedIds.size > 0 && (
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
{checkedIds.size}
@ -665,6 +671,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<Plus className="h-3.5 w-3.5" />
</Button>
{headerRight != null ? <div className="flex items-center">{headerRight}</div> : null}
</div>
</div>
@ -720,7 +727,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
) : (
<div className="p-2">
<div className="py-1">
{tree.map((node) => (
<TreeNode
key={node.valueId}

View File

@ -26,7 +26,7 @@ function TabsList({
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted/30 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
"bg-muted/50 text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg border border-border/50 p-1",
className
)}
{...props}
@ -42,7 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"data-[state=active]:bg-background data-[state=active]:font-semibold dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@ -77,9 +77,9 @@ interface CategoryValueOption {
valueLabel: string;
}
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ───
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType {
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
// (a) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달)
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
const dbType = metaInputType as FieldType;
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
@ -87,9 +87,10 @@ function resolveFieldType(config: Record<string, any>, componentType?: string, m
}
}
// (b) 사용자가 설정 패널에서 직접 선택한 fieldType
if (config.fieldType) return config.fieldType as FieldType;
// v2-select 계열
// (c) v2-select 계열: componentType 또는 config.source 기반
if (componentType === "v2-select" || config.source) {
const source = config.source === "code" ? "category" : config.source;
if (source === "entity") return "entity";
@ -97,11 +98,13 @@ function resolveFieldType(config: Record<string, any>, componentType?: string, m
return "select";
}
// v2-input 계열
// (d) saved config fallback (config.inputType / config.type)
const it = config.inputType || config.type;
if (it === "number") return "number";
if (it === "textarea") return "textarea";
if (it === "numbering") return "numbering";
// (e) 최종 기본값
return "text";
}

View File

@ -2,12 +2,13 @@
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { LoginFormData, LoginResponse } from "@/types/auth";
import { LoginFormData } from "@/types/auth";
import { AUTH_CONFIG, FORM_VALIDATION } from "@/constants/auth";
import { API_BASE_URL } from "@/lib/api/client";
import { apiCall } from "@/lib/api/client";
/**
*
* API lib/api/client의 apiCall(Axios) (fetch )
*/
export const useLogin = () => {
const router = useRouter();
@ -73,67 +74,34 @@ export const useLogin = () => {
}, [formData]);
/**
* API
*/
const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise<LoginResponse> => {
// 로컬 스토리지에서 토큰 가져오기
const token = localStorage.getItem("authToken");
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
...options,
});
const result = await response.json();
return result;
}, []);
/**
*
* (apiCall )
*/
const checkExistingAuth = useCallback(async () => {
try {
// 로컬 스토리지에서 토큰 확인
const token = localStorage.getItem("authToken");
if (!token) {
// 토큰이 없으면 로그인 페이지 유지
return;
}
if (!token) return;
// 토큰이 있으면 API 호출로 유효성 확인
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS);
const result = await apiCall<{ isAuthenticated?: boolean }>("GET", AUTH_CONFIG.ENDPOINTS.STATUS);
// 백엔드가 isAuthenticated 필드를 반환함
if (result.success && result.data?.isAuthenticated) {
// 이미 로그인된 경우 메인으로 리다이렉트
router.push(AUTH_CONFIG.ROUTES.MAIN);
} else {
// 토큰이 유효하지 않으면 제거
localStorage.removeItem("authToken");
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
}
} catch (error) {
// 에러가 발생하면 토큰 제거
} catch {
localStorage.removeItem("authToken");
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
console.debug("기존 인증 체크 중 오류 (정상):", error);
}
}, [apiCall, router]);
}, [router]);
/**
*
* (apiCall - Axios , fetch )
*/
const handleLogin = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
// 입력값 검증
const validationError = validateForm();
if (validationError) {
setError(validationError);
@ -144,9 +112,13 @@ export const useLogin = () => {
setError("");
try {
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.LOGIN, {
method: "POST",
body: JSON.stringify(formData),
const result = await apiCall<{
token?: string;
firstMenuPath?: string;
popLandingPath?: string;
}>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, {
userId: formData.userId,
password: formData.password,
});
if (result.success && result.data?.token) {
@ -185,7 +157,7 @@ export const useLogin = () => {
setIsLoading(false);
}
},
[formData, validateForm, apiCall, router, isPopMode],
[formData, validateForm, router, isPopMode],
);
// 컴포넌트 마운트 시 기존 인증 상태 확인

View File

@ -30,18 +30,20 @@ export function invalidateColumnMetaCache(tableName?: string): void {
}
}
async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
export async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
const now = Date.now();
const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS);
const cachedAt = columnMetaTimestamp[tableName];
const isStale =
typeof cachedAt === "number" && now - cachedAt > CACHE_TTL_MS;
if (!forceReload && !isStale && columnMetaCache[tableName]) return;
if (!forceReload && !isStale && tableName in columnMetaCache && columnMetaCache[tableName]) return;
if (forceReload || isStale) {
delete columnMetaCache[tableName];
delete columnMetaLoading[tableName];
}
if (columnMetaLoading[tableName]) {
if (tableName in columnMetaLoading) {
await columnMetaLoading[tableName];
return;
}
@ -663,7 +665,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
// 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType);
const newComponent =
componentType != null ? ComponentRegistry.getComponent(componentType) : null;
if (newComponent) {
// 새 컴포넌트 시스템으로 렌더링
@ -775,7 +778,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 렌더러 props 구성
// 숨김 값 추출
const hiddenValue = component.hidden || component.componentConfig?.hidden;
const hiddenValue = (component as any).hidden || component.componentConfig?.hidden;
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
if (hiddenValue && isInteractive) {
@ -892,7 +895,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 새로운 기능들 전달
// 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
autoGeneration:
component.autoGeneration ||
(component as any).autoGeneration ||
component.componentConfig?.autoGeneration ||
((component as any).webTypeConfig?.numberingRuleId
? {
@ -992,7 +995,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
let renderedElement: React.ReactElement;
if (isClass) {
const rendererInstance = new NewComponentRenderer(rendererProps);
const RendererClass = NewComponentRenderer as new (props: any) => { render: () => React.ReactElement };
const rendererInstance = new RendererClass(rendererProps);
renderedElement = rendererInstance.render();
} else {
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
@ -1004,7 +1008,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = getAdaptiveLabelColor(component.style?.labelColor);
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName);
const isRequired =
effectiveComponent.required ||
isColumnRequiredByMeta(screenTableName ?? "", baseColumnName ?? "");
const isLeft = labelPosition === "left";
return (
@ -1038,7 +1044,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
// 2. 레거시 시스템에서 조회
const renderer = legacyComponentRegistry.get(componentType);
const renderer =
componentType != null ? legacyComponentRegistry.get(componentType) : undefined;
if (!renderer) {
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {

View File

@ -2,18 +2,19 @@
/**
* V2
* -
* - 레이아웃: Stat Strip + nav + + /
* - 3 (//)
*/
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import type { CategoryColumn } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
import { LayoutList, TreeDeciduous } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
interface V2CategoryManagerComponentProps {
@ -33,80 +34,87 @@ export function V2CategoryManagerComponent({
componentConfig,
...props
}: V2CategoryManagerComponentProps) {
// 설정 병합 (componentConfig도 포함)
const config: V2CategoryManagerConfig = {
...defaultV2CategoryManagerConfig,
...externalConfig,
...componentConfig,
};
// tableName 우선순위: props > selectedScreen > componentConfig
const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
// menuObjid 우선순위: props > selectedScreen
const effectiveTableName =
tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
// 디버그 로그
useEffect(() => {
console.log("🔍 V2CategoryManagerComponent props:", {
tableName,
menuObjid,
selectedScreen,
effectiveTableName,
effectiveMenuObjid,
config,
});
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
// 선택된 컬럼 상태
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [selectedColumn, setSelectedColumn] = useState<{
uniqueKey: string;
columnName: string;
columnLabel: string;
tableName: string;
} | null>(null);
// 뷰 모드 상태
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
// 컬럼 선택 핸들러
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
const columnName = uniqueKey.split(".")[1];
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
const handleColumnsLoaded = useCallback((loaded: CategoryColumn[]) => {
setColumns(loaded);
if (loaded.length > 0) {
setSelectedTable((prev) => prev ?? loaded[0].tableName);
}
}, []);
// 우측 패널 콘텐츠
const handleTableSelect = useCallback((tableName: string) => {
setSelectedTable(tableName);
setSelectedColumn(null);
}, []);
const handleColumnSelect = useCallback(
(uniqueKey: string, columnLabel: string, colTableName: string) => {
const columnName = uniqueKey.includes(".") ? uniqueKey.split(".")[1] : uniqueKey;
setSelectedColumn({ uniqueKey: uniqueKey.includes(".") ? uniqueKey : `${colTableName}.${uniqueKey}`, columnName, columnLabel, tableName: colTableName });
},
[],
);
const stats = useMemo(() => {
const columnCount = columns.length;
const totalValues = columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
const tableCount = new Set(columns.map((c) => c.tableName)).size;
const inactiveCount = 0;
return { columnCount, totalValues, tableCount, inactiveCount };
}, [columns]);
const columnsForSelectedTable = useMemo(
() => (selectedTable ? columns.filter((c) => c.tableName === selectedTable) : []),
[columns, selectedTable],
);
/** 편집기 헤더에 표시할 트리/목록 세그먼트 (보기 방식 토글) */
const viewModeSegment =
config.showViewModeToggle ? (
<div className="flex rounded-md border p-0.5">
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
onClick={() => setViewMode("tree")}
>
<TreeDeciduous className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
onClick={() => setViewMode("list")}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
) : null;
const rightContent = (
<>
{/* 뷰 모드 토글 */}
{config.showViewModeToggle && (
<div className="mb-2 flex items-center justify-end gap-1">
<span className="text-muted-foreground mr-2 text-xs"> :</span>
<div className="flex rounded-md border p-0.5">
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
onClick={() => setViewMode("tree")}
>
<TreeDeciduous className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
onClick={() => setViewMode("list")}
>
<LayoutList className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* 카테고리 값 관리 */}
<div className="min-h-0 flex-1 overflow-y-auto">
{selectedColumn ? (
viewMode === "tree" ? (
@ -115,6 +123,7 @@ export function V2CategoryManagerComponent({
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
headerRight={viewModeSegment}
/>
) : (
<CategoryValueManager
@ -123,6 +132,7 @@ export function V2CategoryManagerComponent({
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
headerRight={viewModeSegment}
/>
)
) : (
@ -130,7 +140,9 @@ export function V2CategoryManagerComponent({
<div className="flex flex-col items-center gap-2 text-center">
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
<p className="text-muted-foreground text-sm">
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
{config.showColumnList
? "칩에서 카테고리 컬럼을 선택하세요"
: "카테고리 컬럼이 설정되지 않았습니다"}
</p>
</div>
</div>
@ -148,24 +160,107 @@ export function V2CategoryManagerComponent({
}
return (
<ResponsiveSplitPanel
left={
<CategoryColumnList
tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
/>
}
right={rightContent}
leftTitle="카테고리 컬럼"
leftWidth={config.leftPanelWidth}
minLeftWidth={10}
maxLeftWidth={40}
height={config.height}
/>
<div
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm"
style={{ height: config.height }}
>
{/* Stat Strip: 카테고리 컬럼(primary) | 전체 값(success) | 테이블(primary) | 비활성(warning) */}
<div className="grid grid-cols-4 border-b bg-background">
<div className="border-r py-3.5 text-center last:border-r-0">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
{stats.columnCount}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
<div className="border-r py-3.5 text-center last:border-r-0">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-success">
{stats.totalValues}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
<div className="border-r py-3.5 text-center last:border-r-0">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-primary">
{stats.tableCount}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
<div className="py-3.5 text-center">
<div className="text-[22px] font-extrabold leading-none tracking-tight text-warning">
{stats.inactiveCount}
</div>
<div className="mt-1 text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
</div>
</div>
</div>
<div className="flex min-h-0 flex-1">
{/* 좌측 테이블 nav: 240px */}
<div className="flex w-[240px] shrink-0 flex-col border-r">
<CategoryColumnList
tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey ?? null}
onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid}
selectedTable={selectedTable}
onTableSelect={setSelectedTable}
onColumnsLoaded={handleColumnsLoaded}
/>
</div>
{/* 우측: 칩 바 + 편집기 */}
<div className="flex min-w-0 flex-1 flex-col">
{/* 칩 바 */}
<div className="flex flex-wrap gap-1.5 border-b bg-background px-4 py-3">
{columnsForSelectedTable.map((col) => {
const uniqueKey = `${col.tableName}.${col.columnName}`;
const isActive = selectedColumn?.uniqueKey === uniqueKey;
return (
<button
key={uniqueKey}
type="button"
onClick={() =>
handleColumnSelect(uniqueKey, col.columnLabel || col.columnName, col.tableName)
}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-[10px] py-[5px] text-[11px] font-semibold transition-colors",
isActive
? "border-primary bg-primary/5 text-primary"
: "border-border bg-muted/50 hover:border-primary hover:bg-primary/5 hover:text-primary",
)}
>
<span>{col.columnLabel || col.columnName}</span>
<Badge
variant="secondary"
className={cn(
"h-4 rounded-full px-1.5 text-[9px] font-bold",
isActive ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
)}
>
{col.valueCount ?? 0}
</Badge>
</button>
);
})}
{selectedTable && columnsForSelectedTable.length === 0 && (
<span className="text-muted-foreground text-xs"> </span>
)}
</div>
{/* 편집기 영역 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
{rightContent}
</div>
</div>
</div>
</div>
);
}
export default V2CategoryManagerComponent;

View File

@ -35,7 +35,11 @@ interface SingleTableWithStickyProps {
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement>;
onEditSave?: () => void;
editInputRef?: React.RefObject<HTMLInputElement | HTMLSelectElement>;
// 인라인 편집 타입별 옵션 (select/category/code, number, date 지원)
columnMeta?: Record<string, { inputType?: string }>;
categoryMappings?: Record<string, Record<string, { label: string }>>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
editingValue,
onEditingValueChange,
onEditKeyDown,
onEditSave,
editInputRef,
columnMeta,
categoryMappings,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
@ -102,9 +109,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
}}
>
<TableHeader
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
className={cn("border-b border-border/60", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
style={{ backgroundColor: "hsl(var(--muted) / 0.4)" }}
>
<TableRow className="border-b">
<TableRow className="border-b border-border/60">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
@ -125,10 +133,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm",
? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
column.sortable && "hover:bg-muted/50",
// 고정 컬럼 스타일
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
@ -136,28 +144,33 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
whiteSpace: "nowrap",
backgroundColor: "hsl(var(--muted) / 0.4)",
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
<div className={cn("flex items-center", column.columnName === "__checkbox__" ? "justify-center" : "gap-2")}>
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
style={{
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)",
zIndex: 1,
}}
/>
)
) : (
@ -221,8 +234,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableRow
key={`row-${index}`}
className={cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
"cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)}
onClick={(e) => handleRowClick?.(row, index, e)}
>
@ -266,9 +280,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
highlightArray[currentSearchIndex] === cellKey;
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
const rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
// 이미지 등 JSX 반환 여부 확인
const formattedValue = formatCellValue(row[column.columnName], column.format, column.columnName, row);
const rawCellValue = (formattedValue === null || formattedValue === undefined || formattedValue === "")
? <span className="text-muted-foreground/50">-</span>
: formattedValue;
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
// 셀 값에서 검색어 하이라이트 렌더링
@ -317,26 +332,22 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm",
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
"text-foreground h-10 align-middle text-[11px] transition-colors",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
!isReactElement && "whitespace-nowrap",
`text-${column.align}`,
// 고정 컬럼 스타일
column.columnName !== "__checkbox__" && `text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
boxSizing: "border-box",
// 이미지 셀은 overflow 허용
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
@ -350,15 +361,20 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
{column.columnName === "__checkbox__" ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
// 인라인 편집: inputType에 따라 select(category/code), number, date, text
(() => {
const meta = columnMeta?.[column.columnName];
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
const isCategoryType = inputType === "category" || inputType === "code";
const categoryOptions = categoryMappings?.[column.columnName];
const hasCategoryOptions =
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
// 인라인 편집: 행 높이 유지를 위해 select/input 모두 h-8(32px) 고정
const commonInputClass =
"border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm";
const handleBlurSave = () => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
@ -366,10 +382,79 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
onEditSave?.();
};
// category/code 타입: select는 반드시 h-8(32px)로 행 높이 유지
if (hasCategoryOptions) {
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
value,
label: info.label,
}));
return (
<select
ref={editInputRef as React.RefObject<HTMLSelectElement>}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={cn(commonInputClass, "h-8")}
onClick={(e) => e.stopPropagation()}
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
if (inputType === "date" || inputType === "datetime") {
try {
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue ?? ""}
onChange={(v) => onEditingValueChange?.(v)}
onSave={() => {
handleBlurSave();
}}
onKeyDown={onEditKeyDown}
inputRef={editInputRef as React.RefObject<HTMLInputElement>}
/>
);
} catch {
return (
<input
ref={editInputRef as React.RefObject<HTMLInputElement>}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
onClick={(e) => e.stopPropagation()}
/>
);
}
}
return (
<input
ref={editInputRef as React.RefObject<HTMLInputElement>}
type={isNumeric ? "number" : "text"}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
style={isNumeric ? { textAlign: "right" } : undefined}
onClick={(e) => e.stopPropagation()}
/>
);
})()
) : (
renderCellContent()
)}

View File

@ -368,6 +368,7 @@ import {
CheckSquare,
Trash2,
Lock,
GripVertical,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
@ -1067,10 +1068,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
const [showGridLines, setShowGridLines] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
// 체크박스 컬럼은 항상 기본 틀고정
const [frozenColumns, setFrozenColumns] = useState<string[]>(
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [],
);
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 🆕 Search Panel (통합 검색) 관련 상태
@ -1373,14 +1371,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
frozenColumnCount, // 현재 틀고정 컬럼 수
onFrozenColumnCountChange: (count: number) => {
setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
const visibleCols = columnsToRegister
.filter((col) => col.visible !== false)
.map((col) => col.columnName || col.field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns);
setFrozenColumns(visibleCols.slice(0, count));
},
// 탭 관련 정보 (탭 내부의 테이블인 경우)
parentTabId,
@ -3280,12 +3274,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (state.sortDirection) setSortDirection(state.sortDirection);
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
if (state.frozenColumns) {
// 체크박스 컬럼이 항상 포함되도록 보장
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
const restoredFrozenColumns =
checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
? [checkboxColumn, ...state.frozenColumns]
: state.frozenColumns;
// 체크박스 컬럼은 frozen 대상에서 제외 (배경색 이중 적용 방지)
const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__");
setFrozenColumns(restoredFrozenColumns);
}
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
@ -4434,7 +4424,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!tableConfig.checkbox?.selectAll) return null;
if (tableConfig.checkbox?.multiple === false) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
return (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)",
}}
/>
);
};
const renderCheckboxCell = (row: any, index: number) => {
@ -4446,6 +4448,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
style={{
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isChecked ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)",
}}
/>
);
};
@ -5634,6 +5642,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
onCellDoubleClick={handleCellDoubleClick}
editingCell={editingCell}
editingValue={editingValue}
onEditingValueChange={setEditingValue}
onEditKeyDown={handleEditKeyDown}
onEditSave={saveEditing}
editInputRef={editInputRef}
columnMeta={columnMeta}
categoryMappings={categoryMappings}
/>
</div>
@ -5838,7 +5855,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
</div>
{/* 🆕 배치 편집 툴바 */}
{/* 필터 칩 바 */}
{filterGroups.length > 0 && filterGroups.some(g => g.conditions.some(c => c.column && c.value)) && (
<div className="border-border bg-muted/30 flex items-center gap-2 border-b px-4 py-1.5">
{filterGroups.flatMap(group =>
group.conditions
.filter(c => c.column && c.value)
.map(condition => {
const label = columnLabels[condition.column] || condition.column;
const opLabel = condition.operator === "equals" ? "=" : condition.operator === "contains" ? "⊃" : condition.operator === "notEquals" ? "≠" : condition.operator === "startsWith" ? "^" : condition.operator === "endsWith" ? "$" : condition.operator === "greaterThan" ? ">" : condition.operator === "lessThan" ? "<" : condition.operator;
return (
<span
key={condition.id}
className="border-border bg-background text-muted-foreground inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-semibold"
>
{label} {opLabel} {condition.value}
<button
onClick={() => removeFilterCondition(group.id, condition.id)}
className="hover:text-destructive ml-0.5 leading-none transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
);
})
)}
<button
onClick={clearFilterBuilder}
className="text-muted-foreground hover:text-foreground ml-auto text-[9px] font-semibold transition-colors"
>
</button>
</div>
)}
{/* 배치 편집 툴바 */}
{(editMode === "batch" || pendingChanges.size > 0) && (
<div className="border-border flex items-center justify-between border-b bg-amber-50 px-4 py-2 sm:px-6 dark:bg-amber-950/30">
<div className="flex items-center gap-3 text-xs sm:text-sm">
@ -5946,8 +5997,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 🆕 Multi-Level Headers (Column Bands) */}
{columnBandsInfo?.hasBands && (
<tr
className="border-primary/10 bg-muted/70 h-8 border-b sm:h-10"
style={{ backgroundColor: "hsl(var(--muted) / 0.7)" }}
className="border-border/60 bg-muted/40 h-8 border-b sm:h-10"
style={{ backgroundColor: "hsl(var(--muted) / 0.4)" }}
>
{visibleColumns.map((column, colIdx) => {
// 이 컬럼이 속한 band 찾기
@ -5988,9 +6039,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</tr>
)}
<tr
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
className="bg-muted/80 h-10 border-b border-border/60 sm:h-12"
style={{
backgroundColor: "hsl(var(--muted))",
backgroundColor: "hsl(var(--muted) / 0.4)",
}}
>
{visibleColumns.map((column, columnIndex) => {
@ -6018,11 +6069,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
key={column.columnName}
ref={(el) => (columnRefs.current[column.columnName] = el)}
className={cn(
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
"group text-muted-foreground relative h-8 overflow-hidden text-[10px] font-bold uppercase tracking-[0.04em] text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-xs",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2",
column.sortable !== false &&
column.columnName !== "__checkbox__" &&
"hover:bg-muted/70 cursor-pointer transition-colors",
"hover:text-foreground hover:bg-muted/50 cursor-pointer transition-colors",
sortColumn === column.columnName && "!text-primary",
isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
// 🆕 Column Reordering 스타일
isColumnDragEnabled &&
@ -6042,7 +6094,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
userSelect: "none",
backgroundColor: "hsl(var(--muted))",
backgroundColor: "hsl(var(--muted) / 0.4)",
...(isFrozen && { left: `${leftPosition}px` }),
}}
// 🆕 Column Reordering 이벤트
@ -6062,9 +6114,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
renderCheckboxHeader()
) : (
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
{isColumnDragEnabled && (
<GripVertical className="absolute left-0.5 top-1/2 h-3 w-3 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-40" />
)}
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false &&
@ -6289,7 +6344,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
"hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
)}
onClick={(e) => handleRowClick(row, index, e)}
>
@ -6320,13 +6376,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td
key={column.columnName}
className={cn(
"text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
"text-foreground text-[11px] font-normal",
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
column.columnName === "__checkbox__"
? "px-0 py-1"
: "px-2 py-1 sm:px-4 sm:py-1.5",
? "px-0 py-[7px] text-center"
: "px-3 py-[7px]",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
(inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium",
isNumeric && "tabular-nums",
)}
style={{
textAlign:
@ -6341,16 +6398,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
: `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--background))",
}),
}}
>
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: index % 2 === 0
? "hsl(var(--background))"
: "hsl(var(--muted) / 0.2)",
}),
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
</td>
);
})}
</tr>
@ -6426,10 +6485,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
isRowSelected && "bg-primary/10 hover:bg-primary/15",
"hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
isRowSelected && "!bg-primary/10 hover:!bg-primary/15",
isRowFocused && "ring-primary/50 ring-1 ring-inset",
// 🆕 Drag & Drop 스타일
isDragEnabled && "cursor-grab active:cursor-grabbing",
isDragging && "bg-muted opacity-50",
isDropTarget && "border-t-primary border-t-2",
@ -6489,23 +6548,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
data-row={index}
data-col={colIndex}
className={cn(
"text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
"text-foreground text-[11px] font-normal",
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]",
column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
// 🆕 포커스된 셀 스타일
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
// 🆕 편집 중인 셀 스타일
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
// 🆕 유효성 에러: 빨간 테두리 및 배경
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
// 🆕 검색 하이라이트 스타일 (노란 배경)
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
column.editable === false && "bg-muted/10 dark:bg-muted/10",
// 코드 컬럼: mono 폰트 + primary 색상
(inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium",
// 숫자 컬럼: tabular-nums 오른쪽 정렬
isNumeric && "tabular-nums",
)}
// 🆕 유효성 에러 툴팁
title={cellValidationError || undefined}
@ -6522,7 +6578,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--background))",
backgroundColor: index % 2 === 0
? "hsl(var(--background))"
: "hsl(var(--muted) / 0.2)",
}),
}}
onClick={(e) => handleCellClick(index, colIndex, e)}
@ -6581,7 +6639,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={saveEditing}
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
autoFocus
>
<option value=""></option>
@ -6609,7 +6667,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
// 일반 입력 필드
// 일반 입력 필드 (행 높이 유지: h-8 고정)
return (
<input
ref={editInputRef}
@ -6618,7 +6676,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onChange={(e) => setEditingValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={saveEditing}
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
className="border-primary bg-background h-8 w-full shrink-0 border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
style={{
textAlign: isNumeric ? "right" : column.align || "left",
}}
@ -6627,7 +6685,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})()
: column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
: (cellValue === null || cellValue === undefined || cellValue === "")
? <span className="text-muted-foreground/50">-</span>
: formatCellValue(cellValue, column, row)}
</td>
);
})}
@ -6687,7 +6747,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
: undefined,
...(isFrozen && {
left: `${leftPosition}px`,
backgroundColor: "hsl(var(--muted) / 0.8)",
backgroundColor: "hsl(var(--muted) / 0.4)",
}),
}}
>

View File

@ -48,7 +48,7 @@ const TabsDesignEditor: React.FC<{
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
? "bg-primary/20 dark:bg-primary/25 border-b-2 border-primary text-primary font-semibold"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
);
};
@ -283,7 +283,7 @@ const TabsDesignEditor: React.FC<{
return (
<div className="flex h-full w-full flex-col overflow-hidden rounded-lg border bg-background">
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex items-center border-b bg-muted/50">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div
@ -649,8 +649,8 @@ ComponentRegistry.registerComponent({
return cn(
"px-4 py-2 text-sm font-medium cursor-pointer transition-colors",
isActive
? "bg-background border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
? "bg-primary/10 border-b-2 border-primary text-primary font-semibold"
: "text-foreground/70 hover:text-foreground hover:bg-muted/50"
);
};
@ -662,7 +662,7 @@ ComponentRegistry.registerComponent({
onDragEnd={onDragEnd}
>
{/* 탭 헤더 */}
<div className="flex items-center border-b bg-muted/30">
<div className="flex items-center border-b bg-muted/50">
{tabs.length > 0 ? (
tabs.map((tab) => (
<div