Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
; Conflicts: ; frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
// 일련번호 현황 집계
|
// 일련번호 현황 집계
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -764,15 +842,12 @@ export default function SubcontractorManagementPage() {
|
|||||||
<p className="text-sm font-medium">등록된 외주업체가 없어요</p>
|
<p className="text-sm font-medium">등록된 외주업체가 없어요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<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 style={ts.thStyle("subcontractor_code")} 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 style={ts.thStyle("subcontractor_name")} 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 style={ts.thStyle("contact_person")} className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>}
|
))}
|
||||||
{ts.isVisible("contact_phone") && <TableHead style={ts.thStyle("contact_phone")} className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">연락처</TableHead>}
|
|
||||||
{ts.isVisible("division_label") && <TableHead style={ts.thStyle("division_label")} className="text-xs font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>}
|
|
||||||
{ts.isVisible("status_label") && <TableHead style={ts.thStyle("status_label")} 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 style={ts.thStyle("subcontractor_code")} className="text-[13px] text-primary">{sub.subcontractor_code}</TableCell>}
|
{ts.visibleColumns.map((col) => (
|
||||||
{ts.isVisible("subcontractor_name") && <TableCell style={ts.thStyle("subcontractor_name")} className="text-[13px] font-medium">{sub.subcontractor_name}</TableCell>}
|
<TableCell key={col.key} className="text-[13px]">
|
||||||
{ts.isVisible("contact_person") && <TableCell style={ts.thStyle("contact_person")} className="text-[13px]">{sub.contact_person || "-"}</TableCell>}
|
{renderCellValue(sub, col.key)}
|
||||||
{ts.isVisible("contact_phone") && <TableCell style={ts.thStyle("contact_phone")} className="text-[13px]">{sub.contact_phone || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("division_label") && (
|
|
||||||
<TableCell style={ts.thStyle("division_label")} 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 style={ts.thStyle("status_label")} 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 상수 =====
|
// ===== 상수 =====
|
||||||
@@ -216,6 +218,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);
|
||||||
@@ -251,7 +254,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,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
const [baseFilter, setBaseFilter] = useState<BaseFilter | undefined>();
|
const [baseFilter, setBaseFilter] = useState<BaseFilter | undefined>();
|
||||||
|
|
||||||
@@ -72,9 +74,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);
|
||||||
}
|
}
|
||||||
@@ -92,7 +97,7 @@ export function useTableSettings<T extends { key: string }>(
|
|||||||
// 기본 데이터 필터
|
// 기본 데이터 필터
|
||||||
setBaseFilter(settings.baseFilter);
|
setBaseFilter(settings.baseFilter);
|
||||||
},
|
},
|
||||||
[defaultColumns],
|
[defaultColumns, initialVisibleKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 마운트 시 저장된 설정 복원
|
// 마운트 시 저장된 설정 복원
|
||||||
@@ -167,6 +172,6 @@ export function useTableSettings<T extends { key: string }>(
|
|||||||
/** 기본 데이터 필터 (예: division = '판매') */
|
/** 기본 데이터 필터 (예: division = '판매') */
|
||||||
baseFilter,
|
baseFilter,
|
||||||
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
||||||
defaultVisibleKeys: defaultColumns.map((c) => c.key),
|
defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user