ERP-node/frontend/lib/registry/pop-components/pop-field/PopFieldComponent.tsx

865 lines
28 KiB
TypeScript
Raw Normal View History

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