feat(v0.7 round2): 매입 발주서 양식 + 좌-우 분할 + 공급업체 일괄 불러오기
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:
chpark
2026-05-07 22:26:21 +09:00
parent 99565bf6e0
commit 6b60178b1d
6 changed files with 852 additions and 53 deletions
+13 -5
View File
@@ -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>
+551 -48
View File
@@ -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();
}
}
+121
View File
@@ -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 });
}