WIP: preset + inspection (임시, 나중에 squash)

This commit is contained in:
SeongHyun Kim
2026-03-27 17:05:36 +09:00
parent f10946ae5b
commit eacfe60f89
9 changed files with 2454 additions and 7 deletions
@@ -231,11 +231,13 @@ export default function PopViewerWithModals({
if (!isTopModal || !closeOnEsc) e.preventDefault();
}}
>
<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>
{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>
)}
<div className={isFull ? "flex-1 overflow-auto" : "px-4 pb-4"}>
<PopRenderer
layout={modalLayout}
@@ -0,0 +1,481 @@
"use client";
/**
* 검사 모달 컴포넌트
*
* 카드 하단 "검사" 행 클릭 시 열리는 모달.
* - 검사 항목 목록 (item_inspection_info 기반)
* - 각 항목별 측정값 입력 + 합불 판정
* - 종합 판정 + 비고
* - 검사 완료 버튼
*/
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Loader2, CheckCircle2, XCircle, AlertCircle, ClipboardList } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
// ===== 타입 =====
interface InspectionInfoItem {
id: string;
item_id: string;
item_code: string;
item_name: string;
inspection_type: string;
inspection_item_name: string;
inspection_standard: string;
pass_criteria: string;
is_required: string;
sort_order: string;
memo: string;
}
interface InspectionResultItem {
inspectionInfoId: string;
inspectionItemName: string;
inspectionStandard: string;
passCriteria: string;
isRequired: string;
measuredValue: string;
judgment: "pass" | "fail" | "";
}
interface InspectionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 카드 행 데이터 */
rowData: Record<string, unknown>;
/** 품목 코드 (item_inspection_info 조회용) */
itemCode?: string;
/** 품목명 */
itemName?: string;
/** 참조 ID (검사 결과 저장 key) */
referenceId?: string;
/** 참조 테이블 */
referenceTable?: string;
/** 화면 ID */
screenId?: string;
/** 검사 유형 필터 */
inspectionType?: string;
/** 저장 완료 콜백 */
onSaved?: (overallJudgment: "pass" | "fail") => void;
}
// ===== 판정 배지 =====
function JudgmentBadge({ judgment }: { judgment: "pass" | "fail" | "" }) {
if (!judgment) return null;
return judgment === "pass" ? (
<Badge className="bg-green-600 text-white hover:bg-green-700"></Badge>
) : (
<Badge className="bg-red-600 text-white hover:bg-red-700"></Badge>
);
}
// ===== 메인 컴포넌트 =====
export function InspectionModal({
open,
onOpenChange,
rowData,
itemCode,
itemName,
referenceId,
referenceTable,
screenId,
inspectionType,
onSaved,
}: InspectionModalProps) {
const [infoItems, setInfoItems] = useState<InspectionInfoItem[]>([]);
const [resultItems, setResultItems] = useState<InspectionResultItem[]>([]);
const [overallJudgment, setOverallJudgment] = useState<"pass" | "fail" | "">("");
const [memo, setMemo] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 검사 항목 + 기존 결과 로드
const loadData = useCallback(async () => {
if (!open) return;
setLoading(true);
setError(null);
try {
// 검사 기준 조회
const params = new URLSearchParams();
if (itemCode) params.set("itemCode", itemCode);
if (inspectionType) params.set("inspectionType", inspectionType);
const infoRes = await apiClient.get<{ success: boolean; data: InspectionInfoItem[] }>(
`/pop/inspection-result/info?${params.toString()}`
);
const infoData = infoRes.data?.data || [];
setInfoItems(infoData);
// 기존 결과 조회
let existingMap: Record<string, { measuredValue: string; judgment: "pass" | "fail" | "" }> = {};
if (referenceId && referenceTable) {
const resultParams = new URLSearchParams({
referenceId,
referenceTable,
});
if (screenId) resultParams.set("screenId", screenId);
const resultRes = await apiClient.get<{
success: boolean;
data: Array<{
inspection_info_id: string;
measured_value: string;
judgment: string;
overall_judgment: string;
memo: string;
}>;
}>(`/pop/inspection-result?${resultParams.toString()}`);
if (resultRes.data?.data && resultRes.data.data.length > 0) {
resultRes.data.data.forEach((r) => {
existingMap[r.inspection_info_id] = {
measuredValue: r.measured_value || "",
judgment: (r.judgment === "pass" || r.judgment === "fail") ? r.judgment : "",
};
});
const firstOverall = resultRes.data.data[0]?.overall_judgment;
if (firstOverall === "pass" || firstOverall === "fail") {
setOverallJudgment(firstOverall);
}
setMemo(resultRes.data.data[0]?.memo || "");
}
}
// 결과 항목 초기화
const items: InspectionResultItem[] = infoData.map((info) => ({
inspectionInfoId: info.id,
inspectionItemName: info.inspection_item_name,
inspectionStandard: info.inspection_standard,
passCriteria: info.pass_criteria,
isRequired: info.is_required,
measuredValue: existingMap[info.id]?.measuredValue || "",
judgment: existingMap[info.id]?.judgment || "",
}));
setResultItems(items);
} catch (err: any) {
setError(err?.message || "데이터 조회 실패");
} finally {
setLoading(false);
}
}, [open, itemCode, inspectionType, referenceId, referenceTable, screenId]);
useEffect(() => {
if (open) loadData();
}, [open, loadData]);
// 종합 판정 자동 계산 (필수 항목 모두 합격이면 합격)
useEffect(() => {
if (resultItems.length === 0) return;
const judgedItems = resultItems.filter((i) => i.judgment !== "");
if (judgedItems.length === 0) {
setOverallJudgment("");
return;
}
const hasAnyFail = resultItems.some((i) => i.isRequired === "Y" && i.judgment === "fail");
if (hasAnyFail) {
setOverallJudgment("fail");
return;
}
const requiredItems = resultItems.filter((i) => i.isRequired === "Y");
const allRequiredJudged = requiredItems.every((i) => i.judgment !== "");
if (allRequiredJudged && requiredItems.length > 0) {
setOverallJudgment("pass");
}
}, [resultItems]);
const updateItem = (index: number, partial: Partial<InspectionResultItem>) => {
setResultItems((prev) => {
const next = [...prev];
next[index] = { ...next[index], ...partial };
return next;
});
};
const handleSave = async (isCompleted: boolean) => {
if (!overallJudgment && isCompleted) {
setError("종합 판정을 선택해주세요.");
return;
}
setSaving(true);
setError(null);
try {
await apiClient.post("/pop/inspection-result", {
referenceTable: referenceTable || "",
referenceId: referenceId || "",
screenId: screenId || "",
itemCode: itemCode || "",
itemName: itemName || "",
inspectionType: inspectionType || "",
items: resultItems,
overallJudgment,
memo,
isCompleted,
});
if (isCompleted && onSaved) {
onSaved(overallJudgment as "pass" | "fail");
}
onOpenChange(false);
} catch (err: any) {
setError(err?.message || "저장 실패");
} finally {
setSaving(false);
}
};
// 진행률
const judgedCount = resultItems.filter((i) => i.judgment !== "").length;
const total = resultItems.length;
const progress = total > 0 ? Math.round((judgedCount / total) * 100) : 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-full max-w-lg flex-col gap-0 p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle className="flex items-center gap-2 text-base font-bold">
<ClipboardList className="h-5 w-5 text-primary" />
</DialogTitle>
{(itemCode || itemName) && (
<p className="mt-1 text-sm text-muted-foreground">
{itemCode && <span className="font-medium">{itemCode}</span>}
{itemCode && itemName && " · "}
{itemName}
</p>
)}
</DialogHeader>
{/* 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<AlertCircle className="h-4 w-4 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
</div>
) : resultItems.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<ClipboardList className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
{itemCode
? `"${itemCode}"에 등록된 검사 항목이 없습니다.`
: "검사 항목이 없습니다."}
</p>
</div>
) : (
<div className="space-y-4">
{/* 진행률 표시 */}
<div className="flex items-center justify-between rounded-md bg-muted/30 px-3 py-2">
<span className="text-xs text-muted-foreground">
{judgedCount}/{total}
</span>
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
progress === 100 ? "bg-green-500" : "bg-primary"
)}
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs font-medium">{progress}%</span>
</div>
</div>
{/* 검사 항목 목록 */}
<div className="space-y-3">
{resultItems.map((item, index) => (
<div
key={item.inspectionInfoId}
className={cn(
"rounded-lg border p-3 transition-colors",
item.judgment === "pass" && "border-green-200 bg-green-50",
item.judgment === "fail" && "border-red-200 bg-red-50",
!item.judgment && "border-border bg-card"
)}
>
{/* 항목 헤더 */}
<div className="mb-2 flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">{item.inspectionItemName}</span>
{item.isRequired === "Y" && (
<Badge variant="outline" className="h-4 px-1 py-0 text-[9px] text-destructive border-destructive/50">
</Badge>
)}
</div>
{item.inspectionStandard && (
<p className="mt-0.5 text-xs text-muted-foreground">
: {item.inspectionStandard}
</p>
)}
{item.passCriteria && (
<p className="text-xs text-muted-foreground">
: {item.passCriteria}
</p>
)}
</div>
<JudgmentBadge judgment={item.judgment} />
</div>
{/* 측정값 입력 */}
<div className="mb-2">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={item.measuredValue}
onChange={(e) => updateItem(index, { measuredValue: e.target.value })}
placeholder="측정값 입력"
className="mt-1 h-10 text-sm"
/>
</div>
{/* 합불 판정 버튼 */}
<div className="flex gap-2">
<button
type="button"
onClick={() => updateItem(index, { judgment: "pass" })}
className={cn(
"flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md border-2 text-sm font-semibold transition-colors",
item.judgment === "pass"
? "border-green-500 bg-green-500 text-white"
: "border-green-200 bg-white text-green-700 hover:bg-green-50"
)}
>
<CheckCircle2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => updateItem(index, { judgment: "fail" })}
className={cn(
"flex h-11 flex-1 items-center justify-center gap-1.5 rounded-md border-2 text-sm font-semibold transition-colors",
item.judgment === "fail"
? "border-red-500 bg-red-500 text-white"
: "border-red-200 bg-white text-red-700 hover:bg-red-50"
)}
>
<XCircle className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
{/* 종합 판정 */}
<div className="rounded-lg border-2 border-dashed p-3">
<Label className="text-sm font-semibold"> </Label>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => setOverallJudgment("pass")}
className={cn(
"flex h-12 flex-1 items-center justify-center gap-2 rounded-md border-2 text-sm font-bold transition-colors",
overallJudgment === "pass"
? "border-green-500 bg-green-500 text-white"
: "border-green-200 bg-white text-green-700 hover:bg-green-50"
)}
>
<CheckCircle2 className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => setOverallJudgment("fail")}
className={cn(
"flex h-12 flex-1 items-center justify-center gap-2 rounded-md border-2 text-sm font-bold transition-colors",
overallJudgment === "fail"
? "border-red-500 bg-red-500 text-white"
: "border-red-200 bg-white text-red-700 hover:bg-red-50"
)}
>
<XCircle className="h-5 w-5" />
</button>
</div>
</div>
{/* 비고 */}
<div>
<Label className="text-sm font-medium"></Label>
<Textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="비고 입력 (선택사항)"
className="mt-1 min-h-[72px] text-sm"
/>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-2">
<AlertCircle className="h-4 w-4 shrink-0 text-destructive" />
<p className="text-xs text-destructive">{error}</p>
</div>
)}
</div>
)}
</div>
{/* 하단 버튼 */}
<DialogFooter className="shrink-0 border-t px-6 py-4">
<div className="flex w-full gap-2">
<Button
variant="outline"
className="flex-1 h-12 text-sm"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button
variant="outline"
className="flex-1 h-12 text-sm"
onClick={() => handleSave(false)}
disabled={saving || loading || resultItems.length === 0}
>
{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}
</Button>
<Button
className={cn(
"flex-1 h-12 text-sm font-semibold",
overallJudgment === "pass" && "bg-green-600 hover:bg-green-700",
overallJudgment === "fail" && "bg-red-600 hover:bg-red-700"
)}
onClick={() => handleSave(true)}
disabled={saving || loading || resultItems.length === 0 || !overallJudgment}
>
{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Trash2, Search,
Trash2, Search, ClipboardCheck, ClipboardX, ClipboardList,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@@ -31,8 +31,9 @@ import type {
PackageEntry,
CollectDataRequest,
CollectedDataResponse,
CardListInspectionConfig,
} from "../types";
import {
import {
DEFAULT_CARD_IMAGE,
CARD_PRESET_SPECS,
} from "../types";
@@ -41,6 +42,7 @@ import { screenApi } from "@/lib/api/screen";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "./NumberInputModal";
import { InspectionModal } from "./InspectionModal";
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
@@ -856,6 +858,8 @@ export function PopCardListComponent({
}}
onDeleteItem={handleDeleteItem}
onUpdateQuantity={handleUpdateQuantity}
inspectionConfig={effectiveConfig?.inspectionConfig}
screenId={screenId}
/>
);
})}
@@ -943,6 +947,8 @@ function Card({
onToggleSelect,
onDeleteItem,
onUpdateQuantity,
inspectionConfig,
screenId,
}: {
row: RowData;
template?: CardTemplateConfig;
@@ -961,6 +967,8 @@ function Card({
onToggleSelect?: () => void;
onDeleteItem?: (cartId: string) => void;
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
inspectionConfig?: CardListInspectionConfig;
screenId?: string;
}) {
const header = template?.header;
const image = template?.image;
@@ -971,6 +979,10 @@ function Card({
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
// 검사 연동 상태
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [inspectionStatus, setInspectionStatus] = useState<"pending" | "pass" | "fail">("pending");
const codeValue = header?.codeField ? row[header.codeField] : null;
const titleValue = header?.titleField ? row[header.titleField] : null;
@@ -1312,6 +1324,61 @@ function Card({
</div>
)}
{/* 검사 상태 행 */}
{inspectionConfig?.enabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsInspectionModalOpen(true);
}}
className={`flex w-full items-center justify-between border-t px-3 py-1.5 transition-colors hover:bg-muted/50 ${
inspectionStatus === "pass"
? "bg-green-50"
: inspectionStatus === "fail"
? "bg-red-50"
: "bg-muted/20"
}`}
>
<div className="flex items-center gap-1.5">
{inspectionStatus === "pass" ? (
<ClipboardCheck className="h-3 w-3 text-green-600" />
) : inspectionStatus === "fail" ? (
<ClipboardX className="h-3 w-3 text-red-600" />
) : (
<ClipboardList className="h-3 w-3 text-muted-foreground" />
)}
<span
className={`text-[10px] font-medium ${
inspectionStatus === "pass"
? "text-green-700"
: inspectionStatus === "fail"
? "text-red-700"
: "text-muted-foreground"
}`}
>
</span>
{inspectionConfig.inspectionType && (
<span className="text-[9px] text-muted-foreground">
[{inspectionConfig.inspectionType}]
</span>
)}
</div>
<span
className={`text-[10px] font-semibold ${
inspectionStatus === "pass"
? "text-green-600"
: inspectionStatus === "fail"
? "text-red-600"
: "text-amber-600"
}`}
>
{inspectionStatus === "pass" ? "합격" : inspectionStatus === "fail" ? "불합격" : "대기"}
</span>
</button>
)}
{inputField?.enabled && (
<NumberInputModal
open={isModalOpen}
@@ -1325,6 +1392,26 @@ function Card({
onConfirm={handleInputConfirm}
/>
)}
{/* 검사 모달 */}
{inspectionConfig?.enabled && (
<InspectionModal
open={isInspectionModalOpen}
onOpenChange={setIsInspectionModalOpen}
rowData={row}
itemCode={inspectionConfig.itemCodeColumn ? String(row[inspectionConfig.itemCodeColumn] ?? "") : undefined}
itemName={inspectionConfig.itemNameColumn ? String(row[inspectionConfig.itemNameColumn] ?? "") : undefined}
referenceId={
inspectionConfig.referenceIdColumn
? String(row[inspectionConfig.referenceIdColumn] ?? "")
: String(row.id ?? "")
}
referenceTable={inspectionConfig.referenceTable || ""}
screenId={screenId}
inspectionType={inspectionConfig.inspectionType}
onSaved={(judgment) => setInspectionStatus(judgment)}
/>
)}
</div>
);
}
@@ -54,12 +54,15 @@ import type {
CartListModeConfig,
CardListSaveMapping,
CardListSaveMappingEntry,
CardListPresetMode,
CardListInspectionConfig,
} from "../types";
import { screenApi } from "@/lib/api/screen";
import {
CARD_SCROLL_DIRECTION_LABELS,
RESPONSIVE_DISPLAY_LABELS,
DEFAULT_CARD_IMAGE,
CARD_LIST_PRESET_MODE_LABELS,
} from "../types";
import {
fetchTableList,
@@ -330,6 +333,15 @@ function BasicSettingsTab({
return (
<div className="space-y-4">
{/* ===== 프리셋 모드 (최상단) ===== */}
<CollapsibleSection sectionKey="basic-preset-mode" title="모드 선택" sections={sections}>
<CartPresetModeSection
config={config}
columns={columns}
onUpdate={onUpdate}
/>
</CollapsibleSection>
{/* 장바구니 목록 모드 */}
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection
@@ -480,6 +492,23 @@ function BasicSettingsTab({
</CollapsibleSection>
)}
{/* 점검 연동 (일반 목록 모드, 테이블 선택 시) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection
sectionKey="basic-inspection"
title="점검 연동"
sections={sections}
badge={config.inspectionConfig?.enabled ? "ON" : undefined}
>
<InspectionConfigSection
inspectionConfig={config.inspectionConfig}
columns={columns}
tableName={dataSource.tableName}
onUpdate={(inspectionConfig) => onUpdate({ inspectionConfig })}
/>
</CollapsibleSection>
)}
{/* 레이아웃 설정 */}
<CollapsibleSection sectionKey="basic-layout" title="레이아웃 설정" sections={sections}>
<div className="space-y-3">
@@ -3218,3 +3247,264 @@ function SaveMappingSection({
</div>
);
}
// ===== 장바구니 프리셋 모드 섹션 =====
function CartPresetModeSection({
config,
columns,
onUpdate,
}: {
config: PopCardListConfig;
columns: ColumnInfo[];
onUpdate: (partial: Partial<PopCardListConfig>) => void;
}) {
const presetMode = config.presetMode || "normal";
const applyPreset = (mode: CardListPresetMode) => {
if (mode === "normal") {
onUpdate({
presetMode: "normal",
cartAction: undefined,
requireFilter: false,
});
return;
}
if (mode === "cart-add") {
// 컬럼명 패턴으로 자동 추천
const nameCol = columns.find((c) => /_name$/.test(c.name))?.name;
const codeCol = columns.find((c) => /_code$/.test(c.name))?.name;
const qtyCol = columns.find((c) => /_qty$|_quantity$/i.test(c.name))?.name;
const updatedConfig: Partial<PopCardListConfig> = {
presetMode: "cart-add",
requireFilter: true,
requireFilterMessage: "검색 후 목록이 표시됩니다.",
cartAction: {
saveMode: "cart",
keyColumn: "id",
label: "담기",
cancelLabel: "취소",
},
inputField: {
enabled: true,
unit: "EA",
limitColumn: qtyCol,
},
};
// 카드 헤더 자동 설정
if (nameCol || codeCol) {
updatedConfig.cardTemplate = {
...(config.cardTemplate || { body: { fields: [] }, image: { enabled: false } }),
header: {
codeField: codeCol,
titleField: nameCol,
},
};
}
onUpdate(updatedConfig);
return;
}
if (mode === "cart-confirm") {
onUpdate({
presetMode: "cart-confirm",
cartListMode: {
enabled: true,
statusFilter: "in_cart",
},
requireFilter: false,
});
}
};
return (
<div className="space-y-3">
<p className="text-[9px] text-muted-foreground">
.
</p>
<div className="space-y-1.5">
{(["normal", "cart-add", "cart-confirm"] as CardListPresetMode[]).map((mode) => (
<button
key={mode}
type="button"
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-xs transition-colors ${
presetMode === mode
? "border-primary bg-primary/10 text-primary"
: "border-input bg-background hover:bg-accent"
}`}
onClick={() => applyPreset(mode)}
>
<div
className={`h-3 w-3 rounded-full border-2 ${
presetMode === mode
? "border-primary bg-primary"
: "border-muted-foreground"
}`}
/>
<div className="flex-1 text-left">
<span className="font-medium">{CARD_LIST_PRESET_MODE_LABELS[mode]}</span>
{mode === "cart-add" && (
<p className="mt-0.5 text-[9px] text-muted-foreground">
saveMode=cart, , , requireFilter
</p>
)}
{mode === "cart-confirm" && (
<p className="mt-0.5 text-[9px] text-muted-foreground">
cartListMode ( )
</p>
)}
</div>
</button>
))}
</div>
{presetMode === "cart-add" && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2">
<p className="text-[9px] font-medium text-amber-700"> </p>
<ul className="mt-1 space-y-0.5 text-[9px] text-amber-600">
<li> saveMode: cart ()</li>
<li> 입력: 활성화</li>
<li> / 버튼: 활성화</li>
<li> requireFilter: 활성화</li>
</ul>
<p className="mt-1.5 text-[9px] text-amber-600">
(*_name, *_code) (*_qty) .
릿 .
</p>
</div>
)}
</div>
);
}
// ===== 점검 연동 설정 섹션 =====
function InspectionConfigSection({
inspectionConfig,
columns,
tableName,
onUpdate,
}: {
inspectionConfig?: CardListInspectionConfig;
columns: ColumnInfo[];
tableName: string;
onUpdate: (config: CardListInspectionConfig) => void;
}) {
const cfg: CardListInspectionConfig = inspectionConfig || { enabled: false };
const update = (partial: Partial<CardListInspectionConfig>) => {
onUpdate({ ...cfg, ...partial });
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={cfg.enabled}
onCheckedChange={(enabled) => update({ enabled })}
/>
</div>
<p className="text-[9px] text-muted-foreground">
, .
</p>
{cfg.enabled && (
<div className="space-y-3">
{/* 품목 코드 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={cfg.itemCodeColumn || "__none__"}
onValueChange={(val) => update({ itemCodeColumn: val === "__none__" ? undefined : val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[9px] text-muted-foreground">
item_inspection_info
</p>
</div>
{/* 품목명 컬럼 */}
<div>
<Label className="text-[10px]"> </Label>
<Select
value={cfg.itemNameColumn || "__none__"}
onValueChange={(val) => update({ itemNameColumn: val === "__none__" ? undefined : val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 참조 ID 컬럼 */}
<div>
<Label className="text-[10px]"> ID </Label>
<Select
value={cfg.referenceIdColumn || "__none__"}
onValueChange={(val) => update({ referenceIdColumn: val === "__none__" ? undefined : val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="기본: id" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> (id)</SelectItem>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name}>{col.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[9px] text-muted-foreground">
ID로
</p>
</div>
{/* 검사 유형 */}
<div>
<Label className="text-[10px]"> ()</Label>
<Input
value={cfg.inspectionType || ""}
onChange={(e) => update({ inspectionType: e.target.value || undefined })}
placeholder="예: 입고검사, 공정검사 (빈값=전체)"
className="mt-1 h-7 text-xs"
/>
</div>
{/* 참조 테이블 */}
<div>
<Label className="text-[10px]"> </Label>
<Input
value={cfg.referenceTable || tableName}
onChange={(e) => update({ referenceTable: e.target.value || tableName })}
placeholder={tableName}
className="mt-1 h-7 text-xs"
/>
<p className="mt-1 text-[9px] text-muted-foreground">
(: {tableName})
</p>
</div>
</div>
)}
</div>
);
}
@@ -709,6 +709,28 @@ export interface CardListSaveMapping {
mappings: CardListSaveMappingEntry[];
}
// ----- 장바구니 프리셋 모드 -----
export type CardListPresetMode = "normal" | "cart-add" | "cart-confirm";
export const CARD_LIST_PRESET_MODE_LABELS: Record<CardListPresetMode, string> = {
normal: "일반 목록",
"cart-add": "장바구니 담기",
"cart-confirm": "장바구니 확정 (담긴 목록)",
};
// ----- 검사 연동 설정 -----
export interface CardListInspectionConfig {
enabled: boolean;
itemCodeColumn?: string; // 카드 데이터에서 품목 코드를 읽을 컬럼
itemIdColumn?: string; // 카드 데이터에서 품목 ID를 읽을 컬럼
itemNameColumn?: string; // 품목명 컬럼
inspectionType?: string; // 검사 유형 필터 (빈값=전체)
referenceTable?: string; // 참조 테이블명 (카드 데이터 테이블)
referenceIdColumn?: string; // 참조 ID 컬럼 (기본: id)
}
// ----- pop-card-list 전체 설정 -----
export interface PopCardListConfig {
@@ -732,6 +754,12 @@ export interface PopCardListConfig {
requireFilter?: boolean;
requireFilterMessage?: string;
/** 장바구니 프리셋 모드 (normal | cart-add | cart-confirm) */
presetMode?: CardListPresetMode;
/** 검사 연동 설정 */
inspectionConfig?: CardListInspectionConfig;
}
// =============================================