feat: field-only INSERT + pop-field key 수정 + 모달 접근성 개선
- popActionRoutes.ts: 카드리스트 없이 필드만으로 INSERT 가능 (field-only 분기) - PopFieldComponent.tsx: React duplicate key 에러 수정 (staticOptions 문자열 변환 + key fallback) - pop-field/index.tsx: preview nested map key fallback - PopViewerWithModals.tsx: 모달 제목 없을 때 sr-only 접근성 처리 - PopWorkDetailComponent.tsx: 모달 내부 헤더 중복 제거 + isInModal 자동 감지
This commit is contained in:
@@ -183,7 +183,8 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
|
||||
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
|
||||
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0 && items.length > 0) {
|
||||
// ── 카드리스트 기반 INSERT (기존: items 반복) ──
|
||||
if (!isSafeIdentifier(cardMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
@@ -300,6 +301,84 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
// ── 필드 단독 INSERT (카드리스트 없이 pop-field만으로 저장) ──
|
||||
items.length === 0 &&
|
||||
fieldMapping?.targetTable &&
|
||||
Object.keys(fieldMapping.columnMapping).length > 0 &&
|
||||
Object.keys(fieldValues).length > 0
|
||||
) {
|
||||
if (!isSafeIdentifier(fieldMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
|
||||
}
|
||||
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
// 필드 매핑 값 추가
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
|
||||
// hiddenMappings 처리
|
||||
for (const hm of (fieldMapping.hiddenMappings ?? [])) {
|
||||
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||
let value: unknown = null;
|
||||
if (hm.valueSource === "static") {
|
||||
value = hm.staticValue ?? null;
|
||||
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||
value = fieldValues[hm.sourceDbColumn] ?? null;
|
||||
}
|
||||
columns.push(`"${hm.targetColumn}"`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
// autoGenMappings 채번 처리
|
||||
for (const ag of (fieldMapping.autoGenMappings ?? [])) {
|
||||
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||
try {
|
||||
const generatedCode = await numberingRuleService.allocateCode(
|
||||
ag.numberingRuleId, companyCode, fieldValues,
|
||||
);
|
||||
columns.push(`"${ag.targetColumn}"`);
|
||||
values.push(generatedCode);
|
||||
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||
} catch (err: any) {
|
||||
logger.error("[pop/execute-action] 필드 단독 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 필드 추가 (created_date, updated_date, writer)
|
||||
if (!columns.includes('"created_date"')) {
|
||||
columns.push('"created_date"');
|
||||
values.push(new Date().toISOString());
|
||||
}
|
||||
if (!columns.includes('"updated_date"')) {
|
||||
columns.push('"updated_date"');
|
||||
values.push(new Date().toISOString());
|
||||
}
|
||||
if (!columns.includes('"writer"') && userId) {
|
||||
columns.push('"writer"');
|
||||
values.push(userId);
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await client.query(
|
||||
`INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
values,
|
||||
);
|
||||
insertedCount++;
|
||||
logger.info("[pop/execute-action] 필드 단독 INSERT 실행", {
|
||||
table: fieldMapping.targetTable,
|
||||
columnCount: columns.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -231,12 +231,14 @@ export default function PopViewerWithModals({
|
||||
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{definition.title && (
|
||||
{definition.title ? (
|
||||
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
|
||||
<DialogTitle className="text-base">
|
||||
{definition.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DialogTitle className="sr-only">팝업</DialogTitle>
|
||||
)}
|
||||
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
|
||||
<PopRenderer
|
||||
|
||||
@@ -464,13 +464,13 @@ export function PopFieldComponent({
|
||||
ref={containerRef}
|
||||
className="flex h-full w-full flex-col gap-2 overflow-auto p-1"
|
||||
>
|
||||
{cfg.sections.map((section) => {
|
||||
{cfg.sections.map((section, sIdx) => {
|
||||
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)}>
|
||||
<div key={section.id || `section-${sIdx}`} className={sectionClassName(section)}>
|
||||
{section.label && (
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{section.label}
|
||||
@@ -480,17 +480,17 @@ export function PopFieldComponent({
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
|
||||
>
|
||||
{fields.map((field) => {
|
||||
{fields.map((field, fIdx) => {
|
||||
const fKey = field.fieldName || field.id;
|
||||
return (
|
||||
<FieldRenderer
|
||||
key={field.id}
|
||||
key={field.id || `field-${sIdx}-${fIdx}`}
|
||||
field={{ ...field, fieldName: fKey }}
|
||||
value={allValues[fKey]}
|
||||
showLabel={section.showLabels}
|
||||
error={errors[fKey]}
|
||||
onChange={handleFieldChange}
|
||||
sectionStyle={section.style}
|
||||
sectionStyle={migrateStyle(section.style)}
|
||||
allValues={allValues}
|
||||
fieldIdToName={fieldIdToName}
|
||||
/>
|
||||
@@ -506,8 +506,8 @@ export function PopFieldComponent({
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `repeat(${resolveColumns("auto", visibleAutoGens.length)}, 1fr)` }}
|
||||
>
|
||||
{visibleAutoGens.map((ag) => (
|
||||
<AutoGenFieldDisplay key={ag.id} mapping={ag} />
|
||||
{visibleAutoGens.map((ag, agIdx) => (
|
||||
<AutoGenFieldDisplay key={ag.id || `autogen-${agIdx}`} mapping={ag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -546,7 +546,7 @@ function FieldRenderer({
|
||||
[onChange, field.fieldName]
|
||||
);
|
||||
|
||||
const resolvedStyle = sectionStyle === "summary" ? "display" : sectionStyle === "form" ? "input" : sectionStyle;
|
||||
const resolvedStyle = sectionStyle;
|
||||
const inputClassName = cn(
|
||||
"h-9 w-full rounded-md border px-3 text-sm",
|
||||
field.readOnly
|
||||
@@ -740,7 +740,12 @@ function SelectFieldInput({
|
||||
if (!source) return;
|
||||
|
||||
if (source.type === "static" && source.staticOptions) {
|
||||
setOptions(source.staticOptions);
|
||||
setOptions(
|
||||
source.staticOptions.map((o) => ({
|
||||
value: String(o.value ?? ""),
|
||||
label: String(o.label ?? ""),
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -850,8 +855,8 @@ function SelectFieldInput({
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
options.map((opt, idx) => (
|
||||
<SelectItem key={opt.value || `opt-${idx}`} value={opt.value || `__empty_${idx}`}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))
|
||||
|
||||
@@ -30,10 +30,10 @@ function PopFieldPreviewComponent({
|
||||
{label || "입력 필드"}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cfg.sections.map((section) =>
|
||||
(section.fields || []).slice(0, 3).map((field) => (
|
||||
{cfg.sections.map((section, sIdx) =>
|
||||
(section.fields || []).slice(0, 3).map((field, fIdx) => (
|
||||
<div
|
||||
key={field.id}
|
||||
key={field.id || `preview-${sIdx}-${fIdx}`}
|
||||
className="flex h-5 items-center rounded border border-dashed border-muted-foreground/30 px-1.5"
|
||||
>
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
|
||||
@@ -288,6 +288,15 @@ export function PopWorkDetailComponent({
|
||||
const { getSharedData, publish } = usePopEvent(screenId || "default");
|
||||
const { user } = useAuth();
|
||||
|
||||
// 모달 내부 렌더링 감지: 부모에 [role="dialog"]가 있으면 모달 모드
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isInModal, setIsInModal] = useState(false);
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
setIsInModal(!!containerRef.current.closest('[role="dialog"]'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cfg: PopWorkDetailConfig = {
|
||||
...DEFAULT_CFG,
|
||||
...config,
|
||||
@@ -880,23 +889,25 @@ export function PopWorkDetailComponent({
|
||||
const woNo = parentRow?.wo_no ? String(parentRow.wo_no) : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-2xl overflow-hidden" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
{/* ── 모달 헤더: 글자 크게 ── */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between border-b border-gray-100 px-6"
|
||||
style={{ height: `${DESIGN.header.height}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="font-bold tracking-tight text-gray-900" style={{ fontSize: 20 }}>작업 상세</h2>
|
||||
{woNo && <span className="font-mono text-gray-400" style={{ fontSize: 14 }}>{woNo}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={() => publish("close_modal", {})}
|
||||
<div ref={containerRef} className="flex h-full flex-col rounded-2xl overflow-hidden" style={{ backgroundColor: DESIGN.bg.card }}>
|
||||
{/* ── 내부 헤더: 모달이 아닐 때만 표시 (모달은 Dialog 자체 헤더 사용) ── */}
|
||||
{!isInModal && (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between border-b border-gray-100 px-6"
|
||||
style={{ height: `${DESIGN.header.height}px` }}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="font-bold tracking-tight text-gray-900" style={{ fontSize: 20 }}>작업 상세</h2>
|
||||
{woNo && <span className="font-mono text-gray-400" style={{ fontSize: 14 }}>{woNo}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={() => publish("__pop_modal_close__", {})}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 정보바: 미니멀 다크 (고정) ── */}
|
||||
{cfg.infoBar.enabled && (
|
||||
|
||||
Reference in New Issue
Block a user