diff --git a/public/manual.html b/public/manual.html index 573e761..2ad07b0 100644 --- a/public/manual.html +++ b/public/manual.html @@ -550,13 +550,21 @@

입고 담당자는 도매처에서 물건을 사 와서 창고에 쌓는 사람이에요. 입고 등록을 하면 창고 재고가 자동으로 늘어요(+). 물품 등록 담당자는 팔 물건, 가게 회원, 도매처 등 기본 정보를 등록·관리해요.

-

다-1. 물건 매입하기 (도매처에 주문)

-

왼쪽 메뉴 매입/입고 → 매입 발주.

+

다-1. 물건 매입하기 (공급업체에 주문)

+

왼쪽 메뉴 매입/입고 → 매입 발주. 화면이 좌-우로 갈라져서 왼쪽엔 발주서 목록, 오른쪽엔 양식이 보여요.

    -
  1. [+ 매입 발주] 버튼도매처를 고르고, 살 물건과 개수, 가격, 입고 예정일을 적어요
  2. -
  3. 저장도매처에 주문서가 만들어지고 상태는 발주
  4. -
  5. 도매처에서 물건이 도착하면다음 단계인 '입고 처리'로 이동
  6. +
  7. [+ 새 발주] 버튼오른쪽 발주서 양식이 빈 상태로 새로 열려요. 발주서 번호가 자동으로 부여돼요(예: PRC-20260507-0001)
  8. +
  9. 공급업체 선택발주서 위쪽 표에서 공급업체를 드롭다운으로 선택. 미리 [공급업체 관리]에 등록되어 있어야 해요.
  10. +
  11. [+ 품목 추가] 버튼 → 품목 모달새 창이 떠요. 검색창에 품목명/코드 적고 [검색]. 위쪽에 '현재 발주서 공급업체만' 필터 토글이 있어 그 공급업체 품목만 골라 볼 수 있고, '결과 내 검색' 칸으로 더 좁힐 수 있어요. 체크박스로 다중 선택(헤더 체크로 전체) 후 [선택한 N개 추가].
  12. +
  13. 발주서에서 수량/단가 직접 수정각 행의 수량과 단가 칸을 클릭해서 바꾸고, 다른 곳을 누르면 자동 저장. 행 오른쪽 [×]로 삭제. 합계는 자동 재계산.
  14. +
  15. 비고 적기발주서 아래쪽 '비고' 칸에 납품 장소, 납품 기간, 대금지불 조건 등 자유 입력. 다른 곳 누르면 자동 저장.
  16. +
  17. [발주 요청] 버튼오른쪽 위 초록색 버튼. 누르면:
    ① 상태가 발주요청으로 바뀌고
    ② 공급업체 이메일로 발주서 메일이 자동 발송됩니다.
    이메일이 없으면 '메일 미발송' 안내가 떠요 — 직접 통보 필요.
  18. +
  19. 물건이 도착하면다음 단계인 [입고 처리] 화면에서 입고 등록
+
+ 💡 공급업체별 품목 일괄 불러오기 +

품목 모달에서 '현재 발주서 공급업체만' 필터를 켜면 그 공급업체에 등록된 모든 품목이 보여요. 헤더 체크박스로 전체 선택 → [선택한 N개 추가] 누르면 한 번에 다 들어가요. 그 후 필요한 것만 남기고 [×] 로 빼면 돼요.

+

다-2. 물건 들어오면 등록하기 (창고에 쌓임)

