diff --git a/db/migrations/016_vendor_extend.sql b/db/migrations/016_vendor_extend.sql new file mode 100644 index 0000000..f16ce64 --- /dev/null +++ b/db/migrations/016_vendor_extend.sql @@ -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; diff --git a/db/migrations/017_menu_rename_vendor.sql b/db/migrations/017_menu_rename_vendor.sql new file mode 100644 index 0000000..497d99f --- /dev/null +++ b/db/migrations/017_menu_rename_vendor.sql @@ -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; diff --git a/public/manual.html b/public/manual.html index 13c82f2..573e761 100644 --- a/public/manual.html +++ b/public/manual.html @@ -379,6 +379,11 @@

가-2. 내가 주문한 내역 보기

왼쪽 메뉴의 거래처 주문 → 내 주문 내역 을 누르면 보여요.

+

주문 상태별 뜻

@@ -586,6 +591,7 @@
물건 이름 *가게 주문 화면에 표시될 이름
제조사[제조사 관리]에 미리 등록한 회사 중에서 고르기
+
공급업체⭐ 이 물건을 사 오는 도매처. [공급업체 관리]에서 미리 등록. 매입 발주 시 자동으로 이 업체에 발주서가 연결됨
단위개(EA), 박스(BOX), 킬로그램(KG), 리터(L), 팩(PACK) 중 선택
구분 (면세/과세)면세 = 세금 없음 / 과세 = 세금 있음. 라디오로 골라요
판매가 (세금 포함)가게에게 보여주는 가격. 세금이 포함된 금액
@@ -644,7 +650,7 @@
상태무슨 뜻인가요?가게가 할 일
- + diff --git a/src/app/(main)/m/admin/inbounds/new/page.tsx b/src/app/(main)/m/admin/inbounds/new/page.tsx index 3d7be6a..07777a9 100644 --- a/src/app/(main)/m/admin/inbounds/new/page.tsx +++ b/src/app/(main)/m/admin/inbounds/new/page.tsx @@ -108,7 +108,7 @@ export default function NewInboundPage() {
- +
- + diff --git a/src/app/(main)/m/admin/items/page.tsx b/src/app/(main)/m/admin/items/page.tsx index 4a9c0da..9f466f2 100644 --- a/src/app/(main)/m/admin/items/page.tsx +++ b/src/app/(main)/m/admin/items/page.tsx @@ -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([]); const [makers, setMakers] = useState([]); + const [vendors, setVendors] = useState([]); const [keyword, setKeyword] = useState(""); const [filterStatus, setFilterStatus] = useState(""); const [editing, setEditing] = useState | 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) => { @@ -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() { ))} + + + setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1"> {vendors.map((v) => )} diff --git a/src/app/(main)/m/admin/procurements/page.tsx b/src/app/(main)/m/admin/procurements/page.tsx index 87852f2..eccc4e2 100644 --- a/src/app/(main)/m/admin/procurements/page.tsx +++ b/src/app/(main)/m/admin/procurements/page.tsx @@ -35,7 +35,7 @@ export default function ProcurementsPage() { - + diff --git a/src/app/(main)/m/admin/vendors/page.tsx b/src/app/(main)/m/admin/vendors/page.tsx index d2c79ed..2b23213 100644 --- a/src/app/(main)/m/admin/vendors/page.tsx +++ b/src/app/(main)/m/admin/vendors/page.tsx @@ -37,18 +37,18 @@ export default function VendorsPage() {
-

매입처 관리

+

공급업체 관리

모모유통이 제품을 매입하는 도매처 / 제조사 목록

