feat: Implement approval request validation and enhance UI components

- Added validation to prevent duplicate approval requests for the same target, ensuring that only one active or completed approval exists at a time.
- Implemented a check to disallow self-approval in the approval line unless the approval type is 'self'.
- Integrated the ApprovalDetailModal component into the main layout for improved user experience.
- Updated the SalesOrderPage to include approval status in the data structure, enhancing visibility of approval states.
- Enhanced BOM management modals across multiple company implementations to accommodate new UI requirements.
This commit is contained in:
kjs
2026-04-16 10:26:38 +09:00
parent a89e99560d
commit d3491a79bb
23 changed files with 1389 additions and 172 deletions
@@ -892,6 +892,40 @@ export class ApprovalRequestController {
const userName = req.user?.userName || "";
const deptName = req.user?.deptName || "";
// 🔒 중복 결재 차단: 같은 target에 활성/완료된 결재가 있으면 거부
// (rejected, cancelled는 재상신 허용)
if (target_record_id) {
const existing = await queryOne<any>(
`SELECT request_id, status FROM approval_requests
WHERE target_table = $1 AND target_record_id = $2 AND company_code = $3
AND status IN ('requested', 'in_progress', 'approved', 'post_pending')
ORDER BY request_id DESC LIMIT 1`,
[target_table, safeTargetRecordId, companyCode]
);
if (existing) {
const statusLabel: Record<string, string> = {
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
};
return res.status(409).json({
success: false,
message: `이미 ${statusLabel[existing.status] || existing.status} 상태의 결재가 존재합니다. (요청 ID: ${existing.request_id})`,
error: { code: "DUPLICATE_APPROVAL", details: existing },
});
}
}
// 🔒 자기 자신 결재 차단: approval_type이 'self'가 아니면 결재선에 본인 포함 불가
if (approval_type !== "self" && Array.isArray(approvers)) {
const selfInLine = approvers.find((a: any) => (a.userId || a.user_id) === userId);
if (selfInLine) {
return res.status(400).json({
success: false,
message: "결재선에 본인을 포함할 수 없습니다. 자기결재(전결)는 별도 유형을 사용해 주세요.",
error: { code: "SELF_APPROVER_NOT_ALLOWED" },
});
}
}
// approval_mode를 target_record_data에 병합 저장 (하위호환)
const mergedRecordData = {
...(target_record_data || {}),
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "approval_status", label: "결재상태", source: "master" },
{ key: "memo", label: "메모", source: "master" },
];
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
const TOTAL_COLS = 16;
// 결재상태 라벨/색상
const APPROVAL_STATUS_LABEL: Record<string, string> = {
requested: "요청",
in_progress: "결재중",
approved: "승인완료",
rejected: "반려",
cancelled: "회수",
post_pending: "후결대기",
};
const APPROVAL_STATUS_CLASS: Record<string, string> = {
requested: "bg-secondary text-secondary-foreground",
in_progress: "bg-primary/10 text-primary border border-primary/20",
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
cancelled: "bg-muted text-muted-foreground",
post_pending: "bg-warning/10 text-warning",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
} catch { /* skip */ }
}
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
let approvalMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
] },
autoFilter: true,
sort: { columnName: "request_id", order: "desc" },
});
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
for (const a of apprs) {
const rid = String(a.target_record_id);
if (!approvalMap[rid]) approvalMap[rid] = a;
}
} catch { /* skip */ }
}
// part_code → item_info 조인
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const appr = approvalMap[String(row.order_no)] || null;
return {
...row,
part_name: row.part_name || item?.item_name || "",
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
approval_status: appr?.status || "",
approval_request_id: appr?.request_id || null,
_master: master || {},
};
});
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 결재 처리 완료 시 목록 새로고침
useEffect(() => {
const handler = () => fetchOrders();
window.addEventListener("approval-processed", handler);
return () => window.removeEventListener("approval-processed", handler);
}, [fetchOrders]);
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isCustomerPrice = priceModeLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const resData = res.data?.data;
let rows = resData?.data || resData?.rows || [];
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isStandardPrice = pmLabel.includes("기준");
const isCustomerPrice = pmLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerPriceMap: Record<string, string> = {};
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
const isStandard = pmLabel.includes("기준");
const isCustomer = pmLabel.includes("거래처");
if (isStandard) {
// 품목 기준단가 조회
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// 조건부 레이어 판단
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
>
<Trash2 className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<Button
variant="outline" size="sm"
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
disabled={checkedIds.length !== 1}
onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (!item) return;
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
const labelMap: Record<string, string> = {
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
};
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
return;
}
window.dispatchEvent(new CustomEvent("open-approval-modal", {
detail: {
targetTable: "sales_order_mng",
targetRecordId: String(item.order_no),
targetRecordData: {
order_no: item.order_no,
partner_id: item._master?.partner_id || item.partner_id,
order_date: item.order_date,
item_name: item.part_name,
qty: item.qty,
amount: item.amount,
},
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}`,
},
}));
}}
>
<ClipboardList className="w-4 h-4" />
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4" />
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
</TableRow>
) : (
ts.groupData(paginatedRows).map((row: any) => {
// 그룹 헤더 행 렌더링
if (row._isGroupHeader) {
return (
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
📂 {row._groupValue} ({row._groupCount})
</TableCell>
</TableRow>
);
}
// 그룹 요약 행 렌더링
if (row._isGroupSummary) {
return (
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
{row._groupLabel || "합계"}: {row._count ? `${row._count}` : ""}
{row._groupValue || "합계"}
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
</TableCell>
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-center">
{row.approval_status && row.approval_request_id ? (
<button
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
detail: { requestId: row.approval_request_id },
}));
}}
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
title="결재 상세보기"
>
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
</button>
) : (
<span className="text-muted-foreground/40 text-[11px]">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
</div>
<Button size="sm" onClick={() => {
setItemSelectedMap(new Map());
setItemSearchResults([]);
setItemTotal(0);
setItemTotalPages(1);
setItemPage(1);
setItemPageInput("1");
setItemSearchKeyword("");
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
{itemSearchLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
</TableCell>
</TableRow>
) : itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground"> </TableCell>
</TableRow>
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -15,6 +15,8 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AddressSearchButton } from "@/components/common/AddressSearchButton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
@@ -1898,13 +1900,48 @@ export default function SupplierManagementPage() {
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<div className="flex gap-2">
<Input
value={supplierForm.address || ""}
onChange={(e) => setSupplierForm((p) => ({ ...p, address: e.target.value }))}
placeholder="주소 검색 버튼으로 빠르게 입력"
className="h-9 flex-1"
/>
<AddressSearchButton
className="h-9 shrink-0"
onComplete={(data) => {
setSupplierForm((p) => ({ ...p, address: data.address }));
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={supplierForm.address || ""}
onChange={(e) => setSupplierForm((p) => ({ ...p, address: e.target.value }))}
placeholder="주소"
value={supplierForm.bank_name || ""}
onChange={(e) => setSupplierForm((p) => ({ ...p, bank_name: e.target.value }))}
placeholder="예: 국민은행"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={supplierForm.account_number || ""}
onChange={(e) => setSupplierForm((p) => ({ ...p, account_number: e.target.value }))}
placeholder="예: 123-456-789012"
className="h-9"
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Textarea
value={supplierForm.remark || ""}
onChange={(e) => setSupplierForm((p) => ({ ...p, remark: e.target.value }))}
placeholder="비고"
className="min-h-[70px]"
/>
</div>
</div>
{/* 세금유형 */}
@@ -15,6 +15,8 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AddressSearchButton } from "@/components/common/AddressSearchButton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
@@ -1889,13 +1891,48 @@ export default function CustomerManagementPage() {
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<div className="flex gap-2">
<Input
value={customerForm.address || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, address: e.target.value }))}
placeholder="주소 검색 버튼으로 빠르게 입력"
className="h-9 flex-1"
/>
<AddressSearchButton
className="h-9 shrink-0"
onComplete={(data) => {
setCustomerForm((p) => ({ ...p, address: data.address }));
}}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.address || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, address: e.target.value }))}
placeholder="주소"
value={customerForm.bank_name || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, bank_name: e.target.value }))}
placeholder="예: 국민은행"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input
value={customerForm.account_number || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, account_number: e.target.value }))}
placeholder="예: 123-456-789012"
className="h-9"
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Textarea
value={customerForm.remark || ""}
onChange={(e) => setCustomerForm((p) => ({ ...p, remark: e.target.value }))}
placeholder="비고"
className="min-h-[70px]"
/>
</div>
</div>
{/* 세금유형 */}
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "approval_status", label: "결재상태", source: "master" },
{ key: "memo", label: "메모", source: "master" },
];
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
const TOTAL_COLS = 16;
// 결재상태 라벨/색상
const APPROVAL_STATUS_LABEL: Record<string, string> = {
requested: "요청",
in_progress: "결재중",
approved: "승인완료",
rejected: "반려",
cancelled: "회수",
post_pending: "후결대기",
};
const APPROVAL_STATUS_CLASS: Record<string, string> = {
requested: "bg-secondary text-secondary-foreground",
in_progress: "bg-primary/10 text-primary border border-primary/20",
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
cancelled: "bg-muted text-muted-foreground",
post_pending: "bg-warning/10 text-warning",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
} catch { /* skip */ }
}
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
let approvalMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
] },
autoFilter: true,
sort: { columnName: "request_id", order: "desc" },
});
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
for (const a of apprs) {
const rid = String(a.target_record_id);
if (!approvalMap[rid]) approvalMap[rid] = a;
}
} catch { /* skip */ }
}
// part_code → item_info 조인
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const appr = approvalMap[String(row.order_no)] || null;
return {
...row,
part_name: row.part_name || item?.item_name || "",
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
approval_status: appr?.status || "",
approval_request_id: appr?.request_id || null,
_master: master || {},
};
});
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 결재 처리 완료 시 목록 새로고침
useEffect(() => {
const handler = () => fetchOrders();
window.addEventListener("approval-processed", handler);
return () => window.removeEventListener("approval-processed", handler);
}, [fetchOrders]);
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isCustomerPrice = priceModeLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const resData = res.data?.data;
let rows = resData?.data || resData?.rows || [];
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isStandardPrice = pmLabel.includes("기준");
const isCustomerPrice = pmLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerPriceMap: Record<string, string> = {};
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
const isStandard = pmLabel.includes("기준");
const isCustomer = pmLabel.includes("거래처");
if (isStandard) {
// 품목 기준단가 조회
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// 조건부 레이어 판단
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
>
<Trash2 className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<Button
variant="outline" size="sm"
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
disabled={checkedIds.length !== 1}
onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (!item) return;
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
const labelMap: Record<string, string> = {
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
};
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
return;
}
window.dispatchEvent(new CustomEvent("open-approval-modal", {
detail: {
targetTable: "sales_order_mng",
targetRecordId: String(item.order_no),
targetRecordData: {
order_no: item.order_no,
partner_id: item._master?.partner_id || item.partner_id,
order_date: item.order_date,
item_name: item.part_name,
qty: item.qty,
amount: item.amount,
},
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}`,
},
}));
}}
>
<ClipboardList className="w-4 h-4" />
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4" />
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
</TableRow>
) : (
ts.groupData(paginatedRows).map((row: any) => {
// 그룹 헤더 행 렌더링
if (row._isGroupHeader) {
return (
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
📂 {row._groupValue} ({row._groupCount})
</TableCell>
</TableRow>
);
}
// 그룹 요약 행 렌더링
if (row._isGroupSummary) {
return (
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
{row._groupLabel || "합계"}: {row._count ? `${row._count}` : ""}
{row._groupValue || "합계"}
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
</TableCell>
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-center">
{row.approval_status && row.approval_request_id ? (
<button
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
detail: { requestId: row.approval_request_id },
}));
}}
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
title="결재 상세보기"
>
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
</button>
) : (
<span className="text-muted-foreground/40 text-[11px]">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
</div>
<Button size="sm" onClick={() => {
setItemSelectedMap(new Map());
setItemSearchResults([]);
setItemTotal(0);
setItemTotalPages(1);
setItemPage(1);
setItemPageInput("1");
setItemSearchKeyword("");
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
{itemSearchLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
</TableCell>
</TableRow>
) : itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground"> </TableCell>
</TableRow>
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "approval_status", label: "결재상태", source: "master" },
{ key: "memo", label: "메모", source: "master" },
];
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
const TOTAL_COLS = 16;
// 결재상태 라벨/색상
const APPROVAL_STATUS_LABEL: Record<string, string> = {
requested: "요청",
in_progress: "결재중",
approved: "승인완료",
rejected: "반려",
cancelled: "회수",
post_pending: "후결대기",
};
const APPROVAL_STATUS_CLASS: Record<string, string> = {
requested: "bg-secondary text-secondary-foreground",
in_progress: "bg-primary/10 text-primary border border-primary/20",
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
cancelled: "bg-muted text-muted-foreground",
post_pending: "bg-warning/10 text-warning",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
} catch { /* skip */ }
}
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
let approvalMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
] },
autoFilter: true,
sort: { columnName: "request_id", order: "desc" },
});
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
for (const a of apprs) {
const rid = String(a.target_record_id);
if (!approvalMap[rid]) approvalMap[rid] = a;
}
} catch { /* skip */ }
}
// part_code → item_info 조인
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const appr = approvalMap[String(row.order_no)] || null;
return {
...row,
part_name: row.part_name || item?.item_name || "",
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
approval_status: appr?.status || "",
approval_request_id: appr?.request_id || null,
_master: master || {},
};
});
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 결재 처리 완료 시 목록 새로고침
useEffect(() => {
const handler = () => fetchOrders();
window.addEventListener("approval-processed", handler);
return () => window.removeEventListener("approval-processed", handler);
}, [fetchOrders]);
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isCustomerPrice = priceModeLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const resData = res.data?.data;
let rows = resData?.data || resData?.rows || [];
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isStandardPrice = pmLabel.includes("기준");
const isCustomerPrice = pmLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerPriceMap: Record<string, string> = {};
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
const isStandard = pmLabel.includes("기준");
const isCustomer = pmLabel.includes("거래처");
if (isStandard) {
// 품목 기준단가 조회
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// 조건부 레이어 판단
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
>
<Trash2 className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<Button
variant="outline" size="sm"
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
disabled={checkedIds.length !== 1}
onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (!item) return;
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
const labelMap: Record<string, string> = {
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
};
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
return;
}
window.dispatchEvent(new CustomEvent("open-approval-modal", {
detail: {
targetTable: "sales_order_mng",
targetRecordId: String(item.order_no),
targetRecordData: {
order_no: item.order_no,
partner_id: item._master?.partner_id || item.partner_id,
order_date: item.order_date,
item_name: item.part_name,
qty: item.qty,
amount: item.amount,
},
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}`,
},
}));
}}
>
<ClipboardList className="w-4 h-4" />
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4" />
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
</TableRow>
) : (
ts.groupData(paginatedRows).map((row: any) => {
// 그룹 헤더 행 렌더링
if (row._isGroupHeader) {
return (
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
📂 {row._groupValue} ({row._groupCount})
</TableCell>
</TableRow>
);
}
// 그룹 요약 행 렌더링
if (row._isGroupSummary) {
return (
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
{row._groupLabel || "합계"}: {row._count ? `${row._count}` : ""}
{row._groupValue || "합계"}
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
</TableCell>
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-center">
{row.approval_status && row.approval_request_id ? (
<button
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
detail: { requestId: row.approval_request_id },
}));
}}
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
title="결재 상세보기"
>
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
</button>
) : (
<span className="text-muted-foreground/40 text-[11px]">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
</div>
<Button size="sm" onClick={() => {
setItemSelectedMap(new Map());
setItemSearchResults([]);
setItemTotal(0);
setItemTotalPages(1);
setItemPage(1);
setItemPageInput("1");
setItemSearchKeyword("");
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
{itemSearchLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
</TableCell>
</TableRow>
) : itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground"> </TableCell>
</TableRow>
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "approval_status", label: "결재상태", source: "master" },
{ key: "memo", label: "메모", source: "master" },
];
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
const TOTAL_COLS = 16;
// 결재상태 라벨/색상
const APPROVAL_STATUS_LABEL: Record<string, string> = {
requested: "요청",
in_progress: "결재중",
approved: "승인완료",
rejected: "반려",
cancelled: "회수",
post_pending: "후결대기",
};
const APPROVAL_STATUS_CLASS: Record<string, string> = {
requested: "bg-secondary text-secondary-foreground",
in_progress: "bg-primary/10 text-primary border border-primary/20",
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
cancelled: "bg-muted text-muted-foreground",
post_pending: "bg-warning/10 text-warning",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
} catch { /* skip */ }
}
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
let approvalMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
] },
autoFilter: true,
sort: { columnName: "request_id", order: "desc" },
});
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
for (const a of apprs) {
const rid = String(a.target_record_id);
if (!approvalMap[rid]) approvalMap[rid] = a;
}
} catch { /* skip */ }
}
// part_code → item_info 조인
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const appr = approvalMap[String(row.order_no)] || null;
return {
...row,
part_name: row.part_name || item?.item_name || "",
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
approval_status: appr?.status || "",
approval_request_id: appr?.request_id || null,
_master: master || {},
};
});
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 결재 처리 완료 시 목록 새로고침
useEffect(() => {
const handler = () => fetchOrders();
window.addEventListener("approval-processed", handler);
return () => window.removeEventListener("approval-processed", handler);
}, [fetchOrders]);
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isCustomerPrice = priceModeLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const resData = res.data?.data;
let rows = resData?.data || resData?.rows || [];
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isStandardPrice = pmLabel.includes("기준");
const isCustomerPrice = pmLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerPriceMap: Record<string, string> = {};
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
const isStandard = pmLabel.includes("기준");
const isCustomer = pmLabel.includes("거래처");
if (isStandard) {
// 품목 기준단가 조회
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// 조건부 레이어 판단
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
>
<Trash2 className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<Button
variant="outline" size="sm"
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
disabled={checkedIds.length !== 1}
onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (!item) return;
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
const labelMap: Record<string, string> = {
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
};
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
return;
}
window.dispatchEvent(new CustomEvent("open-approval-modal", {
detail: {
targetTable: "sales_order_mng",
targetRecordId: String(item.order_no),
targetRecordData: {
order_no: item.order_no,
partner_id: item._master?.partner_id || item.partner_id,
order_date: item.order_date,
item_name: item.part_name,
qty: item.qty,
amount: item.amount,
},
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}`,
},
}));
}}
>
<ClipboardList className="w-4 h-4" />
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4" />
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
</TableRow>
) : (
ts.groupData(paginatedRows).map((row: any) => {
// 그룹 헤더 행 렌더링
if (row._isGroupHeader) {
return (
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
📂 {row._groupValue} ({row._groupCount})
</TableCell>
</TableRow>
);
}
// 그룹 요약 행 렌더링
if (row._isGroupSummary) {
return (
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
{row._groupLabel || "합계"}: {row._count ? `${row._count}` : ""}
{row._groupValue || "합계"}
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
</TableCell>
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-center">
{row.approval_status && row.approval_request_id ? (
<button
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
detail: { requestId: row.approval_request_id },
}));
}}
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
title="결재 상세보기"
>
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
</button>
) : (
<span className="text-muted-foreground/40 text-[11px]">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
</div>
<Button size="sm" onClick={() => {
setItemSelectedMap(new Map());
setItemSearchResults([]);
setItemTotal(0);
setItemTotalPages(1);
setItemPage(1);
setItemPageInput("1");
setItemSearchKeyword("");
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
{itemSearchLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
</TableCell>
</TableRow>
) : itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground"> </TableCell>
</TableRow>
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
@@ -58,6 +58,7 @@ const FLAT_COLUMNS = [
{ key: "unit_price", label: "단가", source: "detail" },
{ key: "amount", label: "금액", source: "detail" },
{ key: "due_date", label: "납기일", source: "detail" },
{ key: "approval_status", label: "결재상태", source: "master" },
{ key: "memo", label: "메모", source: "master" },
];
@@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail");
// 필터용 전체 키
const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label }));
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15
const TOTAL_COLS = 15;
// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(15) = 16
const TOTAL_COLS = 16;
// 결재상태 라벨/색상
const APPROVAL_STATUS_LABEL: Record<string, string> = {
requested: "요청",
in_progress: "결재중",
approved: "승인완료",
rejected: "반려",
cancelled: "회수",
post_pending: "후결대기",
};
const APPROVAL_STATUS_CLASS: Record<string, string> = {
requested: "bg-secondary text-secondary-foreground",
in_progress: "bg-primary/10 text-primary border border-primary/20",
approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20",
rejected: "bg-destructive/10 text-destructive border border-destructive/20",
cancelled: "bg-muted text-muted-foreground",
post_pending: "bg-warning/10 text-warning",
};
// 헤더 필터 Popover
function HeaderFilterPopover({
@@ -333,6 +352,28 @@ export default function SalesOrderPage() {
} catch { /* skip */ }
}
// 결재 상태 조인 (target_table='sales_order_mng', target_record_id = order_no)
let approvalMap: Record<string, any> = {};
if (orderNos.length > 0) {
try {
const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, {
page: 1, size: orderNos.length + 10,
dataFilter: { enabled: true, filters: [
{ columnName: "target_table", operator: "equals", value: "sales_order_mng" },
{ columnName: "target_record_id", operator: "in", value: orderNos.map(String) },
] },
autoFilter: true,
sort: { columnName: "request_id", order: "desc" },
});
const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || [];
// 같은 order_no에 여러 결재가 있으면 최신만 (sort desc 첫 번째)
for (const a of apprs) {
const rid = String(a.target_record_id);
if (!approvalMap[rid]) approvalMap[rid] = a;
}
} catch { /* skip */ }
}
// part_code → item_info 조인
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
@@ -359,6 +400,7 @@ export default function SalesOrderPage() {
const item = itemMap[row.part_code];
const master = masterMap[row.order_no];
const rawUnit = row.unit || item?.inventory_unit || "";
const appr = approvalMap[String(row.order_no)] || null;
return {
...row,
part_name: row.part_name || item?.item_name || "",
@@ -366,6 +408,8 @@ export default function SalesOrderPage() {
material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""),
unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit,
memo: row.memo || master?.memo || "",
approval_status: appr?.status || "",
approval_request_id: appr?.request_id || null,
_master: master || {},
};
});
@@ -381,6 +425,13 @@ export default function SalesOrderPage() {
useEffect(() => { fetchOrders(); }, [fetchOrders]);
// 결재 처리 완료 시 목록 새로고침
useEffect(() => {
const handler = () => fetchOrders();
window.addEventListener("approval-processed", handler);
return () => window.removeEventListener("approval-processed", handler);
}, [fetchOrders]);
// 카테고리 코드→라벨 변환
const resolveLabel = useCallback((key: string, code: string) => {
if (!code) return "";
@@ -705,19 +756,16 @@ export default function SalesOrderPage() {
const filters: any[] = [];
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
// 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응)
// 관리품목 필터: 다중값(콤마 구분) 저장된 경우도 매칭되도록 contains 사용
if (itemSearchDivision !== "all") {
const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || "";
// 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용
const divValues = [itemSearchDivision];
if (divLabel) divValues.push(divLabel);
filters.push({ columnName: "division", operator: "in", value: divValues });
filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision });
}
// 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
// 거래처우선 단가방식일 때 거래처 매핑 id 정규화 → 서버 필터 적용
// price_mode의 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isCustomerPrice = priceModeLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerItemIds: Set<string> | null = null;
if (isCustomerPrice && partnerId) {
try {
@@ -727,7 +775,36 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || [];
customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean));
const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[];
if (rawIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
// UUID와 문자열(item_number) 분리
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const uuidIds = rawIds.filter(v => uuidRegex.test(v));
const codeIds = rawIds.filter(v => !uuidRegex.test(v));
// 문자열(item_number)을 item_info에서 id로 변환
let convertedIds: string[] = [];
if (codeIds.length > 0) {
const convRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: codeIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] },
autoFilter: true,
});
const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || [];
convertedIds = convRows.map((r: any) => r.id).filter(Boolean);
}
const finalIds = [...new Set([...uuidIds, ...convertedIds])];
if (finalIds.length === 0) {
setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1);
setItemSearchLoading(false);
return;
}
filters.push({ columnName: "id", operator: "in", value: finalIds });
} catch { /* skip */ }
}
@@ -737,14 +814,9 @@ export default function SalesOrderPage() {
autoFilter: true,
});
const resData = res.data?.data;
let rows = resData?.data || resData?.rows || [];
const rows = resData?.data || resData?.rows || [];
const serverTotal = resData?.total || resData?.totalCount || rows.length;
// 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터)
if (customerItemIds) {
rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id));
}
setItemSearchResults(rows);
setItemTotal(serverTotal);
setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s)));
@@ -778,8 +850,9 @@ export default function SalesOrderPage() {
const selected = Array.from(itemSelectedMap.values());
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || "";
const isStandardPrice = pmLabel.includes("기준");
const isCustomerPrice = pmLabel.includes("거래처");
const partnerId = masterForm.partner_id;
let customerPriceMap: Record<string, string> = {};
@@ -847,10 +920,10 @@ export default function SalesOrderPage() {
// 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신
const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => {
if (detailRows.length === 0) return;
const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"];
const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"];
const isStandard = STANDARD_CODES.includes(priceMode);
const isCustomer = CUSTOMER_CODES.includes(priceMode);
// price_mode 라벨로 판단 (카테고리 코드는 회사마다 다를 수 있음)
const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || "";
const isStandard = pmLabel.includes("기준");
const isCustomer = pmLabel.includes("거래처");
if (isStandard) {
// 품목 기준단가 조회
@@ -925,9 +998,11 @@ export default function SalesOrderPage() {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// 조건부 레이어 판단
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
// 조건부 레이어 판단 (라벨 기반 — 카테고리 코드는 회사마다 다를 수 있음)
const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || "";
const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || "";
const isSupplierFirst = inputModeLabel.includes("공급") || inputModeLabel.includes("거래처");
const isOverseas = sellModeLabel.includes("해외") || sellModeLabel.includes("수출");
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
@@ -994,6 +1069,42 @@ export default function SalesOrderPage() {
>
<Trash2 className="w-4 h-4" /> {checkedIds.length > 0 && ` (${checkedIds.length})`}
</Button>
<Button
variant="outline" size="sm"
className="text-primary border-primary/20 bg-primary/5 hover:bg-primary/10"
disabled={checkedIds.length !== 1}
onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (!item) return;
// 이미 활성 결재가 있으면 차단 (재상신은 rejected/cancelled만 허용)
const blockedStatuses = ["requested", "in_progress", "approved", "post_pending"];
if (item.approval_status && blockedStatuses.includes(item.approval_status)) {
const labelMap: Record<string, string> = {
requested: "요청됨", in_progress: "결재중", approved: "승인완료", post_pending: "후결대기",
};
toast.error(`이미 ${labelMap[item.approval_status]} 상태의 결재가 존재합니다.`);
return;
}
window.dispatchEvent(new CustomEvent("open-approval-modal", {
detail: {
targetTable: "sales_order_mng",
targetRecordId: String(item.order_no),
targetRecordData: {
order_no: item.order_no,
partner_id: item._master?.partner_id || item.partner_id,
order_date: item.order_date,
item_name: item.part_name,
qty: item.qty,
amount: item.amount,
},
defaultTitle: `수주결재: ${item.order_no} - ${item.part_name || ""}`,
defaultDescription: `수주번호: ${item.order_no}\n품목: ${item.part_name || ""}\n수량: ${item.qty || 0}\n금액: ${Number(item.amount || 0).toLocaleString()}`,
},
}));
}}
>
<ClipboardList className="w-4 h-4" />
</Button>
<div className="h-5 w-px bg-border mx-0.5" />
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4" />
@@ -1030,6 +1141,7 @@ export default function SalesOrderPage() {
<col style={{ width: "90px" }} />
<col style={{ width: "110px" }} />
<col style={{ width: "100px" }} />
<col style={{ width: "90px" }} />
<col style={{ width: "120px" }} />
</colgroup>
<TableHeader className="sticky top-0 z-10">
@@ -1095,12 +1207,22 @@ export default function SalesOrderPage() {
</TableRow>
) : (
ts.groupData(paginatedRows).map((row: any) => {
// 그룹 헤더 행 렌더링
if (row._isGroupHeader) {
return (
<TableRow key={`header-${row._groupValue}-${Math.random()}`} className="bg-primary/5 font-semibold border-t-2 border-primary/30">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
📂 {row._groupValue} ({row._groupCount})
</TableCell>
</TableRow>
);
}
// 그룹 요약 행 렌더링
if (row._isGroupSummary) {
return (
<TableRow key={`summary-${row._groupKey || Math.random()}`} className="bg-muted/60 font-semibold border-t border-primary/20">
<TableCell colSpan={TOTAL_COLS} className="py-2 px-3 text-[13px] text-primary">
{row._groupLabel || "합계"}: {row._count ? `${row._count}` : ""}
{row._groupValue || "합계"}
{row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""}
{row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""}
</TableCell>
@@ -1146,6 +1268,24 @@ export default function SalesOrderPage() {
<TableCell className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>
<TableCell className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell className="text-[13px]">{row.due_date || ""}</TableCell>
<TableCell className="text-center">
{row.approval_status && row.approval_request_id ? (
<button
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent("open-approval-detail-modal", {
detail: { requestId: row.approval_request_id },
}));
}}
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold cursor-pointer hover:opacity-80 transition-opacity", APPROVAL_STATUS_CLASS[row.approval_status] || "bg-muted text-muted-foreground")}
title="결재 상세보기"
>
{APPROVAL_STATUS_LABEL[row.approval_status] || row.approval_status}
</button>
) : (
<span className="text-muted-foreground/40 text-[11px]">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground"><span className="block truncate max-w-[120px]">{row.memo || ""}</span></TableCell>
</TableRow>
);
@@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {
</div>
<Button size="sm" onClick={() => {
setItemSelectedMap(new Map());
setItemSearchResults([]);
setItemTotal(0);
setItemTotalPages(1);
setItemPage(1);
setItemPageInput("1");
setItemSearchKeyword("");
@@ -1680,7 +1823,16 @@ export default function SalesOrderPage() {
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
{itemSearchLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
</TableCell>
</TableRow>
) : itemSearchResults.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground"> </TableCell>
</TableRow>
@@ -1986,7 +1986,7 @@ export default function BomManagementPage() {
{/* ─── BOM 등록/수정 모달 ─────────────────── */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[85vh] overflow-y-auto" onPointerDownOutside={(e) => { if (showItemSearchModal) e.preventDefault(); }}>
<DialogHeader>
<DialogTitle>{isEditMode ? "BOM 수정" : "BOM 등록"}</DialogTitle>
<DialogDescription>{isEditMode ? "BOM 정보를 수정해요" : "새로운 BOM을 등록해요"}</DialogDescription>
+2
View File
@@ -3,6 +3,7 @@ import { MenuProvider } from "@/contexts/MenuContext";
import { MessengerProvider } from "@/contexts/MessengerContext";
import { AppLayout } from "@/components/layout/AppLayout";
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
import { ApprovalDetailModal } from "@/components/approval/ApprovalDetailModal";
import { MessengerFAB } from "@/components/messenger/MessengerFAB";
import { MessengerModal } from "@/components/messenger/MessengerModal";
@@ -13,6 +14,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
<MessengerProvider>
<AppLayout>{children}</AppLayout>
<ApprovalGlobalListener />
<ApprovalDetailModal />
<MessengerFAB />
<MessengerModal />
</MessengerProvider>
+3 -1
View File
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { useTabStore } from "@/stores/tabStore";
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar } from "lucide-react";
const quickAccessItems = [
@@ -16,6 +17,7 @@ const quickAccessItems = [
export default function MainPage() {
const router = useRouter();
const { user } = useAuth();
const { openTab } = useTabStore();
const userName = user?.userName || "사용자";
const today = new Date();
@@ -40,7 +42,7 @@ export default function MainPage() {
return (
<button
key={item.href}
onClick={() => router.push(item.href)}
onClick={() => openTab({ type: "admin", title: item.label, adminUrl: item.href })}
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
>
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
+3 -1
View File
@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { useTabStore } from "@/stores/tabStore";
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar, ArrowRight } from "lucide-react";
const quickAccessItems = [
@@ -16,6 +17,7 @@ const quickAccessItems = [
export default function MainHomePage() {
const router = useRouter();
const { user } = useAuth();
const { openTab } = useTabStore();
const userName = user?.userName || "사용자";
const today = new Date();
@@ -40,7 +42,7 @@ export default function MainHomePage() {
return (
<button
key={item.href}
onClick={() => router.push(item.href)}
onClick={() => openTab({ type: "admin", title: item.label, adminUrl: item.href })}
className="group flex flex-col items-center gap-2.5 rounded-lg border bg-card p-4 transition-all hover:shadow-md"
>
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${item.color} transition-transform group-hover:scale-105`}>
@@ -0,0 +1,207 @@
"use client";
import React, { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2, CheckCircle2, XCircle, Clock, FileCheck2 } from "lucide-react";
import {
getApprovalRequest,
processApprovalLine,
cancelApprovalRequest,
type ApprovalRequest,
} from "@/lib/api/approval";
import { useAuth } from "@/hooks/useAuth";
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
requested: { label: "요청됨", variant: "secondary" },
in_progress: { label: "진행 중", variant: "default" },
approved: { label: "승인됨", variant: "outline" },
rejected: { label: "반려됨", variant: "destructive" },
cancelled: { label: "취소됨", variant: "secondary" },
post_pending: { label: "후결대기", variant: "secondary" },
};
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
approved: { label: "승인", icon: <CheckCircle2 className="h-3 w-3 text-emerald-600" /> },
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
};
export interface ApprovalDetailEventDetail {
requestId: number;
}
export const ApprovalDetailModal: React.FC = () => {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [request, setRequest] = useState<ApprovalRequest | null>(null);
const [loading, setLoading] = useState(false);
const [comment, setComment] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
// 글로벌 이벤트 수신
useEffect(() => {
const handler = async (e: Event) => {
const detail = (e as CustomEvent).detail as ApprovalDetailEventDetail;
if (!detail?.requestId) return;
setOpen(true);
setLoading(true);
const res = await getApprovalRequest(detail.requestId);
setLoading(false);
if (res.success && res.data) setRequest(res.data);
else setRequest(null);
};
window.addEventListener("open-approval-detail-modal", handler);
return () => window.removeEventListener("open-approval-detail-modal", handler);
}, []);
useEffect(() => {
if (!open) {
setComment("");
setRequest(null);
}
}, [open]);
// 내가 처리할 결재 라인 ID 찾기
const pendingLineId = request?.lines?.find(
(l) => l.approver_id === user?.userId && l.status === "pending"
)?.line_id;
const handleProcess = async (action: "approved" | "rejected") => {
if (!pendingLineId) return;
setIsProcessing(true);
const res = await processApprovalLine(pendingLineId, { action, comment: comment.trim() || undefined });
setIsProcessing(false);
if (res.success) {
setOpen(false);
// 호출한 페이지에 새로고침 알림
window.dispatchEvent(new CustomEvent("approval-processed", { detail: { requestId: request?.request_id, action } }));
}
};
const handleCancel = async () => {
if (!request) return;
setIsCancelling(true);
const res = await cancelApprovalRequest(request.request_id);
setIsCancelling(false);
if (res.success) {
setOpen(false);
window.dispatchEvent(new CustomEvent("approval-processed", { detail: { requestId: request.request_id, action: "cancelled" } }));
}
};
if (!open) return null;
const statusInfo = request ? (statusConfig[request.status] || { label: request.status, variant: "secondary" as const }) : null;
const isRequester = request?.requester_id === user?.userId;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
{loading || !request ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileCheck2 className="h-5 w-5" />
{request.title}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{statusInfo && (
<Badge variant={statusInfo.variant} className="mr-2">
{statusInfo.label}
</Badge>
)}
: {request.requester_name || request.requester_id}
{request.requester_dept ? ` (${request.requester_dept})` : ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{request.description && (
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium"> </p>
<p className="rounded-md bg-muted p-3 text-xs sm:text-sm whitespace-pre-line">{request.description}</p>
</div>
)}
<div>
<p className="text-muted-foreground mb-2 text-xs font-medium"></p>
<div className="space-y-2">
{(request.lines || []).map((line) => {
const lineStatus = lineStatusConfig[line.status] || { label: line.status, icon: null };
return (
<div key={line.line_id} className="flex items-start justify-between rounded-md border p-3">
<div className="flex items-center gap-2">
{lineStatus.icon}
<div>
<p className="text-xs font-medium sm:text-sm">
{line.approver_label || `${line.step_order}차 결재`} {line.approver_name || line.approver_id}
</p>
{line.approver_position && (
<p className="text-muted-foreground text-[10px] sm:text-xs">{line.approver_position}</p>
)}
{line.comment && (
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">: {line.comment}</p>
)}
</div>
</div>
<span className="text-muted-foreground text-[10px] sm:text-xs">{lineStatus.label}</span>
</div>
);
})}
</div>
</div>
{pendingLineId && (
<div>
<p className="text-muted-foreground mb-1 text-xs font-medium"> ()</p>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="결재 의견을 입력하세요"
className="min-h-[60px] text-xs sm:text-sm"
/>
</div>
)}
</div>
<DialogFooter className="flex-wrap gap-2 sm:gap-1">
{isRequester && (request.status === "requested" || request.status === "in_progress") && !pendingLineId && (
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isCancelling} className="h-8 text-xs">
{isCancelling ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : null}
</Button>
)}
<Button variant="outline" onClick={() => setOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
{pendingLineId && (
<>
<Button variant="destructive" onClick={() => handleProcess("rejected")} disabled={isProcessing} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <XCircle className="mr-1 h-3 w-3" />}
</Button>
<Button onClick={() => handleProcess("approved")} disabled={isProcessing} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{isProcessing ? <Loader2 className="mr-1 h-3 w-3 animate-spin" /> : <CheckCircle2 className="mr-1 h-3 w-3" />}
</Button>
</>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
export default ApprovalDetailModal;
@@ -23,6 +23,8 @@ import {
type ApprovalLineTemplate,
} from "@/lib/api/approval";
import { getUserList } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
// 결재 방식
type ApprovalMode = "sequential" | "parallel";
@@ -54,6 +56,8 @@ export interface ApprovalModalEventDetail {
definitionId?: number;
screenId?: number;
buttonComponentId?: string;
defaultTitle?: string;
defaultDescription?: string;
}
interface ApprovalRequestModalProps {
@@ -84,6 +88,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
onOpenChange,
eventDetail,
}) => {
const { user: currentUser } = useAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [approvalMode, setApprovalMode] = useState<ApprovalMode>("sequential");
@@ -118,8 +123,12 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
setAllUsers([]);
setSelectedTemplateId(null);
setShowTemplatePopover(false);
} else if (eventDetail) {
// 모달 열릴 때 defaultTitle/defaultDescription 적용
if (eventDetail.defaultTitle) setTitle(eventDetail.defaultTitle);
if (eventDetail.defaultDescription) setDescription(eventDetail.defaultDescription);
}
}, [open]);
}, [open, eventDetail]);
// 모달 열릴 때 템플릿 + 사용자 목록 로드
useEffect(() => {
@@ -207,6 +216,16 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
);
const addApprover = (user: UserSearchResult) => {
// 본인 결재자 추가 차단 (자기결재 모드 제외)
if (approvalType !== "self" && currentUser?.userId && user.userId === currentUser.userId) {
toast.error("본인은 결재선에 추가할 수 없습니다.");
return;
}
// 이미 추가된 경우: 제거 (토글 방식)
if (approvers.some((a) => a.user_id === user.userId)) {
setApprovers((prev) => prev.filter((a) => a.user_id !== user.userId));
return;
}
setApprovers((prev) => [
...prev,
{
@@ -217,7 +236,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
dept_name: user.deptName || "",
},
]);
setComboboxOpen(false);
// 팝오버는 닫지 않음 - 연속 선택 가능
};
const removeApprover = (id: string) => {
@@ -509,18 +528,31 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
.
</CommandEmpty>
<CommandGroup>
{availableUsers.map((user) => (
{availableUsers.map((user) => {
const selectedIdx = approvers.findIndex((a) => a.user_id === user.userId);
const isSelected = selectedIdx >= 0;
return (
<CommandItem
key={user.userId}
value={`${user.userName} ${user.userId} ${user.deptName || ""} ${user.positionName || ""}`}
onSelect={() => addApprover(user)}
className="flex cursor-pointer items-center gap-3 px-3 py-2 text-xs sm:text-sm"
className={cn(
"flex cursor-pointer items-center gap-3 px-3 py-2 text-xs sm:text-sm",
isSelected && "bg-primary/5"
)}
>
<div className="bg-muted flex h-7 w-7 shrink-0 items-center justify-center rounded-full">
<Users className="h-3.5 w-3.5" />
<div className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
isSelected ? "bg-primary text-primary-foreground font-bold" : "bg-muted"
)}>
{isSelected ? (
<span className="text-[11px]">{selectedIdx + 1}</span>
) : (
<Users className="h-3.5 w-3.5" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
<p className={cn("truncate font-medium", isSelected && "text-primary")}>
{user.userName}
<span className="text-muted-foreground ml-1 text-[10px]">
({user.userId})
@@ -530,9 +562,14 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
</p>
</div>
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
{isSelected ? (
<X className="text-destructive h-4 w-4 shrink-0" />
) : (
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
)}
</CommandItem>
))}
);
})}
</CommandGroup>
</CommandList>
</Command>
@@ -0,0 +1,97 @@
"use client";
import React, { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { toast } from "sonner";
interface AddressSearchButtonProps {
onComplete: (data: {
address: string; // 선택된 주소 (도로명 또는 지번)
roadAddress: string; // 도로명주소
jibunAddress: string; // 지번주소
zonecode: string; // 우편번호
buildingName?: string; // 건물명
}) => void;
size?: "sm" | "default" | "lg";
variant?: "default" | "outline" | "secondary" | "ghost";
className?: string;
label?: string;
}
declare global {
interface Window {
daum?: any;
}
}
const SCRIPT_SRC = "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js";
// 스크립트 로드 (1회)
function loadScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === "undefined") return reject(new Error("SSR"));
if (window.daum?.Postcode) return resolve();
const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () => reject(new Error("load failed")));
return;
}
const script = document.createElement("script");
script.src = SCRIPT_SRC;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error("load failed"));
document.body.appendChild(script);
});
}
export const AddressSearchButton: React.FC<AddressSearchButtonProps> = ({
onComplete,
size = "sm",
variant = "outline",
className,
label = "주소 검색",
}) => {
const handleClick = useCallback(async () => {
try {
await loadScript();
if (!window.daum?.Postcode) {
toast.error("주소 검색 서비스를 불러올 수 없습니다.");
return;
}
new window.daum.Postcode({
oncomplete: (data: any) => {
const road = data.roadAddress || "";
const jibun = data.jibunAddress || data.autoJibunAddress || "";
const picked = road || jibun;
onComplete({
address: picked,
roadAddress: road,
jibunAddress: jibun,
zonecode: data.zonecode || "",
buildingName: data.buildingName || "",
});
},
}).open();
} catch {
toast.error("주소 검색 스크립트 로드에 실패했습니다.");
}
}, [onComplete]);
return (
<Button
type="button"
variant={variant}
size={size}
onClick={handleClick}
className={className}
>
<Search className="w-3.5 h-3.5" />
{label}
</Button>
);
};
export default AddressSearchButton;
+2 -2
View File
@@ -777,7 +777,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<DropdownMenuItem onClick={() => openTab({ type: "admin", title: "결재함", adminUrl: "/admin/approvalBox" })}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
@@ -1071,7 +1071,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<DropdownMenuItem onClick={() => openTab({ type: "admin", title: "결재함", adminUrl: "/admin/approvalBox" })}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
+3 -1
View File
@@ -10,6 +10,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { LogOut, FileCheck, Monitor, User } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTabStore } from "@/stores/tabStore";
interface UserDropdownProps {
user: any;
@@ -23,6 +24,7 @@ interface UserDropdownProps {
*/
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
const router = useRouter();
const { openTab } = useTabStore();
if (!user) return null;
@@ -82,7 +84,7 @@ export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }:
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
<DropdownMenuItem onClick={() => openTab({ type: "admin", title: "결재함", adminUrl: "/admin/approvalBox" })}>
<FileCheck className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>