This commit is contained in:
DDD1542 2026-03-17 21:50:37 +09:00
parent cfd7ee9fce
commit b293d184bb
8 changed files with 219 additions and 141 deletions

View File

@ -76,18 +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);
: [ const raw = referenceTableOptions.length
{ value: "none", label: "선택 안함" }, ? [...referenceTableOptions]
...tables.map((t) => ({ : [
value: t.tableName, { value: "none", label: "없음" },
label: ...tables.map((t) => ({
t.displayName && t.displayName !== t.tableName value: t.tableName,
? `${t.displayName} (${t.tableName})` label:
: t.tableName, 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">
@ -183,23 +199,33 @@ 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) => {
<CommandItem const hasKorean = opt.value !== "none" && opt.label !== opt.value && !opt.label.startsWith(opt.value);
key={opt.value} return (
value={`${opt.label} ${opt.value}`} <CommandItem
onSelect={() => { key={opt.value}
onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value); value={`${opt.label} ${opt.value}`}
if (opt.value !== "none") onLoadReferenceColumns?.(opt.value); onSelect={() => {
setEntityTableOpen(false); onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value);
}} if (opt.value !== "none") onLoadReferenceColumns?.(opt.value);
className="text-xs" setEntityTableOpen(false);
> }}
<Check className="text-xs"
className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")} >
/> <Check
{opt.label} className={cn("mr-2 h-3 w-3", column.referenceTable === opt.value ? "opacity-100" : "opacity-0")}
</CommandItem> />
))} {hasKorean ? (
<div className="flex flex-col">
<span className="font-medium">{opt.label.replace(` (${opt.value})`, "")}</span>
<span className="text-[10px] text-muted-foreground">{opt.value}</span>
</div>
) : (
opt.label
)}
</CommandItem>
);
})}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
@ -263,13 +289,14 @@ export function ColumnDetailPanel({
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0", column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> {refCol.displayName && refCol.displayName !== refCol.columnName ? (
<span className="font-medium"> <div className="flex flex-col">
{refCol.displayName && refCol.displayName !== refCol.columnName <span className="font-medium">{refCol.displayName}</span>
? `${refCol.displayName} (${refCol.columnName})` <span className="text-[10px] text-muted-foreground">{refCol.columnName}</span>
: refCol.columnName} </div>
</span> ) : (
</div> <span>{refCol.columnName}</span>
)}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@ -568,18 +568,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
); );
} }
if (!user) { const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center">
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p>...</p>
</div>
</div>
);
}
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
// 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장
useEffect(() => { useEffect(() => {
@ -603,6 +592,17 @@ function AppLayoutInner({ children }: AppLayoutProps) {
} }
}, [activeTab, uiMenus, isMenuActive, expandedMenus]); }, [activeTab, uiMenus, isMenuActive, expandedMenus]);
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center">
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
<p>...</p>
</div>
</div>
);
}
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

@ -1552,16 +1552,22 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
/> />
<SwitchRow <SwitchRow
label="수정 버튼" label="수정 버튼"
checked={config.rightPanel?.showEdit ?? false} checked={(config.rightPanel?.showEdit ?? config.rightPanel?.editButton?.enabled) ?? false}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateRightPanel({ showEdit: checked }) updateRightPanel({
showEdit: checked,
editButton: { ...config.rightPanel?.editButton!, enabled: checked },
})
} }
/> />
<SwitchRow <SwitchRow
label="삭제 버튼" label="삭제 버튼"
checked={config.rightPanel?.showDelete ?? false} checked={(config.rightPanel?.showDelete ?? config.rightPanel?.deleteButton?.enabled) ?? false}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
updateRightPanel({ showDelete: checked }) updateRightPanel({
showDelete: checked,
deleteButton: { ...config.rightPanel?.deleteButton!, enabled: checked },
})
} }
/> />
</div> </div>

View File

