feat(v0.7 round2): 매입 발주서 양식 + 좌-우 분할 + 공급업체 일괄 불러오기
Deploy momo-erp / deploy (push) Successful in 51s
Deploy momo-erp / deploy (push) Successful in 51s
[화면 — /m/admin/procurements 전면 개편] - 좌측: 발주서 리스트 (상태 필터, 발주번호, 공급업체, 금액) - 우측: 발주서 양식 (이미지의 표준 발주서 형태) · 분류번호/발주서번호/발주일/공급업체 표 · "1. 물품의 표시" 표 (품명·단위·수량·단가·금액) · "2. 비고" 텍스트 영역 · 합계 자동 계산 - [+ 새 발주] / [발주 요청] 상단 버튼 - 작성중(OPEN) 상태에서만 인라인 편집 가능, 발주요청 후 잠김 [품목 추가 모달] - 검색 + [공급업체 필터(현재/전체)] + [결과 내 검색] - 다중 선택 + 헤더 체크박스로 전체 선택 - 이미 담긴 품목은 '이미' 표시 - 한 번에 N개 일괄 추가 (수량 1, 원가는 품목 마스터의 cost_price) [API 4종 신설] - POST /api/m/procurements/create-empty: 빈 발주서 1건 생성 (proc_no 자동 부여, status=OPEN) - POST /api/m/procurements/lines/save: 라인 추가/수정/삭제 + 합계 재집계 (트랜잭션) · 같은 품목 중복 추가 시 수량 누적 - POST /api/m/procurements/update-header: 공급업체/메모 수정 - POST /api/m/procurements/send: 발주 요청 — status OPEN→REQUESTED + 공급업체 이메일로 발주서 HTML 메일 발송 · 메일 실패해도 상태는 변경 (mailSent/mailError 응답) [매뉴얼] - 다-1 매입 발주 단계별 가이드 재작성 - "공급업체별 품목 일괄 불러오기" 팁 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+13
-5
@@ -550,13 +550,21 @@
|
||||
<p class="lead">입고 담당자는 도매처에서 물건을 사 와서 창고에 쌓는 사람이에요. <b>입고 등록을 하면 창고 재고가 자동으로 늘어요(+)</b>. 물품 등록 담당자는 팔 물건, 가게 회원, 도매처 등 기본 정보를 등록·관리해요.</p>
|
||||
|
||||
<!-- 다.1 -->
|
||||
<h3 id="i-procurement">다-1. 물건 매입하기 (도매처에 주문)</h3>
|
||||
<p>왼쪽 메뉴 <b>매입/입고 → 매입 발주</b>.</p>
|
||||
<h3 id="i-procurement">다-1. 물건 매입하기 (공급업체에 주문)</h3>
|
||||
<p>왼쪽 메뉴 <b>매입/입고 → 매입 발주</b>. 화면이 좌-우로 갈라져서 왼쪽엔 발주서 목록, 오른쪽엔 양식이 보여요.</p>
|
||||
<ol class="steps">
|
||||
<li><b>[+ 매입 발주] 버튼</b><small>도매처를 고르고, 살 물건과 개수, 가격, 입고 예정일을 적어요</small></li>
|
||||
<li><b>저장</b><small>도매처에 주문서가 만들어지고 상태는 <span class="badge b-amber">발주</span></small></li>
|
||||
<li><b>도매처에서 물건이 도착하면</b><small>다음 단계인 '입고 처리'로 이동</small></li>
|
||||
<li><b>[+ 새 발주] 버튼</b><small>오른쪽 발주서 양식이 빈 상태로 새로 열려요. 발주서 번호가 자동으로 부여돼요(예: PRC-20260507-0001)</small></li>
|
||||
<li><b>공급업체 선택</b><small>발주서 위쪽 표에서 공급업체를 드롭다운으로 선택. 미리 [공급업체 관리]에 등록되어 있어야 해요.</small></li>
|
||||
<li><b>[+ 품목 추가] 버튼 → 품목 모달</b><small>새 창이 떠요. 검색창에 품목명/코드 적고 [검색]. 위쪽에 <b>'현재 발주서 공급업체만'</b> 필터 토글이 있어 그 공급업체 품목만 골라 볼 수 있고, <b>'결과 내 검색'</b> 칸으로 더 좁힐 수 있어요. 체크박스로 다중 선택(헤더 체크로 전체) 후 [선택한 N개 추가].</small></li>
|
||||
<li><b>발주서에서 수량/단가 직접 수정</b><small>각 행의 수량과 단가 칸을 클릭해서 바꾸고, 다른 곳을 누르면 자동 저장. 행 오른쪽 [×]로 삭제. 합계는 자동 재계산.</small></li>
|
||||
<li><b>비고 적기</b><small>발주서 아래쪽 '비고' 칸에 납품 장소, 납품 기간, 대금지불 조건 등 자유 입력. 다른 곳 누르면 자동 저장.</small></li>
|
||||
<li><b>[발주 요청] 버튼</b><small>오른쪽 위 초록색 버튼. 누르면:<br>① 상태가 <span class="badge b-amber">발주요청</span>으로 바뀌고<br>② 공급업체 이메일로 발주서 메일이 자동 발송됩니다.<br>이메일이 없으면 '메일 미발송' 안내가 떠요 — 직접 통보 필요.</small></li>
|
||||
<li><b>물건이 도착하면</b><small>다음 단계인 [입고 처리] 화면에서 입고 등록</small></li>
|
||||
</ol>
|
||||
<div class="tip">
|
||||
<b>💡 공급업체별 품목 일괄 불러오기</b>
|
||||
<p>품목 모달에서 '현재 발주서 공급업체만' 필터를 켜면 그 공급업체에 등록된 모든 품목이 보여요. 헤더 체크박스로 전체 선택 → [선택한 N개 추가] 누르면 한 번에 다 들어가요. 그 후 필요한 것만 남기고 [×] 로 빼면 돼요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 다.2 -->
|
||||
<h3 id="i-inbound">다-2. 물건 들어오면 등록하기 (창고에 쌓임)</h3>
|
||||
|
||||
@@ -1,65 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Eye } from "lucide-react";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Send, Search, RefreshCcw, X } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Proc { OBJID: string; PROC_NO: string; PROC_DATE: string; VENDOR_NAME: string; STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number }
|
||||
interface ProcRow {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string;
|
||||
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
|
||||
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
|
||||
}
|
||||
interface ProcDetail {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string;
|
||||
TOTAL_AMOUNT: number; MEMO?: string;
|
||||
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
|
||||
}
|
||||
interface ProcLine {
|
||||
OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
UNIT: string; QTY: number; COST_PRICE: number; TOTAL_AMOUNT: number;
|
||||
RECEIVED_QTY: number;
|
||||
}
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
interface Item {
|
||||
OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string;
|
||||
COST_PRICE: number; UNIT_PRICE: number;
|
||||
VENDOR_OBJID?: string; VENDOR_NAME?: string;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { OPEN: "진행중", RECEIVED: "입고완료", CLOSED: "마감" };
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const fmt = (n: number | string | undefined | null) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
OPEN: "작성중", REQUESTED: "발주요청", RECEIVED: "입고완료", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
|
||||
};
|
||||
|
||||
export default function ProcurementsPage() {
|
||||
const [list, setList] = useState<Proc[]>([]);
|
||||
const [list, setList] = useState<ProcRow[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/procurements/list", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/m/procurements/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: statusFilter || undefined }),
|
||||
});
|
||||
const j = await res.json();
|
||||
const rows: ProcRow[] = j.RESULTLIST ?? [];
|
||||
setList(rows);
|
||||
if (rows.length && !rows.some((r) => r.OBJID === activeId)) setActiveId(rows[0].OBJID);
|
||||
if (!rows.length) { setActiveId(""); setDetail(null); }
|
||||
}, [statusFilter, activeId]);
|
||||
|
||||
const loadVendors = async () => {
|
||||
const r = await fetch("/api/m/vendors/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
setVendors((await r.json()).RESULTLIST ?? []);
|
||||
};
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!activeId) { setDetail(null); return; }
|
||||
const res = await fetch("/api/m/procurements/detail", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: activeId }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) setDetail({ proc: j.proc, items: j.items });
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => { loadVendors(); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadDetail(); }, [loadDetail]);
|
||||
|
||||
const createNew = async () => {
|
||||
const res = await fetch("/api/m/procurements/create-empty", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
setActiveId(j.objId);
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const updateHeader = async (patch: { vendorObjid?: string | null; memo?: string }) => {
|
||||
if (!detail) return;
|
||||
const res = await fetch("/api/m/procurements/update-header", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: detail.proc.OBJID, ...patch }),
|
||||
});
|
||||
if ((await res.json()).success) loadDetail();
|
||||
};
|
||||
|
||||
const updateLine = async (line: { objid?: string; itemObjid?: string; qty: number; costPrice: number; delete?: boolean }) => {
|
||||
if (!detail) return;
|
||||
const res = await fetch("/api/m/procurements/lines/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ procObjid: detail.proc.OBJID, lines: [line] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) loadDetail();
|
||||
else Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||
};
|
||||
|
||||
const deleteLine = async (objid: string) => {
|
||||
const ok = await Swal.fire({ icon: "question", title: "라인을 삭제하시겠습니까?", showCancelButton: true });
|
||||
if (!ok.isConfirmed) return;
|
||||
updateLine({ objid, qty: 0, costPrice: 0, delete: true });
|
||||
};
|
||||
|
||||
const sendOrder = async () => {
|
||||
if (!detail) return;
|
||||
if (!detail.proc.VENDOR_OBJID) {
|
||||
Swal.fire({ icon: "warning", title: "공급업체를 먼저 선택하세요." });
|
||||
return;
|
||||
}
|
||||
if (detail.items.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "발주 라인을 1개 이상 입력하세요." });
|
||||
return;
|
||||
}
|
||||
const ok = await Swal.fire({
|
||||
icon: "question", title: "공급업체로 발주서를 발송하시겠습니까?",
|
||||
html: `<b>${detail.proc.VENDOR_NAME ?? "-"}</b> 에게 메일 발송 + 상태 변경<br>총액: ₩${fmt(detail.proc.TOTAL_AMOUNT)}`,
|
||||
showCancelButton: true, confirmButtonText: "발주 요청", cancelButtonText: "취소",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/procurements/send", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: detail.proc.OBJID }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({
|
||||
icon: j.mailSent ? "success" : "warning",
|
||||
title: "발주 요청 완료",
|
||||
html: j.mailSent ? `메일 발송 완료 → <b>${j.vendorEmail}</b>` : (j.vendorEmail ? `메일 실패: ${j.mailError ?? ""}` : "공급업체 이메일 미설정 — 별도 통보 필요"),
|
||||
});
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "실패", text: j.message });
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">매입 발주</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">모모유통 → 도매처/제조사로 보내는 발주서</p>
|
||||
<h1 className="text-xl font-bold">매입 발주서 관리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 발주서를 선택하면 오른쪽에 양식이 보여요. [+ 새 발주]로 작성, [발주 요청]으로 공급업체에 메일 발송.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
<option value="">전체 상태</option>
|
||||
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<button onClick={load} className="h-9 px-3 rounded bg-white border border-slate-300 text-sm font-semibold inline-flex items-center gap-1">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
<button onClick={createNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
|
||||
<Plus size={14} /> 새 발주
|
||||
</button>
|
||||
</div>
|
||||
<Link href="/m/admin/procurements/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||
<Plus size={16} /> 매입발주 작성
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">발주번호</th>
|
||||
<th className="text-left px-4 py-3">발주일</th>
|
||||
<th className="text-left px-4 py-3">공급업체</th>
|
||||
<th className="text-right px-4 py-3">라인</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">매입발주가 없습니다.</td></tr>
|
||||
) : list.map((p) => (
|
||||
<tr key={p.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{p.PROC_NO}</td>
|
||||
<td className="px-4 py-3">{p.PROC_DATE}</td>
|
||||
<td className="px-4 py-3">{p.VENDOR_NAME || "-"}</td>
|
||||
<td className="px-4 py-3 text-right">{p.LINE_CNT}건</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(p.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${p.STATUS === "RECEIVED" ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[p.STATUS] || p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
|
||||
발주서 목록 ({list.length}건)
|
||||
</div>
|
||||
<div className="overflow-x-auto max-h-[calc(100vh-220px)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-2">발주번호</th>
|
||||
<th className="text-left px-2 py-2">일자</th>
|
||||
<th className="text-left px-2 py-2">공급업체</th>
|
||||
<th className="text-right px-2 py-2">금액</th>
|
||||
<th className="text-center px-2 py-2">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-slate-400">발주서가 없습니다.</td></tr>
|
||||
) : list.map((p) => (
|
||||
<tr key={p.OBJID}
|
||||
onClick={() => setActiveId(p.OBJID)}
|
||||
className={`cursor-pointer border-t border-slate-100 ${activeId === p.OBJID ? "bg-emerald-50" : "hover:bg-slate-50"}`}>
|
||||
<td className="px-2 py-2 font-semibold">{p.PROC_NO}</td>
|
||||
<td className="px-2 py-2 text-slate-500">{p.PROC_DATE}</td>
|
||||
<td className="px-2 py-2 truncate max-w-[120px]">{p.VENDOR_NAME ?? <span className="text-slate-300">미선택</span>}</td>
|
||||
<td className="px-2 py-2 text-right tabular-nums">₩{fmt(p.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
|
||||
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
||||
<span>발주서</span>
|
||||
{detail && detail.proc.STATUS === "OPEN" && (
|
||||
<button onClick={sendOrder} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Send size={12} /> 발주 요청
|
||||
</button>
|
||||
)}
|
||||
{detail && detail.proc.STATUS === "REQUESTED" && (
|
||||
<span className="text-[11px] text-amber-700">발주 요청 완료 — 공급업체 응답 대기</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{!detail ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">왼쪽에서 발주서를 선택하거나 [+ 새 발주]를 누르세요.</div>
|
||||
) : (
|
||||
<ProcurementForm
|
||||
detail={detail}
|
||||
vendors={vendors}
|
||||
onSetVendor={(v) => updateHeader({ vendorObjid: v || null })}
|
||||
onSetMemo={(m) => updateHeader({ memo: m })}
|
||||
onAddPicker={() => setPickerOpen(true)}
|
||||
onUpdateLine={updateLine}
|
||||
onDeleteLine={deleteLine}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pickerOpen && detail && (
|
||||
<ItemPicker
|
||||
vendorObjid={detail.proc.VENDOR_OBJID}
|
||||
existingItemIds={new Set(detail.items.map((i) => i.ITEM_OBJID))}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onAddItems={async (selected) => {
|
||||
if (selected.length === 0) { setPickerOpen(false); return; }
|
||||
const res = await fetch("/api/m/procurements/lines/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
procObjid: detail.proc.OBJID,
|
||||
lines: selected.map((it) => ({
|
||||
itemObjid: it.OBJID,
|
||||
qty: 1,
|
||||
costPrice: Number(it.COST_PRICE) || 0,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) { setPickerOpen(false); loadDetail(); }
|
||||
else Swal.fire({ icon: "error", title: "추가 실패", text: j.message });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onAddPicker, onUpdateLine, onDeleteLine }: {
|
||||
detail: { proc: ProcDetail; items: ProcLine[] };
|
||||
vendors: Vendor[];
|
||||
onSetVendor: (id: string) => void;
|
||||
onSetMemo: (m: string) => void;
|
||||
onAddPicker: () => void;
|
||||
onUpdateLine: (line: { objid?: string; itemObjid?: string; qty: number; costPrice: number }) => void;
|
||||
onDeleteLine: (objid: string) => void;
|
||||
}) {
|
||||
const editable = detail.proc.STATUS === "OPEN";
|
||||
return (
|
||||
<div className="text-[12px]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold tracking-[0.4em] text-slate-900">발 주 서</h2>
|
||||
</div>
|
||||
|
||||
<table className="text-[11px] mt-3 border border-slate-400" style={{borderCollapse:'collapse',width:'auto'}}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 w-[100px] text-center">분류번호</th>
|
||||
<td className="border border-slate-400 px-3 py-1 font-semibold w-[200px]">매입발주</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">발주서번호</th>
|
||||
<td className="border border-slate-400 px-3 py-1 font-mono">{detail.proc.PROC_NO}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">발주일</th>
|
||||
<td className="border border-slate-400 px-3 py-1">{detail.proc.PROC_DATE}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">공급업체</th>
|
||||
<td className="border border-slate-400 px-3 py-1">
|
||||
{editable ? (
|
||||
<select value={detail.proc.VENDOR_OBJID ?? ""}
|
||||
onChange={(e) => onSetVendor(e.target.value)}
|
||||
className="h-7 px-2 rounded border border-slate-300 text-[11px] bg-white">
|
||||
<option value="">-- 공급업체 선택 --</option>
|
||||
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-semibold">{detail.proc.VENDOR_NAME ?? "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-3 font-semibold text-[12px]">1. 물품의 표시</p>
|
||||
{editable && (
|
||||
<div className="flex gap-2 mb-2 text-[11px] flex-wrap">
|
||||
<button onClick={onAddPicker}
|
||||
className="inline-flex items-center gap-1 h-7 px-3 rounded bg-emerald-100 text-emerald-800 font-bold hover:bg-emerald-200">
|
||||
<Plus size={12} /> 품목 추가
|
||||
</button>
|
||||
<span className="self-center text-slate-400">— 모달에서 공급업체 필터, 결과내 검색, 다중 선택 가능</span>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-[11px] border border-slate-400" style={{borderCollapse:'collapse'}}>
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="border border-slate-400 px-1 py-1 w-8">#</th>
|
||||
<th className="border border-slate-400 px-2 py-1 text-left">품명</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-12">단위</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-16">수량</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-20">단가</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-24">금액</th>
|
||||
{editable && <th className="border border-slate-400 px-1 py-1 w-10"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{detail.items.length === 0 ? (
|
||||
<tr><td colSpan={editable ? 7 : 6} className="border border-slate-400 px-2 py-6 text-center text-slate-400">품목이 없습니다. {editable && "[품목 추가] 버튼으로 등록."}</td></tr>
|
||||
) : detail.items.map((it, idx) => (
|
||||
<ProcLineRow key={it.OBJID} line={it} idx={idx + 1} editable={editable}
|
||||
onSave={(qty, cost) => onUpdateLine({ objid: it.OBJID, qty, costPrice: cost })}
|
||||
onDelete={() => onDeleteLine(it.OBJID)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-right text-[11px] mt-1 text-slate-500">(V.A.T 별도, 단위: 원)</div>
|
||||
|
||||
<table className="ml-auto text-[12px] tabular-nums mt-3">
|
||||
<tbody>
|
||||
<tr className="border-t-2 border-slate-900 font-bold">
|
||||
<td className="px-3 py-1.5">총액</td>
|
||||
<td className="px-3 py-1.5 text-right text-emerald-700 min-w-[140px]">₩ {fmt(detail.proc.TOTAL_AMOUNT)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-4 font-semibold text-[12px]">2. 비고</p>
|
||||
{editable ? (
|
||||
<textarea
|
||||
defaultValue={detail.proc.MEMO ?? ""}
|
||||
onBlur={(e) => onSetMemo(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="납품장소, 납품기간, 대금지불 조건 등을 적어주세요"
|
||||
className="w-full mt-1 px-3 py-2 rounded border border-slate-300 text-[11px] resize-y"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[11px] whitespace-pre-wrap min-h-[60px]">
|
||||
{detail.proc.MEMO || <span className="text-slate-400">없음</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcLineRow({ line, idx, editable, onSave, onDelete }: {
|
||||
line: ProcLine; idx: number; editable: boolean;
|
||||
onSave: (qty: number, cost: number) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [qty, setQty] = useState(Number(line.QTY));
|
||||
const [cost, setCost] = useState(Number(line.COST_PRICE));
|
||||
useEffect(() => { setQty(Number(line.QTY)); setCost(Number(line.COST_PRICE)); }, [line.OBJID, line.QTY, line.COST_PRICE]);
|
||||
const total = Math.round(qty * cost);
|
||||
const dirty = qty !== Number(line.QTY) || cost !== Number(line.COST_PRICE);
|
||||
return (
|
||||
<tr className="bg-white">
|
||||
<td className="border border-slate-400 px-1 py-1 text-center">{idx}</td>
|
||||
<td className="border border-slate-400 px-2 py-1">{line.ITEM_NAME} <span className="text-slate-400">[{line.ITEM_CODE}]</span></td>
|
||||
<td className="border border-slate-400 px-1 py-1 text-center">{line.UNIT ?? "EA"}</td>
|
||||
<td className="border border-slate-400 px-1 py-1">
|
||||
{editable ? (
|
||||
<input type="number" min={1} value={qty}
|
||||
onChange={(e) => setQty(Number(e.target.value))}
|
||||
onBlur={() => { if (dirty) onSave(qty, cost); }}
|
||||
className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" />
|
||||
) : <div className="text-right">{fmt(qty)}</div>}
|
||||
</td>
|
||||
<td className="border border-slate-400 px-1 py-1">
|
||||
{editable ? (
|
||||
<input type="number" min={0} value={cost}
|
||||
onChange={(e) => setCost(Number(e.target.value))}
|
||||
onBlur={() => { if (dirty) onSave(qty, cost); }}
|
||||
className="w-full h-6 px-1 border border-slate-200 rounded text-[11px] text-right tabular-nums bg-white" />
|
||||
) : <div className="text-right">{fmt(cost)}</div>}
|
||||
</td>
|
||||
<td className="border border-slate-400 px-1 py-1 text-right font-semibold">{fmt(total)}</td>
|
||||
{editable && (
|
||||
<td className="border border-slate-400 px-1 py-1 text-center">
|
||||
<button onClick={onDelete} className="text-rose-500 hover:text-rose-700"><X size={12} /></button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPicker({ vendorObjid, existingItemIds, onClose, onAddItems }: {
|
||||
vendorObjid: string | null;
|
||||
existingItemIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onAddItems: (items: Item[]) => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [innerKeyword, setInnerKeyword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [filterVendor, setFilterVendor] = useState(vendorObjid || "");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = { keyword: keyword || undefined };
|
||||
if (filterVendor) body.vendorObjid = filterVendor;
|
||||
const res = await fetch("/api/m/items/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
setItems((await res.json()).RESULTLIST ?? []);
|
||||
setSelected(new Set());
|
||||
} finally { setLoading(false); }
|
||||
}, [keyword, filterVendor]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = innerKeyword
|
||||
? items.filter((it) => it.ITEM_NAME.includes(innerKeyword) || it.ITEM_CODE.includes(innerKeyword))
|
||||
: items;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(id)) n.delete(id); else n.add(id);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
const toggleAll = () => {
|
||||
setSelected((p) => {
|
||||
const allOn = filtered.length > 0 && filtered.every((it) => p.has(it.OBJID));
|
||||
const n = new Set(p);
|
||||
if (allOn) filtered.forEach((it) => n.delete(it.OBJID));
|
||||
else filtered.forEach((it) => n.add(it.OBJID));
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||
<h3 className="font-bold">품목 추가 ({filtered.length}개)</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-700"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-b space-y-2 bg-slate-50">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && load()}
|
||||
placeholder="품목명/코드"
|
||||
className="flex-1 min-w-[160px] h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<button onClick={load} className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-semibold inline-flex items-center gap-1">
|
||||
<Search size={14} /> 검색
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-xs flex-wrap">
|
||||
<span className="text-slate-500">공급업체 필터</span>
|
||||
<button onClick={() => setFilterVendor("")}
|
||||
className={`px-2 py-1 rounded ${!filterVendor ? "bg-emerald-700 text-white" : "bg-white border border-slate-300"}`}>전체</button>
|
||||
{vendorObjid && (
|
||||
<button onClick={() => setFilterVendor(vendorObjid)}
|
||||
className={`px-2 py-1 rounded ${filterVendor === vendorObjid ? "bg-emerald-700 text-white" : "bg-white border border-slate-300"}`}>
|
||||
현재 발주서 공급업체만
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-xs">
|
||||
<span className="text-slate-500">결과 내 검색</span>
|
||||
<input value={innerKeyword} onChange={(e) => setInnerKeyword(e.target.value)}
|
||||
placeholder="결과를 더 좁힐 글자 입력"
|
||||
className="flex-1 h-8 px-2 rounded border border-slate-300 text-xs" />
|
||||
{innerKeyword && (
|
||||
<button onClick={() => setInnerKeyword("")} className="text-slate-400 hover:text-slate-700">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-2 py-2 w-10">
|
||||
<input type="checkbox" onChange={toggleAll}
|
||||
checked={filtered.length > 0 && filtered.every((it) => selected.has(it.OBJID))}
|
||||
className="cursor-pointer" />
|
||||
</th>
|
||||
<th className="text-left px-2 py-2">품목명</th>
|
||||
<th className="text-left px-2 py-2">공급업체</th>
|
||||
<th className="text-right px-2 py-2">원가</th>
|
||||
<th className="text-center px-2 py-2 w-14">담김?</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="text-center py-8 text-slate-400">불러오는 중...</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-8 text-slate-400">결과가 없습니다.</td></tr>
|
||||
) : filtered.map((it) => (
|
||||
<tr key={it.OBJID}
|
||||
onClick={() => toggle(it.OBJID)}
|
||||
className={`border-t border-slate-100 cursor-pointer ${selected.has(it.OBJID) ? "bg-emerald-50" : "hover:bg-slate-50"}`}>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<input type="checkbox" checked={selected.has(it.OBJID)} onChange={() => toggle(it.OBJID)} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<div className="font-semibold">{it.ITEM_NAME}</div>
|
||||
<div className="text-slate-400 text-[10px]">{it.ITEM_CODE}</div>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-slate-600">{it.VENDOR_NAME ?? "-"}</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums">{fmt(it.COST_PRICE)}</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
{existingItemIds.has(it.OBJID) ? <span className="text-[10px] text-amber-600">이미</span> : <span className="text-slate-300">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t flex justify-between items-center">
|
||||
<span className="text-xs text-slate-500">선택 {selected.size}개</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="px-3 h-9 rounded border border-slate-300 text-sm">닫기</button>
|
||||
<button
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => onAddItems(filtered.filter((it) => selected.has(it.OBJID)))}
|
||||
className="px-4 h-9 rounded bg-emerald-700 text-white text-sm font-bold disabled:opacity-50 hover:bg-emerald-800">
|
||||
선택한 {selected.size}개 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 빈 매입 발주서 생성 — 좌-우 분할 화면에서 [+ 새 발주] 누를 때 사용
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool, queryOne } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { vendorObjid, memo } = body as { vendorObjid?: string; memo?: string };
|
||||
|
||||
const procObjid = createObjectId();
|
||||
const procNo = await genProcNo();
|
||||
await pool.query(
|
||||
`INSERT INTO momo_procurements (objid, proc_no, vendor_objid, proc_date, status, total_amount, memo, regdate)
|
||||
VALUES ($1, $2, $3, CURRENT_DATE, 'OPEN', 0, $4, NOW())`,
|
||||
[procObjid, procNo, vendorObjid ?? null, memo ?? null]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: procObjid, procNo });
|
||||
}
|
||||
|
||||
async function genProcNo(): Promise<string> {
|
||||
const today = new Date();
|
||||
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const prefix = `PRC-${ymd}-`;
|
||||
const row = await queryOne<{ MAX_NO: string }>(
|
||||
`SELECT COALESCE(MAX(proc_no), '') AS "MAX_NO" FROM momo_procurements WHERE proc_no LIKE $1 || '%'`,
|
||||
[prefix]
|
||||
);
|
||||
const lastNum = row?.MAX_NO ? Number(row.MAX_NO.replace(prefix, "")) || 0 : 0;
|
||||
return prefix + String(lastNum + 1).padStart(4, "0");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 매입 발주서 라인 추가/수정/삭제 — 관리자 전용. OPEN/REQUESTED 상태만.
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool } from "@/lib/db";
|
||||
import { createObjectId } from "@/lib/utils";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
interface LineInput {
|
||||
objid?: string;
|
||||
itemObjid?: string;
|
||||
qty: number;
|
||||
costPrice: number;
|
||||
delete?: boolean;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { procObjid, lines } = body as { procObjid?: string; lines?: LineInput[] };
|
||||
if (!procObjid || !Array.isArray(lines)) {
|
||||
return NextResponse.json({ success: false, message: "잘못된 요청입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const procRes = await client.query(
|
||||
`SELECT status FROM momo_procurements WHERE objid = $1 FOR UPDATE`,
|
||||
[procObjid]
|
||||
);
|
||||
if (procRes.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "발주서를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
const status = procRes.rows[0].status;
|
||||
if (status !== "OPEN" && status !== "REQUESTED") {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "작성/요청 상태에서만 수정할 수 있습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const ln of lines) {
|
||||
const qty = Number(ln.qty) || 0;
|
||||
const cost = Math.round(Number(ln.costPrice) || 0);
|
||||
const total = Math.round(cost * qty);
|
||||
|
||||
if (ln.delete && ln.objid) {
|
||||
await client.query(`DELETE FROM momo_procurement_items WHERE objid = $1 AND proc_objid = $2`, [ln.objid, procObjid]);
|
||||
continue;
|
||||
}
|
||||
if (qty <= 0 || cost < 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "수량/단가 형식 오류" }, { status: 400 });
|
||||
}
|
||||
if (ln.objid) {
|
||||
await client.query(
|
||||
`UPDATE momo_procurement_items SET qty=$2, cost_price=$3, total_amount=$4 WHERE objid=$1`,
|
||||
[ln.objid, qty, cost, total]
|
||||
);
|
||||
} else if (ln.itemObjid) {
|
||||
// 중복 체크: 같은 procObjid + itemObjid 가 이미 있으면 수량/단가만 갱신
|
||||
const dup = await client.query(
|
||||
`SELECT objid, qty FROM momo_procurement_items WHERE proc_objid=$1 AND item_objid=$2 LIMIT 1`,
|
||||
[procObjid, ln.itemObjid]
|
||||
);
|
||||
if (dup.rowCount && dup.rowCount > 0) {
|
||||
const existObjid = dup.rows[0].objid;
|
||||
const newQty = Number(dup.rows[0].qty) + qty;
|
||||
const newTotal = Math.round(cost * newQty);
|
||||
await client.query(
|
||||
`UPDATE momo_procurement_items SET qty=$2, cost_price=$3, total_amount=$4 WHERE objid=$1`,
|
||||
[existObjid, newQty, cost, newTotal]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO momo_procurement_items (objid, proc_objid, item_objid, cost_price, qty, total_amount, received_qty, received_normal, received_defect)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, 0, 0)`,
|
||||
[createObjectId(), procObjid, ln.itemObjid, cost, qty, total]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 합계 재집계
|
||||
const sum = await client.query(
|
||||
`SELECT COALESCE(SUM(total_amount), 0) AS total FROM momo_procurement_items WHERE proc_objid = $1`,
|
||||
[procObjid]
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE momo_procurements SET total_amount = $2 WHERE objid = $1`,
|
||||
[procObjid, Number(sum.rows[0].total)]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("[procurements/lines/save]", err);
|
||||
const msg = err instanceof Error ? err.message : "라인 저장 오류";
|
||||
return NextResponse.json({ success: false, message: msg }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// 매입 발주서 → 공급업체 발송 (상태 OPEN → REQUESTED + 메일)
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool, queryOne, queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const { objid } = await req.json().catch(() => ({}));
|
||||
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||
|
||||
const proc = await queryOne<Record<string, unknown>>(
|
||||
`SELECT P.objid, P.proc_no, TO_CHAR(P.proc_date,'YYYY-MM-DD') AS proc_date,
|
||||
P.status, P.total_amount, P.memo,
|
||||
V.objid AS vendor_objid, V.supply_name AS vendor_name,
|
||||
V.email AS vendor_email, V.charge_user_name AS vendor_contact,
|
||||
V.supply_tel_no AS vendor_phone
|
||||
FROM momo_procurements P
|
||||
LEFT JOIN supply_mng V ON P.vendor_objid = V.objid::text
|
||||
WHERE P.objid = $1`,
|
||||
[objid]
|
||||
);
|
||||
if (!proc) return NextResponse.json({ success: false, message: "찾을 수 없음" }, { status: 404 });
|
||||
if (proc.status !== "OPEN" && proc.status !== "REQUESTED") {
|
||||
return NextResponse.json({ success: false, message: "작성/요청 상태에서만 발송 가능" }, { status: 400 });
|
||||
}
|
||||
const items = await queryRows<Record<string, unknown>>(
|
||||
`SELECT I.item_code, I.item_name, I.unit, PI.qty, PI.cost_price, PI.total_amount
|
||||
FROM momo_procurement_items PI JOIN momo_items I ON PI.item_objid = I.objid
|
||||
WHERE PI.proc_objid = $1 ORDER BY PI.objid`,
|
||||
[objid]
|
||||
);
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json({ success: false, message: "라인이 없습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// 상태 변경
|
||||
await pool.query(
|
||||
`UPDATE momo_procurements SET status = 'REQUESTED' WHERE objid = $1`,
|
||||
[objid]
|
||||
);
|
||||
|
||||
// 메일 발송 (실패해도 발주 요청은 성공)
|
||||
let mailSent = false;
|
||||
let mailError: string | undefined;
|
||||
if (proc.vendor_email) {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT || 465),
|
||||
secure: Number(process.env.SMTP_PORT || 465) === 465,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = (n: unknown) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const itemRows = items.map((it, idx) => `
|
||||
<tr>
|
||||
<td style="border:1px solid #ccc;padding:6px;text-align:center">${idx + 1}</td>
|
||||
<td style="border:1px solid #ccc;padding:6px">${it.item_name}</td>
|
||||
<td style="border:1px solid #ccc;padding:6px;text-align:center">${it.unit ?? "EA"}</td>
|
||||
<td style="border:1px solid #ccc;padding:6px;text-align:right">${fmt(it.qty)}</td>
|
||||
<td style="border:1px solid #ccc;padding:6px;text-align:right">${fmt(it.cost_price)}</td>
|
||||
<td style="border:1px solid #ccc;padding:6px;text-align:right;font-weight:bold">${fmt(it.total_amount)}</td>
|
||||
</tr>`).join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family:Apple SD Gothic Neo,sans-serif;font-size:14px;color:#222;max-width:720px">
|
||||
<h2 style="text-align:center;letter-spacing:0.3em;margin:0 0 20px">발 주 서</h2>
|
||||
<table style="width:100%;margin-bottom:14px;font-size:13px">
|
||||
<tr>
|
||||
<td><b>발주번호</b> · ${proc.proc_no}</td>
|
||||
<td style="text-align:right"><b>발주일</b> · ${proc.proc_date}</td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><b>공급업체</b> · ${proc.vendor_name ?? "-"}</td></tr>
|
||||
</table>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px">
|
||||
<thead style="background:#f1f5f9">
|
||||
<tr>
|
||||
<th style="border:1px solid #ccc;padding:6px;width:40px">#</th>
|
||||
<th style="border:1px solid #ccc;padding:6px;text-align:left">품명</th>
|
||||
<th style="border:1px solid #ccc;padding:6px;width:50px">단위</th>
|
||||
<th style="border:1px solid #ccc;padding:6px;width:80px">수량</th>
|
||||
<th style="border:1px solid #ccc;padding:6px;width:100px">단가</th>
|
||||
<th style="border:1px solid #ccc;padding:6px;width:120px">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${itemRows}</tbody>
|
||||
</table>
|
||||
<div style="text-align:right;margin-top:14px;font-size:14px"><b>총액 ₩${fmt(proc.total_amount)}</b> (V.A.T 별도)</div>
|
||||
${proc.memo ? `<div style="margin-top:14px;padding:10px;background:#f8fafc;border-left:4px solid #94a3b8;font-size:12px">${String(proc.memo).replace(/\n/g,"<br>")}</div>` : ""}
|
||||
<p style="margin-top:24px;text-align:center">상기와 같이 발주합니다.</p>
|
||||
<p style="margin-top:8px;text-align:center;font-weight:bold">${process.env.MOMO_COMPANY_NAME ?? "모모유통"}</p>
|
||||
<p style="margin-top:0;text-align:center;font-size:12px;color:#64748b">전화: ${process.env.MOMO_PHONE ?? ""}</p>
|
||||
</div>`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM ?? process.env.SMTP_USER,
|
||||
to: proc.vendor_email as string,
|
||||
subject: `[${process.env.MOMO_COMPANY_NAME ?? "모모유통"}] 발주서 ${proc.proc_no}`,
|
||||
html,
|
||||
});
|
||||
mailSent = true;
|
||||
} catch (err) {
|
||||
mailError = err instanceof Error ? err.message : "메일 전송 실패";
|
||||
console.error("[procurements/send mail]", err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
procNo: proc.proc_no,
|
||||
mailSent,
|
||||
mailError,
|
||||
vendorEmail: proc.vendor_email ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 매입 발주서 헤더 (공급업체/메모) 수정 — OPEN/REQUESTED 상태만
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { objid, vendorObjid, memo } = body as { objid?: string; vendorObjid?: string | null; memo?: string };
|
||||
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||
|
||||
const cur = await pool.query(`SELECT status FROM momo_procurements WHERE objid = $1`, [objid]);
|
||||
if (cur.rowCount === 0) return NextResponse.json({ success: false, message: "찾을 수 없음" }, { status: 404 });
|
||||
const status = cur.rows[0].status;
|
||||
if (status !== "OPEN" && status !== "REQUESTED") {
|
||||
return NextResponse.json({ success: false, message: "작성/요청 상태에서만 수정 가능" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [objid];
|
||||
let i = 2;
|
||||
if (vendorObjid !== undefined) { sets.push(`vendor_objid = $${i++}`); params.push(vendorObjid); }
|
||||
if (memo !== undefined) { sets.push(`memo = $${i++}`); params.push(memo); }
|
||||
if (sets.length === 0) return NextResponse.json({ success: true });
|
||||
|
||||
await pool.query(`UPDATE momo_procurements SET ${sets.join(", ")} WHERE objid = $1`, params);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
Reference in New Issue
Block a user