Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-18 10:31:04 +09:00
commit 579461a6cb
28 changed files with 3262 additions and 2664 deletions

View File

@ -217,10 +217,16 @@ export default function TableManagementPage() {
// 메모이제이션된 입력타입 옵션 // 메모이제이션된 입력타입 옵션
const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []); const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []);
// 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) // 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시)
const referenceTableOptions = [ const referenceTableOptions = [
{ value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, { 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) => onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", 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> <p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? ( {pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => ( {pendingPkColumns.map((col) => {
<Badge key={col} variant="secondary" className="font-mono text-xs"> const colInfo = columns.find((c) => c.columnName === col);
{col} return (
<Badge key={col} variant="secondary" className="text-xs">
{colInfo?.displayName && colInfo.displayName !== col
? `${colInfo.displayName} (${col})`
: col}
</Badge> </Badge>
))} );
})}
</div> </div>
) : ( ) : (
<p className="text-destructive mt-2 text-sm">PK가 </p> <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" ? "bg-accent/50 font-semibold"
: "" : ""
}`} }`}
role="button"
aria-label={`${company.company_name} ${company.company_code}`}
onClick={() => handleCompanySwitch(company.company_code)} onClick={() => handleCompanySwitch(company.company_code)}
> >
<div className="flex flex-col"> <div className="flex flex-col">

View File

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