메뉴 이름여기에 등록하는 것
매입처 관리도매처(우리가 물건을 사 오는 곳) — 회사 이름, 연락처, 사업자번호
공급업체 관리물건을 사 오는 도매처/제조처 — 회사 이름, 연락처, 사업자번호, 이메일, 주소. 발주서를 메일로 받을 곳.
창고 관리창고 — 본사 창고, 김포 지사 창고, 픽업 장소 등 분류
제조사 관리물건을 만든 회사. 물건 등록할 때 여기서 골라요.
입고번호 입고일매입처공급업체 창고 매입발주 정상
발주번호 발주일매입처공급업체 라인 합계 상태
- + @@ -58,7 +58,7 @@ export default function VendorsPage() { {list.length === 0 ? ( - + ) : list.map((v) => ( @@ -78,9 +78,9 @@ export default function VendorsPage() { {editing && (
setEditing(null)}>
e.stopPropagation()} className="bg-white rounded-xl max-w-lg w-full p-6"> -

{editing.OBJID ? "매입처 수정" : "매입처 추가"}

+

{editing.OBJID ? "공급업체 수정" : "공급업체 추가"}

- setEditing({ ...editing, VENDOR_NAME: e.target.value })} className="col-span-2 h-10 px-3 rounded-lg border border-slate-200" /> + setEditing({ ...editing, VENDOR_NAME: e.target.value })} className="col-span-2 h-10 px-3 rounded-lg border border-slate-200" /> setEditing({ ...editing, CONTACT: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" /> setEditing({ ...editing, PHONE: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" /> setEditing({ ...editing, BIZ_NO: e.target.value })} className="h-10 px-3 rounded-lg border border-slate-200" /> diff --git a/src/app/(main)/m/orders/page.tsx b/src/app/(main)/m/orders/page.tsx index bea0049..012a622 100644 --- a/src/app/(main)/m/orders/page.tsx +++ b/src/app/(main)/m/orders/page.tsx @@ -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 = { REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소", @@ -33,6 +46,7 @@ const STATUS_COLOR: Record = { export default function MyOrdersPage() { const [orders, setOrders] = useState([]); 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() {

내 발주 이력

-

전체 {orders.length}건

+

전체 {orders.length}건 — 행을 누르면 거래명세표가 열려요

- @@ -100,15 +138,16 @@ export default function MyOrdersPage() {
- + {orders.length === 0 ? ( ) : orders.map((o) => ( - - + openDetail(o)}> + @@ -119,17 +158,186 @@ export default function MyOrdersPage() { ))}
매입처명공급업체명 담당자 연락처 사업자번호
등록된 매입처가 없습니다.
등록된 공급업체가 없습니다.
{v.VENDOR_NAME}과세 합계 상태명세서동작
발주 이력이 없습니다.
{o.ORDER_NO}
{o.ORDER_NO} {o.ORDER_DATE} {fmt(o.TOTAL_TAXFREE)} {fmt(o.TOTAL_TAXABLE)} - {(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") ? ( - - 엑셀 - - ) : -} +
+ + {detail && ( + setDetail(null)} + onCancel={() => cancelOrder(detail.order)} + /> + )} + + ); +} + +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(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 ( +
+
e.stopPropagation()}> +
+
+ + + 엑셀 다운로드 + + {editable && ( + + )} +
+ +
+ +
+
+

거 래 명 세 표

+
+
+
+
발주번호 · {order.ORDER_NO}
+
발주일자 · {order.ORDER_DATE}
+
현재상태 · {STATUS_LABEL[order.STATUS] ?? order.STATUS}
+
+ + + + + + + + + + + + + + + + +
공급자결제
계좌
{supplier.BANK_ACCOUNT}
전화{supplier.PHONE}
이메일{supplier.EMAIL}
+
+ +
+
{order.COMPANY_NAME} 귀하
+
+ {order.CEO_NAME && <>대표: {order.CEO_NAME} · } + {order.PHONE && <>전화: {order.PHONE} · } + {order.EMAIL && <>이메일: {order.EMAIL}} +
+
+ + + + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + ); + })} + +
#품명구분수량단가공급가세액합계비고
{idx + 1} + {kindBadge && {kindBadge}} + {isExtra ? (it.EXTRA_LABEL || it.ITEM_NAME) : it.ITEM_NAME} + + {it.IS_TAX_FREE === "Y" ? "면세" : "과세"} + {fmt(it.QTY)}{fmt(it.UNIT_PRICE)}{fmt(it.SUPPLY_AMOUNT)}{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}{fmt(it.TOTAL_AMOUNT)}{it.REMARK || ""}
+ + + + + + + + + + + +
면세 합계₩ {fmt(order.TOTAL_TAXFREE)}
과세 공급가₩ {fmt(order.TOTAL_TAXABLE)}
세액 합계₩ {fmt(order.TOTAL_VAT)}
총 합계 (VAT포함)₩ {fmt(order.TOTAL_AMOUNT)}
+
+ + {editable && ( +
+ ※ 출고요청 상태이므로 [주문 취소] 가능합니다. 수량 수정·재요청은 주문 취소 후 [새 발주]로 다시 작성하세요. +
+ )} +
); } diff --git a/src/app/api/m/items/list/route.ts b/src/app/api/m/items/list/route.ts index 5222523..1fbf15c 100644 --- a/src/app/api/m/items/list/route.ts +++ b/src/app/api/m/items/list/route.ts @@ -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 `; diff --git a/src/app/api/m/items/save/route.ts b/src/app/api/m/items/save/route.ts index 5a9c690..d8e8791 100644 --- a/src/app/api/m/items/save/route.ts +++ b/src/app/api/m/items/save/route.ts @@ -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 }); diff --git a/src/app/api/m/vendors/list/route.ts b/src/app/api/m/vendors/list/route.ts index f7ff9d2..f85e368 100644 --- a/src/app/api/m/vendors/list/route.ts +++ b/src/app/api/m/vendors/list/route.ts @@ -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"; diff --git a/src/app/api/m/vendors/save/route.ts b/src/app/api/m/vendors/save/route.ts index 14e1e27..1a33196 100644 --- a/src/app/api/m/vendors/save/route.ts +++ b/src/app/api/m/vendors/save/route.ts @@ -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();