feat: Enhance mold management functionality

- Added new `updateMoldSerial` API endpoint for updating mold serial details.
- Modified existing mold-related SQL queries to include `id` and `created_date` fields.
- Updated frontend to handle mold serial updates and image uploads.
- Improved subcontractor management table with additional fields and rendering logic.

This update improves the overall functionality and user experience in managing molds and subcontractors.
This commit is contained in:
kjs
2026-04-03 14:17:26 +09:00
parent 8d95d3b0ed
commit adcc16da36
8 changed files with 458 additions and 188 deletions
+3
View File
@@ -1,6 +1,9 @@
# Claude Code
.claude/
# Test checklists
docs/test-checklists/
# Dependencies
node_modules/
jspm_packages/
+46 -13
View File
@@ -104,11 +104,11 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
const sql = `
INSERT INTO mold_mng (
company_code, mold_code, mold_name, mold_type, category,
id, company_code, mold_code, mold_name, mold_type, category,
manufacturer, manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
operation_status, remarks, image_path, memo, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
RETURNING *
`;
const params = [
@@ -231,7 +231,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
const { serial_number, status, progress, work_description, manager, completion_date, remarks, current_shot_count, storage_location } = req.body;
let finalSerialNumber = serial_number;
@@ -266,14 +266,15 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
}
const sql = `
INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
INSERT INTO mold_serial (id, company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, current_shot_count, storage_location, writer, created_date)
VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
RETURNING *
`;
const params = [
companyCode, moldCode, finalSerialNumber, status || "STORED",
progress || 0, work_description || null, manager || null,
completion_date || null, remarks || null, userId,
completion_date || null, remarks || null, current_shot_count || 0,
storage_location || null, userId,
];
const result = await query(sql, params);
@@ -288,6 +289,38 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
}
}
export async function updateMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const { status, current_shot_count, storage_location, remarks } = req.body;
const sql = `
UPDATE mold_serial SET
status = COALESCE($1, status),
current_shot_count = COALESCE($2, current_shot_count),
storage_location = $3,
remarks = $4,
updated_date = NOW()
WHERE id = $5 AND company_code = $6
RETURNING *
`;
const params = [status, current_shot_count, storage_location || null, remarks || null, id, companyCode];
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." });
return;
}
logger.info("일련번호 수정", { companyCode, id });
res.json({ success: true, data: result[0], message: "일련번호가 수정되었습니다." });
} catch (error: any) {
logger.error("일련번호 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
@@ -347,10 +380,10 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo
const sql = `
INSERT INTO mold_inspection_item (
company_code, mold_code, inspection_item, inspection_cycle,
id, company_code, mold_code, inspection_item, inspection_cycle,
inspection_method, inspection_content, lower_limit, upper_limit,
unit, is_active, checklist, remarks, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
unit, is_active, checklist, remarks, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
RETURNING *
`;
const params = [
@@ -426,10 +459,10 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response):
const sql = `
INSERT INTO mold_part (
company_code, mold_code, part_name, replacement_cycle,
id, company_code, mold_code, part_name, replacement_cycle,
unit, specification, manufacturer, manufacturer_code,
image_path, remarks, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
image_path, remarks, writer, created_date
) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
RETURNING *
`;
const params = [
+2
View File
@@ -8,6 +8,7 @@ import {
deleteMold,
getMoldSerials,
createMoldSerial,
updateMoldSerial,
deleteMoldSerial,
getMoldInspections,
createMoldInspection,
@@ -31,6 +32,7 @@ router.delete("/:moldCode", deleteMold);
// 일련번호
router.get("/:moldCode/serials", getMoldSerials);
router.post("/:moldCode/serials", createMoldSerial);
router.put("/serials/:id", updateMoldSerial);
router.delete("/serials/:id", deleteMoldSerial);
// 일련번호 현황 집계
+216 -78
View File
@@ -5,7 +5,7 @@
*
* 좌측: 금형 카드 리스트 (이미지 + 코드 + 이름 + 유형뱃지 + 수명진행률)
* 우측: 금형 상세 + 탭 3개 (일련번호 / 점검항목 / 부품)
* 전용 API: /api/molds/*
* 전용 API: /molds/*
*/
import React, { useState, useEffect, useCallback } from "react";
@@ -21,7 +21,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
import {
Plus, Trash2, Loader2, Pencil, Box, Inbox, Search,
Wrench, ClipboardCheck, Package, LayoutGrid, List,
Image as ImageIcon,
Image as ImageIcon, Upload, X as XIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -30,7 +30,7 @@ import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
// ─── API base ───
const API = "/api/molds";
const API = "/mold";
// ─── 상태/유형 매핑 ───
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
@@ -99,6 +99,8 @@ export default function MoldInfoPage() {
const [moldEditMode, setMoldEditMode] = useState(false);
const [moldForm, setMoldForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -205,6 +207,7 @@ export default function MoldInfoPage() {
const handleOpenRegister = () => {
setMoldEditMode(false);
setMoldForm({});
setMoldImagePreview(null);
setMoldModalOpen(true);
};
@@ -212,9 +215,22 @@ export default function MoldInfoPage() {
if (!detail) return;
setMoldEditMode(true);
setMoldForm({ ...detail });
setMoldImagePreview(detail.image_path || null);
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
const handleSaveMold = async () => {
if (!moldForm.mold_code || !moldForm.mold_name) {
toast.error("금형코드와 금형명은 필수예요.");
@@ -260,18 +276,23 @@ export default function MoldInfoPage() {
};
// ─── 일련번호 CRUD ───
const handleAddSerial = async () => {
const handleSaveSerial = async () => {
if (!selectedMoldCode) return;
setSaving(true);
try {
await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm);
toast.success("일련번호가 등록되었어요.");
if (serialForm.id) {
await apiClient.put(`${API}/serials/${serialForm.id}`, serialForm);
toast.success("일련번호가 수정되었어요.");
} else {
await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm);
toast.success("일련번호가 등록되었어요.");
}
setSerialModalOpen(false);
setSerialForm({});
fetchSerials(selectedMoldCode);
fetchDetail(selectedMoldCode);
} catch (err: any) {
toast.error(err?.response?.data?.message || "등록에 실패했어요.");
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
@@ -332,6 +353,10 @@ export default function MoldInfoPage() {
toast.error("부품명은 필수예요.");
return;
}
if (!partForm.replacement_cycle) {
toast.error("교체주기는 필수예요.");
return;
}
setSaving(true);
try {
await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm);
@@ -467,7 +492,7 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label>
<Select value={filterType} onValueChange={setFilterType}>
<Select value={filterType || "__all__"} onValueChange={(v) => setFilterType(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
@@ -482,7 +507,7 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<Select value={filterStatus || "__all__"} onValueChange={(v) => setFilterStatus(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
@@ -495,11 +520,7 @@ export default function MoldInfoPage() {
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-9" onClick={() => {
if (filterType === "__all__") setFilterType("");
if (filterStatus === "__all__") setFilterStatus("");
fetchMolds();
}}>
<Button size="sm" className="h-9" onClick={fetchMolds}>
<Search className="w-4 h-4 mr-1" />
</Button>
@@ -748,31 +769,70 @@ export default function MoldInfoPage() {
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground min-w-[200px]"> </TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs w-[60px]" />
<TableHead className="text-xs w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono">{s.serial_number}</TableCell>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getProgressColor(pct))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn("text-xs font-bold min-w-[36px] text-right", getProgressTextColor(pct))}>
{pct}%
</span>
</div>
<p className="text-[11px] text-muted-foreground">
{curShot.toLocaleString()} / {maxShot.toLocaleString()}
</p>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-[13px]">{s.storage_location || "-"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{s.remarks || "-"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteSerial(s.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
setSerialForm(s);
setSerialModalOpen(true);
}}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteSerial(s.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
@@ -1037,12 +1097,40 @@ export default function MoldInfoPage() {
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<Input
className="h-9 text-sm"
value={moldForm.image_path || ""}
onChange={(e) => setMoldForm({ ...moldForm, image_path: e.target.value })}
placeholder="이미지 URL"
<Label className="text-xs"> </Label>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
/>
</div>
</div>
@@ -1057,12 +1145,12 @@ export default function MoldInfoPage() {
</DialogContent>
</Dialog>
{/* ========== 일련번호 등록 모달 ========== */}
{/* ========== 일련번호 등록/수정 모달 ========== */}
<Dialog open={serialModalOpen} onOpenChange={setSerialModalOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
<DialogTitle>{serialForm.id ? "일련번호 수정" : "일련번호 등록"}</DialogTitle>
<DialogDescription>{serialForm.id ? `${serialForm.serial_number} 정보를 수정해요.` : "일련번호는 자동으로 채번돼요."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5">
@@ -1078,9 +1166,21 @@ export default function MoldInfoPage() {
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={serialForm.current_shot_count ?? ""}
onChange={(e) => setSerialForm({ ...serialForm, current_shot_count: e.target.value ? Number(e.target.value) : null })}
placeholder="0"
min={0}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
@@ -1090,7 +1190,7 @@ export default function MoldInfoPage() {
placeholder="보관위치"
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
@@ -1102,9 +1202,9 @@ export default function MoldInfoPage() {
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setSerialModalOpen(false)}></Button>
<Button size="sm" onClick={handleAddSerial} disabled={saving}>
<Button size="sm" onClick={handleSaveSerial} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{serialForm.id ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
@@ -1137,46 +1237,71 @@ export default function MoldInfoPage() {
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select
value={inspectionForm.inspection_method || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_method: e.target.value })}
placeholder="예: 육안검사"
/>
onValueChange={(v) => {
const updated: Record<string, any> = { ...inspectionForm, inspection_method: v };
if (v !== "숫자") {
updated.lower_limit = "";
updated.upper_limit = "";
updated.unit = "";
}
setInspectionForm(updated);
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="숫자"></SelectItem>
<SelectItem value="텍스트"></SelectItem>
<SelectItem value="합격/불합격">/</SelectItem>
<SelectItem value="양호/불량">/</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm font-mono"
value={inspectionForm.lower_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm font-mono"
value={inspectionForm.upper_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={inspectionForm.unit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, unit: e.target.value })}
placeholder="mm, ℃ 등"
/>
</div>
<div className="flex flex-col gap-1.5">
{inspectionForm.inspection_method === "숫자" && (
<>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={inspectionForm.lower_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })}
placeholder="기준값"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">±</Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={inspectionForm.upper_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })}
placeholder="허용 오차"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={inspectionForm.unit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, unit: e.target.value })}
placeholder="mm, ℃ 등"
/>
</div>
</>
)}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 resize-y"
value={inspectionForm.inspection_content || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_content: e.target.value })}
placeholder="상세 내용"
rows={3}
/>
</div>
</div>
@@ -1208,13 +1333,25 @@ export default function MoldInfoPage() {
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select
value={partForm.replacement_cycle || ""}
onChange={(e) => setPartForm({ ...partForm, replacement_cycle: e.target.value })}
placeholder="예: 20,000회"
/>
onValueChange={(v) => setPartForm({ ...partForm, replacement_cycle: v })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1만 샷">1 </SelectItem>
<SelectItem value="5만 샷">5 </SelectItem>
<SelectItem value="10만 샷">10 </SelectItem>
<SelectItem value="월 1회"> 1</SelectItem>
<SelectItem value="3개월">3</SelectItem>
<SelectItem value="6개월">6</SelectItem>
<SelectItem value="1년">1</SelectItem>
<SelectItem value="수시"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
@@ -1245,11 +1382,12 @@ export default function MoldInfoPage() {
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 resize-y"
value={partForm.remarks || ""}
onChange={(e) => setPartForm({ ...partForm, remarks: e.target.value })}
placeholder="비고"
rows={3}
/>
</div>
</div>
@@ -37,12 +37,26 @@ const MAPPING_TABLE = "subcontractor_item_mapping";
const PRICE_TABLE = "subcontractor_item_prices";
const GRID_COLUMNS_CONFIG = [
{ key: "subcontractor_code", label: "외주업체코드" },
{ key: "subcontractor_name", label: "외주업체명" },
{ key: "subcontractor_code", label: "외주코드" },
{ key: "subcontractor_name", label: "외주명" },
{ key: "division", label: "업체 유형" },
{ key: "status", label: "상태" },
{ key: "contact_person", label: "담당자" },
{ key: "contact_phone", label: "연락처" },
{ key: "division_label", label: "유형" },
{ key: "status_label", label: "상태" },
{ key: "contact_phone", label: "담당자 전화번호" },
{ key: "contact_email", label: "담당자 이메일" },
{ key: "email", label: "이메일" },
{ key: "business_number", label: "사업자번호" },
{ key: "address", label: "주소" },
{ key: "phone", label: "전화번호" },
{ key: "fax", label: "팩스" },
{ key: "representative", label: "대표자명" },
{ key: "grade", label: "등급" },
{ key: "process_type", label: "공정 유형" },
{ key: "payment_terms", label: "결제 조건" },
{ key: "remarks", label: "비고" },
{ key: "writer", label: "작성자" },
{ key: "created_date", label: "생성일시" },
{ key: "updated_date", label: "수정일시" },
];
export default function SubcontractorManagementPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -97,7 +111,8 @@ export default function SubcontractorManagementPage() {
const [excelDetecting, setExcelDetecting] = useState(false);
// 테이블 설정
const ts = useTableSettings("c16-subcontractor", SUBCONTRACTOR_TABLE, GRID_COLUMNS_CONFIG);
const DEFAULT_VISIBLE_KEYS = ["subcontractor_code", "subcontractor_name", "division", "status", "contact_person", "contact_phone"];
const ts = useTableSettings("c16-subcontractor-v2", SUBCONTRACTOR_TABLE, GRID_COLUMNS_CONFIG, DEFAULT_VISIBLE_KEYS);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
@@ -139,6 +154,19 @@ export default function SubcontractorManagementPage() {
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 셀 렌더링 헬퍼
const renderCellValue = (row: any, key: string) => {
const val = key === "division" ? (row.division_label || row.division)
: key === "status" ? (row.status_label || row.status)
: row[key];
if (!val) return "-";
if (key === "subcontractor_code") return <span className="text-primary">{val}</span>;
if (key === "division") return <Badge variant="secondary" className="text-[10px] px-1.5 py-0">{val}</Badge>;
if (key === "status") return <Badge variant="outline" className="text-[10px] px-1.5 py-0">{val}</Badge>;
if (key === "subcontractor_name") return <span className="font-medium">{val}</span>;
return val;
};
// 외주업체 목록 조회
const fetchSubcontractors = useCallback(async () => {
setSubcontractorLoading(true);
@@ -365,6 +393,7 @@ export default function SubcontractorManagementPage() {
}
setItemMappings(mappings);
setItemPrices(prices);
setEditItemData(null);
setItemSelectOpen(false);
setItemDetailOpen(true);
};
@@ -430,7 +459,7 @@ export default function SubcontractorManagementPage() {
}));
};
// 우측 품목 편집 열기
// 우측 품목 편집 열기 — 해당 item_number의 모든 매핑+단가를 로드
const openEditItem = async (row: any) => {
const itemKey = row.item_number || row.item_id;
let itemInfo: any = { item_number: itemKey, item_name: row.item_name || "", size: "", unit: "" };
@@ -444,25 +473,49 @@ export default function SubcontractorManagementPage() {
if (found) itemInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
subcontractor_item_code: row.subcontractor_item_code || "",
subcontractor_item_name: row.subcontractor_item_name || "",
}].filter((m) => m.subcontractor_item_code || m.subcontractor_item_name);
// 같은 item_number를 가진 모든 priceItems 행에서 매핑 정보 추출
const allRowsForItem = priceItems.filter((p: any) => (p.item_number || p.item_id) === itemKey);
const allMappingIds = allRowsForItem.map((r: any) => r.id).filter(Boolean);
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
const mappingRows = allRowsForItem
.filter((r: any) => r.subcontractor_item_code || r.subcontractor_item_name)
.map((r: any) => ({
_id: `m_existing_${r.id}`,
subcontractor_item_code: r.subcontractor_item_code || "",
subcontractor_item_name: r.subcontractor_item_name || "",
}));
// 서버에서 이 item+subcontractor의 모든 단가를 raw 코드로 가져오기
let priceRows: Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}> = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]},
autoFilter: true,
});
const rawPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
priceRows = rawPrices.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date || "",
end_date: p.end_date || "",
currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: p.base_price ? String(p.base_price) : "",
discount_type: p.discount_type || "",
discount_value: p.discount_value ? String(p.discount_value) : "",
rounding_type: p.rounding_type || "",
rounding_unit_value: p.rounding_unit_value || "",
calculated_price: p.calculated_price ? String(p.calculated_price) : "",
}));
} catch { /* skip */ }
if (priceRows.length === 0) {
priceRows.push({
@@ -475,7 +528,8 @@ export default function SubcontractorManagementPage() {
setSelectedItemsForDetail([itemInfo]);
setItemMappings({ [itemKey]: mappingRows });
setItemPrices({ [itemKey]: priceRows });
setEditItemData(row);
// editItemData에 원본 매핑 ID 목록 저장 (삭제 시 사용)
setEditItemData({ ...row, _allMappingIds: allMappingIds });
setItemDetailOpen(true);
};
@@ -489,23 +543,21 @@ export default function SubcontractorManagementPage() {
const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editItemData.id },
updatedData: {
subcontractor_item_code: mappingRows[0]?.subcontractor_item_code || "",
subcontractor_item_name: mappingRows[0]?.subcontractor_item_name || "",
base_price: null,
discount_type: null,
discount_value: null,
calculated_price: null,
},
});
// 1) 기존 매핑 모두 삭제
const allMappingIds: string[] = editItemData._allMappingIds || [editItemData.id];
if (allMappingIds.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: allMappingIds.map((mid: string) => ({ id: mid })),
});
}
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
try {
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 100,
page: 1, size: 500,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editItemData.id },
{ columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code },
{ columnName: "item_id", operator: "equals", value: itemKey },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
@@ -516,13 +568,39 @@ export default function SubcontractorManagementPage() {
}
} catch { /* skip */ }
const priceRows = (itemPrices[itemKey] || []).filter((p) =>
// 3) 모든 매핑 재삽입
let firstMappingId: string | null = null;
for (let mi = 0; mi < mappingRows.length; mi++) {
const newId = crypto.randomUUID();
if (mi === 0) firstMappingId = newId;
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: newId,
subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey,
subcontractor_item_code: mappingRows[mi].subcontractor_item_code || "",
subcontractor_item_name: mappingRows[mi].subcontractor_item_name || "",
});
}
// 매핑이 비어있으면 빈 매핑 1개 생성 (item_id 연결 유지)
if (mappingRows.length === 0) {
firstMappingId = crypto.randomUUID();
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: firstMappingId,
subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey,
subcontractor_item_code: "",
subcontractor_item_name: "",
});
}
// 4) 모든 단가 재삽입
const filteredPriceRows = (itemPrices[itemKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
for (const price of filteredPriceRows) {
await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
id: crypto.randomUUID(),
mapping_id: editItemData.id,
mapping_id: firstMappingId || "",
subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey,
start_date: price.start_date || null, end_date: price.end_date || null,
@@ -767,12 +845,9 @@ export default function SubcontractorManagementPage() {
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
{ts.isVisible("subcontractor_code") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("subcontractor_name") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("contact_person") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("contact_phone") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("division_label") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.isVisible("status_label") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>}
{ts.visibleColumns.map((col) => (
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
@@ -788,24 +863,11 @@ export default function SubcontractorManagementPage() {
onClick={() => setSelectedSubcontractorId(sub.id)}
onDoubleClick={openSubcontractorEdit}
>
{ts.isVisible("subcontractor_code") && <TableCell className="text-[13px] text-primary">{sub.subcontractor_code}</TableCell>}
{ts.isVisible("subcontractor_name") && <TableCell className="text-[13px] font-medium">{sub.subcontractor_name}</TableCell>}
{ts.isVisible("contact_person") && <TableCell className="text-[13px]">{sub.contact_person || "-"}</TableCell>}
{ts.isVisible("contact_phone") && <TableCell className="text-[13px]">{sub.contact_phone || "-"}</TableCell>}
{ts.isVisible("division_label") && (
<TableCell className="text-[13px]">
{sub.division_label
? <Badge variant="secondary" className="text-[10px] px-1.5 py-0">{sub.division_label}</Badge>
: "-"}
{ts.visibleColumns.map((col) => (
<TableCell key={col.key} className="text-[13px]">
{renderCellValue(sub, col.key)}
</TableCell>
)}
{ts.isVisible("status_label") && (
<TableCell className="text-[13px]">
{sub.status_label
? <Badge variant="outline" className="text-[10px] px-1.5 py-0">{sub.status_label}</Badge>
: "-"}
</TableCell>
)}
))}
</TableRow>
))}
</TableBody>
@@ -877,24 +939,47 @@ export default function SubcontractorManagementPage() {
</TableRow>
</TableHeader>
<TableBody>
{priceItems.map((item) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onDoubleClick={() => openEditItem(item)}
>
<TableCell className="text-[13px] text-primary">{item.item_number}</TableCell>
<TableCell className="text-[13px]">{item.item_name}</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.base_price_type || "-"}</TableCell>
<TableCell className="text-[13px] text-right">{item.base_price ? Number(item.base_price).toLocaleString() : "-"}</TableCell>
<TableCell className="text-[13px]">{item.discount_type || "-"}</TableCell>
<TableCell className="text-[13px] text-right">{item.discount_value || "-"}</TableCell>
<TableCell className="text-[13px] text-right font-semibold">{item.calculated_price ? Number(item.calculated_price).toLocaleString() : "-"}</TableCell>
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>
</TableRow>
))}
{(() => {
// item_number 기준 그룹화 (순서 유지)
const grouped: { itemNumber: string; rows: any[] }[] = [];
const groupMap = new Map<string, any[]>();
for (const item of priceItems) {
const key = item.item_number || item.item_id || item.id;
if (!groupMap.has(key)) {
const rows: any[] = [];
groupMap.set(key, rows);
grouped.push({ itemNumber: key, rows });
}
groupMap.get(key)!.push(item);
}
return grouped.map((group, gIdx) =>
group.rows.map((item, rowIdx) => (
<TableRow
key={item.id}
className={cn(
"cursor-pointer hover:bg-muted/50",
rowIdx === 0 && gIdx > 0 && "border-t-2 border-t-border/60"
)}
onDoubleClick={() => openEditItem(item)}
>
<TableCell className="text-[13px] text-primary">
{rowIdx === 0 ? item.item_number : ""}
</TableCell>
<TableCell className="text-[13px]">
{rowIdx === 0 ? item.item_name : ""}
</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell>
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell>
<TableCell className="text-[13px]">{item.base_price_type || "-"}</TableCell>
<TableCell className="text-[13px] text-right">{item.base_price ? Number(item.base_price).toLocaleString() : "-"}</TableCell>
<TableCell className="text-[13px]">{item.discount_type || "-"}</TableCell>
<TableCell className="text-[13px] text-right">{item.discount_value || "-"}</TableCell>
<TableCell className="text-[13px] text-right font-semibold">{item.calculated_price ? Number(item.calculated_price).toLocaleString() : "-"}</TableCell>
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>
</TableRow>
))
);
})()}
</TableBody>
</Table>
)}
@@ -1186,7 +1271,7 @@ export default function SubcontractorManagementPage() {
className="h-8 text-xs flex-1"
/>
<div className="w-[80px]">
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(itemKey, price._id, "currency_code", v)}>
<Select value={price.currency_code || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
@@ -1197,8 +1282,8 @@ export default function SubcontractorManagementPage() {
{/* 기준가/할인/반올림 */}
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
<Select value={price.base_price_type || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준유형" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
@@ -1211,8 +1296,8 @@ export default function SubcontractorManagementPage() {
placeholder="기준가"
/>
<div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
<Select value={price.discount_type || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인유형" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
@@ -1226,7 +1311,7 @@ export default function SubcontractorManagementPage() {
placeholder="0"
/>
<div className="w-[90px]">
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(itemKey, price._id, "rounding_unit_value", v)}>
<Select value={price.rounding_unit_value || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
@@ -1292,6 +1377,7 @@ export default function SubcontractorManagementPage() {
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
includeAutoColumns={["created_date", "updated_date", "writer"]}
onSave={ts.applySettings}
/>
@@ -78,6 +78,8 @@ export interface TableSettingsModalProps {
initialTab?: "columns" | "filters" | "groups";
/** 기본 표시 컬럼 키 목록 (GRID_COLUMNS 기준). 미지정 시 전체 표시 */
defaultVisibleKeys?: string[];
/** AUTO_COLS에서 제외하지 않을 컬럼 키 목록 (예: ["created_date", "updated_date", "writer"]) */
includeAutoColumns?: string[];
}
// ===== 상수 =====
@@ -207,6 +209,7 @@ export function TableSettingsModal({
onSave,
initialTab = "columns",
defaultVisibleKeys,
includeAutoColumns,
}: TableSettingsModalProps) {
const [activeTab, setActiveTab] = useState(initialTab);
const [loading, setLoading] = useState(false);
@@ -240,7 +243,7 @@ export function TableSettingsModal({
// 기본 컬럼 설정 생성
const unsortedColumns: ColumnSetting[] = types
.filter((t) => !AUTO_COLS.includes(t.columnName))
.filter((t) => !AUTO_COLS.includes(t.columnName) || includeAutoColumns?.includes(t.columnName))
.map((t) => ({
columnName: t.columnName,
displayName: t.displayName || t.columnLabel || t.columnName,
+1 -1
View File
@@ -62,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[2000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[10002] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+11 -6
View File
@@ -34,14 +34,16 @@ export function useTableSettings<T extends { key: string }>(
settingsId: string,
tableName: string,
defaultColumns: T[],
/** 초기 표시 컬럼 키 (미지정 시 defaultColumns 전체) */
initialVisibleKeys?: string[],
) {
const [open, setOpen] = useState(false);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(
() => new Set(defaultColumns.map((c) => c.key)),
() => new Set(initialVisibleKeys || defaultColumns.map((c) => c.key)),
);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [orderedKeys, setOrderedKeys] = useState<string[]>(
() => defaultColumns.map((c) => c.key),
() => initialVisibleKeys || defaultColumns.map((c) => c.key),
);
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>(
@@ -70,9 +72,12 @@ export function useTableSettings<T extends { key: string }>(
}
}
// settings에 없는 새 컬럼은 보이도록 추가
// settings에 없는 새 컬럼은 초기 표시 목록에 있을 때만 보이도록 추가
const initKeys = initialVisibleKeys
? new Set(initialVisibleKeys)
: new Set(defaultColumns.map((c) => c.key));
for (const col of defaultColumns) {
if (!settings.columns.find((c) => c.columnName === col.key)) {
if (!settings.columns.find((c) => c.columnName === col.key) && initKeys.has(col.key)) {
visible.add(col.key);
order.push(col.key);
}
@@ -87,7 +92,7 @@ export function useTableSettings<T extends { key: string }>(
settings.filters?.filter((f) => visible.has(f.columnName)),
);
},
[defaultColumns],
[defaultColumns, initialVisibleKeys],
);
// 마운트 시 저장된 설정 복원
@@ -148,6 +153,6 @@ export function useTableSettings<T extends { key: string }>(
/** 필터 설정 */
filterConfig,
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
defaultVisibleKeys: defaultColumns.map((c) => c.key),
defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key),
};
}