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 Code
.claude/ .claude/
# Test checklists
docs/test-checklists/
# Dependencies # Dependencies
node_modules/ node_modules/
jspm_packages/ jspm_packages/
+46 -13
View File
@@ -104,11 +104,11 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom
const sql = ` const sql = `
INSERT INTO mold_mng ( 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, manufacturer, manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty, cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo, writer operation_status, remarks, image_path, memo, writer, created_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
RETURNING * RETURNING *
`; `;
const params = [ const params = [
@@ -231,7 +231,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const userId = req.user!.userId; const userId = req.user!.userId;
const { moldCode } = req.params; 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; let finalSerialNumber = serial_number;
@@ -266,14 +266,15 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response)
} }
const sql = ` const sql = `
INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer) 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 ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
RETURNING * RETURNING *
`; `;
const params = [ const params = [
companyCode, moldCode, finalSerialNumber, status || "STORED", companyCode, moldCode, finalSerialNumber, status || "STORED",
progress || 0, work_description || null, manager || null, 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); 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> { export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
@@ -347,10 +380,10 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo
const sql = ` const sql = `
INSERT INTO mold_inspection_item ( 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, inspection_method, inspection_content, lower_limit, upper_limit,
unit, is_active, checklist, remarks, writer unit, is_active, checklist, remarks, writer, created_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW())
RETURNING * RETURNING *
`; `;
const params = [ const params = [
@@ -426,10 +459,10 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response):
const sql = ` const sql = `
INSERT INTO mold_part ( 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, unit, specification, manufacturer, manufacturer_code,
image_path, remarks, writer image_path, remarks, writer, created_date
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
RETURNING * RETURNING *
`; `;
const params = [ const params = [
+2
View File
@@ -8,6 +8,7 @@ import {
deleteMold, deleteMold,
getMoldSerials, getMoldSerials,
createMoldSerial, createMoldSerial,
updateMoldSerial,
deleteMoldSerial, deleteMoldSerial,
getMoldInspections, getMoldInspections,
createMoldInspection, createMoldInspection,
@@ -31,6 +32,7 @@ router.delete("/:moldCode", deleteMold);
// 일련번호 // 일련번호
router.get("/:moldCode/serials", getMoldSerials); router.get("/:moldCode/serials", getMoldSerials);
router.post("/:moldCode/serials", createMoldSerial); router.post("/:moldCode/serials", createMoldSerial);
router.put("/serials/:id", updateMoldSerial);
router.delete("/serials/:id", deleteMoldSerial); router.delete("/serials/:id", deleteMoldSerial);
// 일련번호 현황 집계 // 일련번호 현황 집계
+216 -78
View File
@@ -5,7 +5,7 @@
* *
* 좌측: 금형 카드 리스트 (이미지 + 코드 + 이름 + 유형뱃지 + 수명진행률) * 좌측: 금형 카드 리스트 (이미지 + 코드 + 이름 + 유형뱃지 + 수명진행률)
* 우측: 금형 상세 + 탭 3개 (일련번호 / 점검항목 / 부품) * 우측: 금형 상세 + 탭 3개 (일련번호 / 점검항목 / 부품)
* 전용 API: /api/molds/* * 전용 API: /molds/*
*/ */
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
@@ -21,7 +21,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
import { import {
Plus, Trash2, Loader2, Pencil, Box, Inbox, Search, Plus, Trash2, Loader2, Pencil, Box, Inbox, Search,
Wrench, ClipboardCheck, Package, LayoutGrid, List, Wrench, ClipboardCheck, Package, LayoutGrid, List,
Image as ImageIcon, Image as ImageIcon, Upload, X as XIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@@ -30,7 +30,7 @@ import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useConfirmDialog } from "@/components/common/ConfirmDialog";
// ─── API base ─── // ─── API base ───
const API = "/api/molds"; const API = "/mold";
// ─── 상태/유형 매핑 ─── // ─── 상태/유형 매핑 ───
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { 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 [moldEditMode, setMoldEditMode] = useState(false);
const [moldForm, setMoldForm] = useState<Record<string, any>>({}); const [moldForm, setMoldForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false); const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({}); const [serialForm, setSerialForm] = useState<Record<string, any>>({});
@@ -205,6 +207,7 @@ export default function MoldInfoPage() {
const handleOpenRegister = () => { const handleOpenRegister = () => {
setMoldEditMode(false); setMoldEditMode(false);
setMoldForm({}); setMoldForm({});
setMoldImagePreview(null);
setMoldModalOpen(true); setMoldModalOpen(true);
}; };
@@ -212,9 +215,22 @@ export default function MoldInfoPage() {
if (!detail) return; if (!detail) return;
setMoldEditMode(true); setMoldEditMode(true);
setMoldForm({ ...detail }); setMoldForm({ ...detail });
setMoldImagePreview(detail.image_path || null);
setMoldModalOpen(true); 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 () => { const handleSaveMold = async () => {
if (!moldForm.mold_code || !moldForm.mold_name) { if (!moldForm.mold_code || !moldForm.mold_name) {
toast.error("금형코드와 금형명은 필수예요."); toast.error("금형코드와 금형명은 필수예요.");
@@ -260,18 +276,23 @@ export default function MoldInfoPage() {
}; };
// ─── 일련번호 CRUD ─── // ─── 일련번호 CRUD ───
const handleAddSerial = async () => { const handleSaveSerial = async () => {
if (!selectedMoldCode) return; if (!selectedMoldCode) return;
setSaving(true); setSaving(true);
try { try {
await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm); if (serialForm.id) {
toast.success("일련번호가 등록되었어요."); await apiClient.put(`${API}/serials/${serialForm.id}`, serialForm);
toast.success("일련번호가 수정되었어요.");
} else {
await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm);
toast.success("일련번호가 등록되었어요.");
}
setSerialModalOpen(false); setSerialModalOpen(false);
setSerialForm({}); setSerialForm({});
fetchSerials(selectedMoldCode); fetchSerials(selectedMoldCode);
fetchDetail(selectedMoldCode); fetchDetail(selectedMoldCode);
} catch (err: any) { } catch (err: any) {
toast.error(err?.response?.data?.message || "등록에 실패했어요."); toast.error(err?.response?.data?.message || "저장에 실패했어요.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -332,6 +353,10 @@ export default function MoldInfoPage() {
toast.error("부품명은 필수예요."); toast.error("부품명은 필수예요.");
return; return;
} }
if (!partForm.replacement_cycle) {
toast.error("교체주기는 필수예요.");
return;
}
setSaving(true); setSaving(true);
try { try {
await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm); await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm);
@@ -467,7 +492,7 @@ export default function MoldInfoPage() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label> <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"> <SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="전체" /> <SelectValue placeholder="전체" />
</SelectTrigger> </SelectTrigger>
@@ -482,7 +507,7 @@ export default function MoldInfoPage() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label> <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"> <SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="전체" /> <SelectValue placeholder="전체" />
</SelectTrigger> </SelectTrigger>
@@ -495,11 +520,7 @@ export default function MoldInfoPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button size="sm" className="h-9" onClick={() => { <Button size="sm" className="h-9" onClick={fetchMolds}>
if (filterType === "__all__") setFilterType("");
if (filterStatus === "__all__") setFilterStatus("");
fetchMolds();
}}>
<Search className="w-4 h-4 mr-1" /> <Search className="w-4 h-4 mr-1" />
</Button> </Button>
@@ -748,31 +769,70 @@ export default function MoldInfoPage() {
<TableRow className="bg-muted hover:bg-muted"> <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"></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 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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{serials.map((s: any) => { {serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const }; 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 ( return (
<TableRow key={s.id}> <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> <TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge> <Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
</TableCell> </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]">{s.storage_location || "-"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{s.remarks || "-"}</TableCell> <TableCell className="text-[13px] text-muted-foreground">{s.remarks || "-"}</TableCell>
<TableCell> <TableCell>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="sm" variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive" size="sm"
onClick={() => handleDeleteSerial(s.id)} className="h-7 w-7 p-0"
> onClick={() => {
<Trash2 className="w-3.5 h-3.5" /> setSerialForm(s);
</Button> 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> </TableCell>
</TableRow> </TableRow>
); );
@@ -1037,12 +1097,40 @@ export default function MoldInfoPage() {
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <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">
className="h-9 text-sm" {moldImagePreview ? (
value={moldForm.image_path || ""} <>
onChange={(e) => setMoldForm({ ...moldForm, image_path: e.target.value })} <img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
placeholder="이미지 URL" <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>
</div> </div>
@@ -1057,12 +1145,12 @@ export default function MoldInfoPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* ========== 일련번호 등록 모달 ========== */} {/* ========== 일련번호 등록/수정 모달 ========== */}
<Dialog open={serialModalOpen} onOpenChange={setSerialModalOpen}> <Dialog open={serialModalOpen} onOpenChange={setSerialModalOpen}>
<DialogContent className="sm:max-w-[480px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle> </DialogTitle> <DialogTitle>{serialForm.id ? "일련번호 수정" : "일련번호 등록"}</DialogTitle>
<DialogDescription> .</DialogDescription> <DialogDescription>{serialForm.id ? `${serialForm.serial_number} 정보를 수정해요.` : "일련번호는 자동으로 채번돼요."}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2"> <div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -1078,9 +1166,21 @@ export default function MoldInfoPage() {
<SelectItem value="IN_USE"></SelectItem> <SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem> <SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem> <SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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"> <div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Input <Input
@@ -1090,7 +1190,7 @@ export default function MoldInfoPage() {
placeholder="보관위치" placeholder="보관위치"
/> />
</div> </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> <Label className="text-xs"></Label>
<Input <Input
className="h-9 text-sm" className="h-9 text-sm"
@@ -1102,9 +1202,9 @@ export default function MoldInfoPage() {
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" size="sm" onClick={() => setSerialModalOpen(false)}></Button> <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" />} {saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{serialForm.id ? "수정하기" : "등록하기"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1137,46 +1237,71 @@ export default function MoldInfoPage() {
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label> <Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input <Select
className="h-9 text-sm"
value={inspectionForm.inspection_method || ""} value={inspectionForm.inspection_method || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_method: e.target.value })} onValueChange={(v) => {
placeholder="예: 육안검사" 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>
<div className="flex flex-col gap-1.5"> {inspectionForm.inspection_method === "숫자" && (
<Label className="text-xs"></Label> <>
<Input <div className="flex flex-col gap-1.5">
className="h-9 text-sm font-mono" <Label className="text-xs"></Label>
value={inspectionForm.lower_limit || ""} <Input
onChange={(e) => setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })} type="number"
/> className="h-9 text-sm font-mono"
</div> value={inspectionForm.lower_limit || ""}
<div className="flex flex-col gap-1.5"> onChange={(e) => setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })}
<Label className="text-xs"></Label> placeholder="기준값"
<Input />
className="h-9 text-sm font-mono" </div>
value={inspectionForm.upper_limit || ""} <div className="flex flex-col gap-1.5">
onChange={(e) => setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })} <Label className="text-xs">±</Label>
/> <Input
</div> type="number"
<div className="flex flex-col gap-1.5"> className="h-9 text-sm font-mono"
<Label className="text-xs"></Label> value={inspectionForm.upper_limit || ""}
<Input onChange={(e) => setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })}
className="h-9 text-sm" placeholder="허용 오차"
value={inspectionForm.unit || ""} />
onChange={(e) => setInspectionForm({ ...inspectionForm, unit: e.target.value })} </div>
placeholder="mm, ℃ 등" <div className="flex flex-col gap-1.5">
/> <Label className="text-xs"></Label>
</div> <Input
<div className="flex flex-col gap-1.5"> 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> <Label className="text-xs"></Label>
<Input <textarea
className="h-9 text-sm" 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 || ""} value={inspectionForm.inspection_content || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_content: e.target.value })} onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_content: e.target.value })}
placeholder="상세 내용" placeholder="상세 내용"
rows={3}
/> />
</div> </div>
</div> </div>
@@ -1208,13 +1333,25 @@ export default function MoldInfoPage() {
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label> <Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input <Select
className="h-9 text-sm"
value={partForm.replacement_cycle || ""} value={partForm.replacement_cycle || ""}
onChange={(e) => setPartForm({ ...partForm, replacement_cycle: e.target.value })} onValueChange={(v) => setPartForm({ ...partForm, replacement_cycle: v })}
placeholder="예: 20,000회" >
/> <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>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
@@ -1245,11 +1382,12 @@ export default function MoldInfoPage() {
</div> </div>
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Input <textarea
className="h-9 text-sm" 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 || ""} value={partForm.remarks || ""}
onChange={(e) => setPartForm({ ...partForm, remarks: e.target.value })} onChange={(e) => setPartForm({ ...partForm, remarks: e.target.value })}
placeholder="비고" placeholder="비고"
rows={3}
/> />
</div> </div>
</div> </div>
@@ -37,12 +37,26 @@ const MAPPING_TABLE = "subcontractor_item_mapping";
const PRICE_TABLE = "subcontractor_item_prices"; const PRICE_TABLE = "subcontractor_item_prices";
const GRID_COLUMNS_CONFIG = [ const GRID_COLUMNS_CONFIG = [
{ key: "subcontractor_code", label: "외주업체코드" }, { key: "subcontractor_code", label: "외주코드" },
{ key: "subcontractor_name", label: "외주업체명" }, { key: "subcontractor_name", label: "외주명" },
{ key: "division", label: "업체 유형" },
{ key: "status", label: "상태" },
{ key: "contact_person", label: "담당자" }, { key: "contact_person", label: "담당자" },
{ key: "contact_phone", label: "연락처" }, { key: "contact_phone", label: "담당자 전화번호" },
{ key: "division_label", label: "유형" }, { key: "contact_email", label: "담당자 이메일" },
{ key: "status_label", 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() { export default function SubcontractorManagementPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog(); const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -97,7 +111,8 @@ export default function SubcontractorManagementPage() {
const [excelDetecting, setExcelDetecting] = useState(false); 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 }[]>>({}); 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; 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 () => { const fetchSubcontractors = useCallback(async () => {
setSubcontractorLoading(true); setSubcontractorLoading(true);
@@ -365,6 +393,7 @@ export default function SubcontractorManagementPage() {
} }
setItemMappings(mappings); setItemMappings(mappings);
setItemPrices(prices); setItemPrices(prices);
setEditItemData(null);
setItemSelectOpen(false); setItemSelectOpen(false);
setItemDetailOpen(true); setItemDetailOpen(true);
}; };
@@ -430,7 +459,7 @@ export default function SubcontractorManagementPage() {
})); }));
}; };
// 우측 품목 편집 열기 // 우측 품목 편집 열기 — 해당 item_number의 모든 매핑+단가를 로드
const openEditItem = async (row: any) => { const openEditItem = async (row: any) => {
const itemKey = row.item_number || row.item_id; const itemKey = row.item_number || row.item_id;
let itemInfo: any = { item_number: itemKey, item_name: row.item_name || "", size: "", unit: "" }; 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; if (found) itemInfo = found;
} catch { /* skip */ } } catch { /* skip */ }
const mappingRows = [{ // 같은 item_number를 가진 모든 priceItems 행에서 매핑 정보 추출
_id: `m_existing_${row.id}`, const allRowsForItem = priceItems.filter((p: any) => (p.item_number || p.item_id) === itemKey);
subcontractor_item_code: row.subcontractor_item_code || "", const allMappingIds = allRowsForItem.map((r: any) => r.id).filter(Boolean);
subcontractor_item_name: row.subcontractor_item_name || "",
}].filter((m) => m.subcontractor_item_code || m.subcontractor_item_name);
const priceRows = [{ const mappingRows = allRowsForItem
_id: `p_existing_${row.id}`, .filter((r: any) => r.subcontractor_item_code || r.subcontractor_item_name)
start_date: row.start_date || "", .map((r: any) => ({
end_date: row.end_date || "", _id: `m_existing_${r.id}`,
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", subcontractor_item_code: r.subcontractor_item_code || "",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", subcontractor_item_name: r.subcontractor_item_name || "",
base_price: row.base_price ? String(row.base_price) : "", }));
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "", // 서버에서 이 item+subcontractor의 모든 단가를 raw 코드로 가져오기
rounding_type: row.rounding_type || "", let priceRows: Array<{
rounding_unit_value: row.rounding_unit_value || "", _id: string; start_date: string; end_date: string; currency_code: string;
calculated_price: row.calculated_price ? String(row.calculated_price) : "", base_price_type: string; base_price: string; discount_type: string;
}].filter((p) => p.base_price || p.start_date); 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) { if (priceRows.length === 0) {
priceRows.push({ priceRows.push({
@@ -475,7 +528,8 @@ export default function SubcontractorManagementPage() {
setSelectedItemsForDetail([itemInfo]); setSelectedItemsForDetail([itemInfo]);
setItemMappings({ [itemKey]: mappingRows }); setItemMappings({ [itemKey]: mappingRows });
setItemPrices({ [itemKey]: priceRows }); setItemPrices({ [itemKey]: priceRows });
setEditItemData(row); // editItemData에 원본 매핑 ID 목록 저장 (삭제 시 사용)
setEditItemData({ ...row, _allMappingIds: allMappingIds });
setItemDetailOpen(true); setItemDetailOpen(true);
}; };
@@ -489,23 +543,21 @@ export default function SubcontractorManagementPage() {
const mappingRows = itemMappings[itemKey] || []; const mappingRows = itemMappings[itemKey] || [];
if (isEditingExisting && editItemData?.id) { if (isEditingExisting && editItemData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { // 1) 기존 매핑 모두 삭제
originalData: { id: editItemData.id }, const allMappingIds: string[] = editItemData._allMappingIds || [editItemData.id];
updatedData: { if (allMappingIds.length > 0) {
subcontractor_item_code: mappingRows[0]?.subcontractor_item_code || "", await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
subcontractor_item_name: mappingRows[0]?.subcontractor_item_name || "", data: allMappingIds.map((mid: string) => ({ id: mid })),
base_price: null, });
discount_type: null, }
discount_value: null,
calculated_price: null,
},
});
// 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준)
try { try {
const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, {
page: 1, size: 100, page: 1, size: 500,
dataFilter: { enabled: true, filters: [ 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, ]}, autoFilter: true,
}); });
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
@@ -516,13 +568,39 @@ export default function SubcontractorManagementPage() {
} }
} catch { /* skip */ } } 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 (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`, { await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, {
id: crypto.randomUUID(), id: crypto.randomUUID(),
mapping_id: editItemData.id, mapping_id: firstMappingId || "",
subcontractor_id: selectedSubcontractor.subcontractor_code, subcontractor_id: selectedSubcontractor.subcontractor_code,
item_id: itemKey, item_id: itemKey,
start_date: price.start_date || null, end_date: price.end_date || null, start_date: price.start_date || null, end_date: price.end_date || null,
@@ -767,12 +845,9 @@ export default function SubcontractorManagementPage() {
<Table> <Table>
<TableHeader className="sticky top-0 z-10"> <TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted"> <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.visibleColumns.map((col) => (
{ts.isVisible("subcontractor_name") && <TableHead className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>} <TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</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>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -788,24 +863,11 @@ export default function SubcontractorManagementPage() {
onClick={() => setSelectedSubcontractorId(sub.id)} onClick={() => setSelectedSubcontractorId(sub.id)}
onDoubleClick={openSubcontractorEdit} onDoubleClick={openSubcontractorEdit}
> >
{ts.isVisible("subcontractor_code") && <TableCell className="text-[13px] text-primary">{sub.subcontractor_code}</TableCell>} {ts.visibleColumns.map((col) => (
{ts.isVisible("subcontractor_name") && <TableCell className="text-[13px] font-medium">{sub.subcontractor_name}</TableCell>} <TableCell key={col.key} className="text-[13px]">
{ts.isVisible("contact_person") && <TableCell className="text-[13px]">{sub.contact_person || "-"}</TableCell>} {renderCellValue(sub, col.key)}
{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>
: "-"}
</TableCell> </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> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -877,24 +939,47 @@ export default function SubcontractorManagementPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{priceItems.map((item) => ( {(() => {
<TableRow // item_number 기준 그룹화 (순서 유지)
key={item.id} const grouped: { itemNumber: string; rows: any[] }[] = [];
className="cursor-pointer hover:bg-muted/50" const groupMap = new Map<string, any[]>();
onDoubleClick={() => openEditItem(item)} for (const item of priceItems) {
> const key = item.item_number || item.item_id || item.id;
<TableCell className="text-[13px] text-primary">{item.item_number}</TableCell> if (!groupMap.has(key)) {
<TableCell className="text-[13px]">{item.item_name}</TableCell> const rows: any[] = [];
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell> groupMap.set(key, rows);
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell> grouped.push({ itemNumber: key, rows });
<TableCell className="text-[13px]">{item.base_price_type || "-"}</TableCell> }
<TableCell className="text-[13px] text-right">{item.base_price ? Number(item.base_price).toLocaleString() : "-"}</TableCell> groupMap.get(key)!.push(item);
<TableCell className="text-[13px]">{item.discount_type || "-"}</TableCell> }
<TableCell className="text-[13px] text-right">{item.discount_value || "-"}</TableCell> return grouped.map((group, gIdx) =>
<TableCell className="text-[13px] text-right font-semibold">{item.calculated_price ? Number(item.calculated_price).toLocaleString() : "-"}</TableCell> group.rows.map((item, rowIdx) => (
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell> <TableRow
</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> </TableBody>
</Table> </Table>
)} )}
@@ -1186,7 +1271,7 @@ export default function SubcontractorManagementPage() {
className="h-8 text-xs flex-1" className="h-8 text-xs flex-1"
/> />
<div className="w-[80px]"> <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> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent> <SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)} {(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="flex gap-2 items-center">
<div className="w-[90px]"> <div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "base_price_type", v)}> <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> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준유형" /></SelectTrigger>
<SelectContent> <SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)} {(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent> </SelectContent>
@@ -1211,8 +1296,8 @@ export default function SubcontractorManagementPage() {
placeholder="기준가" placeholder="기준가"
/> />
<div className="w-[90px]"> <div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)}> <Select value={price.discount_type || undefined} onValueChange={(v) => updatePriceRow(itemKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인유형" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none"></SelectItem> <SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</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" placeholder="0"
/> />
<div className="w-[90px]"> <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> <SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent> <SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)} {(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} tableName={ts.tableName}
settingsId={ts.settingsId} settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys} defaultVisibleKeys={ts.defaultVisibleKeys}
includeAutoColumns={["created_date", "updated_date", "writer"]}
onSave={ts.applySettings} onSave={ts.applySettings}
/> />
@@ -78,6 +78,8 @@ export interface TableSettingsModalProps {
initialTab?: "columns" | "filters" | "groups"; initialTab?: "columns" | "filters" | "groups";
/** 기본 표시 컬럼 키 목록 (GRID_COLUMNS 기준). 미지정 시 전체 표시 */ /** 기본 표시 컬럼 키 목록 (GRID_COLUMNS 기준). 미지정 시 전체 표시 */
defaultVisibleKeys?: string[]; defaultVisibleKeys?: string[];
/** AUTO_COLS에서 제외하지 않을 컬럼 키 목록 (예: ["created_date", "updated_date", "writer"]) */
includeAutoColumns?: string[];
} }
// ===== 상수 ===== // ===== 상수 =====
@@ -207,6 +209,7 @@ export function TableSettingsModal({
onSave, onSave,
initialTab = "columns", initialTab = "columns",
defaultVisibleKeys, defaultVisibleKeys,
includeAutoColumns,
}: TableSettingsModalProps) { }: TableSettingsModalProps) {
const [activeTab, setActiveTab] = useState(initialTab); const [activeTab, setActiveTab] = useState(initialTab);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -240,7 +243,7 @@ export function TableSettingsModal({
// 기본 컬럼 설정 생성 // 기본 컬럼 설정 생성
const unsortedColumns: ColumnSetting[] = types const unsortedColumns: ColumnSetting[] = types
.filter((t) => !AUTO_COLS.includes(t.columnName)) .filter((t) => !AUTO_COLS.includes(t.columnName) || includeAutoColumns?.includes(t.columnName))
.map((t) => ({ .map((t) => ({
columnName: t.columnName, columnName: t.columnName,
displayName: t.displayName || t.columnLabel || t.columnName, displayName: t.displayName || t.columnLabel || t.columnName,
+1 -1
View File
@@ -62,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( 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" && 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", "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, className,
+11 -6
View File
@@ -34,14 +34,16 @@ export function useTableSettings<T extends { key: string }>(
settingsId: string, settingsId: string,
tableName: string, tableName: string,
defaultColumns: T[], defaultColumns: T[],
/** 초기 표시 컬럼 키 (미지정 시 defaultColumns 전체) */
initialVisibleKeys?: string[],
) { ) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>( 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 [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [orderedKeys, setOrderedKeys] = useState<string[]>( const [orderedKeys, setOrderedKeys] = useState<string[]>(
() => defaultColumns.map((c) => c.key), () => initialVisibleKeys || defaultColumns.map((c) => c.key),
); );
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성) // 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>( 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) { 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); visible.add(col.key);
order.push(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)), settings.filters?.filter((f) => visible.has(f.columnName)),
); );
}, },
[defaultColumns], [defaultColumns, initialVisibleKeys],
); );
// 마운트 시 저장된 설정 복원 // 마운트 시 저장된 설정 복원
@@ -148,6 +153,6 @@ export function useTableSettings<T extends { key: string }>(
/** 필터 설정 */ /** 필터 설정 */
filterConfig, filterConfig,
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */ /** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
defaultVisibleKeys: defaultColumns.map((c) => c.key), defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key),
}; };
} }