865 lines
28 KiB
TypeScript
865 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Loader2 } from "lucide-react";
|
|
import { usePopEvent } from "@/hooks/pop";
|
|
import { dataApi } from "@/lib/api/data";
|
|
import type {
|
|
PopFieldConfig,
|
|
PopFieldItem,
|
|
PopFieldSection,
|
|
FieldSectionStyle,
|
|
PopFieldReadSource,
|
|
PopFieldAutoGenMapping,
|
|
SelectLinkedFilter,
|
|
} from "./types";
|
|
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
|
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
|
|
|
// ========================================
|
|
// Props
|
|
// ========================================
|
|
|
|
interface PopFieldComponentProps {
|
|
config?: PopFieldConfig;
|
|
screenId?: string;
|
|
componentId?: string;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 컴포넌트
|
|
// ========================================
|
|
|
|
export function PopFieldComponent({
|
|
config,
|
|
screenId,
|
|
componentId,
|
|
}: PopFieldComponentProps) {
|
|
const cfg: PopFieldConfig = {
|
|
...DEFAULT_FIELD_CONFIG,
|
|
...config,
|
|
sections: config?.sections?.length ? config.sections : DEFAULT_FIELD_CONFIG.sections,
|
|
};
|
|
const { publish, subscribe } = usePopEvent(screenId || "default");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [allValues, setAllValues] = useState<Record<string, unknown>>({});
|
|
const [hiddenValues, setHiddenValues] = useState<Record<string, unknown>>({});
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
|
|
const hiddenMappings = cfg.saveConfig?.hiddenMappings ?? [];
|
|
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
|
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
|
|
|
const fieldIdToName = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
for (const section of cfg.sections) {
|
|
for (const f of section.fields ?? []) {
|
|
map[f.id] = f.fieldName || f.id;
|
|
}
|
|
}
|
|
return map;
|
|
}, [cfg.sections]);
|
|
|
|
// ResizeObserver로 컨테이너 너비 감시
|
|
useEffect(() => {
|
|
if (typeof window === "undefined" || !containerRef.current) return;
|
|
const ro = new ResizeObserver(([entry]) => {
|
|
setContainerWidth(entry.contentRect.width);
|
|
});
|
|
ro.observe(containerRef.current);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
// readSource 기반 DB 조회 + JSON 파싱
|
|
const fetchReadSourceData = useCallback(
|
|
async (pkValue: unknown, readSource: PopFieldReadSource) => {
|
|
if (!readSource.tableName || !readSource.pkColumn || !pkValue) return;
|
|
try {
|
|
const res = await dataApi.getTableData(readSource.tableName, {
|
|
page: 1,
|
|
size: 1,
|
|
filters: { [readSource.pkColumn]: String(pkValue) },
|
|
});
|
|
if (!Array.isArray(res.data) || res.data.length === 0) return;
|
|
const row = res.data[0] as Record<string, unknown>;
|
|
|
|
const extracted: Record<string, unknown> = {};
|
|
for (const mapping of readSource.fieldMappings || []) {
|
|
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
|
|
const raw = row[mapping.columnName];
|
|
let parsed: Record<string, unknown> = {};
|
|
if (typeof raw === "string") {
|
|
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
|
|
} else if (typeof raw === "object" && raw !== null) {
|
|
parsed = raw as Record<string, unknown>;
|
|
}
|
|
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
|
|
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
|
|
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
|
|
}
|
|
}
|
|
|
|
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
|
|
const valuesUpdate: Record<string, unknown> = {};
|
|
for (const [fieldId, val] of Object.entries(extracted)) {
|
|
const f = allFieldsInConfig.find((fi) => fi.id === fieldId);
|
|
const key = f?.fieldName || f?.id || fieldId;
|
|
valuesUpdate[key] = val;
|
|
}
|
|
if (Object.keys(valuesUpdate).length > 0) {
|
|
setAllValues((prev) => ({ ...prev, ...valuesUpdate }));
|
|
}
|
|
} catch {
|
|
// 조회 실패 시 무시
|
|
}
|
|
},
|
|
[cfg.sections]
|
|
);
|
|
|
|
// set_value 이벤트 수신 (useConnectionResolver의 enrichedPayload도 처리)
|
|
useEffect(() => {
|
|
if (!componentId) return;
|
|
const unsub = subscribe(
|
|
`__comp_input__${componentId}__set_value`,
|
|
(payload: unknown) => {
|
|
const raw = payload as Record<string, unknown> | undefined;
|
|
if (!raw) return;
|
|
|
|
// useConnectionResolver가 감싼 enrichedPayload인지 확인
|
|
const isConnectionPayload = raw._connectionId !== undefined;
|
|
const actual = isConnectionPayload
|
|
? (raw.value as Record<string, unknown> | undefined)
|
|
: raw;
|
|
if (!actual) return;
|
|
|
|
const data = actual as {
|
|
fieldName?: string;
|
|
value?: unknown;
|
|
values?: Record<string, unknown>;
|
|
pkValue?: unknown;
|
|
};
|
|
|
|
// row 객체가 통째로 온 경우 (pop-card-list selected_row 등)
|
|
if (!data.fieldName && !data.values && !data.pkValue && typeof actual === "object") {
|
|
const rowObj = actual as Record<string, unknown>;
|
|
setAllValues((prev) => ({ ...prev, ...rowObj }));
|
|
// 숨은 필드 값 추출 (valueSource 기반)
|
|
if (hiddenMappings.length > 0) {
|
|
const extracted: Record<string, unknown> = {};
|
|
for (const hm of hiddenMappings) {
|
|
if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
|
if (rowObj[hm.sourceDbColumn] !== undefined) {
|
|
extracted[hm.targetColumn] = rowObj[hm.sourceDbColumn];
|
|
}
|
|
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
|
const raw = rowObj[hm.sourceJsonColumn];
|
|
let parsed: Record<string, unknown> = {};
|
|
if (typeof raw === "string") {
|
|
try { parsed = JSON.parse(raw); } catch { /* ignore */ }
|
|
} else if (typeof raw === "object" && raw !== null) {
|
|
parsed = raw as Record<string, unknown>;
|
|
}
|
|
if (parsed[hm.sourceJsonKey] !== undefined) {
|
|
extracted[hm.targetColumn] = parsed[hm.sourceJsonKey];
|
|
}
|
|
}
|
|
}
|
|
if (Object.keys(extracted).length > 0) {
|
|
setHiddenValues((prev) => ({ ...prev, ...extracted }));
|
|
}
|
|
}
|
|
const pkCol = cfg.readSource?.pkColumn;
|
|
const pkVal = pkCol ? rowObj[pkCol] : undefined;
|
|
if (pkVal && cfg.readSource) {
|
|
fetchReadSourceData(pkVal, cfg.readSource);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.values) {
|
|
setAllValues((prev) => ({ ...prev, ...data.values }));
|
|
} else if (data.fieldName) {
|
|
setAllValues((prev) => ({
|
|
...prev,
|
|
[data.fieldName!]: data.value,
|
|
}));
|
|
}
|
|
if (data.pkValue && cfg.readSource) {
|
|
fetchReadSourceData(data.pkValue, cfg.readSource);
|
|
}
|
|
}
|
|
);
|
|
return unsub;
|
|
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
|
|
|
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
|
|
useEffect(() => {
|
|
if (!componentId) return;
|
|
const unsub = subscribe(
|
|
`__comp_input__${componentId}__collect_data`,
|
|
(payload: unknown) => {
|
|
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
|
|
|
|
const response: CollectedDataResponse = {
|
|
requestId: request?.requestId ?? "",
|
|
componentId: componentId,
|
|
componentType: "pop-field",
|
|
data: { values: allValues },
|
|
mapping: cfg.saveConfig?.tableName
|
|
? {
|
|
targetTable: cfg.saveConfig.tableName,
|
|
columnMapping: Object.fromEntries(
|
|
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
|
),
|
|
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
|
|
.filter((m) => m.numberingRuleId)
|
|
.map((m) => ({
|
|
numberingRuleId: m.numberingRuleId!,
|
|
targetColumn: m.targetColumn,
|
|
showResultModal: m.showResultModal,
|
|
})),
|
|
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
|
|
.filter((m) => m.targetColumn)
|
|
.map((m) => ({
|
|
valueSource: m.valueSource,
|
|
targetColumn: m.targetColumn,
|
|
staticValue: m.staticValue,
|
|
sourceJsonColumn: m.sourceJsonColumn,
|
|
sourceJsonKey: m.sourceJsonKey,
|
|
sourceDbColumn: m.sourceDbColumn,
|
|
})),
|
|
}
|
|
: null,
|
|
};
|
|
|
|
publish(`__comp_output__${componentId}__collected_data`, response);
|
|
}
|
|
);
|
|
return unsub;
|
|
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
|
|
|
|
// 필드 값 변경 핸들러
|
|
const handleFieldChange = useCallback(
|
|
(fieldName: string, value: unknown) => {
|
|
setAllValues((prev) => {
|
|
const next = { ...prev, [fieldName]: value };
|
|
if (componentId) {
|
|
publish(`__comp_output__${componentId}__value_changed`, {
|
|
fieldName,
|
|
value,
|
|
allValues: next,
|
|
hiddenValues,
|
|
targetTable: cfg.saveConfig?.tableName || cfg.targetTable,
|
|
saveConfig: cfg.saveConfig,
|
|
readSource: cfg.readSource,
|
|
});
|
|
}
|
|
return next;
|
|
});
|
|
setErrors((prev) => {
|
|
if (!prev[fieldName]) return prev;
|
|
const next = { ...prev };
|
|
delete next[fieldName];
|
|
return next;
|
|
});
|
|
},
|
|
[componentId, publish, cfg.targetTable, cfg.saveConfig, cfg.readSource, hiddenValues]
|
|
);
|
|
|
|
// readSource 설정 시 자동 샘플 데이터 조회 (디자인 모드 미리보기)
|
|
const readSourceKey = cfg.readSource
|
|
? `${cfg.readSource.tableName}__${cfg.readSource.pkColumn}__${(cfg.readSource.fieldMappings || []).map((m) => `${m.fieldId}:${m.columnName}:${m.jsonKey || ""}`).join(",")}`
|
|
: "";
|
|
const previewFetchedRef = useRef("");
|
|
useEffect(() => {
|
|
if (!cfg.readSource?.tableName || !cfg.readSource.fieldMappings?.length) return;
|
|
if (previewFetchedRef.current === readSourceKey) return;
|
|
previewFetchedRef.current = readSourceKey;
|
|
|
|
(async () => {
|
|
try {
|
|
const res = await dataApi.getTableData(cfg.readSource!.tableName, {
|
|
page: 1,
|
|
size: 1,
|
|
});
|
|
if (!Array.isArray(res.data) || res.data.length === 0) return;
|
|
const row = res.data[0] as Record<string, unknown>;
|
|
|
|
const extracted: Record<string, unknown> = {};
|
|
for (const mapping of cfg.readSource!.fieldMappings || []) {
|
|
if (mapping.valueSource === "json_extract" && mapping.columnName && mapping.jsonKey) {
|
|
const rawVal = row[mapping.columnName];
|
|
let parsed: Record<string, unknown> = {};
|
|
if (typeof rawVal === "string") {
|
|
try { parsed = JSON.parse(rawVal); } catch { /* ignore */ }
|
|
} else if (typeof rawVal === "object" && rawVal !== null) {
|
|
parsed = rawVal as Record<string, unknown>;
|
|
}
|
|
extracted[mapping.fieldId] = parsed[mapping.jsonKey] ?? "";
|
|
} else if (mapping.valueSource === "db_column" && mapping.columnName) {
|
|
extracted[mapping.fieldId] = row[mapping.columnName] ?? "";
|
|
}
|
|
}
|
|
|
|
const allFieldsInConfig = cfg.sections.flatMap((s) => s.fields || []);
|
|
const valuesUpdate: Record<string, unknown> = {};
|
|
for (const [fieldId, val] of Object.entries(extracted)) {
|
|
const f = allFieldsInConfig.find((fi) => fi.id === fieldId);
|
|
const key = f?.fieldName || f?.id || fieldId;
|
|
valuesUpdate[key] = val;
|
|
}
|
|
if (Object.keys(valuesUpdate).length > 0) {
|
|
setAllValues((prev) => ({ ...prev, ...valuesUpdate }));
|
|
}
|
|
} catch {
|
|
// 미리보기 조회 실패 시 무시
|
|
}
|
|
})();
|
|
}, [readSourceKey, cfg.readSource, cfg.sections]);
|
|
|
|
// "auto" 열 수 계산
|
|
function resolveColumns(
|
|
columns: "auto" | 1 | 2 | 3 | 4,
|
|
fieldCount: number
|
|
): number {
|
|
if (columns !== "auto") return columns;
|
|
if (containerWidth < 200) return 1;
|
|
if (containerWidth < 400) return Math.min(2, fieldCount);
|
|
if (containerWidth < 600) return Math.min(3, fieldCount);
|
|
return Math.min(4, fieldCount);
|
|
}
|
|
|
|
function migrateStyle(style: string): FieldSectionStyle {
|
|
if (style === "display" || style === "input") return style;
|
|
if (style === "summary") return "display";
|
|
if (style === "form") return "input";
|
|
return "input";
|
|
}
|
|
|
|
function sectionClassName(section: PopFieldSection): string {
|
|
const resolved = migrateStyle(section.style);
|
|
const defaults = DEFAULT_SECTION_APPEARANCES[resolved];
|
|
const a = section.appearance || {};
|
|
const bg = a.bgColor || defaults.bgColor;
|
|
const border = a.borderColor || defaults.borderColor;
|
|
return cn("rounded-lg border px-4", bg, border, resolved === "display" ? "py-2" : "py-3");
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="flex h-full w-full flex-col gap-2 overflow-auto p-1"
|
|
>
|
|
{cfg.sections.map((section) => {
|
|
const fields = section.fields || [];
|
|
const fieldCount = fields.length;
|
|
if (fieldCount === 0) return null;
|
|
const cols = resolveColumns(section.columns, fieldCount);
|
|
return (
|
|
<div key={section.id} className={sectionClassName(section)}>
|
|
{section.label && (
|
|
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
|
{section.label}
|
|
</div>
|
|
)}
|
|
<div
|
|
className="grid gap-3"
|
|
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
|
|
>
|
|
{fields.map((field) => {
|
|
const fKey = field.fieldName || field.id;
|
|
return (
|
|
<FieldRenderer
|
|
key={field.id}
|
|
field={{ ...field, fieldName: fKey }}
|
|
value={allValues[fKey]}
|
|
showLabel={section.showLabels}
|
|
error={errors[fKey]}
|
|
onChange={handleFieldChange}
|
|
sectionStyle={section.style}
|
|
allValues={allValues}
|
|
fieldIdToName={fieldIdToName}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{visibleAutoGens.length > 0 && (
|
|
<div className="rounded-lg border border-border bg-background px-4 py-3">
|
|
<div
|
|
className="grid gap-3"
|
|
style={{ gridTemplateColumns: `repeat(${resolveColumns("auto", visibleAutoGens.length)}, 1fr)` }}
|
|
>
|
|
{visibleAutoGens.map((ag) => (
|
|
<AutoGenFieldDisplay key={ag.id} mapping={ag} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// FieldRenderer: 개별 필드 렌더링
|
|
// ========================================
|
|
|
|
interface FieldRendererProps {
|
|
field: PopFieldItem;
|
|
value: unknown;
|
|
showLabel: boolean;
|
|
error?: string;
|
|
onChange: (fieldName: string, value: unknown) => void;
|
|
sectionStyle: FieldSectionStyle;
|
|
allValues?: Record<string, unknown>;
|
|
fieldIdToName?: Record<string, string>;
|
|
}
|
|
|
|
function FieldRenderer({
|
|
field,
|
|
value,
|
|
showLabel,
|
|
error,
|
|
onChange,
|
|
sectionStyle,
|
|
allValues,
|
|
fieldIdToName,
|
|
}: FieldRendererProps) {
|
|
const handleChange = useCallback(
|
|
(v: unknown) => onChange(field.fieldName, v),
|
|
[onChange, field.fieldName]
|
|
);
|
|
|
|
const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle;
|
|
const inputClassName = cn(
|
|
"h-9 w-full rounded-md border px-3 text-sm",
|
|
field.readOnly
|
|
? "cursor-default bg-muted text-muted-foreground"
|
|
: "bg-background",
|
|
resolvedStyle === "display" &&
|
|
"border-transparent bg-transparent text-sm font-medium"
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{showLabel && field.labelText && (
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
{field.labelText}
|
|
{field.validation?.required && (
|
|
<span className="ml-0.5 text-destructive">*</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
{renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)}
|
|
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 서브타입별 렌더링 분기
|
|
// ========================================
|
|
|
|
function renderByType(
|
|
field: PopFieldItem,
|
|
value: unknown,
|
|
onChange: (v: unknown) => void,
|
|
className: string,
|
|
allValues?: Record<string, unknown>,
|
|
fieldIdToName?: Record<string, string>,
|
|
) {
|
|
switch (field.inputType) {
|
|
case "text":
|
|
return (
|
|
<Input
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
readOnly={field.readOnly}
|
|
placeholder={field.placeholder}
|
|
className={className}
|
|
/>
|
|
);
|
|
case "number":
|
|
return (
|
|
<NumberFieldInput
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
className={className}
|
|
/>
|
|
);
|
|
case "date":
|
|
return (
|
|
<Input
|
|
type="date"
|
|
value={String(value ?? "")}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
readOnly={field.readOnly}
|
|
className={className}
|
|
/>
|
|
);
|
|
case "select":
|
|
return (
|
|
<SelectFieldInput
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
className={className}
|
|
allValues={allValues}
|
|
fieldIdToName={fieldIdToName}
|
|
/>
|
|
);
|
|
case "auto":
|
|
return <AutoFieldInput field={field} value={value} className={className} />;
|
|
case "numpad":
|
|
return (
|
|
<NumpadFieldInput
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
className={className}
|
|
/>
|
|
);
|
|
default:
|
|
return (
|
|
<Input
|
|
value={String(value ?? "")}
|
|
readOnly
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// NumberFieldInput
|
|
// ========================================
|
|
|
|
function NumberFieldInput({
|
|
field,
|
|
value,
|
|
onChange,
|
|
className,
|
|
}: {
|
|
field: PopFieldItem;
|
|
value: unknown;
|
|
onChange: (v: unknown) => void;
|
|
className: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type="number"
|
|
value={value !== undefined && value !== null ? String(value) : ""}
|
|
onChange={(e) => {
|
|
const num = e.target.value === "" ? "" : Number(e.target.value);
|
|
onChange(num);
|
|
}}
|
|
readOnly={field.readOnly}
|
|
placeholder={field.placeholder}
|
|
min={field.validation?.min}
|
|
max={field.validation?.max}
|
|
className={cn(className, "flex-1")}
|
|
/>
|
|
{field.unit && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{field.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// SelectFieldInput
|
|
// ========================================
|
|
|
|
function SelectFieldInput({
|
|
field,
|
|
value,
|
|
onChange,
|
|
className,
|
|
allValues,
|
|
fieldIdToName,
|
|
}: {
|
|
field: PopFieldItem;
|
|
value: unknown;
|
|
onChange: (v: unknown) => void;
|
|
className: string;
|
|
allValues?: Record<string, unknown>;
|
|
fieldIdToName?: Record<string, string>;
|
|
}) {
|
|
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
|
[]
|
|
);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const source = field.selectSource;
|
|
const linkedFilters = source?.linkedFilters;
|
|
const hasLinkedFilters = !!linkedFilters?.length;
|
|
|
|
// 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화
|
|
const linkedFilterKey = useMemo(() => {
|
|
if (!hasLinkedFilters || !allValues || !fieldIdToName) return "";
|
|
return linkedFilters!
|
|
.map((lf) => {
|
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
|
const val = allValues[fieldName] ?? "";
|
|
return `${lf.filterColumn}=${String(val)}`;
|
|
})
|
|
.join("&");
|
|
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
|
|
|
// 연동 필터의 소스 값이 모두 채워졌는지 확인
|
|
const linkedFiltersFilled = useMemo(() => {
|
|
if (!hasLinkedFilters || !allValues || !fieldIdToName) return true;
|
|
return linkedFilters!.every((lf) => {
|
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
|
const val = allValues[fieldName];
|
|
return val != null && val !== "";
|
|
});
|
|
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
|
|
|
useEffect(() => {
|
|
if (!source) return;
|
|
|
|
if (source.type === "static" && source.staticOptions) {
|
|
setOptions(source.staticOptions);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
source.type === "table" &&
|
|
source.tableName &&
|
|
source.valueColumn &&
|
|
source.labelColumn
|
|
) {
|
|
// 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시
|
|
if (hasLinkedFilters && !linkedFiltersFilled) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
// 동적 필터 구성
|
|
const dynamicFilters: Record<string, string> = {};
|
|
if (hasLinkedFilters && allValues && fieldIdToName) {
|
|
for (const lf of linkedFilters!) {
|
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
|
const val = allValues[fieldName];
|
|
if (val != null && val !== "" && lf.filterColumn) {
|
|
dynamicFilters[lf.filterColumn] = String(val);
|
|
}
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
dataApi
|
|
.getTableData(source.tableName, {
|
|
page: 1,
|
|
size: 500,
|
|
sortBy: source.labelColumn,
|
|
sortOrder: "asc",
|
|
...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}),
|
|
})
|
|
.then((res) => {
|
|
if (Array.isArray(res.data)) {
|
|
const seen = new Set<string>();
|
|
const deduped: { value: string; label: string }[] = [];
|
|
for (const row of res.data) {
|
|
const v = String(row[source.valueColumn!] ?? "");
|
|
if (!v || seen.has(v)) continue;
|
|
seen.add(v);
|
|
deduped.push({ value: v, label: String(row[source.labelColumn!] ?? "") });
|
|
}
|
|
setOptions(deduped);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setOptions([]);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}
|
|
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]);
|
|
|
|
// W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화
|
|
useEffect(() => {
|
|
if (!hasLinkedFilters || !value || loading) return;
|
|
const currentStr = String(value);
|
|
if (options.length > 0 && !options.some((o) => o.value === currentStr)) {
|
|
onChange("");
|
|
}
|
|
}, [options, hasLinkedFilters]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className={cn(className, "flex items-center justify-center")}>
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (field.readOnly) {
|
|
const selectedLabel =
|
|
options.find((o) => o.value === String(value ?? ""))?.label ??
|
|
String(value ?? "-");
|
|
return (
|
|
<Input value={selectedLabel} readOnly className={className} />
|
|
);
|
|
}
|
|
|
|
if (!source) {
|
|
return (
|
|
<div className={cn(className, "flex items-center text-muted-foreground")}>
|
|
옵션 소스를 설정해주세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// W2: 연동 필터의 소스 값이 비어있으면 안내 메시지
|
|
const emptyMessage = hasLinkedFilters && !linkedFiltersFilled
|
|
? "상위 필드를 먼저 선택하세요"
|
|
: "옵션이 없습니다";
|
|
|
|
return (
|
|
<Select
|
|
value={String(value ?? "")}
|
|
onValueChange={(v) => onChange(v)}
|
|
>
|
|
<SelectTrigger className={cn(className, "justify-between")}>
|
|
<SelectValue placeholder={field.placeholder || "선택"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.length === 0 ? (
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
options.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// AutoFieldInput (자동 채번 - 읽기전용)
|
|
// ========================================
|
|
|
|
function AutoFieldInput({
|
|
field,
|
|
value,
|
|
className,
|
|
}: {
|
|
field: PopFieldItem;
|
|
value: unknown;
|
|
className: string;
|
|
}) {
|
|
const displayValue = useMemo(() => {
|
|
if (value) return String(value);
|
|
if (!field.autoNumber) return "자동생성";
|
|
|
|
const { prefix, separator, dateFormat, sequenceDigits } = field.autoNumber;
|
|
const parts: string[] = [];
|
|
if (prefix) parts.push(prefix);
|
|
if (dateFormat) {
|
|
const now = new Date();
|
|
const dateStr = dateFormat
|
|
.replace("YYYY", String(now.getFullYear()))
|
|
.replace("MM", String(now.getMonth() + 1).padStart(2, "0"))
|
|
.replace("DD", String(now.getDate()).padStart(2, "0"));
|
|
parts.push(dateStr);
|
|
}
|
|
if (sequenceDigits) {
|
|
parts.push("0".repeat(sequenceDigits));
|
|
}
|
|
return parts.join(separator || "-") || "자동생성";
|
|
}, [value, field.autoNumber]);
|
|
|
|
return (
|
|
<Input
|
|
value={displayValue}
|
|
readOnly
|
|
className={cn(className, "cursor-default bg-muted text-muted-foreground")}
|
|
placeholder="자동생성"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// AutoGenFieldDisplay (자동생성 필드 - showInForm일 때 표시)
|
|
// ========================================
|
|
|
|
function AutoGenFieldDisplay({ mapping }: { mapping: PopFieldAutoGenMapping }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
{mapping.label && (
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
{mapping.label}
|
|
</label>
|
|
)}
|
|
<div className="flex h-9 items-center rounded-md border border-dashed border-muted-foreground/30 bg-muted px-3">
|
|
<span className="text-xs text-muted-foreground">
|
|
저장 시 자동발급
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// NumpadFieldInput (클릭 시 숫자 직접 입력)
|
|
// ========================================
|
|
|
|
function NumpadFieldInput({
|
|
field,
|
|
value,
|
|
onChange,
|
|
className,
|
|
}: {
|
|
field: PopFieldItem;
|
|
value: unknown;
|
|
onChange: (v: unknown) => void;
|
|
className: string;
|
|
}) {
|
|
const displayValue =
|
|
value !== undefined && value !== null ? String(value) : "";
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type="number"
|
|
value={displayValue}
|
|
onChange={(e) => {
|
|
const num = e.target.value === "" ? "" : Number(e.target.value);
|
|
onChange(num);
|
|
}}
|
|
readOnly={field.readOnly}
|
|
placeholder={field.placeholder || "수량 입력"}
|
|
className={cn(className, "flex-1")}
|
|
/>
|
|
{field.unit && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{field.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|