From 8c89c44b5f504f34515edebe0deb54fb99657f17 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 7 May 2026 20:51:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B1=B0=EB=9E=98=EB=AA=85=EC=84=B8?= =?UTF-8?q?=ED=91=9C):=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EB=B0=95=EC=8A=A4=20+=20=EB=B9=84=EA=B3=A0=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20+=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20+=20=EC=97=91=EC=85=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [공급자 정보 박스 (우측 상단)] - 결제계좌번호 / 전화번호 / 이메일 표를 거래명세표 우측 상단에 표시 - 환경변수: MOMO_BANK_ACCOUNT / MOMO_PHONE / MOMO_EMAIL / MOMO_COMPANY_CEO 등 - detail API 응답에 supplier 객체 추가 [비고(remark) 컬럼] - 모든 라인(품목/택배/용차)에 비고 입력 가능 - /api/m/orders/items/remark 신설 — REQUESTED 상태에서만 본인/관리자 수정 - 인풋에서 포커스 이탈/엔터 시 자동 저장 - 모든 라인에 momo_order_items.remark 컬럼 활용 (이미 존재) [이미지 공유 + 인쇄] - 거래명세표 위쪽에 [📤 이미지 공유] [🖨 인쇄] 버튼 신설 - html-to-image 라이브러리로 PNG 캡처 → Web Share API 가 있으면 카톡/메신저로 직접 공유, 없으면 PNG 파일 다운로드 (모바일/PC 호환) - statementRef 로 캡처 영역 분리 (버튼은 영역 밖) [엑셀 다운로드 수정] - 기존: SELECT 쿼리에 alias 빠져 있어(`U.user_name, NULL, NULL`) 회사명/대표자/사업자번호가 모두 빈 값 - 수정: company_name/ceo_name/biz_no/phone/address/email 명시 alias - 택배/용차 라인은 [택배]/[용차] 라벨로 출력 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 7 + package.json | 1 + src/app/(main)/m/admin/orders/page.tsx | 151 ++++++++++++++++--- src/app/api/m/orders/detail/route.ts | 14 +- src/app/api/m/orders/items/remark/route.ts | 43 ++++++ src/app/api/m/orders/statement/[id]/route.ts | 34 +++-- 6 files changed, 220 insertions(+), 30 deletions(-) create mode 100644 src/app/api/m/orders/items/remark/route.ts diff --git a/package-lock.json b/package-lock.json index cc94187..8176e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "file-saver": "^2.0.5", + "html-to-image": "^1.11.13", "jose": "^6.2.2", "lucide-react": "^1.7.0", "next": "16.2.2", @@ -5045,6 +5046,12 @@ "node": ">=16.9.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", diff --git a/package.json b/package.json index 26f5fcb..c71fe29 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "file-saver": "^2.0.5", + "html-to-image": "^1.11.13", "jose": "^6.2.2", "lucide-react": "^1.7.0", "next": "16.2.2", diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index 8ecb04d..1de9a36 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, useCallback } from "react"; +import { useEffect, useMemo, useState, useCallback, useRef } from "react"; import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package } from "lucide-react"; import Swal from "sweetalert2"; @@ -21,6 +21,11 @@ interface DetailLine { STOCK_QTY: 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 | string | undefined | null) => @@ -48,7 +53,7 @@ export default function AdminOrdersPage() { const [status, setStatus] = useState(""); const [selected, setSelected] = useState>(new Set()); const [activeId, setActiveId] = useState(""); - const [detail, setDetail] = useState<{ order: DetailOrder; items: DetailLine[] } | null>(null); + const [detail, setDetail] = useState<{ order: DetailOrder; items: DetailLine[]; supplier: Supplier } | null>(null); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); @@ -84,7 +89,7 @@ export default function AdminOrdersPage() { }); const j = await res.json(); if (j.success) { - setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[] }); + setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[], supplier: j.supplier as Supplier }); } }, [activeId]); @@ -99,7 +104,7 @@ export default function AdminOrdersPage() { }); const j = await res.json(); if (!cancelled && j.success) { - setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[] }); + setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[], supplier: j.supplier as Supplier }); } })(); return () => { cancelled = true; }; @@ -311,7 +316,7 @@ export default function AdminOrdersPage() {
왼쪽에서 발주를 선택하세요.
) : ( - + )} @@ -323,19 +328,62 @@ export default function AdminOrdersPage() { function StatementPreview({ order, items, + supplier, onCancel, busy, onReload, }: { order: DetailOrder; items: DetailLine[]; + supplier: Supplier; onCancel: (o: Order) => void; busy: boolean; onReload: () => void; }) { + const statementRef = useRef(null); const lowStock = items.filter((it) => it.KIND === "ITEM" && Number(it.STOCK_QTY) < Number(it.QTY)); const editable = order.STATUS === "REQUESTED"; + // 거래명세표를 이미지로 캡처 → 공유 또는 다운로드 + const captureAndShare = async () => { + try { + const mod = await import("html-to-image"); + if (!statementRef.current) return; + const dataUrl = await mod.toPng(statementRef.current, { + backgroundColor: "#ffffff", + pixelRatio: 2, + }); + const blob = await (await fetch(dataUrl)).blob(); + const file = new File([blob], `${order.ORDER_NO}_거래명세표.png`, { type: "image/png" }); + // Web Share API (모바일/지원 브라우저) → 카카오톡/메신저 등으로 공유 + 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} 거래명세표`, text: order.COMPANY_NAME }); + 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("[capture]", err); + Swal.fire({ icon: "error", title: "이미지 캡처 실패", text: "잠시 후 다시 시도하세요." }); + } + }; + + // 비고 저장 + const saveRemark = async (lineObjid: string, remark: string) => { + const res = await fetch("/api/m/orders/items/remark", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lineObjid, remark }), + }); + const j = await res.json(); + if (j.success) onReload(); + else Swal.fire({ icon: "error", title: "비고 저장 실패", text: j.message }); + }; + const upsertExtra = async (line: { objid?: string; kind: "DELIVERY" | "CHARTER"; label: string; unitPrice: number; qty: number }) => { const res = await fetch("/api/m/orders/lines/save", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -376,23 +424,58 @@ function StatementPreview({ }; return (
-
-

거 래 명 세 표

+ {/* 공유/캡처 버튼 — 캡처 영역 밖에 배치 */} +
+ +
-
+
+
+

거 래 명 세 표

+
+ + {/* 좌: 발주 정보 / 우: 공급자 정보 박스 */} +
발주번호 · {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} · } @@ -437,7 +520,7 @@ function StatementPreview({
)} - +
@@ -449,6 +532,7 @@ function StatementPreview({ + {editable && } @@ -466,8 +550,10 @@ function StatementPreview({ key={it.OBJID} line={it} displaySeq={displaySeq} + editable={editable} onSave={(updated) => upsertExtra({ objid: it.OBJID, kind: it.KIND as "DELIVERY" | "CHARTER", ...updated })} onDelete={() => deleteExtra(it.OBJID)} + onSaveRemark={(r) => saveRemark(it.OBJID, r)} /> ); } @@ -490,17 +576,22 @@ function StatementPreview({ + {editable && } ); })} {items.length === 0 && ( - + )}
#공급가 세액 합계비고
{fmt(it.SUPPLY_AMOUNT)} {it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)} {fmt(it.TOTAL_AMOUNT)} + {editable + ? saveRemark(it.OBJID, r)} /> + : {it.REMARK || ""}} +
품목이 없습니다.
품목이 없습니다.
- +
@@ -511,6 +602,7 @@ function StatementPreview({
면세 합계₩ {fmt(order.TOTAL_TAXFREE)}
과세 공급가₩ {fmt(order.TOTAL_TAXABLE)}
+
{/* /statementRef capture area */} {order.STATUS === "REQUESTED" && (
@@ -538,11 +630,13 @@ function StatementPreview({ ); } -function ExtraRow({ line, displaySeq, onSave, onDelete }: { +function ExtraRow({ line, displaySeq, editable, onSave, onDelete, onSaveRemark }: { line: DetailLine; displaySeq: number; + editable: boolean; onSave: (data: { label: string; unitPrice: number; qty: number }) => void; onDelete: () => void; + onSaveRemark: (r: string) => void; }) { const [label, setLabel] = useState(line.EXTRA_LABEL || line.ITEM_NAME); const [unitPrice, setUnitPrice] = useState(Number(line.UNIT_PRICE) || 0); @@ -591,6 +685,11 @@ function ExtraRow({ line, displaySeq, onSave, onDelete }: { {Number(supply).toLocaleString("ko-KR")} {Number(vat).toLocaleString("ko-KR")} {Number(total).toLocaleString("ko-KR")} + + {editable + ? + : {line.REMARK || ""}} +
{dirty && ( @@ -683,3 +782,21 @@ function IssueEinvoiceButton({ order }: { order: DetailOrder }) {
); } + +function RemarkInput({ initial, onSave }: { initial: string; onSave: (r: string) => void }) { + const [val, setVal] = useState(initial); + useEffect(() => { setVal(initial); }, [initial]); + const dirty = val !== initial; + return ( +
+ setVal(e.target.value)} + onBlur={() => { if (dirty) onSave(val); }} + onKeyDown={(e) => { if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); } }} + placeholder="비고" + className="w-full h-6 px-1.5 border border-slate-200 rounded text-[10px] bg-white" + /> +
+ ); +} diff --git a/src/app/api/m/orders/detail/route.ts b/src/app/api/m/orders/detail/route.ts index 4bc05b5..596a60a 100644 --- a/src/app/api/m/orders/detail/route.ts +++ b/src/app/api/m/orders/detail/route.ts @@ -51,6 +51,7 @@ export async function POST(req: NextRequest) { OI.total_amount AS "TOTAL_AMOUNT", COALESCE(OI.kind, 'ITEM') AS "KIND", OI.extra_label AS "EXTRA_LABEL", + OI.remark AS "REMARK", I.unit AS "UNIT", I.image_url AS "IMAGE_URL", COALESCE( @@ -74,5 +75,16 @@ export async function POST(req: NextRequest) { [objid] ); - return NextResponse.json({ success: true, order, items }); + // 공급자(모모유통) 정보 — 환경변수 또는 기본값 + const supplier = { + NAME: process.env.MOMO_COMPANY_NAME || "모모유통", + CEO: process.env.MOMO_COMPANY_CEO || "이상용", + BIZ_NO: process.env.MOMO_COMPANY_BIZNO || "", + BANK_ACCOUNT: process.env.MOMO_BANK_ACCOUNT || "기업은행 434-115361-01-016 (이상용)", + PHONE: process.env.MOMO_PHONE || "010-6369-8443", + EMAIL: process.env.MOMO_EMAIL || "momo8443@daum.net", + ADDRESS: process.env.MOMO_COMPANY_ADDR || "", + }; + + return NextResponse.json({ success: true, order, items, supplier }); } diff --git a/src/app/api/m/orders/items/remark/route.ts b/src/app/api/m/orders/items/remark/route.ts new file mode 100644 index 0000000..138a7be --- /dev/null +++ b/src/app/api/m/orders/items/remark/route.ts @@ -0,0 +1,43 @@ +// 발주 라인의 비고(remark) 수정 — 관리자 또는 본인. +// REQUESTED 상태에서만 수정 가능. ITEM/DELIVERY/CHARTER 모두 가능. +import { NextRequest, NextResponse } from "next/server"; +import { pool } from "@/lib/db"; +import { requireMomoUser } from "@/lib/momo-guard"; + +export async function POST(req: NextRequest) { + const r = await requireMomoUser(); + if (r instanceof NextResponse) return r; + + const body = await req.json().catch(() => ({})); + const { lineObjid, remark } = body as { lineObjid?: string; remark?: string }; + if (!lineObjid) { + return NextResponse.json({ success: false, message: "라인 식별자가 필요합니다." }, { status: 400 }); + } + + const isAdmin = r.user.isAdmin || r.user.role === "ADMIN" || r.user.userType === "A"; + + // 권한 확인 — 본인 발주이거나 관리자 + const own = await pool.query( + `SELECT O.customer_objid, O.status + FROM momo_order_items OI + JOIN momo_orders O ON O.objid = OI.order_objid + WHERE OI.objid = $1`, + [lineObjid] + ); + if (own.rowCount === 0) { + return NextResponse.json({ success: false, message: "라인을 찾을 수 없습니다." }, { status: 404 }); + } + const row = own.rows[0]; + if (!isAdmin && row.customer_objid !== r.user.objid) { + return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 }); + } + if (row.status !== "REQUESTED") { + return NextResponse.json({ success: false, message: "출고 요청 상태에서만 비고를 수정할 수 있습니다." }, { status: 400 }); + } + + await pool.query( + `UPDATE momo_order_items SET remark = $2 WHERE objid = $1`, + [lineObjid, (remark ?? "").trim() || null] + ); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/m/orders/statement/[id]/route.ts b/src/app/api/m/orders/statement/[id]/route.ts index 615cef4..135f10e 100644 --- a/src/app/api/m/orders/statement/[id]/route.ts +++ b/src/app/api/m/orders/statement/[id]/route.ts @@ -12,7 +12,12 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const order = await queryOne>( `SELECT O.objid, O.order_no, TO_CHAR(O.order_date,'YYYY-MM-DD') AS order_date, O.customer_objid, - U.user_name, NULL, NULL, U.cell_phone, + U.user_name AS company_name, + U.ceo_name AS ceo_name, + U.biz_no AS biz_no, + U.cell_phone AS phone, + U.address AS address, + U.email AS email, O.total_supply, O.total_vat, O.total_amount, O.total_taxfree, O.total_taxable FROM momo_orders O LEFT JOIN user_info U ON U.user_id = O.customer_objid @@ -54,17 +59,22 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string phone: process.env.MOMO_PHONE ?? "010-6624-5315", email: process.env.SMTP_FROM ?? "momo8443@daum.net", }, - items: items.map((it, idx) => ({ - seq: idx + 1, - itemName: String(it.item_name_snap), - unit: String(it.unit ?? "EA"), - qty: Number(it.qty), - unitPrice: Number(it.unit_price), - supplyAmount: Number(it.supply_amount), - vatAmount: Number(it.vat_amount), - totalAmount: Number(it.total_amount), - isTaxFree: it.is_tax_free === "Y", - })), + items: items.map((it, idx) => { + const isExtra = it.kind === "DELIVERY" || it.kind === "CHARTER"; + return { + seq: idx + 1, + itemName: isExtra + ? (`[${it.kind === "DELIVERY" ? "택배" : "용차"}] ${String(it.extra_label || it.item_name_snap || "")}`) + : String(it.item_name_snap || ""), + unit: isExtra ? "" : String(it.unit ?? "EA"), + qty: Number(it.qty), + unitPrice: Number(it.unit_price), + supplyAmount: Number(it.supply_amount), + vatAmount: Number(it.vat_amount), + totalAmount: Number(it.total_amount), + isTaxFree: it.is_tax_free === "Y", + }; + }), totals: { supply: Number(order.total_supply), vat: Number(order.total_vat),