feat(v0.7 round1): 공급업체 명칭 변경 + 품목-공급업체 연결 + 거래처 출고이력 거래명세표 모달
Deploy momo-erp / deploy (push) Successful in 55s

[DB]
- 016: momo_items.vendor_objid 추가, momo_vendors 컬럼 보강 (email/address/memo/regdate)
- 017: 메뉴 9000202 "매입처 관리" → "공급업체 관리"

[명칭 일괄 변경]
- src/app/api/m/vendors/* + (main)/m/admin/vendors/* + procurements/* + inbounds/*
- 모든 UI/메시지의 '매입처' → '공급업체'

[품목 ↔ 공급업체 연결]
- /api/m/items/list 응답에 VENDOR_OBJID/VENDOR_NAME 추가, vendorObjid 필터 지원
- /api/m/items/save: vendorObjid 입력/저장 (insert + update)
- 품목 등록·수정 폼에 [공급업체] 드롭다운 신설 (제조사 옆)

[/m/orders 거래처 출고 이력 화면 — 모달 + 이미지 공유]
- 행 클릭 / [보기] 버튼 → 거래명세표 모달
- 모달 안에 [📤 이미지 공유] [⬇ 엑셀 다운로드] 버튼 (출고/정산 화면과 동일)
- 출고요청 상태이면 [🗑 주문 취소] 버튼 노출 → /api/m/orders/cancel
- html-to-image 로 PNG 캡처 → Web Share API 또는 다운로드

[매뉴얼]
- 공급업체 명칭 반영, 출고이력 거래명세표 보기 동작 추가, 품목 폼에 공급업체 필드 설명 추가

Round 2 예정: 매입 발주 양식 (좌측 리스트 + 우측 발주서 + 품목 검색/공급업체 일괄 불러오기) + 매뉴얼 보강

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-07 22:19:08 +09:00
parent 8c89c44b5f
commit 99565bf6e0
14 changed files with 335 additions and 48 deletions
+25
View File
@@ -0,0 +1,25 @@
-- 016_vendor_extend.sql
-- v0.7 (2026-05-07)
-- 매입처 → 공급업체 명칭 변경 + 품목에 공급업체 연결 + 공급업체 정보 보강
BEGIN;
-- 1. 공급업체(momo_vendors) 컬럼 보강
ALTER TABLE momo_vendors
ADD COLUMN IF NOT EXISTS email VARCHAR(200),
ADD COLUMN IF NOT EXISTS address TEXT,
ADD COLUMN IF NOT EXISTS memo TEXT,
ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW();
COMMENT ON TABLE momo_vendors IS '공급업체 — 발주를 보낼 도매처/제조처';
COMMENT ON COLUMN momo_vendors.email IS '발주서 메일 발송 받을 주소';
COMMENT ON COLUMN momo_vendors.address IS '공급업체 주소';
-- 2. 품목 ↔ 공급업체 연결 컬럼
ALTER TABLE momo_items
ADD COLUMN IF NOT EXISTS vendor_objid TEXT;
COMMENT ON COLUMN momo_items.vendor_objid IS '주 공급업체 (momo_vendors.objid). 매입 발주 시 자동 채움';
CREATE INDEX IF NOT EXISTS idx_momo_items_vendor ON momo_items(vendor_objid);
COMMIT;
+12
View File
@@ -0,0 +1,12 @@
-- 017_menu_rename_vendor.sql
-- v0.7 (2026-05-07)
-- 메뉴 명칭 변경: "매입처 관리" → "공급업체 관리"
BEGIN;
UPDATE menu_info
SET menu_name_kor = '공급업체 관리',
menu_name_eng = 'Vendors'
WHERE objid = 9000202;
COMMIT;
+7 -1
View File
@@ -379,6 +379,11 @@
<!-- 가.2 주문 내역 -->
<h3 id="u-orders">가-2. 내가 주문한 내역 보기</h3>
<p>왼쪽 메뉴의 <b>거래처 주문 → 내 주문 내역</b> 을 누르면 보여요.</p>
<ul>
<li><b>주문번호 또는 [보기] 클릭</b> → 거래명세표가 큰 창으로 떠요</li>
<li>거래명세표 위쪽에 <span class="btn btn-orange">📤 이미지 공유</span> <span class="btn btn-emerald">⬇ 엑셀 다운로드</span> 버튼이 있어 카톡 공유나 파일 저장 가능</li>
<li><b>출고요청 상태</b>인 주문은 <span class="btn btn-rose">🗑 주문 취소</span> 버튼이 떠 있어요. 수량 수정이 필요하면 취소 후 새로 작성하세요.</li>
</ul>
<h4>주문 상태별 뜻</h4>
<table>
<thead><tr><th width="130">상태</th><th>무슨 뜻인가요?</th><th>가게가 할 일</th></tr></thead>
@@ -586,6 +591,7 @@
<div style="border:1px solid var(--border);border-radius:8px;background:#fafafa">
<div class="field-row"><b>물건 이름 *</b><span class="desc">가게 주문 화면에 표시될 이름</span></div>
<div class="field-row"><b>제조사</b><span class="desc">[제조사 관리]에 미리 등록한 회사 중에서 고르기</span></div>
<div class="field-row"><b>공급업체</b><span class="desc">⭐ 이 물건을 사 오는 도매처. [공급업체 관리]에서 미리 등록. 매입 발주 시 자동으로 이 업체에 발주서가 연결됨</span></div>
<div class="field-row"><b>단위</b><span class="desc">개(EA), 박스(BOX), 킬로그램(KG), 리터(L), 팩(PACK) 중 선택</span></div>
<div class="field-row"><b>구분 (면세/과세)</b><span class="desc">면세 = 세금 없음 / 과세 = 세금 있음. 라디오로 골라요</span></div>
<div class="field-row"><b>판매가 (세금 포함)</b><span class="desc">가게에게 보여주는 가격. 세금이 포함된 금액</span></div>
@@ -644,7 +650,7 @@
<table>
<thead><tr><th>메뉴 이름</th><th>여기에 등록하는 것</th></tr></thead>
<tbody>
<tr><td>매입처 관리</td><td>도매처(우리가 물건을 사 오는 곳) — 회사 이름, 연락처, 사업자번호</td></tr>
<tr><td>공급업체 관리</td><td>물건을 사 오는 도매처/제조처 — 회사 이름, 연락처, 사업자번호, 이메일, 주소. 발주서를 메일로 받을 곳.</td></tr>
<tr><td>창고 관리</td><td>창고 — 본사 창고, 김포 지사 창고, 픽업 장소 등 분류</td></tr>
<tr><td>제조사 관리</td><td>물건을 만든 회사. 물건 등록할 때 여기서 골라요.</td></tr>
</tbody>
+1 -1
View File
@@ -108,7 +108,7 @@ export default function NewInboundPage() {
</select>
</div>
<div>
<label className="text-xs font-semibold text-slate-600"></label>
<label className="text-xs font-semibold text-slate-600"></label>
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
<option value=""></option>
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
+1 -1
View File
@@ -33,7 +33,7 @@ export default function InboundsPage() {
<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-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>
+25
View File
@@ -22,7 +22,10 @@ interface Item {
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
VENDOR_OBJID?: string;
VENDOR_NAME?: string;
}
interface Vendor { OBJID: string; VENDOR_NAME: string }
interface Maker { OBJID: string; MAKER_NAME: string }
@@ -40,6 +43,7 @@ const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function AdminItemsPage() {
const [items, setItems] = useState<Item[]>([]);
const [makers, setMakers] = useState<Maker[]>([]);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [keyword, setKeyword] = useState("");
const [filterStatus, setFilterStatus] = useState("");
const [editing, setEditing] = useState<Partial<Item> | null>(null);
@@ -65,9 +69,17 @@ export default function AdminItemsPage() {
setMakers((await res.json()).RESULTLIST ?? []);
};
const loadVendors = async () => {
const res = await fetch("/api/m/vendors/list", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}),
});
setVendors((await res.json()).RESULTLIST ?? []);
};
useEffect(() => {
loadItems();
loadMakers();
loadVendors();
}, []); // eslint-disable-line
const openEdit = (item: Partial<Item>) => {
@@ -105,6 +117,7 @@ export default function AdminItemsPage() {
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
vendorObjid: editing.VENDOR_OBJID || null,
};
const res = await fetch("/api/m/items/save", {
method: "POST",
@@ -319,6 +332,18 @@ export default function AdminItemsPage() {
))}
</select>
</Field>
<Field label="공급업체">
<select
value={editing.VENDOR_OBJID ?? ""}
onChange={(e) => setEditing({ ...editing, VENDOR_OBJID: e.target.value })}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white focus:border-emerald-500 outline-none"
>
<option value=""></option>
{vendors.map((v) => (
<option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>
))}
</select>
</Field>
<Field label="단위">
<select
value={editing.UNIT ?? "EA"}
@@ -36,7 +36,7 @@ export default function NewProcPage() {
};
const submit = async () => {
if (!vendorObjid) return Swal.fire({ icon: "warning", title: "매입처 선택" });
if (!vendorObjid) return Swal.fire({ icon: "warning", title: "공급업체 선택" });
if (lines.length === 0) return Swal.fire({ icon: "warning", title: "발주 라인 추가" });
const res = await fetch("/api/m/procurements/save", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -57,7 +57,7 @@ export default function NewProcPage() {
<div className="bg-white border rounded-xl p-5 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-semibold text-slate-600"> *</label>
<label className="text-xs font-semibold text-slate-600"> *</label>
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
<option value=""></option>
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
+1 -1
View File
@@ -35,7 +35,7 @@ export default function ProcurementsPage() {
<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-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>
+6 -6
View File
@@ -37,18 +37,18 @@ export default function VendorsPage() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> </h1>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-sm text-slate-500 mt-1"> / </p>
</div>
<button onClick={() => setEditing({})} 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} />
<Plus size={16} />
</button>
</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-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
@@ -58,7 +58,7 @@ export default function VendorsPage() {
</thead>
<tbody>
{list.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> .</td></tr>
<tr><td colSpan={6} className="text-center py-12 text-slate-400"> .</td></tr>
) : list.map((v) => (
<tr key={v.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3 font-semibold">{v.VENDOR_NAME}</td>
@@ -78,9 +78,9 @@ export default function VendorsPage() {
{editing && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setEditing(null)}>
<form onSubmit={save} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-lg w-full p-6">
<h3 className="font-bold mb-4">{editing.OBJID ? "매입처 수정" : "매입처 추가"}</h3>
<h3 className="font-bold mb-4">{editing.OBJID ? "공급업체 수정" : "공급업체 추가"}</h3>
<div className="grid grid-cols-2 gap-3">
<input required placeholder="매입처명 *" value={editing.VENDOR_NAME ?? ""} onChange={(e) => setEditing({ ...editing, VENDOR_NAME: e.target.value })} className="col-span-2 h-10 px-3 rounded-lg border border-slate-200" />
<input required placeholder="공급업체명 *" value={editing.VENDOR_NAME ?? ""} onChange={(e) => setEditing({ ...editing, VENDOR_NAME: e.target.value })} className="col-span-2 h-10 px-3 rounded-lg border border-slate-200" />
<input placeholder="담당자" value={editing.CONTACT ?? ""} onChange={(e) => setEditing({ ...editing, CONTACT: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
<input placeholder="연락처" value={editing.PHONE ?? ""} onChange={(e) => setEditing({ ...editing, PHONE: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
<input placeholder="사업자번호" value={editing.BIZ_NO ?? ""} onChange={(e) => setEditing({ ...editing, BIZ_NO: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" />
+237 -29
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import Link from "next/link";
import { Download } from "lucide-react";
import { Download, Image as ImageIcon, X, Eye, Trash2 } from "lucide-react";
import Swal from "sweetalert2";
import { downloadXlsx } from "@/lib/xlsx-export";
interface Order {
@@ -13,10 +14,22 @@ interface Order {
TOTAL_AMOUNT: number;
TOTAL_TAXFREE: number;
TOTAL_TAXABLE: number;
TOTAL_SUPPLY?: number;
TOTAL_VAT?: number;
COMPANY_NAME?: string;
}
interface DetailLine {
OBJID: string; SEQ: number; ITEM_NAME: string; UNIT?: string;
UNIT_PRICE: number; QTY: number; IS_TAX_FREE: string;
SUPPLY_AMOUNT: number; VAT_AMOUNT: number; TOTAL_AMOUNT: number;
KIND: "ITEM" | "DELIVERY" | "CHARTER"; EXTRA_LABEL?: string; REMARK?: string;
}
interface Supplier {
NAME: string; CEO: string; BIZ_NO: string;
BANK_ACCOUNT: string; PHONE: string; EMAIL: string; ADDRESS: string;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const fmt = (n: number | string | undefined) => Number(n || 0).toLocaleString("ko-KR");
const STATUS_LABEL: Record<string, string> = {
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소",
@@ -33,6 +46,7 @@ const STATUS_COLOR: Record<string, string> = {
export default function MyOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [status, setStatus] = useState("");
const [detail, setDetail] = useState<{ order: Order & { CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string; EMAIL?: string; MEMO?: string }; items: DetailLine[]; supplier: Supplier } | null>(null);
const load = async () => {
const res = await fetch("/api/m/orders/list", {
@@ -45,20 +59,47 @@ export default function MyOrdersPage() {
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const openDetail = async (o: Order) => {
const res = await fetch("/api/m/orders/detail", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: o.OBJID }),
});
const j = await res.json();
if (j.success) setDetail({ order: j.order, items: j.items, supplier: j.supplier });
};
const cancelOrder = async (o: Order) => {
const ok = await Swal.fire({
icon: "warning", title: "주문을 취소하시겠습니까?",
text: o.ORDER_NO,
showCancelButton: true, confirmButtonText: "취소", cancelButtonText: "닫기",
confirmButtonColor: "#dc2626",
});
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/orders/cancel", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: o.OBJID }),
});
const j = await res.json();
if (j.success) {
Swal.fire({ icon: "success", title: "취소되었습니다", timer: 1200, showConfirmButton: false });
setDetail(null);
load();
} else {
Swal.fire({ icon: "error", title: "취소 실패", text: j.message });
}
};
const onExport = () => {
if (orders.length === 0) return;
downloadXlsx(
"발주이력",
orders,
[
{ header: "발주번호", key: "ORDER_NO", width: 18 },
{ header: "발주일", key: "ORDER_DATE", width: 12 },
{ header: "면세", key: (r) => Number(r.TOTAL_TAXFREE), width: 14 },
{ header: "과세", key: (r) => Number(r.TOTAL_TAXABLE), width: 14 },
{ header: "합계", key: (r) => Number(r.TOTAL_AMOUNT), width: 14 },
{ header: "상태", key: (r) => STATUS_LABEL[String(r.STATUS)] || String(r.STATUS), width: 10 },
]
);
downloadXlsx("발주이력", orders, [
{ header: "발주번호", key: "ORDER_NO", width: 18 },
{ header: "발주일", key: "ORDER_DATE", width: 12 },
{ header: "면세", key: (r) => Number(r.TOTAL_TAXFREE), width: 14 },
{ header: "과세", key: (r) => Number(r.TOTAL_TAXABLE), width: 14 },
{ header: "합계", key: (r) => Number(r.TOTAL_AMOUNT), width: 14 },
{ header: "상태", key: (r) => STATUS_LABEL[String(r.STATUS)] || String(r.STATUS), width: 10 },
]);
};
return (
@@ -66,14 +107,11 @@ export default function MyOrdersPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-xl sm:text-2xl font-bold"> </h1>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {orders.length}</p>
<p className="text-xs sm:text-sm text-slate-500 mt-1"> {orders.length} </p>
</div>
<div className="flex gap-2">
<button
onClick={onExport}
disabled={orders.length === 0}
className="inline-flex items-center gap-1.5 h-10 px-3 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:opacity-50"
>
<button onClick={onExport} disabled={orders.length === 0}
className="inline-flex items-center gap-1.5 h-10 px-3 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
<Download size={14} />
</button>
<Link href="/m/orders/new" className="px-3 sm:px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold">
@@ -100,15 +138,16 @@ export default function MyOrdersPage() {
<th className="text-right px-4 py-3 font-semibold"></th>
<th className="text-right px-4 py-3 font-semibold"></th>
<th className="text-center px-4 py-3 font-semibold"></th>
<th className="text-right px-4 py-3 font-semibold"></th>
<th className="text-right px-4 py-3 font-semibold"></th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400"> .</td></tr>
) : orders.map((o) => (
<tr key={o.OBJID} className="border-t border-slate-100">
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
<tr key={o.OBJID} className="border-t border-slate-100 hover:bg-emerald-50/40 cursor-pointer"
onClick={() => openDetail(o)}>
<td className="px-4 py-3 font-semibold text-emerald-700 underline-offset-2 hover:underline">{o.ORDER_NO}</td>
<td className="px-4 py-3">{o.ORDER_DATE}</td>
<td className="px-4 py-3 text-right tabular-nums text-violet-700">{fmt(o.TOTAL_TAXFREE)}</td>
<td className="px-4 py-3 text-right tabular-nums text-rose-700">{fmt(o.TOTAL_TAXABLE)}</td>
@@ -119,17 +158,186 @@ export default function MyOrdersPage() {
</span>
</td>
<td className="px-4 py-3 text-right">
{(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") ? (
<a href={`/api/m/orders/statement/${o.OBJID}`} className="inline-flex items-center gap-1 text-emerald-700 hover:underline text-xs font-semibold">
<Download size={12} />
</a>
) : <span className="text-slate-300 text-xs">-</span>}
<button onClick={(e) => { e.stopPropagation(); openDetail(o); }}
className="inline-flex items-center gap-1 text-emerald-700 hover:underline text-xs font-semibold">
<Eye size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{detail && (
<DetailModal
order={detail.order}
items={detail.items}
supplier={detail.supplier}
onClose={() => setDetail(null)}
onCancel={() => cancelOrder(detail.order)}
/>
)}
</div>
);
}
function DetailModal({ order, items, supplier, onClose, onCancel }: {
order: Order & { CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string; EMAIL?: string };
items: DetailLine[];
supplier: Supplier;
onClose: () => void;
onCancel: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const editable = order.STATUS === "REQUESTED";
const captureAndShare = async () => {
try {
const mod = await import("html-to-image");
if (!ref.current) return;
const dataUrl = await mod.toPng(ref.current, { backgroundColor: "#ffffff", pixelRatio: 2 });
const blob = await (await fetch(dataUrl)).blob();
const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" });
const navAny = navigator as Navigator & { canShare?: (data: { files: File[] }) => boolean };
if (navAny.canShare && navAny.canShare({ files: [file] })) {
await navigator.share({ files: [file], title: `${order.ORDER_NO} 거래명세표` });
return;
}
const a = document.createElement("a");
a.href = dataUrl;
a.download = `${order.ORDER_NO}_거래명세표.png`;
a.click();
Swal.fire({ icon: "success", title: "이미지 저장 완료", text: "다운로드한 이미지를 카톡 등에 첨부해 보내세요.", timer: 1500, showConfirmButton: false });
} catch (err) {
console.error(err);
Swal.fire({ icon: "error", title: "이미지 캡처 실패", text: "잠시 후 다시 시도하세요." });
}
};
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-[92vh] overflow-y-auto p-5" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-3 print:hidden">
<div className="flex gap-2">
<button onClick={captureAndShare}
className="inline-flex items-center gap-1 h-9 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200">
<ImageIcon size={14} />
</button>
<a href={`/api/m/orders/statement/${order.OBJID}`}
className="inline-flex items-center gap-1 h-9 px-3 rounded-lg bg-emerald-100 text-emerald-800 text-xs font-bold hover:bg-emerald-200">
<Download size={14} />
</a>
{editable && (
<button onClick={onCancel}
className="inline-flex items-center gap-1 h-9 px-3 rounded-lg bg-rose-100 text-rose-700 text-xs font-bold hover:bg-rose-200">
<Trash2 size={14} />
</button>
)}
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 p-1.5">
<X size={18} />
</button>
</div>
<div ref={ref} className="bg-white p-3 text-[12px] text-slate-800">
<div className="text-center">
<h2 className="text-xl font-bold tracking-[0.3em] text-slate-900"> </h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3 text-[11px]">
<div>
<div><b></b> · {order.ORDER_NO}</div>
<div><b></b> · {order.ORDER_DATE}</div>
<div><b></b> · <span className="font-semibold">{STATUS_LABEL[order.STATUS] ?? order.STATUS}</span></div>
</div>
<table className="text-[11px] border border-slate-400 self-start ml-auto" style={{borderCollapse:'collapse'}}>
<tbody>
<tr>
<td rowSpan={3} className="border border-slate-400 bg-slate-700 text-white text-center font-bold px-2 py-1" style={{writingMode:'vertical-rl',textOrientation:'upright',width:'24px'}}></td>
<td className="border border-slate-400 bg-slate-100 text-center font-semibold px-2 py-1" style={{width:'80px'}}><br/></td>
<td className="border border-slate-400 px-3 py-1 font-bold">{supplier.BANK_ACCOUNT}</td>
</tr>
<tr>
<td className="border border-slate-400 bg-slate-100 text-center font-semibold px-2 py-1"></td>
<td className="border border-slate-400 px-3 py-1">{supplier.PHONE}</td>
</tr>
<tr>
<td className="border border-slate-400 bg-slate-100 text-center font-semibold px-2 py-1"></td>
<td className="border border-slate-400 px-3 py-1 text-blue-700">{supplier.EMAIL}</td>
</tr>
</tbody>
</table>
</div>
<div className="border border-slate-200 rounded p-2 bg-slate-50/60 mt-3">
<div className="font-semibold text-slate-900">{order.COMPANY_NAME} <span className="text-slate-500 font-normal"></span></div>
<div className="text-[11px] text-slate-600 mt-0.5">
{order.CEO_NAME && <>: {order.CEO_NAME} · </>}
{order.PHONE && <>: {order.PHONE} · </>}
{order.EMAIL && <>: {order.EMAIL}</>}
</div>
</div>
<table className="w-full text-[11px] border border-slate-300 mt-3">
<thead className="bg-slate-100">
<tr>
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
<th className="border border-slate-300 px-1.5 py-1.5 text-left"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-12"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-14"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-20"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th>
<th className="border border-slate-300 px-1.5 py-1.5"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-24"></th>
</tr>
</thead>
<tbody className="tabular-nums">
{items.map((it, idx) => {
const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER";
const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : null;
const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : "";
return (
<tr key={it.OBJID || idx} className={kindBg}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{idx + 1}</td>
<td className="border border-slate-300 px-1.5 py-1">
{kindBadge && <span className={`mr-1 text-[9px] font-bold px-1 py-0.5 rounded ${it.KIND === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>{kindBadge}</span>}
{isExtra ? (it.EXTRA_LABEL || it.ITEM_NAME) : it.ITEM_NAME}
</td>
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.UNIT_PRICE)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.SUPPLY_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-[10px] text-slate-600">{it.REMARK || ""}</td>
</tr>
);
})}
</tbody>
</table>
<table className="ml-auto text-[12px] tabular-nums mt-3">
<tbody>
<tr><td className="px-3 py-1 text-violet-700"> </td><td className="px-3 py-1 text-right min-w-[120px]"> {fmt(order.TOTAL_TAXFREE)}</td></tr>
<tr><td className="px-3 py-1 text-rose-700"> </td><td className="px-3 py-1 text-right"> {fmt(order.TOTAL_TAXABLE)}</td></tr>
<tr><td className="px-3 py-1"> </td><td className="px-3 py-1 text-right"> {fmt(order.TOTAL_VAT)}</td></tr>
<tr className="border-t-2 border-slate-900 font-bold">
<td className="px-3 py-1.5"> (VAT포함)</td>
<td className="px-3 py-1.5 text-right text-emerald-700"> {fmt(order.TOTAL_AMOUNT)}</td>
</tr>
</tbody>
</table>
</div>
{editable && (
<div className="mt-4 pt-3 border-t border-slate-200 text-[11px] text-amber-700 bg-amber-50 rounded p-2">
[ ] . · [ ] .
</div>
)}
</div>
</div>
);
}
+8
View File
@@ -55,6 +55,11 @@ export async function POST(req: NextRequest) {
conditions.push(`I.maker_objid = $${i++}`);
params.push(makerObjid);
}
const { vendorObjid } = body as { vendorObjid?: string };
if (vendorObjid) {
conditions.push(`I.vendor_objid = $${i++}`);
params.push(vendorObjid);
}
if (onlyAvailable) {
conditions.push(
`COALESCE((SELECT SUM(S.qty) FROM momo_stocks S JOIN momo_warehouses W ON S.wh_objid = W.objid WHERE S.item_objid = I.objid AND COALESCE(W.is_del,'N') != 'Y'), 0) > 0`
@@ -79,6 +84,8 @@ export async function POST(req: NextRequest) {
I.max_order_qty AS "MAX_ORDER_QTY",
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
I.vendor_objid AS "VENDOR_OBJID",
V.supply_name AS "VENDOR_NAME",
COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid
@@ -87,6 +94,7 @@ export async function POST(req: NextRequest) {
TO_CHAR(I.regdate, 'YYYY-MM-DD') AS "REGDATE"
FROM momo_items I
LEFT JOIN momo_makers M ON I.maker_objid = M.objid
LEFT JOIN supply_mng V ON I.vendor_objid = V.objid
WHERE ${conditions.join(" AND ")}
ORDER BY I.item_name ASC
`;
+7 -4
View File
@@ -25,6 +25,7 @@ export async function POST(req: NextRequest) {
maxOrderQty,
isHidden,
requiresDelivery,
vendorObjid,
} = body;
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
const hidden = isHidden === "Y" ? "Y" : "N";
@@ -45,12 +46,12 @@ export async function POST(req: NextRequest) {
const itemCode = await genItemCode();
await execute(
`INSERT INTO momo_items (
objid, item_code, item_name, item_detail, maker_objid,
objid, item_code, item_name, item_detail, maker_objid, vendor_objid,
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
max_order_qty, is_hidden, requires_delivery,
is_del, regdate, regid
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,'N',NOW(),$16)`,
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16,'N',NOW(),$17)`,
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null, vendorObjid ?? null,
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null,
@@ -68,7 +69,8 @@ export async function POST(req: NextRequest) {
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
attributes=$10::jsonb, status=$11,
max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
update_date=NOW(), update_id=$15
vendor_objid=$15,
update_date=NOW(), update_id=$16
WHERE objid=$1`,
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
Number(unitPrice ?? 0), Number(costPrice ?? 0),
@@ -76,6 +78,7 @@ export async function POST(req: NextRequest) {
attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE",
maxQty, hidden, reqDelivery,
vendorObjid ?? null,
userId]
);
return NextResponse.json({ success: true, objId: objid });
+1 -1
View File
@@ -1,4 +1,4 @@
// 매입처 목록 — supply_mng 재사용 (charger_type 같은 컬럼 없으므로 모든 supply_mng 행 노출)
// 공급업체 목록 — supply_mng 재사용 (charger_type 같은 컬럼 없으므로 모든 supply_mng 행 노출)
import { NextResponse } from "next/server";
import { queryRows } from "@/lib/db";
import { requireMomoUser } from "@/lib/momo-guard";
+2 -2
View File
@@ -1,4 +1,4 @@
// 매입처 등록/수정 — supply_mng 재사용
// 공급업체 등록/수정 — supply_mng 재사용
import { NextRequest, NextResponse } from "next/server";
import { execute } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
@@ -10,7 +10,7 @@ export async function POST(req: NextRequest) {
const userId = g.user.userId;
const { objid, actionType, vendorName, contact, phone, bizNo, email, address } = await req.json();
if (!vendorName) return NextResponse.json({ success: false, message: "매입처명 필수" }, { status: 400 });
if (!vendorName) return NextResponse.json({ success: false, message: "공급업체명 필수" }, { status: 400 });
if (actionType === "regist") {
const id = createObjectId();