View File

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

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, Suspense, useEffect } from "react"; import { useState, Suspense, useEffect, useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -341,6 +341,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const currentMenus = isAdminMode ? adminMenus : userMenus; 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 toggleMenu = (menuId: string) => {
const newExpanded = new Set(expandedMenus); const newExpanded = new Set(expandedMenus);
if (newExpanded.has(menuId)) { 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 스타일 적용) // 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
const renderMenu = (menu: any, level: number = 0) => { const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id); const isExpanded = expandedMenus.has(menu.id);
@ -489,8 +513,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
draggable={isLeaf} draggable={isLeaf}
onDragStart={(e) => handleMenuDragStart(e, menu)} 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] ${ 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 isMenuActive(menu)
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold" ? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold"
: isExpanded : isExpanded
? "bg-accent/60 text-foreground" ? "bg-accent/60 text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground"
@ -518,8 +542,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
draggable={!child.hasChildren} draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)} 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] ${ 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 isMenuActive(child)
? "border-primary bg-primary/8 text-primary border-l-3 font-semibold" ? "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" : "text-muted-foreground hover:bg-accent hover:text-foreground"
}`} }`}
onClick={() => handleMenuClick(child)} 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) { if (!user) {
return ( return (
<div className="flex h-screen items-center justify-center"> <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 ( return (
<div className="bg-background flex h-screen flex-col"> <div className="bg-background flex h-screen flex-col">
{/* 모바일 헤더 */} {/* 모바일 헤더 */}

View File

@ -493,8 +493,8 @@ export function TabBar() {
className={cn( 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", "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 isActive
? "text-foreground z-10 -mb-px h-[30px] bg-white" ? "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-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent", : "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent",
)} )}
style={{ style={{
width: TAB_WIDTH, width: TAB_WIDTH,

View File

@ -478,7 +478,7 @@ const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{sourceTableName && columns.length === 0 && !loadingColumns && ( {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> </p>
)} )}

View File

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

View File

@ -5,28 +5,16 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview"; import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule"; import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
}
interface NumberingRuleDesignerProps { interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig; initialConfig?: NumberingRuleConfig;
onSave?: (config: NumberingRuleConfig) => void; onSave?: (config: NumberingRuleConfig) => void;
@ -34,8 +22,8 @@ interface NumberingRuleDesignerProps {
maxRules?: number; maxRules?: number;
isPreview?: boolean; isPreview?: boolean;
className?: string; className?: string;
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) currentTableName?: string;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) menuObjid?: number;
} }
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
@ -48,124 +36,84 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName, currentTableName,
menuObjid, menuObjid,
}) => { }) => {
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]); const [rulesList, setRulesList] = useState<NumberingRuleConfig[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null); const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null); const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [selectedPartOrder, setSelectedPartOrder] = useState<number | null>(null);
const [loading, setLoading] = useState(false); 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 [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({}); const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 좌측: 채번 타입 컬럼 목록 로드 const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule;
// 좌측: 규칙 목록 로드
useEffect(() => { useEffect(() => {
loadNumberingColumns(); loadRules();
}, []); }, []);
const loadNumberingColumns = async () => { const loadRules = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get("/table-management/numbering-columns"); const response = await getNumberingRules();
if (response.data.success && response.data.data) { if (response.success && response.data) {
setNumberingColumns(response.data.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 { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드 const handleSelectRule = (rule: NumberingRuleConfig) => {
const handleSelectColumn = async (tableName: string, columnName: string) => { setSelectedRuleId(rule.ruleId);
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))); setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else { setSelectedPartOrder(null);
// 규칙 없으면 신규 생성 모드 };
const handleAddNewRule = () => {
const newRule: NumberingRuleConfig = { const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`, ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`, ruleName: "새 규칙",
parts: [], parts: [],
separator: "-", separator: "-",
resetPeriod: "none", resetPeriod: "none",
currentSequence: 1, currentSequence: 1,
scopeType: "table", scopeType: "global",
tableName, tableName: currentTableName ?? "",
columnName, columnName: "",
}; };
setCurrentRule(newRule); setRulesList((prev) => [...prev, newRule]);
} setSelectedRuleId(newRule.ruleId);
} catch { setCurrentRule(JSON.parse(JSON.stringify(newRule)));
const newRule: NumberingRuleConfig = { setSelectedPartOrder(null);
ruleId: `rule-${Date.now()}`, toast.success("새 규칙이 추가되었습니다");
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
}; };
setCurrentRule(newRule);
} finally {
setLoading(false);
}
};
// 테이블별로 그룹화
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)
)
);
});
useEffect(() => { useEffect(() => {
if (currentRule) { if (currentRule) onChange?.(currentRule);
onChange?.(currentRule);
}
}, [currentRule, onChange]); }, [currentRule, onChange]);
// currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => { useEffect(() => {
if (currentRule && currentRule.parts.length > 0) { if (currentRule && currentRule.parts.length > 0) {
const newSepTypes: Record<number, SeparatorType> = {}; const newSepTypes: Record<number, SeparatorType> = {};
const newCustomSeps: Record<number, string> = {}; const newCustomSeps: Record<number, string> = {};
currentRule.parts.forEach((part) => { currentRule.parts.forEach((part) => {
const sep = part.separatorAfter ?? currentRule.separator ?? "-"; const sep = part.separatorAfter ?? currentRule.separator ?? "-";
if (sep === "") { if (sep === "") {
newSepTypes[part.order] = "none"; newSepTypes[part.order] = "none";
newCustomSeps[part.order] = ""; newCustomSeps[part.order] = "";
} else { } else {
const predefinedOption = SEPARATOR_OPTIONS.find( const opt = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep (o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep
); );
if (predefinedOption) { if (opt) {
newSepTypes[part.order] = predefinedOption.value; newSepTypes[part.order] = opt.value;
newCustomSeps[part.order] = ""; newCustomSeps[part.order] = "";
} else { } else {
newSepTypes[part.order] = "custom"; newSepTypes[part.order] = "custom";
@ -173,54 +121,45 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} }
} }
}); });
setSeparatorTypes(newSepTypes); setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps); setCustomSeparators(newCustomSeps);
} }
}, [currentRule?.ruleId]); }, [currentRule?.ruleId]);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type }));
if (type !== "custom") { 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 ?? ""; const newSeparator = option?.displayValue ?? "";
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" }));
setCurrentRule((prev) => { setCurrentRule((prev) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
parts: prev.parts.map((part) => parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)),
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
}; };
}); });
} }
}, []); }, []);
// 개별 파트 직접 입력 구분자 변경 핸들러
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
const trimmedValue = value.slice(0, 2); const trimmedValue = value.slice(0, 2);
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => { setCurrentRule((prev) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
parts: prev.parts.map((part) => parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)),
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
}; };
}); });
}, []); }, []);
const handleAddPart = useCallback(() => { const handleAddPart = useCallback(() => {
if (!currentRule) return; if (!currentRule) return;
if (currentRule.parts.length >= maxRules) { if (currentRule.parts.length >= maxRules) {
toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`); toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`);
return; return;
} }
const newPart: NumberingRulePart = { const newPart: NumberingRulePart = {
id: `part-${Date.now()}`, id: `part-${Date.now()}`,
order: currentRule.parts.length + 1, order: currentRule.parts.length + 1,
@ -229,40 +168,33 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
autoConfig: { textValue: "CODE" }, autoConfig: { textValue: "CODE" },
separatorAfter: "-", separatorAfter: "-",
}; };
setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null));
setCurrentRule((prev) => { setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" }));
if (!prev) return null; setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" }));
return { ...prev, parts: [...prev.parts, newPart] };
});
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`); toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]); }, [currentRule, maxRules]);
// partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용)
const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => { const handleUpdatePart = useCallback((partOrder: number, updates: Partial<NumberingRulePart>) => {
setCurrentRule((prev) => { setCurrentRule((prev) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...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) => { const handleDeletePart = useCallback((partOrder: number) => {
setCurrentRule((prev) => { setCurrentRule((prev) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...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("규칙이 삭제되었습니다"); toast.success("규칙이 삭제되었습니다");
}, []); }, []);
@ -271,221 +203,254 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
toast.error("저장할 규칙이 없습니다"); toast.error("저장할 규칙이 없습니다");
return; return;
} }
if (currentRule.parts.length === 0) { if (currentRule.parts.length === 0) {
toast.error("최소 1개 이상의 규칙을 추가해주세요"); toast.error("최소 1개 이상의 규칙을 추가해주세요");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
// 파트별 기본 autoConfig 정의
const defaultAutoConfigs: Record<string, any> = { const defaultAutoConfigs: Record<string, any> = {
sequence: { sequenceLength: 3, startFrom: 1 }, sequence: { sequenceLength: 3, startFrom: 1 },
number: { numberLength: 4, numberValue: 1 }, number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" }, date: { dateFormat: "YYYYMMDD" },
text: { textValue: "" }, text: { textValue: "" },
}; };
// 저장 전에 각 파트의 autoConfig에 기본값 채우기
const partsWithDefaults = currentRule.parts.map((part) => { const partsWithDefaults = currentRule.parts.map((part) => {
if (part.generationMethod === "auto") { if (part.generationMethod === "auto") {
const defaults = defaultAutoConfigs[part.partType] || {}; const defaults = defaultAutoConfigs[part.partType] || {};
return { return { ...part, autoConfig: { ...defaults, ...part.autoConfig } };
...part,
autoConfig: { ...defaults, ...part.autoConfig },
};
} }
return part; return part;
}); });
const ruleToSave = { const ruleToSave = {
...currentRule, ...currentRule,
parts: partsWithDefaults, parts: partsWithDefaults,
scopeType: "table" as const, scopeType: "global" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "", tableName: currentRule.tableName || currentTableName || "",
columnName: selectedColumn?.columnName || currentRule.columnName || "", columnName: currentRule.columnName || "",
}; };
// 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave); const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) { if (response.success && response.data) {
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data));
setCurrentRule(currentData); 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); await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다"); toast.success("채번 규칙이 저장되었습니다");
} else { } else {
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); showErrorToast("채번 규칙 저장에 실패했습니다", response.error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
} }
} catch (error: any) { } catch (error: unknown) {
showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); showErrorToast("채번 규칙 저장에 실패했습니다", error, {
guidance: "설정을 확인하고 다시 시도해 주세요.",
});
} finally { } finally {
setLoading(false); 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 ( return (
<div className={`flex h-full gap-4 ${className}`}> <div className={cn("flex h-full", className)}>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */} {/* 좌측: 규칙 리스트 (code-nav, 220px) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3"> <div className="code-nav flex w-[220px] flex-shrink-0 flex-col border-r border-border">
<h2 className="text-sm font-semibold sm:text-base"> </h2> <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">
<Input <ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />
value={columnSearch} <span className="truncate text-xs font-bold"> ({rulesList.length})</span>
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> </div>
) : filteredGroups.length === 0 ? ( <Button
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed"> size="sm"
<p className="text-muted-foreground text-xs"> variant="default"
{numberingColumns.length === 0 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>
) : 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>
) : (
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>
{/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 (code-main) */}
<div className="code-main flex min-w-0 flex-1 flex-col overflow-hidden">
{!currentRule ? (
<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> </p>
</div> </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>
))
)}
</div>
</div>
{/* 구분선 */}
<div className="bg-border h-full w-px"></div>
{/* 우측: 편집 영역 */}
<div className="flex flex-1 flex-col gap-4">
{!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>
) : ( ) : (
<> <>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-2 px-6 pt-4">
{editingRightTitle ? ( <Label className="text-xs font-medium"></Label>
<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>
<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 <Input
value={currentRule.ruleName} value={currentRule.ruleName}
onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} onChange={(e) => setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))}
className="h-9"
placeholder="예: 프로젝트 코드" placeholder="예: 프로젝트 코드"
className="h-9 text-sm"
/> />
</div> </div>
<div className="flex-1 space-y-2">
<Label className="text-sm font-medium"></Label> {/* 큰 미리보기 스트립 (code-preview-strip) */}
<NumberingRulePreview config={currentRule} /> <div className="code-preview-strip flex-shrink-0 border-b border-border px-6 py-5">
</div> <NumberingRulePreview config={currentRule} variant="strip" />
</div> </div>
{/* 파이프라인 영역 (code-pipeline-area) */}
</div> <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">
<div className="flex-1 overflow-y-auto"> <span className="text-xs font-bold"> </span>
<div className="mb-3 flex items-center justify-between"> <span className="cnt text-xs font-medium text-muted-foreground">
<h3 className="text-sm font-semibold"> </h3>
<span className="text-muted-foreground text-xs">
{currentRule.parts.length}/{maxRules} {currentRule.parts.length}/{maxRules}
</span> </span>
</div> </div>
<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 ? ( {currentRule.parts.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed"> <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">
<p className="text-muted-foreground text-xs sm:text-sm"> </p>
</div> </div>
) : ( ) : (
<div className="flex flex-wrap items-stretch gap-3"> <>
{currentRule.parts.map((part, index) => ( {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}`}> <React.Fragment key={`part-${part.order}-${index}`}>
<div className="flex w-[200px] flex-col"> <button
<NumberingRuleCard type="button"
part={part} className={cn(
onUpdate={(updates) => handleUpdatePart(part.order, updates)} "pipe-segment min-w-[120px] flex-shrink-0 rounded-[10px] border-2 px-4 py-3 text-center transition-all",
onDelete={() => handleDeletePart(part.order)} part.partType === "date" && "border-warning",
isPreview={isPreview} part.partType === "text" && "border-primary",
tableName={selectedColumn?.tableName} 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"
{index < currentRule.parts.length - 1 && ( )}
<div className="mt-2 flex items-center gap-1"> onClick={() => setSelectedPartOrder(part.order)}
<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]"> <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>
{/* 설정 패널 (선택된 세그먼트 상세, 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SEPARATOR_OPTIONS.map((option) => ( {SEPARATOR_OPTIONS.map((opt) => (
<SelectItem key={option.value} value={option.value} className="text-xs"> <SelectItem key={opt.value} value={opt.value} className="text-xs">
{option.label} {opt.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{separatorTypes[part.order] === "custom" && ( {separatorTypes[selectedPart.order] === "custom" && (
<Input <Input
value={customSeparators[part.order] || ""} value={customSeparators[selectedPart.order] ?? ""}
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)} onChange={(e) => handlePartCustomSeparatorChange(selectedPart.order, e.target.value)}
className="h-6 w-14 text-center text-[10px]" className="h-7 w-14 text-center text-[10px]"
placeholder="2자" placeholder="2자"
maxLength={2} maxLength={2}
/> />
@ -493,24 +458,28 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div> </div>
)} )}
</div> </div>
</React.Fragment> )}
))}
</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> </div>
<div className="flex gap-2">
<Button <Button
onClick={handleAddPart} onClick={handleSave}
disabled={currentRule.parts.length >= maxRules || isPreview || loading} disabled={isPreview || loading}
variant="outline" className="h-9 gap-2 text-sm font-medium"
className="h-9 flex-1 text-sm"
> >
<Plus className="mr-2 h-4 w-4" /> <Save className="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" />
{loading ? "저장 중..." : "저장"} {loading ? "저장 중..." : "저장"}
</Button> </Button>
</div> </div>

View File

@ -1,88 +1,163 @@
"use client"; "use client";
import React, { useMemo } from "react"; 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 { interface NumberingRulePreviewProps {
config: NumberingRuleConfig; config: NumberingRuleConfig;
compact?: boolean; compact?: boolean;
/** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */
variant?: "default" | "strip";
} }
export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
config, 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(() => { const generatedCode = useMemo(() => {
if (!config.parts || config.parts.length === 0) { if (partItems.length === 0) return "규칙을 추가해주세요";
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";
}
});
// 파트별 개별 구분자로 결합
const globalSep = config.separator ?? "-"; const globalSep = config.separator ?? "-";
let result = ""; let result = "";
partValues.forEach((val, idx) => { partItems.forEach((item, idx) => {
result += val; result += item.displayValue;
if (idx < partValues.length - 1) { if (idx < partItems.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? globalSep; const part = sortedParts.find((p) => p.order === item.order);
result += sep; result += part?.separatorAfter ?? globalSep;
} }
}); });
return result; 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) { if (compact) {
return ( return (

View File

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

View File

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

View File

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

View File

@ -5,10 +5,11 @@ import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface CategoryColumn { export interface CategoryColumn {
tableName: string; tableName: string;
tableLabel?: string; // 테이블 라벨 추가 tableLabel?: string;
columnName: string; columnName: string;
columnLabel: string; columnLabel: string;
inputType: string; inputType: string;
@ -16,17 +17,30 @@ interface CategoryColumn {
} }
interface CategoryColumnListProps { interface CategoryColumnListProps {
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시) tableName: string;
selectedColumn: string | null; selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void; onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void;
menuObjid?: number; // 현재 메뉴 OBJID (필수) 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 [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -151,8 +165,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
); );
setColumns(columnsWithCount); setColumns(columnsWithCount);
onColumnsLoaded?.(columnsWithCount);
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) { if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0]; const firstCol = columnsWithCount[0];
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
@ -160,6 +174,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
} catch (error) { } catch (error) {
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error); console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
setColumns([]); setColumns([]);
onColumnsLoaded?.([]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -248,21 +263,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
} }
setColumns(columnsWithCount); setColumns(columnsWithCount);
onColumnsLoaded?.(columnsWithCount);
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) { if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0]; const firstCol = columnsWithCount[0];
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
} }
} catch (error) { } catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error); console.error("❌ 카테고리 컬럼 조회 실패:", error);
// 에러 시에도 tableName 기반으로 fallback
if (tableName) { if (tableName) {
console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName);
await loadCategoryColumnsByTable(); await loadCategoryColumnsByTable();
return; return;
} else { } else {
setColumns([]); setColumns([]);
onColumnsLoaded?.([]);
} }
} }
setIsLoading(false); 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 ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
@ -298,7 +378,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
<p className="text-muted-foreground text-xs"> </p> <p className="text-muted-foreground text-xs"> </p>
</div> </div>
{/* 검색 입력 필드 */}
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input <Input
@ -310,6 +389,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
/> />
{searchQuery && ( {searchQuery && (
<button <button
type="button"
onClick={() => setSearchQuery("")} onClick={() => setSearchQuery("")}
className="text-muted-foreground hover:text-foreground absolute right-2 top-1/2 -translate-y-1/2" 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 className="space-y-3 sm:space-y-4">
<div> <div>
<Label htmlFor="valueLabel" className="text-xs sm:text-sm"> <Label htmlFor="valueLabel" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="valueLabel" id="valueLabel"

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ function TabsList({
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( 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 className
)} )}
{...props} {...props}
@ -42,7 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -77,9 +77,9 @@ interface CategoryValueOption {
valueLabel: string; valueLabel: string;
} }
// ─── 하위 호환: 기존 config에서 fieldType 추론 ─── // ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ───
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType { 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") { if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
const dbType = metaInputType as FieldType; const dbType = metaInputType as FieldType;
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { 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; if (config.fieldType) return config.fieldType as FieldType;
// v2-select 계열 // (c) v2-select 계열: componentType 또는 config.source 기반
if (componentType === "v2-select" || config.source) { if (componentType === "v2-select" || config.source) {
const source = config.source === "code" ? "category" : config.source; const source = config.source === "code" ? "category" : config.source;
if (source === "entity") return "entity"; if (source === "entity") return "entity";
@ -97,11 +98,13 @@ function resolveFieldType(config: Record<string, any>, componentType?: string, m
return "select"; return "select";
} }
// v2-input 계열 // (d) saved config fallback (config.inputType / config.type)
const it = config.inputType || config.type; const it = config.inputType || config.type;
if (it === "number") return "number"; if (it === "number") return "number";
if (it === "textarea") return "textarea"; if (it === "textarea") return "textarea";
if (it === "numbering") return "numbering"; if (it === "numbering") return "numbering";
// (e) 최종 기본값
return "text"; return "text";
} }

View File

@ -2,12 +2,13 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; 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 { 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 = () => { export const useLogin = () => {
const router = useRouter(); const router = useRouter();
@ -73,67 +74,34 @@ export const useLogin = () => {
}, [formData]); }, [formData]);
/** /**
* API * (apiCall )
*/
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;
}, []);
/**
*
*/ */
const checkExistingAuth = useCallback(async () => { const checkExistingAuth = useCallback(async () => {
try { try {
// 로컬 스토리지에서 토큰 확인
const token = localStorage.getItem("authToken"); const token = localStorage.getItem("authToken");
if (!token) { if (!token) return;
// 토큰이 없으면 로그인 페이지 유지
return;
}
// 토큰이 있으면 API 호출로 유효성 확인 const result = await apiCall<{ isAuthenticated?: boolean }>("GET", AUTH_CONFIG.ENDPOINTS.STATUS);
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS);
// 백엔드가 isAuthenticated 필드를 반환함
if (result.success && result.data?.isAuthenticated) { if (result.success && result.data?.isAuthenticated) {
// 이미 로그인된 경우 메인으로 리다이렉트
router.push(AUTH_CONFIG.ROUTES.MAIN); router.push(AUTH_CONFIG.ROUTES.MAIN);
} else { } else {
// 토큰이 유효하지 않으면 제거
localStorage.removeItem("authToken"); localStorage.removeItem("authToken");
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
} }
} catch (error) { } catch {
// 에러가 발생하면 토큰 제거
localStorage.removeItem("authToken"); localStorage.removeItem("authToken");
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
console.debug("기존 인증 체크 중 오류 (정상):", error);
} }
}, [apiCall, router]); }, [router]);
/** /**
* * (apiCall - Axios , fetch )
*/ */
const handleLogin = useCallback( const handleLogin = useCallback(
async (e: React.FormEvent) => { async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// 입력값 검증
const validationError = validateForm(); const validationError = validateForm();
if (validationError) { if (validationError) {
setError(validationError); setError(validationError);
@ -144,9 +112,13 @@ export const useLogin = () => {
setError(""); setError("");
try { try {
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.LOGIN, { const result = await apiCall<{
method: "POST", token?: string;
body: JSON.stringify(formData), firstMenuPath?: string;
popLandingPath?: string;
}>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, {
userId: formData.userId,
password: formData.password,
}); });
if (result.success && result.data?.token) { if (result.success && result.data?.token) {
@ -185,7 +157,7 @@ export const useLogin = () => {
setIsLoading(false); 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 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) { if (forceReload || isStale) {
delete columnMetaCache[tableName]; delete columnMetaCache[tableName];
delete columnMetaLoading[tableName]; delete columnMetaLoading[tableName];
} }
if (columnMetaLoading[tableName]) { if (tableName in columnMetaLoading) {
await columnMetaLoading[tableName]; await columnMetaLoading[tableName];
return; return;
} }
@ -663,7 +665,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
} }
// 1. 새 컴포넌트 시스템에서 먼저 조회 // 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType); const newComponent =
componentType != null ? ComponentRegistry.getComponent(componentType) : null;
if (newComponent) { if (newComponent) {
// 새 컴포넌트 시스템으로 렌더링 // 새 컴포넌트 시스템으로 렌더링
@ -775,7 +778,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 렌더러 props 구성 // 렌더러 props 구성
// 숨김 값 추출 // 숨김 값 추출
const hiddenValue = component.hidden || component.componentConfig?.hidden; const hiddenValue = (component as any).hidden || component.componentConfig?.hidden;
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시 // 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
if (hiddenValue && isInteractive) { if (hiddenValue && isInteractive) {
@ -892,7 +895,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 새로운 기능들 전달 // 새로운 기능들 전달
// 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환 // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
autoGeneration: autoGeneration:
component.autoGeneration || (component as any).autoGeneration ||
component.componentConfig?.autoGeneration || component.componentConfig?.autoGeneration ||
((component as any).webTypeConfig?.numberingRuleId ((component as any).webTypeConfig?.numberingRuleId
? { ? {
@ -992,7 +995,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
let renderedElement: React.ReactElement; let renderedElement: React.ReactElement;
if (isClass) { 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(); renderedElement = rendererInstance.render();
} else { } else {
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />; renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
@ -1004,7 +1008,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const labelFontSize = component.style?.labelFontSize || "14px"; const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = getAdaptiveLabelColor(component.style?.labelColor); const labelColor = getAdaptiveLabelColor(component.style?.labelColor);
const labelFontWeight = component.style?.labelFontWeight || "500"; const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName); const isRequired =
effectiveComponent.required ||
isColumnRequiredByMeta(screenTableName ?? "", baseColumnName ?? "");
const isLeft = labelPosition === "left"; const isLeft = labelPosition === "left";
return ( return (
@ -1038,7 +1044,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
} }
// 2. 레거시 시스템에서 조회 // 2. 레거시 시스템에서 조회
const renderer = legacyComponentRegistry.get(componentType); const renderer =
componentType != null ? legacyComponentRegistry.get(componentType) : undefined;
if (!renderer) { if (!renderer) {
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {

View File

@ -2,18 +2,19 @@
/** /**
* V2 * V2
* - * - 레이아웃: Stat Strip + nav + + /
* - 3 (//) * - 3 (//)
*/ */
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useMemo } from "react";
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
import type { CategoryColumn } from "@/components/table-category/CategoryColumnList";
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
import { LayoutList, TreeDeciduous } from "lucide-react"; import { LayoutList, TreeDeciduous } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types"; import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
interface V2CategoryManagerComponentProps { interface V2CategoryManagerComponentProps {
@ -33,56 +34,63 @@ export function V2CategoryManagerComponent({
componentConfig, componentConfig,
...props ...props
}: V2CategoryManagerComponentProps) { }: V2CategoryManagerComponentProps) {
// 설정 병합 (componentConfig도 포함)
const config: V2CategoryManagerConfig = { const config: V2CategoryManagerConfig = {
...defaultV2CategoryManagerConfig, ...defaultV2CategoryManagerConfig,
...externalConfig, ...externalConfig,
...componentConfig, ...componentConfig,
}; };
// tableName 우선순위: props > selectedScreen > componentConfig const effectiveTableName =
const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || ""; tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
// menuObjid 우선순위: props > selectedScreen
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined; const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid; const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
// 디버그 로그 const [columns, setColumns] = useState<CategoryColumn[]>([]);
useEffect(() => { const [selectedTable, setSelectedTable] = useState<string | null>(null);
console.log("🔍 V2CategoryManagerComponent props:", {
tableName,
menuObjid,
selectedScreen,
effectiveTableName,
effectiveMenuObjid,
config,
});
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
// 선택된 컬럼 상태
const [selectedColumn, setSelectedColumn] = useState<{ const [selectedColumn, setSelectedColumn] = useState<{
uniqueKey: string; uniqueKey: string;
columnName: string; columnName: string;
columnLabel: string; columnLabel: string;
tableName: string; tableName: string;
} | null>(null); } | null>(null);
// 뷰 모드 상태
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode); const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
// 컬럼 선택 핸들러 const handleColumnsLoaded = useCallback((loaded: CategoryColumn[]) => {
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => { setColumns(loaded);
const columnName = uniqueKey.split(".")[1]; if (loaded.length > 0) {
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName }); setSelectedTable((prev) => prev ?? loaded[0].tableName);
}
}, []); }, []);
// 우측 패널 콘텐츠 const handleTableSelect = useCallback((tableName: string) => {
const rightContent = ( setSelectedTable(tableName);
<> setSelectedColumn(null);
{/* 뷰 모드 토글 */} }, []);
{config.showViewModeToggle && (
<div className="mb-2 flex items-center justify-end gap-1"> const handleColumnSelect = useCallback(
<span className="text-muted-foreground mr-2 text-xs"> :</span> (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"> <div className="flex rounded-md border p-0.5">
<Button <Button
variant="ghost" variant="ghost"
@ -103,10 +111,10 @@ export function V2CategoryManagerComponent({
</Button> </Button>
</div> </div>
</div> ) : null;
)}
{/* 카테고리 값 관리 */} const rightContent = (
<>
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
{selectedColumn ? ( {selectedColumn ? (
viewMode === "tree" ? ( viewMode === "tree" ? (
@ -115,6 +123,7 @@ export function V2CategoryManagerComponent({
tableName={selectedColumn.tableName} tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName} columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel} columnLabel={selectedColumn.columnLabel}
headerRight={viewModeSegment}
/> />
) : ( ) : (
<CategoryValueManager <CategoryValueManager
@ -123,6 +132,7 @@ export function V2CategoryManagerComponent({
columnName={selectedColumn.columnName} columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel} columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid} menuObjid={effectiveMenuObjid}
headerRight={viewModeSegment}
/> />
) )
) : ( ) : (
@ -130,7 +140,9 @@ export function V2CategoryManagerComponent({
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" /> <TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} {config.showColumnList
? "칩에서 카테고리 컬럼을 선택하세요"
: "카테고리 컬럼이 설정되지 않았습니다"}
</p> </p>
</div> </div>
</div> </div>
@ -148,24 +160,107 @@ export function V2CategoryManagerComponent({
} }
return ( return (
<ResponsiveSplitPanel <div
left={ 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 <CategoryColumnList
tableName={effectiveTableName} tableName={effectiveTableName}
selectedColumn={selectedColumn?.uniqueKey || null} selectedColumn={selectedColumn?.uniqueKey ?? null}
onColumnSelect={handleColumnSelect} onColumnSelect={handleColumnSelect}
menuObjid={effectiveMenuObjid} 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)
} }
right={rightContent} className={cn(
leftTitle="카테고리 컬럼" "inline-flex items-center gap-1.5 rounded-full border px-[10px] py-[5px] text-[11px] font-semibold transition-colors",
leftWidth={config.leftPanelWidth} isActive
minLeftWidth={10} ? "border-primary bg-primary/5 text-primary"
maxLeftWidth={40} : "border-border bg-muted/50 hover:border-primary hover:bg-primary/5 hover:text-primary",
height={config.height} )}
/> >
<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; export default V2CategoryManagerComponent;

View File

@ -35,7 +35,11 @@ interface SingleTableWithStickyProps {
editingValue?: string; editingValue?: string;
onEditingValueChange?: (value: string) => void; onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => 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 // 검색 하이라이트 관련 props
searchHighlights?: Set<string>; searchHighlights?: Set<string>;
currentSearchIndex?: number; currentSearchIndex?: number;
@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
editingValue, editingValue,
onEditingValueChange, onEditingValueChange,
onEditKeyDown, onEditKeyDown,
onEditSave,
editInputRef, editInputRef,
columnMeta,
categoryMappings,
// 검색 하이라이트 관련 props // 검색 하이라이트 관련 props
searchHighlights, searchHighlights,
currentSearchIndex = 0, currentSearchIndex = 0,
@ -102,9 +109,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
}} }}
> >
<TableHeader <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) => { {actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산 // 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns const leftFixedWidth = actualColumns
@ -125,10 +133,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={column.columnName} key={column.columnName}
className={cn( className={cn(
column.columnName === "__checkbox__" column.columnName === "__checkbox__"
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2" ? "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", : "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}`, `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 === "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", 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", isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)} )}
style={{ style={{
width: getColumnWidth(column), width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장 minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
maxWidth: "300px", // 최대 너비 제한 maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
boxSizing: "border-box", boxSizing: "border-box",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 whiteSpace: "nowrap",
backgroundColor: "hsl(var(--background))", backgroundColor: "hsl(var(--muted) / 0.4)",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
}} }}
onClick={() => column.sortable && sortHandler(column.columnName)} 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__" ? ( {column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && ( checkboxConfig.selectAll && (
<Checkbox <Checkbox
checked={isAllSelected} checked={isAllSelected}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
aria-label="전체 선택" 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 <TableRow
key={`row-${index}`} key={`row-${index}`}
className={cn( className={cn(
"bg-background h-10 cursor-pointer border-b transition-colors", "cursor-pointer border-b border-border/50 transition-[background] duration-75",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50", index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)} )}
onClick={(e) => handleRowClick?.(row, index, e)} onClick={(e) => handleRowClick?.(row, index, e)}
> >
@ -266,9 +280,10 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
highlightArray[currentSearchIndex] === cellKey; highlightArray[currentSearchIndex] === cellKey;
// formatCellValue 결과 (이미지 등 JSX 반환 가능) // formatCellValue 결과 (이미지 등 JSX 반환 가능)
const rawCellValue = const formattedValue = formatCellValue(row[column.columnName], column.format, column.columnName, row);
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; const rawCellValue = (formattedValue === null || formattedValue === undefined || formattedValue === "")
// 이미지 등 JSX 반환 여부 확인 ? <span className="text-muted-foreground/50">-</span>
: formattedValue;
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue); const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
// 셀 값에서 검색어 하이라이트 렌더링 // 셀 값에서 검색어 하이라이트 렌더링
@ -317,26 +332,22 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={`cell-${column.columnName}`} key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined} id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn( 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", "text-foreground h-10 align-middle text-[11px] transition-colors",
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지) column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
!isReactElement && "whitespace-nowrap", !isReactElement && "whitespace-nowrap",
`text-${column.align}`, column.columnName !== "__checkbox__" && `text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" && column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm", "border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" && column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm", "border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text", onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)} )}
style={{ style={{
width: getColumnWidth(column), width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장 minWidth: column.columnName === "__checkbox__" ? "48px" : "100px",
maxWidth: "300px", // 최대 너비 제한 maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px",
boxSizing: "border-box", boxSizing: "border-box",
// 이미지 셀은 overflow 허용
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }), ...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
}} }}
@ -350,15 +361,20 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
{column.columnName === "__checkbox__" ? ( {column.columnName === "__checkbox__" ? (
renderCheckboxCell?.(row, index) renderCheckboxCell?.(row, index)
) : isEditing ? ( ) : isEditing ? (
// 인라인 편집 입력 필드 // 인라인 편집: inputType에 따라 select(category/code), number, date, text
<input (() => {
ref={editInputRef} const meta = columnMeta?.[column.columnName];
type="text" const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
value={editingValue ?? ""} const isNumeric = inputType === "number" || inputType === "decimal";
onChange={(e) => onEditingValueChange?.(e.target.value)} const isCategoryType = inputType === "category" || inputType === "code";
onKeyDown={onEditKeyDown} const categoryOptions = categoryMappings?.[column.columnName];
onBlur={() => { const hasCategoryOptions =
// blur 시 저장 (Enter와 동일) 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) { if (onEditKeyDown) {
const fakeEvent = { const fakeEvent = {
key: "Enter", key: "Enter",
@ -366,10 +382,79 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
} as React.KeyboardEvent<HTMLInputElement>; } as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent); onEditKeyDown(fakeEvent);
} }
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();
}} }}
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" 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()} 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() renderCellContent()
)} )}

View File

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