@ -21,7 +21,7 @@ import {
Move, Move,
FileSpreadsheet, FileSpreadsheet,
List, List,
LayoutPanelRight, PanelRight,
} from "lucide-react"; } from "lucide-react";
import { dataApi } from "@/lib/api/data"; import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
@ -3524,7 +3524,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{columnsToShow.map((col, idx) => ( {columnsToShow.map((col, idx) => (
<th <th
key={idx} key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" className="px-3 py-[7px] text-left text-[9px] font-bold tracking-[0.04em] text-muted-foreground uppercase whitespace-nowrap"
style={{ style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto", width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
textAlign: col.align || "left", textAlign: col.align || "left",
@ -3534,7 +3534,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th> </th>
))} ))}
{hasGroupedLeftActions && ( {hasGroupedLeftActions && (
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}> <th className="bg-muted sticky right-0 z-10 px-3 py-[7px] text-right text-[9px] font-bold tracking-[0.04em] text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th> </th>
)} )}
</tr> </tr>
@ -3621,7 +3621,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{columnsToShow.map((col, idx) => ( {columnsToShow.map((col, idx) => (
<th <th
key={idx} key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" className="px-3 py-[7px] text-left text-[9px] font-bold tracking-[0.04em] text-muted-foreground uppercase whitespace-nowrap"
style={{ style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto", width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
textAlign: col.align || "left", textAlign: col.align || "left",
@ -3631,7 +3631,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th> </th>
))} ))}
{hasLeftTableActions && ( {hasLeftTableActions && (
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}> <th className="bg-muted sticky right-0 z-10 px-3 py-[7px] text-right text-[9px] font-bold tracking-[0.04em] text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th> </th>
)} )}
</tr> </tr>
@ -3972,17 +3972,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
> >
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
<LayoutPanelRight className="h-4 w-4 shrink-0 text-muted-foreground" /> <PanelRight className="h-4 w-4 shrink-0 text-muted-foreground" />
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */} {/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */}
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? ( {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
<button <button
onClick={() => handleTabChange(0)} onClick={() => handleTabChange(0)}
className={cn( className={cn(
"px-3 py-1 text-sm font-medium transition-colors", "px-3.5 py-2 text-[10px] font-semibold transition-all -mb-px",
activeTabIndex === 0 activeTabIndex === 0
? "text-primary border-b-2 border-primary font-semibold bg-primary/5" ? "text-primary border-b-2 border-primary"
: "text-foreground/70 hover:text-foreground hover:bg-muted/30" : "text-muted-foreground border-b-2 border-transparent hover:text-foreground"
)} )}
> >
{componentConfig.rightPanel?.title || "기본"} {componentConfig.rightPanel?.title || "기본"}
@ -3992,10 +3992,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={tab.tabId || `tab-${index}`} key={tab.tabId || `tab-${index}`}
onClick={() => handleTabChange(index + 1)} onClick={() => handleTabChange(index + 1)}
className={cn( className={cn(
"px-3 py-1 text-sm font-medium transition-colors", "px-3.5 py-2 text-[10px] font-semibold transition-all -mb-px",
activeTabIndex === index + 1 activeTabIndex === index + 1
? "text-primary border-b-2 border-primary font-semibold bg-primary/5" ? "text-primary border-b-2 border-primary"
: "text-foreground/70 hover:text-foreground hover:bg-muted/30" : "text-muted-foreground border-b-2 border-transparent hover:text-foreground"
)} )}
> >
{tab.label || `${index + 1}`} {tab.label || `${index + 1}`}
@ -4120,7 +4120,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<th <th
key={col.name} key={col.name}
className={cn( className={cn(
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold", "text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold uppercase tracking-[0.04em]",
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5", isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
canDragTabColumns && "cursor-grab active:cursor-grabbing", canDragTabColumns && "cursor-grab active:cursor-grabbing",
isDragging && "opacity-50", isDragging && "opacity-50",
@ -4136,7 +4136,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
})} })}
{hasTabActions && ( {hasTabActions && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold"></th> <th className="text-muted-foreground px-3 py-[7px] text-right text-[9px] font-bold uppercase tracking-[0.04em]"></th>
)} )}
</tr> </tr>
</thead> </thead>
@ -4157,13 +4157,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<React.Fragment key={tabItemId}> <React.Fragment key={tabItemId}>
<tr <tr
className={cn( className={cn(
"cursor-pointer border-b border-border/40 transition-colors", "group/action cursor-pointer border-b border-border/50 transition-[background] duration-75",
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30", isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/50 hover:bg-accent" : "hover:bg-accent",
)} )}
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
> >
{tabSummaryColumns.map((col: any) => ( {tabSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <td key={col.name} className="px-3 py-2 text-[11px]" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{col.type === "progress" {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem) ? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue( : formatCellValue(
@ -4256,7 +4256,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<th <th
key={col.name} key={col.name}
className={cn( className={cn(
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold", "text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold uppercase tracking-[0.04em]",
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5", isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
canDragListTabColumns && "cursor-grab active:cursor-grabbing", canDragListTabColumns && "cursor-grab active:cursor-grabbing",
isDragging && "opacity-50", isDragging && "opacity-50",
@ -4272,7 +4272,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
})} })}
{hasTabActions && ( {hasTabActions && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold"></th> <th className="text-muted-foreground px-3 py-[7px] text-right text-[9px] font-bold uppercase tracking-[0.04em]"></th>
)} )}
</tr> </tr>
</thead> </thead>
@ -4292,13 +4292,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<React.Fragment key={tabItemId}> <React.Fragment key={tabItemId}>
<tr <tr
className={cn( className={cn(
"cursor-pointer border-b border-border/40 transition-colors", "group/action cursor-pointer border-b border-border/50 transition-[background] duration-75",
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30", isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/50 hover:bg-accent" : "hover:bg-accent",
)} )}
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
> >
{listSummaryColumns.map((col: any) => ( {listSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <td key={col.name} className="px-3 py-2 text-[11px]" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{col.type === "progress" {col.type === "progress"
? renderProgressCell(col, item, selectedLeftItem) ? renderProgressCell(col, item, selectedLeftItem)
: formatCellValue( : formatCellValue(
@ -4670,7 +4670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<th <th
key={idx} key={idx}
className={cn( className={cn(
"text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap", "text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold uppercase tracking-[0.04em] whitespace-nowrap",
isDropTarget && "border-l-[3px] border-l-primary bg-primary/5", isDropTarget && "border-l-[3px] border-l-primary bg-primary/5",
isDraggable && "cursor-grab active:cursor-grabbing", isDraggable && "cursor-grab active:cursor-grabbing",
isDragging && "opacity-50", isDragging && "opacity-50",
@ -4689,26 +4689,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th> </th>
); );
})} })}
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} {(() => {
{!isDesignMode && const rightEditVisible = (componentConfig.rightPanel?.showEdit ?? componentConfig.rightPanel?.editButton?.enabled) !== false;
((componentConfig.rightPanel?.editButton?.enabled ?? true) || const rightDeleteVisible = (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== false;
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( return !isDesignMode && (rightEditVisible || rightDeleteVisible) ? (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold" style={{ width: '80px' }}> <th className="bg-background text-muted-foreground sticky right-0 z-10 px-3 py-[7px] text-right text-[9px] font-bold uppercase tracking-[0.04em]" style={{ width: '80px' }}>
</th> </th>
)} ) : null;
})()}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredData.map((item, idx) => { {filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx; const itemId = item.id || item.ID || idx;
const rightEditVisible = (componentConfig.rightPanel?.showEdit ?? componentConfig.rightPanel?.editButton?.enabled) !== false;
const rightDeleteVisible = (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== false;
return ( return (
<tr key={itemId} className={cn("group/action border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}> <tr key={itemId} className={cn("group/action border-b border-border/50 transition-[background] duration-75 hover:bg-accent", idx % 2 === 1 && "bg-muted/50")}>
{columnsToShow.map((col, colIdx) => ( {columnsToShow.map((col, colIdx) => (
<td <td
key={colIdx} key={colIdx}
className="px-3 py-2 text-xs" className="px-3 py-2 text-[11px]"
style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} style={{ textAlign: col.align || "left", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
> >
{col.type === "progress" {col.type === "progress"
@ -4722,12 +4725,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td> </td>
))} ))}
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}
{!isDesignMode && {!isDesignMode && (rightEditVisible || rightDeleteVisible) && (
((componentConfig.rightPanel?.editButton?.enabled ?? true) || <td className="bg-card sticky right-0 z-10 px-3 py-2 text-right text-sm whitespace-nowrap group-hover/action:bg-accent">
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
<td className="px-3 py-2 text-right text-sm whitespace-nowrap group/action">
<div className="flex justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100"> <div className="flex justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100">
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( {rightEditVisible && (
<Button <Button
variant={ variant={
componentConfig.rightPanel?.editButton?.buttonVariant || "outline" componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
@ -4743,7 +4744,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} {componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button> </Button>
)} )}
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( {rightDeleteVisible && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -4801,8 +4802,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return sum + w; return sum + w;
}, 0); }, 0);
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.showEdit ?? componentConfig.rightPanel?.editButton?.enabled) !== false;
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== false;
const hasActions = hasEditButton || hasDeleteButton; const hasActions = hasEditButton || hasDeleteButton;
return filteredData.length > 0 ? ( return filteredData.length > 0 ? (
@ -4814,14 +4815,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{columnsToDisplay.map((col) => ( {columnsToDisplay.map((col) => (
<th <th
key={col.name} key={col.name}
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap" className="text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold uppercase tracking-[0.04em] whitespace-nowrap"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }} style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
> >
{col.label} {col.label}
</th> </th>
))} ))}
{hasActions && ( {hasActions && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold" style={{ width: '80px' }}></th> <th className="bg-background text-muted-foreground sticky right-0 z-10 px-3 py-[7px] text-right text-[9px] font-bold uppercase tracking-[0.04em]" style={{ width: '80px' }}></th>
)} )}
</tr> </tr>
</thead> </thead>
@ -4849,13 +4850,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<React.Fragment key={itemId}> <React.Fragment key={itemId}>
<tr <tr
className={cn( className={cn(
"group/action cursor-pointer border-b border-border/40 transition-colors", "group/action cursor-pointer border-b border-border/50 transition-[background] duration-75",
isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30", isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/50 hover:bg-accent" : "hover:bg-accent",
)} )}
onClick={() => toggleRightItemExpansion(itemId)} onClick={() => toggleRightItemExpansion(itemId)}
> >
{columnsToDisplay.map((col) => ( {columnsToDisplay.map((col) => (
<td key={col.name} className="px-3 py-2 text-xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <td key={col.name} className="px-3 py-2 text-[11px]" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{formatCellValue( {formatCellValue(
col.name, col.name,
getEntityJoinValue(item, col.name), getEntityJoinValue(item, col.name),
@ -4865,7 +4866,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td> </td>
))} ))}
{hasActions && ( {hasActions && (
<td className="px-3 py-2 text-right"> <td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover/action:bg-accent">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100"> <div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/action:opacity-100">
{hasEditButton && ( {hasEditButton && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" <Button size="sm" variant="ghost" className="h-7 px-2 text-xs"

View File

@ -109,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.8)" }}
> >
<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
@ -132,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/70",
// 고정 컬럼 스타일 // 고정 컬럼 스타일
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",
@ -150,7 +151,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))", backgroundColor: "hsl(var(--muted) / 0.8)",
// sticky 위치 설정 // sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
@ -228,8 +229,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/70",
tableConfig.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)} )}
onClick={(e) => handleRowClick?.(row, index, e)} onClick={(e) => handleRowClick?.(row, index, e)}
> >
@ -273,9 +275,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);
// 셀 값에서 검색어 하이라이트 렌더링 // 셀 값에서 검색어 하이라이트 렌더링
@ -324,7 +327,7 @@ 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 px-3 py-[7px] align-middle text-[11px] transition-colors",
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지) // 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
!isReactElement && "whitespace-nowrap", !isReactElement && "whitespace-nowrap",
`text-${column.align}`, `text-${column.align}`,

View File

@ -178,6 +178,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";
@ -5676,7 +5677,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">
@ -5826,9 +5861,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.8)",
}} }}
> >
{visibleColumns.map((column, columnIndex) => { {visibleColumns.map((column, columnIndex) => {
@ -5856,11 +5891,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/70 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 &&
@ -5880,7 +5916,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.8)",
...(isFrozen && { left: `${leftPosition}px` }), ...(isFrozen && { left: `${leftPosition}px` }),
}} }}
// 🆕 Column Reordering 이벤트 // 🆕 Column Reordering 이벤트
@ -5900,9 +5936,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 &&
@ -6127,7 +6166,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/70",
)} )}
onClick={(e) => handleRowClick(row, index, e)} onClick={(e) => handleRowClick(row, index, e)}
> >
@ -6158,13 +6198,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]"
: "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:
@ -6264,10 +6305,11 @@ 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/70",
isRowSelected && "!bg-primary/15 hover:!bg-primary/20",
isRowSelected && "[&_td]:!border-b-primary/30",
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",
@ -6327,23 +6369,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]" : "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-gray-50 dark:bg-gray-900/30", 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}
@ -6465,7 +6504,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})() })()
: column.columnName === "__checkbox__" : column.columnName === "__checkbox__"
? renderCheckboxCell(row, index) ? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)} : (cellValue === null || cellValue === undefined || cellValue === "")
? <span className="text-muted-foreground/50">-</span>
: formatCellValue(cellValue, column, row)}
</td> </td>
); );
})} })}

View File

@ -48,8 +48,8 @@ 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-primary/10 border-b-2 border-primary text-primary font-semibold" ? "bg-primary/20 dark:bg-primary/25 border-b-2 border-primary text-primary font-semibold"
: "text-foreground/70 hover:text-foreground hover:bg-muted/50" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
); );
}; };