Merge pull request 'jskim-node' (#46) from jskim-node into main
Some checks failed
Build and Push Images / build-and-push (push) Failing after 1m43s

Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
2026-05-07 04:31:05 +00:00
5 changed files with 2698 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
"use client";
/**
* 외주발주 상세 모달 (TASK:ERP-019)
* 단일 외주발주의 마스터+공정+자재 정보를 읽기전용으로 조회.
*/
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Loader2, Inbox } from "lucide-react";
import { toast } from "sonner";
import { getOutsourcePurchaseOrder, type OPODetail } from "@/lib/api/outsourcePurchase";
import { toLocalDateTime } from "@/lib/utils/localDate";
interface DetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
opoId: string;
}
const STATUS_BADGE: Record<string, string> = {
: "bg-blue-100 text-blue-800",
: "bg-amber-100 text-amber-800",
: "bg-emerald-100 text-emerald-800",
: "bg-violet-100 text-violet-800",
: "bg-rose-100 text-rose-800",
};
const RELEASE_BADGE: Record<string, string> = {
: "bg-slate-100 text-slate-700",
: "bg-violet-100 text-violet-700",
: "bg-emerald-100 text-emerald-700",
};
export function DetailModal({ open, onOpenChange, opoId }: DetailModalProps) {
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<OPODetail | null>(null);
useEffect(() => {
if (!open || !opoId) return;
let alive = true;
(async () => {
setLoading(true);
try {
const d = await getOutsourcePurchaseOrder(opoId);
if (alive) setDetail(d);
} catch (e: any) {
toast.error(e?.message || "외주발주 상세 조회 실패");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [open, opoId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-[95vw] max-w-4xl overflow-auto">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<span> </span>
{detail?.order_no && (
<Badge variant="outline" className="text-xs">
{detail.order_no}
</Badge>
)}
{detail?.status && (
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_BADGE[detail.status] || ""}`}>
{detail.status}
</span>
)}
</div>
</DialogTitle>
<DialogDescription> ·· .</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-400" />
</div>
) : !detail ? (
<div className="flex h-40 flex-col items-center justify-center text-slate-500">
<Inbox className="mb-2 h-6 w-6" />
<span className="text-sm"> </span>
</div>
) : (
<div className="space-y-4 text-sm">
{/* 마스터 */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-600">
</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded border border-slate-200 bg-slate-50 p-3 text-xs">
<Field label="근거" value={detail.source_type} />
<Field label="근거번호" value={detail.source_no} />
<Field label="품목코드" value={detail.item_code} />
<Field label="품목명" value={detail.item_name} />
<Field label="규격" value={detail.spec} />
<Field label="재질" value={detail.material} />
<Field
label="수량"
value={Number(detail.quantity || 0).toLocaleString()}
/>
<Field label="발주일" value={detail.order_date} />
<Field label="납기일" value={detail.due_date} />
<Field label="담당자" value={detail.manager} />
<Field label="작성자" value={detail.writer} />
<Field label="생성일" value={detail.created_date ? toLocalDateTime(new Date(detail.created_date)) : ""} />
</div>
{detail.memo && (
<div className="mt-2 rounded border border-slate-200 bg-white p-2 text-xs text-slate-700">
<span className="font-medium">:</span> {detail.memo}
</div>
)}
</section>
{/* 공정 */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-slate-600">
({detail.processes?.length || 0})
</h3>
{!detail.processes || detail.processes.length === 0 ? (
<div className="rounded border border-dashed border-slate-300 bg-slate-50 p-3 text-center text-xs text-slate-500">
</div>
) : (
<div className="space-y-2">
{detail.processes.map((proc) => (
<div key={proc.id || proc.seq} className="rounded border border-slate-200 bg-white p-3">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<Badge variant="secondary">{proc.seq}.</Badge>
<span className="font-medium text-slate-900">
{proc.process_name || proc.process_code || "(공정)"}
</span>
{proc.vendor_name ? (
<Badge variant="outline">{proc.vendor_name}</Badge>
) : (
<Badge variant="outline" className="border-amber-300 text-amber-700">
</Badge>
)}
{proc.material_needed && (
<Badge variant="secondary" className="bg-rose-100 text-rose-700">
</Badge>
)}
</div>
</div>
{proc.materials && proc.materials.length > 0 && (
<div className="mt-2 space-y-1 border-t pt-2">
{proc.materials.map((mat, idx) => (
<div
key={mat.id || idx}
className="flex items-center justify-between gap-2 rounded bg-slate-50 px-2 py-1 text-xs"
>
<div className="flex items-center gap-2">
<span className="font-medium">{mat.item_code}</span>
<span className="text-slate-600">{mat.item_name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-700">
{Number(mat.qty || 0).toLocaleString()} {mat.unit || ""}
</span>
<span
className={`rounded px-2 py-0.5 ${
RELEASE_BADGE[mat.release_status || "대기"] || ""
}`}
>
{mat.release_status || "대기"}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
)}
</DialogContent>
</Dialog>
);
}
function Field({ label, value }: { label: string; value?: string | null }) {
return (
<div className="flex gap-2">
<span className="w-20 shrink-0 text-slate-500">{label}</span>
<span className="flex-1 text-slate-900">{value || "-"}</span>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
"use client";
/**
* 사급자재 출고요청 모달 (TASK:ERP-019)
*
* 동작 모델:
* - 입력: 선택된 외주발주 ID 배열
* - 사급자재(release_status='대기')를 외주사별로 자동 그룹핑하여 미리보기
* - 사용자는 선택할 게 아니라 "전송" 한 번으로 외주사별 출고요청 N건 일괄 생성
* - 외주사 미지정 자재는 별도 박스에 안내(전송 대상 제외)
*
* 데이터 모델:
* 외주발주 1 : N(외주사별 출고요청 = outbound_mng) : N(자재 = outbound_detail)
* 그룹핑 키: subcontractor_mng.id (vendor_id)
*/
import React, { useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, Send, Inbox, AlertCircle, Building2, Package } from "lucide-react";
import { toast } from "sonner";
import { getOutsourcePurchaseOrder, requestMaterialRelease } from "@/lib/api/outsourcePurchase";
interface ReleaseRequestModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 선택된 외주발주 ID 배열 */
opoIds: string[];
onSubmitted: () => void;
}
interface ReleaseRow {
material_id: string;
order_no: string;
item_code: string;
item_name: string;
process_name: string;
vendor_id: string;
vendor_name: string;
material_item_code: string;
material_item_name: string;
qty: number;
unit: string;
}
interface VendorGroup {
vendor_id: string;
vendor_name: string;
rows: ReleaseRow[];
}
const UNASSIGNED_KEY = "__unassigned__";
export function ReleaseRequestModal({ open, onOpenChange, opoIds, onSubmitted }: ReleaseRequestModalProps) {
const [loading, setLoading] = useState(true);
const [rows, setRows] = useState<ReleaseRow[]>([]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open || opoIds.length === 0) return;
let alive = true;
(async () => {
setLoading(true);
try {
const collected: ReleaseRow[] = [];
for (const id of opoIds) {
const detail = await getOutsourcePurchaseOrder(id);
if (!detail) continue;
for (const proc of detail.processes || []) {
for (const mat of proc.materials || []) {
if (!mat.id || mat.release_status !== "대기") continue;
collected.push({
material_id: mat.id,
order_no: detail.order_no || "",
item_code: detail.item_code || "",
item_name: detail.item_name || "",
process_name: proc.process_name || proc.process_code || "(공정)",
vendor_id: proc.vendor_id || "",
vendor_name: proc.vendor_name || "",
material_item_code: mat.item_code || "",
material_item_name: mat.item_name || "",
qty: Number(mat.qty || 0),
unit: mat.unit || "",
});
}
}
}
if (alive) setRows(collected);
} catch (e: any) {
toast.error(e?.message || "출고요청 대상 자재 조회 실패");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [open, opoIds]);
// 외주사별 그룹핑 (vendor_id 기준, 미지정은 UNASSIGNED_KEY로 별도 그룹)
const vendorGroups: VendorGroup[] = useMemo(() => {
const map = new Map<string, VendorGroup>();
for (const r of rows) {
const key = r.vendor_id || UNASSIGNED_KEY;
const g = map.get(key);
if (g) g.rows.push(r);
else
map.set(key, {
vendor_id: key,
vendor_name: r.vendor_id ? r.vendor_name : "외주사 미지정",
rows: [r],
});
}
// 미지정 그룹은 항상 마지막
return Array.from(map.values()).sort((a, b) => {
if (a.vendor_id === UNASSIGNED_KEY) return 1;
if (b.vendor_id === UNASSIGNED_KEY) return -1;
return a.vendor_name.localeCompare(b.vendor_name, "ko");
});
}, [rows]);
const stats = useMemo(() => {
return {
vendors: vendorGroups.length,
materials: rows.length,
};
}, [vendorGroups, rows]);
const handleSubmit = async () => {
if (rows.length === 0) {
toast.error("출고요청 가능한 자재가 없습니다");
return;
}
setSubmitting(true);
try {
const result = await requestMaterialRelease({
material_ids: rows.map((r) => r.material_id),
});
toast.success(
`외주사 ${result.outbound_count}곳에 사급출고 요청 완료 (자재 ${result.material_count}건). 출고관리 화면에서 후속 처리 가능합니다.`,
);
onSubmitted();
} catch (e: any) {
toast.error(e?.response?.data?.message || e?.message || "출고요청 실패");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[95vw] max-w-3xl overflow-auto">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-violet-600" />
<span> </span>
</div>
</DialogTitle>
<DialogDescription>
( ) . 1 1
<Badge className="mx-1 bg-violet-100 text-violet-800"></Badge>
.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-400" />
</div>
) : rows.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center text-slate-500">
<Inbox className="mb-2 h-6 w-6" />
<span className="text-sm"> </span>
</div>
) : (
<div className="space-y-3">
{vendorGroups.map((g) => {
const isUnassigned = g.vendor_id === UNASSIGNED_KEY;
return (
<div
key={g.vendor_id}
className={
isUnassigned
? "rounded-md border border-amber-200 bg-amber-50"
: "rounded-md border border-slate-200 bg-white"
}
>
<div
className={
isUnassigned
? "flex items-center justify-between gap-2 border-b border-amber-200 bg-amber-100 px-3 py-2"
: "flex items-center justify-between gap-2 border-b bg-slate-50 px-3 py-2"
}
>
<div className="flex items-center gap-2">
{isUnassigned ? (
<AlertCircle className="h-4 w-4 text-amber-600" />
) : (
<Building2 className="h-4 w-4 text-violet-600" />
)}
<span
className={
isUnassigned
? "text-sm font-semibold text-amber-900"
: "text-sm font-semibold text-slate-900"
}
>
{g.vendor_name}
</span>
<Badge variant="outline" className="text-xs">
{g.rows.length}
</Badge>
</div>
<span className={isUnassigned ? "text-xs text-amber-700" : "text-xs text-slate-500"}>
{isUnassigned ? "POP 출고 시 외주사 선택 필요" : "출고전표 1건 생성 예정"}
</span>
</div>
<div className={isUnassigned ? "divide-y divide-amber-100" : "divide-y divide-slate-100"}>
{g.rows.map((r) => (
<div key={r.material_id} className="flex items-center gap-3 px-3 py-2 text-xs">
<Package
className={
isUnassigned ? "h-3 w-3 shrink-0 text-amber-500" : "h-3 w-3 shrink-0 text-rose-500"
}
/>
<div className="w-32 shrink-0 truncate text-slate-700">{r.order_no}</div>
<div className="w-32 shrink-0 truncate text-slate-700">{r.process_name}</div>
<div className="flex-1 truncate">
<span className="font-medium text-slate-900">{r.material_item_code}</span>{" "}
<span className="text-slate-600">{r.material_item_name}</span>
</div>
<div className="w-20 shrink-0 text-right text-slate-700">
{r.qty.toLocaleString()} {r.unit}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
<DialogFooter>
<div className="flex w-full items-center justify-between">
<div className="text-xs text-slate-600">
{stats.materials > 0 ? (
<span>
<strong className="text-violet-700">{stats.vendors}</strong> · {" "}
<strong className="text-violet-700">{stats.materials}</strong>
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting || rows.length === 0}>
{submitting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1 h-4 w-4" />
)}
({stats.vendors})
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,377 @@
"use client";
/**
* 외주발주관리 — 목록 화면 (TASK:ERP-019)
* 회사: COMPANY_28 (엔키드)
* 경로: /COMPANY_28/outsourcing/purchase-order
* 백엔드: /api/outsource-purchase
*
* 구성:
* - DynamicSearchFilter (검색)
* - 통계 칩 3종 (총 / 사급자재 필요 / 미지정 외주사)
* - DataGrid (gridId="c7-outsource-po")
* - 액션 (등록/수정/사급자재 출고요청/삭제)
* - RegistrationModal / DetailModal / ReleaseRequestModal
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Plus, Pencil, Trash2, Send, Loader2, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { toLocalDateTime } from "@/lib/utils/localDate";
import {
listOutsourcePurchaseOrders,
deleteOutsourcePurchaseOrder,
type OPOMaster,
} from "@/lib/api/outsourcePurchase";
import { RegistrationModal } from "./RegistrationModal";
import { DetailModal } from "./DetailModal";
import { ReleaseRequestModal } from "./ReleaseRequestModal";
// ─────────────────────────────────────────────────────────────────────────────
// 상태 / 근거 배지 색상 매핑 (시안 디자인 토큰)
// ─────────────────────────────────────────────────────────────────────────────
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
: { label: "등록", cls: "bg-blue-100 text-blue-800" },
: { label: "진행중", cls: "bg-amber-100 text-amber-800" },
: { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
: { label: "출고요청", cls: "bg-violet-100 text-violet-800" },
: { label: "취소", cls: "bg-rose-100 text-rose-800" },
};
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
: { label: "수주", cls: "bg-blue-100 text-blue-700" },
: { label: "작업지시", cls: "bg-violet-100 text-violet-700" },
: { label: "품목정보", cls: "bg-emerald-100 text-emerald-700" },
: { label: "생산계획", cls: "bg-amber-100 text-amber-700" },
};
// 컬럼 정의 (시안: 16컬럼)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "외주발주번호", width: "150px", sortable: true },
{ key: "source_type_label", label: "근거", width: "100px", align: "center" },
{ key: "source_no", label: "근거번호", width: "150px" },
{ key: "item_code", label: "품목코드", width: "130px" },
{ key: "item_name", label: "품목명", width: "180px", truncate: true },
{ key: "spec", label: "규격", width: "120px", truncate: true },
{ key: "material", label: "재질", width: "100px" },
{ key: "quantity", label: "수량", width: "100px", align: "right", formatNumber: true },
{ key: "process_vendor_summary", label: "공정/외주사", width: "200px", truncate: true },
{ key: "material_status_label", label: "사급자재", width: "110px", align: "center" },
{ key: "order_date", label: "발주일", width: "110px" },
{ key: "due_date", label: "납기일", width: "110px" },
{ key: "status_label", label: "상태", width: "110px", align: "center" },
{ key: "manager", label: "담당자", width: "100px" },
{ key: "memo", label: "메모", width: "200px", truncate: true },
{ key: "created_date", label: "생성일", width: "150px" },
];
// ─────────────────────────────────────────────────────────────────────────────
// 페이지 본체
// ─────────────────────────────────────────────────────────────────────────────
export default function OutsourcePurchaseOrderPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 검색
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 데이터
const [rows, setRows] = useState<OPOMaster[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
// 선택
const [selectedId, setSelectedId] = useState<string | null>(null);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 모달
const [registrationOpen, setRegistrationOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [releaseOpen, setReleaseOpen] = useState(false);
// 데이터 조회
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page: 1, size: 0 };
for (const f of searchFilters) {
if (f.columnName === "order_date" && f.operator === "between" && f.value) {
const [from, to] = f.value.split(",");
if (from) params.date_from = from;
if (to) params.date_to = to;
} else if (f.columnName === "status") {
params.status = f.value;
} else if (f.columnName === "source_type") {
params.source_type = f.value;
} else if (f.value) {
params.keyword = f.value;
}
}
const data = await listOutsourcePurchaseOrders(params);
setRows(data.rows || []);
setTotal(data.total || 0);
} catch (e: any) {
toast.error(e?.message || "외주발주 목록 조회 실패");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 가공된 행 (배지 텍스트, 공정/외주사 요약 등)
const decoratedRows = useMemo(() => {
return rows.map((r) => {
const processVendor =
r.first_process_name && r.first_vendor_name
? `${r.first_process_name} / ${r.first_vendor_name}`
: r.first_process_name
? `${r.first_process_name} / (미지정)`
: "(공정 없음)";
const matLabel =
(r.material_count ?? 0) === 0
? "불필요"
: (r.material_pending ?? 0) > 0
? `필요 ${r.material_pending}`
: "완료";
return {
...r,
source_type_label: r.source_type || "",
status_label: r.status || "",
process_vendor_summary:
(r.process_count || 0) > 1
? `${processVendor}${(r.process_count || 0) - 1}`
: processVendor,
material_status_label: matLabel,
// 생성일 — UTC ISO를 브라우저 로컬 시각(KST 사용자 기준)으로 표시
created_date: r.created_date ? toLocalDateTime(new Date(r.created_date)) : "",
};
});
}, [rows]);
// 통계 칩
const stats = useMemo(() => {
const totalCnt = rows.length;
const matNeed = rows.filter((r) => (r.material_pending ?? 0) > 0).length;
const vendorMissing = rows.filter((r) => (r.vendor_unassigned ?? 0) > 0).length;
return { total: totalCnt, matNeed, vendorMissing };
}, [rows]);
// 선택된 발주(첫번째)
const firstSelectedRow = checkedIds.length > 0
? rows.find((r) => r.id === checkedIds[0]) || null
: null;
// 등록
const handleRegister = () => {
setEditingId(null);
setRegistrationOpen(true);
};
// 수정
const handleEdit = () => {
if (checkedIds.length !== 1) {
toast.error("수정할 발주 1건을 선택해주세요");
return;
}
setEditingId(checkedIds[0]);
setRegistrationOpen(true);
};
// 삭제
const handleDelete = async () => {
if (checkedIds.length === 0) {
toast.error("삭제할 발주를 선택해주세요");
return;
}
const ok = await confirm(`선택된 ${checkedIds.length}건의 외주발주를 삭제하시겠습니까?`, {
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
let success = 0;
let failures = 0;
for (const id of checkedIds) {
try {
await deleteOutsourcePurchaseOrder(id);
success++;
} catch (e: any) {
failures++;
toast.error(e?.response?.data?.message || e?.message || "삭제 실패");
}
}
if (success > 0) toast.success(`${success}건 삭제 완료`);
if (failures === 0) setCheckedIds([]);
fetchData();
};
// 사급자재 출고요청 모달 열기
const handleReleaseRequest = () => {
if (checkedIds.length === 0) {
toast.error("출고요청할 발주를 선택해주세요");
return;
}
setReleaseOpen(true);
};
// 행 더블클릭 → 상세
const handleRowDoubleClick = (row: any) => {
setDetailId(row.id);
setDetailOpen(true);
};
// 모달 닫기 후 갱신 콜백
const handleAfterMutation = () => {
setRegistrationOpen(false);
setEditingId(null);
setReleaseOpen(false);
setCheckedIds([]);
fetchData();
};
return (
<div className="flex h-full flex-col gap-3 bg-slate-50 p-5">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="text-xs text-slate-500"> </p>
</div>
</div>
{/* 검색 필터 */}
<DynamicSearchFilter
tableName="outsource_purchase_order"
filterId="c7-outsource-po"
onFilterChange={setSearchFilters}
dataCount={total}
/>
{/* 액션 + 통계 */}
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-base font-semibold text-slate-900"> </span>
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{stats.total}
</Badge>
<Badge variant="secondary" className="bg-rose-100 text-rose-700">
{stats.matNeed}
</Badge>
<Badge variant="secondary" className="bg-amber-100 text-amber-700">
{stats.vendorMissing}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={fetchData} disabled={loading}>
{loading ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-1 h-4 w-4" />}
</Button>
<Button size="sm" onClick={handleRegister}>
<Plus className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleEdit} disabled={checkedIds.length !== 1}>
<Pencil className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={handleReleaseRequest}
disabled={checkedIds.length === 0}
className="border-violet-300 text-violet-700 hover:bg-violet-50"
>
<Send className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={checkedIds.length === 0}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto rounded-lg border border-slate-200 bg-white">
<DataGrid
gridId="c7-outsource-po"
tableName="outsource_purchase_order"
columns={GRID_COLUMNS.map((c) => {
// 배지 렌더링용 셀 매핑
if (c.key === "source_type_label") {
return {
...c,
filterable: false,
};
}
if (c.key === "status_label") {
return {
...c,
filterable: false,
};
}
return c;
})}
data={decoratedRows}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
selectedId={selectedId}
onSelect={setSelectedId}
onRowDoubleClick={handleRowDoubleClick}
loading={loading}
emptyMessage="외주발주가 없습니다. [외주발주 등록] 버튼을 클릭해 신규 발주를 시작하세요."
defaultSortKey="created_date"
defaultSortDir="desc"
/>
</div>
{/* 등록/수정 모달 */}
{registrationOpen && (
<RegistrationModal
open={registrationOpen}
onOpenChange={setRegistrationOpen}
editId={editingId}
onSaved={handleAfterMutation}
/>
)}
{/* 상세 모달 */}
{detailOpen && detailId && (
<DetailModal
open={detailOpen}
onOpenChange={setDetailOpen}
opoId={detailId}
/>
)}
{/* 출고요청 모달 */}
{releaseOpen && checkedIds.length > 0 && (
<ReleaseRequestModal
open={releaseOpen}
onOpenChange={setReleaseOpen}
opoIds={checkedIds}
onSubmitted={handleAfterMutation}
/>
)}
{ConfirmDialogComponent}
</div>
);
}
// 컬럼 데코레이션 — DataGrid 자체가 render 함수 미지원이므로 미리 가공된 row 사용
// 배지 색상은 status_label/source_type_label 셀을 후처리할 수 있도록 별도 표시
// (DataGrid가 <span> 같은 단순 텍스트만 표시하므로 색상 보강은 향후 별도 enhance 필요)

View File

@@ -278,6 +278,53 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_10/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_10/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_10/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_10/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
// === COMPANY_28 (엔키드) ===
"/COMPANY_28/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_28/master-data/company/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_28/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_28/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/master-data/options": dynamic(() => import("@/app/(main)/COMPANY_28/master-data/options/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/order": dynamic(() => import("@/app/(main)/COMPANY_28/sales/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_28/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_28/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_28/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_28/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_28/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_28/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_28/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/production/result": dynamic(() => import("@/app/(main)/COMPANY_28/production/result/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_28/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_28/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/production/bom": dynamic(() => import("@/app/(main)/COMPANY_28/production/bom/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_28/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_28/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_28/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/monitoring/production": dynamic(() => import("@/app/(main)/COMPANY_28/monitoring/production/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/monitoring/equipment": dynamic(() => import("@/app/(main)/COMPANY_28/monitoring/equipment/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/monitoring/quality": dynamic(() => import("@/app/(main)/COMPANY_28/monitoring/quality/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/monitoring/settings": dynamic(() => import("@/app/(main)/COMPANY_28/monitoring/settings/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/inbound-outbound": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/inbound-outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/warehouse": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/warehouse/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_28/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_28/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_28/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/outsourcing/purchase-order": dynamic(() => import("@/app/(main)/COMPANY_28/outsourcing/purchase-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_28/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_28/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_28/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_28/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/quality/inspection-result": dynamic(() => import("@/app/(main)/COMPANY_28/quality/inspection-result/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_28/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/mold/info": dynamic(() => import("@/app/(main)/COMPANY_28/mold/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/design/project": dynamic(() => import("@/app/(main)/COMPANY_28/design/project/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_28/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_28/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_28/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_28/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_28/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
// === COMPANY_29 (시연용 회사) ===
"/COMPANY_29/master-data/options": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/options/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_29/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),