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
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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 필요)
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user