ERP-node/frontend/components/report/designer/properties/ConditionalProperties.tsx

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>
);
}