521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { Eye, EyeOff, HelpCircle, Trash2, Plus } from "lucide-react";
|
|
import type { ComponentConfig, ConditionalRule } from "@/types/report";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
text: "텍스트",
|
|
label: "레이블",
|
|
table: "테이블",
|
|
image: "이미지",
|
|
divider: "구분선",
|
|
signature: "서명",
|
|
stamp: "도장",
|
|
pageNumber: "페이지 번호",
|
|
card: "카드",
|
|
calculation: "계산",
|
|
barcode: "바코드",
|
|
checkbox: "체크박스",
|
|
};
|
|
|
|
interface OperatorDef {
|
|
value: ConditionalRule["operator"];
|
|
symbol: string;
|
|
label: string;
|
|
summary: string;
|
|
group: "compare" | "text" | "exist";
|
|
}
|
|
|
|
const OPERATORS: OperatorDef[] = [
|
|
{ value: "eq", symbol: "=", label: "같을 때", summary: "가 '$V'일 때", group: "compare" },
|
|
{ value: "ne", symbol: "≠", label: "다를 때", summary: "가 '$V'이(가) 아닐 때", group: "compare" },
|
|
{ value: "gt", symbol: ">", label: "보다 클 때", summary: "이(가) $V보다 클 때", group: "compare" },
|
|
{ value: "lt", symbol: "<", label: "보다 작을 때", summary: "이(가) $V보다 작을 때", group: "compare" },
|
|
{ value: "gte", symbol: "≥", label: "이상일 때", summary: "이(가) $V 이상일 때", group: "compare" },
|
|
{ value: "lte", symbol: "≤", label: "이하일 때", summary: "이(가) $V 이하일 때", group: "compare" },
|
|
{ value: "contains", symbol: "⊃", label: "포함할 때", summary: "에 '$V'이(가) 포함될 때", group: "text" },
|
|
{ value: "notEmpty", symbol: "✓", label: "값이 있을 때", summary: "에 값이 있을 때", group: "exist" },
|
|
{ value: "empty", symbol: "∅", label: "값이 없을 때", summary: "에 값이 없을 때", group: "exist" },
|
|
];
|
|
|
|
const OPERATOR_GROUPS: { key: OperatorDef["group"]; label: string }[] = [
|
|
{ key: "compare", label: "비교" },
|
|
{ key: "text", label: "텍스트" },
|
|
{ key: "exist", label: "값 유무" },
|
|
];
|
|
|
|
const NO_VALUE_OPERATORS = ["notEmpty", "empty"];
|
|
|
|
interface CardColumn {
|
|
column_name: string;
|
|
data_type: string;
|
|
}
|
|
|
|
export interface CardColumnLabel {
|
|
columnName: string;
|
|
label: string;
|
|
}
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
|
cardColumns?: CardColumn[];
|
|
cardTableName?: string;
|
|
cardColumnLabels?: CardColumnLabel[];
|
|
/** conditionalRules 대신 사용할 키 (격자 모드 분리용) */
|
|
rulesKey?: "conditionalRules" | "gridConditionalRules";
|
|
/** conditionalRule 대신 사용할 키 (격자 모드 분리용) */
|
|
ruleKey?: "conditionalRule" | "gridConditionalRule";
|
|
}
|
|
|
|
const DEFAULT_RULE: ConditionalRule = {
|
|
queryId: "",
|
|
field: "",
|
|
operator: "eq",
|
|
value: "",
|
|
action: "show",
|
|
};
|
|
|
|
export function ConditionalProperties({
|
|
component,
|
|
onConfigChange,
|
|
cardColumns,
|
|
cardTableName,
|
|
cardColumnLabels,
|
|
rulesKey = "conditionalRules",
|
|
ruleKey = "conditionalRule",
|
|
}: Props) {
|
|
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
|
|
|
const componentLabel = TYPE_LABELS[component.type] ?? component.type;
|
|
const isCardMode = !!cardColumns;
|
|
|
|
const applyUpdate = useCallback(
|
|
(updates: Partial<ComponentConfig>) => {
|
|
if (onConfigChange) onConfigChange(updates);
|
|
else updateComponent(component.id, updates);
|
|
},
|
|
[onConfigChange, updateComponent, component.id],
|
|
);
|
|
|
|
/** 단일 rule → 배열로 정규화 (rulesKey/ruleKey 기반) */
|
|
const rules: ConditionalRule[] = useMemo(() => {
|
|
const rulesArr = component[rulesKey] as ConditionalRule[] | undefined;
|
|
const singleRule = component[ruleKey] as ConditionalRule | undefined;
|
|
if (rulesArr && rulesArr.length > 0) {
|
|
return rulesArr;
|
|
}
|
|
if (singleRule) {
|
|
return [singleRule];
|
|
}
|
|
return [];
|
|
}, [component, rulesKey, ruleKey]);
|
|
|
|
const action = rules.length > 0 ? rules[0].action : "show";
|
|
|
|
const syncRules = useCallback(
|
|
(newRules: ConditionalRule[]) => {
|
|
applyUpdate({
|
|
[rulesKey]: newRules,
|
|
[ruleKey]: newRules.length > 0 ? newRules[0] : undefined,
|
|
});
|
|
},
|
|
[applyUpdate, rulesKey, ruleKey],
|
|
);
|
|
|
|
const getQueryFields = (queryId: string): string[] => {
|
|
const result = getQueryResult(queryId);
|
|
return result ? result.fields : [];
|
|
};
|
|
|
|
const labelMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
cardColumnLabels?.forEach((cl) => map.set(cl.columnName, cl.label));
|
|
return map;
|
|
}, [cardColumnLabels]);
|
|
|
|
const addRule = useCallback(() => {
|
|
const newRule: ConditionalRule = {
|
|
...DEFAULT_RULE,
|
|
queryId: isCardMode ? "__card__" : (queries[0]?.id || ""),
|
|
action,
|
|
};
|
|
syncRules([...rules, newRule]);
|
|
}, [rules, isCardMode, queries, action, syncRules]);
|
|
|
|
const updateRule = useCallback(
|
|
(index: number, patch: Partial<ConditionalRule>) => {
|
|
const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r));
|
|
syncRules(updated);
|
|
},
|
|
[rules, syncRules],
|
|
);
|
|
|
|
const removeRule = useCallback(
|
|
(index: number) => {
|
|
const updated = rules.filter((_, i) => i !== index);
|
|
syncRules(updated);
|
|
},
|
|
[rules, syncRules],
|
|
);
|
|
|
|
const removeAllRules = useCallback(() => {
|
|
syncRules([]);
|
|
}, [syncRules]);
|
|
|
|
const updateAction = useCallback(
|
|
(newAction: "show" | "hide") => {
|
|
const updated = rules.map((r) => ({ ...r, action: newAction }));
|
|
syncRules(updated);
|
|
},
|
|
[rules, syncRules],
|
|
);
|
|
|
|
const getFieldsForRule = useCallback(
|
|
(rule: ConditionalRule): string[] => {
|
|
if (isCardMode) {
|
|
return cardColumnLabels?.map((cl) => cl.columnName) ?? [];
|
|
}
|
|
return rule.queryId ? getQueryFields(rule.queryId) : [];
|
|
},
|
|
[isCardMode, cardColumnLabels],
|
|
);
|
|
|
|
const getFieldDisplayName = useCallback(
|
|
(fieldName: string): string => labelMap.get(fieldName) || fieldName,
|
|
[labelMap],
|
|
);
|
|
|
|
const summaryText = useMemo(() => {
|
|
const validRules = rules.filter((r) => r.field && (isCardMode || r.queryId));
|
|
if (validRules.length === 0) return null;
|
|
|
|
const parts = validRules.map((rule) => {
|
|
const op = OPERATORS.find((o) => o.value === rule.operator);
|
|
if (!op) return null;
|
|
const fieldLabel = labelMap.get(rule.field) || rule.field;
|
|
const condPart = op.summary.replace("$V", rule.value || "?");
|
|
return `${fieldLabel}${condPart}`;
|
|
}).filter(Boolean);
|
|
|
|
if (parts.length === 0) return null;
|
|
|
|
const conditionStr = parts.join(", ");
|
|
const actionPart = action === "show"
|
|
? `이 ${componentLabel}을(를) 보여줍니다`
|
|
: `이 ${componentLabel}을(를) 숨깁니다`;
|
|
|
|
if (parts.length === 1) {
|
|
return `${conditionStr} ${actionPart}`;
|
|
}
|
|
return `다음 조건을 모두 만족할 때 ${actionPart}: ${conditionStr}`;
|
|
}, [rules, isCardMode, labelMap, componentLabel, action]);
|
|
|
|
const hasNoDataSource = isCardMode
|
|
? !cardTableName || (cardColumnLabels?.length ?? 0) === 0
|
|
: false;
|
|
|
|
return (
|
|
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-800">
|
|
{action === "hide" ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
표시 조건
|
|
{rules.length > 0 && (
|
|
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-blue-100 px-1.5 text-[10px] font-bold text-blue-700">
|
|
{rules.length}
|
|
</span>
|
|
)}
|
|
<TooltipProvider delayDuration={80}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" className="inline-flex">
|
|
<HelpCircle className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="right"
|
|
className="max-w-[300px] bg-gray-700 text-white text-xs leading-relaxed space-y-2 py-2.5"
|
|
>
|
|
<p>
|
|
데이터 값에 따라 이 {componentLabel}을(를) 자동으로
|
|
보이거나 숨길 수 있습니다.
|
|
</p>
|
|
<div className="border-t border-gray-500 pt-2 space-y-1">
|
|
<p className="font-semibold text-gray-300">여러 조건 사용</p>
|
|
<p>
|
|
조건을 여러 개 추가하면 모든 조건을 동시에 만족해야
|
|
결과가 적용됩니다.
|
|
</p>
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
{rules.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={removeAllRules}
|
|
className="h-6 px-2 text-xs text-red-500 hover:bg-red-50 gap-1"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
전체 삭제
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 본문 */}
|
|
<div className="px-4 pb-4 space-y-3">
|
|
{hasNoDataSource ? (
|
|
<p className="text-xs text-gray-400 text-center py-4">
|
|
{!cardTableName
|
|
? "데이터 연결 탭에서 테이블을 먼저 선택해주세요."
|
|
: "레이아웃 구성 탭에서 데이터 항목을 먼저 추가해주세요."}
|
|
</p>
|
|
) : rules.length === 0 ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full text-xs"
|
|
onClick={addRule}
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
조건 추가
|
|
</Button>
|
|
) : (
|
|
<>
|
|
{/* 결과 동작 (공통) */}
|
|
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2.5">
|
|
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">결과</p>
|
|
<Select
|
|
value={action}
|
|
onValueChange={(v) => updateAction(v as "show" | "hide")}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="show">
|
|
조건을 만족하면 이 {componentLabel}을(를) 보여줍니다
|
|
</SelectItem>
|
|
<SelectItem value="hide">
|
|
조건을 만족하면 이 {componentLabel}을(를) 숨깁니다
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 조건 목록 */}
|
|
{rules.map((rule, index) => (
|
|
<RuleEditor
|
|
key={index}
|
|
index={index}
|
|
rule={rule}
|
|
isCardMode={isCardMode}
|
|
queries={queries}
|
|
fields={getFieldsForRule(rule)}
|
|
getFieldDisplayName={getFieldDisplayName}
|
|
onUpdate={updateRule}
|
|
onRemove={removeRule}
|
|
showAndLabel={index > 0}
|
|
/>
|
|
))}
|
|
|
|
{/* 조건 추가 버튼 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full text-xs"
|
|
onClick={addRule}
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
표시 조건 추가
|
|
</Button>
|
|
|
|
{/* 요약 */}
|
|
{summaryText && (
|
|
<div className="rounded-lg border border-blue-100 bg-blue-50/50 px-3 py-2.5">
|
|
<p className="text-[11px] text-blue-500 mb-1 font-medium">요약</p>
|
|
<p className="text-xs text-blue-700 leading-relaxed">{summaryText}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 개별 조건 편집기 */
|
|
interface RuleEditorProps {
|
|
index: number;
|
|
rule: ConditionalRule;
|
|
isCardMode: boolean;
|
|
queries: { id: string; name: string }[];
|
|
fields: string[];
|
|
getFieldDisplayName: (fieldName: string) => string;
|
|
onUpdate: (index: number, patch: Partial<ConditionalRule>) => void;
|
|
onRemove: (index: number) => void;
|
|
showAndLabel: boolean;
|
|
}
|
|
|
|
function RuleEditor({
|
|
index,
|
|
rule,
|
|
isCardMode,
|
|
queries,
|
|
fields,
|
|
getFieldDisplayName,
|
|
onUpdate,
|
|
onRemove,
|
|
showAndLabel,
|
|
}: RuleEditorProps) {
|
|
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator);
|
|
|
|
return (
|
|
<div className="space-y-0">
|
|
{showAndLabel && (
|
|
<div className="flex items-center gap-2 py-1">
|
|
<div className="flex-1 border-t border-dashed border-gray-300" />
|
|
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">동시 만족</span>
|
|
<div className="flex-1 border-t border-dashed border-gray-300" />
|
|
</div>
|
|
)}
|
|
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2.5 relative group">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
|
조건 {index + 1}
|
|
</p>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onRemove(index)}
|
|
className="h-5 w-5 p-0 text-gray-400 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{!isCardMode && (
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500">데이터 출처</label>
|
|
<Select
|
|
value={rule.queryId || ""}
|
|
onValueChange={(v) => onUpdate(index, { queryId: v, field: "" })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="데이터를 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{queries.map((q) => (
|
|
<SelectItem key={q.id} value={q.id}>
|
|
{q.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500">확인할 항목</label>
|
|
<Select
|
|
value={rule.field || ""}
|
|
onValueChange={(v) => onUpdate(index, { field: v })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="항목을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{fields.map((f) => (
|
|
<SelectItem key={f} value={f} className="text-xs">
|
|
{getFieldDisplayName(f)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className={needsValue ? "grid grid-cols-2 gap-2" : ""}>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500">조건</label>
|
|
<Select
|
|
value={rule.operator}
|
|
onValueChange={(v) =>
|
|
onUpdate(index, { operator: v as ConditionalRule["operator"] })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
{rule.operator ? (
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600">
|
|
{OPERATORS.find((o) => o.value === rule.operator)?.symbol}
|
|
</span>
|
|
{OPERATORS.find((o) => o.value === rule.operator)?.label}
|
|
</span>
|
|
) : (
|
|
<SelectValue placeholder="조건 선택" />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{OPERATOR_GROUPS.map((group, gi) => (
|
|
<div key={group.key}>
|
|
{gi > 0 && <div className="mx-2 my-1 border-t border-gray-100" />}
|
|
<div className="px-2 py-1 text-[10px] font-semibold text-gray-400">
|
|
{group.label}
|
|
</div>
|
|
{OPERATORS.filter((op) => op.group === group.key).map((op) => (
|
|
<SelectItem key={op.value} value={op.value} className="text-xs">
|
|
<span className="flex items-center gap-2">
|
|
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600">
|
|
{op.symbol}
|
|
</span>
|
|
{op.label}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</div>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{needsValue && (
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-500">기준 값</label>
|
|
<Input
|
|
value={rule.value}
|
|
onChange={(e) => onUpdate(index, { value: e.target.value })}
|
|
placeholder="값 입력"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|