WIP: preset + inspection (임시, 나중에 squash)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
|
||||
Reference in New Issue
Block a user