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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user