feat(거래명세표): 공급자 정보 박스 + 비고 컬럼 + 이미지 공유 + 엑셀 수정
Deploy momo-erp / deploy (push) Successful in 1m13s

[공급자 정보 박스 (우측 상단)]
- 결제계좌번호 / 전화번호 / 이메일 표를 거래명세표 우측 상단에 표시
- 환경변수: 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) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-07 20:51:30 +09:00
parent 6b751e48d0
commit 8c89c44b5f
6 changed files with 220 additions and 30 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+134 -17
View File
@@ -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<Set<string>>(new Set());
const [activeId, setActiveId] = useState<string>("");
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() {
<div className="text-sm"> .</div>
</div>
) : (
<StatementPreview order={detail.order} items={detail.items} onCancel={cancelOne} busy={busy} onReload={reloadDetail} />
<StatementPreview order={detail.order} items={detail.items} supplier={detail.supplier} onCancel={cancelOne} busy={busy} onReload={reloadDetail} />
)}
</div>
</div>
@@ -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<HTMLDivElement>(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 (
<div className="text-[12px] text-slate-800 space-y-3">
<div className="text-center">
<h2 className="text-xl font-bold tracking-[0.3em] text-slate-900"> </h2>
{/* 공유/캡처 버튼 — 캡처 영역 밖에 배치 */}
<div className="flex justify-end gap-2 print:hidden">
<button
type="button"
onClick={captureAndShare}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-amber-100 text-amber-800 text-xs font-bold hover:bg-amber-200"
title="이미지로 캡처해 카톡 등에 공유"
>
📤
</button>
<button
type="button"
onClick={() => window.print()}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-slate-100 text-slate-700 text-xs font-bold hover:bg-slate-200"
title="인쇄"
>
🖨
</button>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div ref={statementRef} className="bg-white p-3">
<div className="text-center">
<h2 className="text-2xl 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>
<div className="text-right">
<div><b></b> · </div>
<div className="text-slate-500">대표: 한신숙</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:'90px'}}><br/></td>
<td className="border border-slate-400 px-3 py-1 font-bold text-slate-900">{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">
<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 leading-relaxed">
{order.CEO_NAME && <>: {order.CEO_NAME} · </>}
@@ -437,7 +520,7 @@ function StatementPreview({
</div>
)}
<table className="w-full text-[11px] border border-slate-300">
<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>
@@ -449,6 +532,7 @@ function StatementPreview({
<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-32"></th>
{editable && <th className="border border-slate-300 px-1 py-1.5 w-8"></th>}
</tr>
</thead>
@@ -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({
<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 py-0.5">
{editable
? <RemarkInput initial={it.REMARK || ""} onSave={(r) => saveRemark(it.OBJID, r)} />
: <span className="text-slate-600 text-[10px]">{it.REMARK || ""}</span>}
</td>
{editable && <td className="border border-slate-300 px-1 py-1"></td>}
</tr>
);
})}
{items.length === 0 && (
<tr><td colSpan={editable ? 10 : 9} className="border border-slate-300 px-2 py-6 text-center text-slate-400"> .</td></tr>
<tr><td colSpan={editable ? 11 : 10} className="border border-slate-300 px-2 py-6 text-center text-slate-400"> .</td></tr>
)}
</tbody>
</table>
<table className="ml-auto text-[12px] tabular-nums">
<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>
@@ -511,6 +602,7 @@ function StatementPreview({
</tr>
</tbody>
</table>
</div>{/* /statementRef capture area */}
{order.STATUS === "REQUESTED" && (
<div className="flex justify-end gap-2 pt-3 border-t border-slate-200">
@@ -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 }: {
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums">{Number(supply).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums">{Number(vat).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right tabular-nums font-semibold">{Number(total).toLocaleString("ko-KR")}</td>
<td className="border border-slate-300 px-1 py-0.5">
{editable
? <RemarkInput initial={line.REMARK || ""} onSave={onSaveRemark} />
: <span className="text-slate-600 text-[10px]">{line.REMARK || ""}</span>}
</td>
<td className="border border-slate-300 px-1 py-1 text-center">
<div className="inline-flex gap-0.5">
{dirty && (
@@ -683,3 +782,21 @@ function IssueEinvoiceButton({ order }: { order: DetailOrder }) {
</div>
);
}
function RemarkInput({ initial, onSave }: { initial: string; onSave: (r: string) => void }) {
const [val, setVal] = useState(initial);
useEffect(() => { setVal(initial); }, [initial]);
const dirty = val !== initial;
return (
<div className="flex items-center gap-1">
<input
value={val}
onChange={(e) => 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"
/>
</div>
);
}
+13 -1
View File
@@ -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 });
}
@@ -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 });
}
+22 -12
View File
@@ -12,7 +12,12 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
const order = await queryOne<Record<string, unknown>>(
`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),