diff --git a/src/app/(main)/m/admin/procurements/page.tsx b/src/app/(main)/m/admin/procurements/page.tsx index eccc4e2..ae47ad4 100644 --- a/src/app/(main)/m/admin/procurements/page.tsx +++ b/src/app/(main)/m/admin/procurements/page.tsx @@ -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 = { 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 = { + OPEN: "작성중", REQUESTED: "발주요청", RECEIVED: "입고완료", CANCELLED: "취소", +}; +const STATUS_COLOR: Record = { + 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([]); + const [list, setList] = useState([]); + const [statusFilter, setStatusFilter] = useState(""); + const [activeId, setActiveId] = useState(""); + const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null); + const [vendors, setVendors] = useState([]); + 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: `${detail.proc.VENDOR_NAME ?? "-"} 에게 메일 발송 + 상태 변경
총액: ₩${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 ? `메일 발송 완료 → ${j.vendorEmail}` : (j.vendorEmail ? `메일 실패: ${j.mailError ?? ""}` : "공급업체 이메일 미설정 — 별도 통보 필요"), + }); + load(); + } else { + Swal.fire({ icon: "error", title: "실패", text: j.message }); + } + } finally { setBusy(false); } }; - useEffect(() => { load(); }, []); return ( -
-
+
+
-

매입 발주

-

모모유통 → 도매처/제조사로 보내는 발주서

+

매입 발주서 관리

+

왼쪽에서 발주서를 선택하면 오른쪽에 양식이 보여요. [+ 새 발주]로 작성, [발주 요청]으로 공급업체에 메일 발송.

+
+
+ + +
- - 매입발주 작성 -
-
- - - - - - - - - - - - - {list.length === 0 ? ( - - ) : list.map((p) => ( - - - - - - - + +
+
+
+ 발주서 목록 ({list.length}건) +
+
+
발주번호발주일공급업체라인합계상태
매입발주가 없습니다.
{p.PROC_NO}{p.PROC_DATE}{p.VENDOR_NAME || "-"}{p.LINE_CNT}건₩{fmt(p.TOTAL_AMOUNT)} - - {STATUS_LABEL[p.STATUS] || p.STATUS} - -
+ + + + + + + + + + + {list.length === 0 ? ( + + ) : list.map((p) => ( + setActiveId(p.OBJID)} + className={`cursor-pointer border-t border-slate-100 ${activeId === p.OBJID ? "bg-emerald-50" : "hover:bg-slate-50"}`}> + + + + + + + ))} + +
발주번호일자공급업체금액상태
발주서가 없습니다.
{p.PROC_NO}{p.PROC_DATE}{p.VENDOR_NAME ?? 미선택}₩{fmt(p.TOTAL_AMOUNT)} + + {STATUS_LABEL[p.STATUS] ?? p.STATUS} + +
+
+
+ +
+
+ 발주서 + {detail && detail.proc.STATUS === "OPEN" && ( + + )} + {detail && detail.proc.STATUS === "REQUESTED" && ( + 발주 요청 완료 — 공급업체 응답 대기 + )} +
+
+ {!detail ? ( +
왼쪽에서 발주서를 선택하거나 [+ 새 발주]를 누르세요.
+ ) : ( + updateHeader({ vendorObjid: v || null })} + onSetMemo={(m) => updateHeader({ memo: m })} + onAddPicker={() => setPickerOpen(true)} + onUpdateLine={updateLine} + onDeleteLine={deleteLine} + /> + )} +
+
+
+ + {pickerOpen && detail && ( + 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 }); + }} + /> + )} +
+ ); +} + +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 ( +
+
+

발 주 서

+
+ + + + + + + + + + + + + + + + + + + + +
분류번호매입발주
발주서번호{detail.proc.PROC_NO}
발주일{detail.proc.PROC_DATE}
공급업체 + {editable ? ( + + ) : ( + {detail.proc.VENDOR_NAME ?? "-"} + )} +
+ +

1. 물품의 표시

+ {editable && ( +
+ + — 모달에서 공급업체 필터, 결과내 검색, 다중 선택 가능 +
+ )} + + + + + + + + + + {editable && } + + + + {detail.items.length === 0 ? ( + + ) : detail.items.map((it, idx) => ( + onUpdateLine({ objid: it.OBJID, qty, costPrice: cost })} + onDelete={() => onDeleteLine(it.OBJID)} /> + ))} + +
#품명단위수량단가금액
품목이 없습니다. {editable && "[품목 추가] 버튼으로 등록."}
+
(V.A.T 별도, 단위: 원)
+ + + + + + + + +
총액₩ {fmt(detail.proc.TOTAL_AMOUNT)}
+ +

2. 비고

+ {editable ? ( +