feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)

- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
This commit is contained in:
kjs 2025-11-18 10:14:31 +09:00
parent cddce40f35
commit e234f88577
3 changed files with 510 additions and 4 deletions

View File

@ -3,16 +3,18 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { ComponentRendererProps } from "@/types/component";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry } from "./types";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry, DisplayItem } from "./types";
import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { X } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils";
@ -542,6 +544,158 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
};
// 🆕 displayItems를 렌더링하는 헬퍼 함수
const renderDisplayItems = useCallback((entry: GroupEntry, item: ItemData) => {
const displayItems = componentConfig.displayItems || [];
if (displayItems.length === 0) {
// displayItems가 없으면 기본 방식 (모든 필드 나열)
const fields = componentConfig.additionalFields || [];
return fields.map((f) => entry[f.name] || "-").join(" / ");
}
// displayItems 설정대로 렌더링
return (
<>
{displayItems.map((displayItem) => {
const styleClasses = cn(
displayItem.bold && "font-bold",
displayItem.underline && "underline",
displayItem.italic && "italic"
);
const inlineStyle: React.CSSProperties = {
color: displayItem.color,
backgroundColor: displayItem.backgroundColor,
};
switch (displayItem.type) {
case "icon": {
if (!displayItem.icon) return null;
const IconComponent = (LucideIcons as any)[displayItem.icon];
if (!IconComponent) return null;
return (
<IconComponent
key={displayItem.id}
className="h-3 w-3 inline-block mr-1"
style={inlineStyle}
/>
);
}
case "text":
return (
<span
key={displayItem.id}
className={styleClasses}
style={inlineStyle}
>
{displayItem.value}
</span>
);
case "field": {
const fieldValue = entry[displayItem.fieldName || ""];
const isEmpty = fieldValue === null || fieldValue === undefined || fieldValue === "";
// 🆕 빈 값 처리
if (isEmpty) {
switch (displayItem.emptyBehavior) {
case "hide":
return null; // 항목 숨김
case "default":
// 기본값 표시
const defaultValue = displayItem.defaultValue || "-";
return (
<span
key={displayItem.id}
className={cn(styleClasses, "text-muted-foreground")}
style={inlineStyle}
>
{displayItem.label}{defaultValue}
</span>
);
case "blank":
default:
// 빈 칸으로 표시
return (
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
{displayItem.label}
</span>
);
}
}
// 값이 있는 경우, 형식에 맞게 표시
let formattedValue = fieldValue;
switch (displayItem.format) {
case "currency":
// 천 단위 구분
formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0);
break;
case "number":
formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0);
break;
case "date":
// YYYY.MM.DD 형식
if (fieldValue) {
const date = new Date(fieldValue);
if (!isNaN(date.getTime())) {
formattedValue = date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).replace(/\. /g, ".").replace(/\.$/, "");
}
}
break;
case "badge":
// 배지로 표시
return (
<Badge
key={displayItem.id}
variant={displayItem.badgeVariant || "default"}
className={styleClasses}
style={inlineStyle}
>
{displayItem.label}{formattedValue}
</Badge>
);
case "text":
default:
// 일반 텍스트
break;
}
return (
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
{displayItem.label}{formattedValue}
</span>
);
}
case "badge": {
const fieldValue = displayItem.fieldName ? entry[displayItem.fieldName] : displayItem.value;
return (
<Badge
key={displayItem.id}
variant={displayItem.badgeVariant || "default"}
className={styleClasses}
style={inlineStyle}
>
{displayItem.label}{fieldValue}
</Badge>
);
}
default:
return null;
}
})}
</>
);
}, [componentConfig.displayItems, componentConfig.additionalFields]);
// 빈 상태 렌더링
if (items.length === 0) {
return (
@ -650,8 +804,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
className="flex items-center justify-between border rounded p-2 text-xs bg-muted/30 cursor-pointer hover:bg-muted/50"
onClick={() => handleEditGroupEntry(item.id, group.id, entry.id)}
>
<span>
{idx + 1}. {groupFields.map((f) => entry[f.name] || "-").join(" / ")}
<span className="flex items-center gap-1">
{idx + 1}. {renderDisplayItems(entry, item)}
</span>
<Button
type="button"

View File

@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, X } from "lucide-react";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup } from "./types";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
@ -46,6 +46,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 필드 그룹 상태
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
// 🆕 항목 표시 설정 상태
const [displayItems, setDisplayItems] = useState<DisplayItem[]>(config.displayItems || []);
// 🆕 원본 테이블 선택 상태
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
@ -169,6 +172,41 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
);
}, [allTables, sourceTableSearchValue]);
// 🆕 항목 표시 설정 핸들러
const handleDisplayItemsChange = (items: DisplayItem[]) => {
setDisplayItems(items);
handleChange("displayItems", items);
};
const addDisplayItem = (type: DisplayItemType) => {
const newItem: DisplayItem = {
type,
id: `display-${Date.now()}`,
};
if (type === "field") {
newItem.fieldName = localFields[0]?.name || "";
newItem.format = "text";
newItem.emptyBehavior = "default";
} else if (type === "icon") {
newItem.icon = "Circle";
} else if (type === "text") {
newItem.value = "텍스트";
}
handleDisplayItemsChange([...displayItems, newItem]);
};
const removeDisplayItem = (index: number) => {
handleDisplayItemsChange(displayItems.filter((_, i) => i !== index));
};
const updateDisplayItem = (index: number, updates: Partial<DisplayItem>) => {
const updated = [...displayItems];
updated[index] = { ...updated[index], ...updates };
handleDisplayItemsChange(updated);
};
// 🆕 선택된 원본 테이블 표시명
const selectedSourceTableLabel = useMemo(() => {
if (!config.sourceTable) return "원본 테이블을 선택하세요";
@ -723,6 +761,252 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
</div>
{/* 🆕 항목 표시 설정 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addDisplayItem("icon")}
className="h-6 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addDisplayItem("field")}
className="h-6 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addDisplayItem("text")}
className="h-6 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<p className="text-[10px] text-muted-foreground sm:text-xs">
</p>
{displayItems.length === 0 ? (
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-muted-foreground">
. .
<br />
<span className="text-[10px]">( " / " )</span>
</div>
) : (
<div className="space-y-2">
{displayItems.map((item, index) => (
<Card key={item.id} className="border">
<CardContent className="p-3 space-y-2">
{/* 헤더 */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
{item.type === "icon" && "🎨 아이콘"}
{item.type === "field" && "📝 필드"}
{item.type === "text" && "💬 텍스트"}
{item.type === "badge" && "🏷️ 배지"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDisplayItem(index)}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 아이콘 설정 */}
{item.type === "icon" && (
<div className="space-y-2">
<div>
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={item.icon || ""}
onChange={(e) => updateDisplayItem(index, { icon: e.target.value })}
placeholder="예: Building, User, Package"
className="h-7 text-xs"
/>
<p className="mt-1 text-[9px] text-muted-foreground">
lucide-react
</p>
</div>
</div>
)}
{/* 텍스트 설정 */}
{item.type === "text" && (
<div>
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={item.value || ""}
onChange={(e) => updateDisplayItem(index, { value: e.target.value })}
placeholder="예: | , / , - "
className="h-7 text-xs"
/>
</div>
)}
{/* 필드 설정 */}
{item.type === "field" && (
<div className="space-y-2">
{/* 필드 선택 */}
<div>
<Label className="text-[10px] sm:text-xs"></Label>
<Select
value={item.fieldName || ""}
onValueChange={(value) => updateDisplayItem(index, { fieldName: value })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{localFields.map((field) => (
<SelectItem key={field.name} value={field.name} className="text-xs">
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 라벨 */}
<div>
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={item.label || ""}
onChange={(e) => updateDisplayItem(index, { label: e.target.value })}
placeholder="예: 거래처:, 단가:"
className="h-7 text-xs"
/>
</div>
{/* 표시 형식 */}
<div>
<Label className="text-[10px] sm:text-xs"> </Label>
<Select
value={item.format || "text"}
onValueChange={(value) => updateDisplayItem(index, { format: value as DisplayFieldFormat })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text" className="text-xs"> </SelectItem>
<SelectItem value="currency" className="text-xs"> ( )</SelectItem>
<SelectItem value="number" className="text-xs"> ( )</SelectItem>
<SelectItem value="date" className="text-xs"> (YYYY.MM.DD)</SelectItem>
<SelectItem value="badge" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 빈 값 처리 */}
<div>
<Label className="text-[10px] sm:text-xs"> </Label>
<Select
value={item.emptyBehavior || "default"}
onValueChange={(value) => updateDisplayItem(index, { emptyBehavior: value as EmptyBehavior })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hide" className="text-xs"> </SelectItem>
<SelectItem value="default" className="text-xs"> </SelectItem>
<SelectItem value="blank" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 기본값 (emptyBehavior가 "default"일 때만) */}
{item.emptyBehavior === "default" && (
<div>
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={item.defaultValue || ""}
onChange={(e) => updateDisplayItem(index, { defaultValue: e.target.value })}
placeholder="예: 미입력, 0, -"
className="h-7 text-xs"
/>
</div>
)}
</div>
)}
{/* 스타일 설정 */}
<div className="space-y-2 rounded border bg-muted/30 p-2">
<Label className="text-[10px] font-semibold sm:text-xs"></Label>
<div className="flex flex-wrap gap-2">
<label className="flex items-center gap-1 text-[10px] sm:text-xs">
<Checkbox
checked={item.bold || false}
onCheckedChange={(checked) => updateDisplayItem(index, { bold: checked as boolean })}
/>
</label>
<label className="flex items-center gap-1 text-[10px] sm:text-xs">
<Checkbox
checked={item.underline || false}
onCheckedChange={(checked) => updateDisplayItem(index, { underline: checked as boolean })}
/>
</label>
<label className="flex items-center gap-1 text-[10px] sm:text-xs">
<Checkbox
checked={item.italic || false}
onCheckedChange={(checked) => updateDisplayItem(index, { italic: checked as boolean })}
/>
</label>
</div>
{/* 색상 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Input
type="color"
value={item.color || "#000000"}
onChange={(e) => updateDisplayItem(index, { color: e.target.value })}
className="h-7 w-full"
/>
</div>
<div>
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Input
type="color"
value={item.backgroundColor || "#ffffff"}
onChange={(e) => updateDisplayItem(index, { backgroundColor: e.target.value })}
className="h-7 w-full"
/>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* 사용 예시 */}
<div className="rounded-lg bg-blue-50 p-2 text-xs sm:p-3 sm:text-sm">
<p className="mb-1 font-medium text-blue-900">💡 </p>

View File

@ -115,6 +115,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
inputMode?: "inline" | "modal";
/**
* 🆕 ( )
* : [{ type: "icon", icon: "Building" }, { type: "field", fieldName: "customer_name", label: "거래처:" }]
*/
displayItems?: DisplayItem[];
/**
*
*/
@ -135,6 +141,68 @@ export interface GroupEntry {
[key: string]: any;
}
/**
* 🆕
*/
export type DisplayItemType = "icon" | "field" | "text" | "badge";
/**
* 🆕
*/
export type EmptyBehavior = "hide" | "default" | "blank";
/**
* 🆕
*/
export type DisplayFieldFormat = "text" | "date" | "currency" | "number" | "badge";
/**
* 🆕 (, , , )
*/
export interface DisplayItem {
/** 항목 타입 */
type: DisplayItemType;
/** 고유 ID */
id: string;
// === type: "field" 인 경우 ===
/** 필드명 (컬럼명) */
fieldName?: string;
/** 라벨 (예: "거래처:", "단가:") */
label?: string;
/** 표시 형식 */
format?: DisplayFieldFormat;
/** 빈 값일 때 동작 */
emptyBehavior?: EmptyBehavior;
/** 기본값 (빈 값일 때 표시) */
defaultValue?: string;
// === type: "icon" 인 경우 ===
/** 아이콘 이름 (lucide-react 아이콘명) */
icon?: string;
// === type: "text" 인 경우 ===
/** 텍스트 내용 */
value?: string;
// === type: "badge" 인 경우 ===
/** 배지 스타일 */
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
// === 공통 스타일 ===
/** 굵게 표시 */
bold?: boolean;
/** 밑줄 표시 */
underline?: boolean;
/** 기울임 표시 */
italic?: boolean;
/** 텍스트 색상 */
color?: string;
/** 배경 색상 */
backgroundColor?: string;
}
/**
* 🆕 +
*