feat(v0.7 round1): 공급업체 명칭 변경 + 품목-공급업체 연결 + 거래처 출고이력 거래명세표 모달
Deploy momo-erp / deploy (push) Successful in 55s
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:
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Vendored
+1
-1
@@ -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";
|
||||
|
||||
Vendored
+2
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user