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:
@@ -1,6 +1,9 @@
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Test checklists
|
||||
docs/test-checklists/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 일련번호 현황 집계
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user