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:
SeongHyun Kim
2026-03-30 10:16:27 +09:00
parent 6fe7bfbefc
commit 1532184065
5 changed files with 129 additions and 32 deletions

View File

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

View File

@@ -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

View File

@@ -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>
))

View File

@@ -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">

View File

@@ -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 && (