feat(momo): 출고 처리 2분할 UI + 회원가입 주소 + 거래처 정보 보강
Deploy momo-erp / deploy (push) Successful in 50s

- 회원가입 폼·API·DB(user_info.address 컬럼 추가, 마이그레이션 007)에
  주소 필드 추가, 전화/주소를 필수값으로 승격.
- 관리자 발주서 관리 페이지(/m/admin/orders) 를 좌(리스트)·우(거래명세표
  미리보기) 2분할 레이아웃으로 재구성. 체크박스로 출고요청 다중 선택 후
  상단 [출고] 버튼으로 일괄 처리(승인+재고차감+메일발송) 지원.
- 미리보기에 품목별 현재고(STOCK 창고 합산) 노출, 부족분 경고 표시.
- /api/m/orders/detail: ceo_name·biz_no·address 컬럼 + 품목별 현재고
  합산 SELECT 추가. /api/m/orders/approve: 명세서 발송 SQL의 잘못된
  alias 누락(`order.company_name` undefined) 수정.
- 마이그레이션 006: ON CONFLICT(objid) 가 menu_info 의 unique 제약
  부재로 실패하던 idempotent 버그를 EXISTS 분기로 교체.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-26 22:32:03 +09:00
parent 9aae8e7c54
commit 72786bfc98
8 changed files with 429 additions and 184 deletions
+13 -4
View File
@@ -39,15 +39,24 @@ BEGIN
END IF;
-- 자식: 메뉴관리 (LABEL_TO_TAB 매핑이 '메뉴관리' → 'menu' 이므로 정확히 동일 이름 필수)
IF NOT EXISTS (
-- menu_info.objid 에 unique 제약이 없을 수 있으므로 ON CONFLICT 대신 EXISTS 분기로 idempotent 처리
IF EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000601) THEN
UPDATE menu_info
SET parent_obj_id = menu_section_id,
menu_name_kor = '메뉴관리',
menu_name_eng = 'Menus',
menu_url = '',
status = 'active',
system_name = 'PMS'
WHERE objid = 9000601;
ELSIF NOT EXISTS (
SELECT 1 FROM menu_info
WHERE parent_obj_id = menu_section_id AND menu_name_kor = '메뉴관리' AND COALESCE(status,'') = 'active'
WHERE parent_obj_id = menu_section_id AND menu_name_kor = '메뉴관리'
) THEN
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, status, system_name, regdate)
VALUES (9000601, '1', menu_section_id, '메뉴관리', 'Menus',
10, '', 'active', 'PMS', NOW())
ON CONFLICT (objid) DO UPDATE SET status = 'active';
10, '', 'active', 'PMS', NOW());
END IF;
END $$;
+7
View File
@@ -0,0 +1,7 @@
-- 회원가입 주소 입력 항목 추가 (스펙 §1: 이메일/업체명/전화번호/주소 필수)
BEGIN;
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS address VARCHAR(300);
COMMIT;
+7 -4
View File
@@ -4,7 +4,7 @@ import { useState, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
import { Mail, Lock, Building2, User as UserIcon, Phone, FileText, ArrowRight, Eye, EyeOff } from "lucide-react";
import { Mail, Lock, Building2, User as UserIcon, Phone, FileText, MapPin, ArrowRight, Eye, EyeOff } from "lucide-react";
export default function SignupPage() {
const router = useRouter();
@@ -16,6 +16,7 @@ export default function SignupPage() {
ceoName: "",
bizNo: "",
phone: "",
address: "",
});
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);
@@ -25,8 +26,8 @@ export default function SignupPage() {
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!form.email || !form.password || !form.companyName) {
Swal.fire({ icon: "warning", title: "필수 항목을 입력하세요." });
if (!form.email || !form.password || !form.companyName || !form.phone || !form.address) {
Swal.fire({ icon: "warning", title: "필수 항목을 입력하세요.", text: "이메일·비밀번호·업체명·전화번호·주소는 필수입니다." });
return;
}
if (form.password.length < 8) {
@@ -49,6 +50,7 @@ export default function SignupPage() {
ceoName: form.ceoName,
bizNo: form.bizNo,
phone: form.phone,
address: form.address,
}),
});
const data = await res.json();
@@ -139,8 +141,9 @@ export default function SignupPage() {
<Field icon={<Building2 size={16} />} label="업체명 *" value={form.companyName} onChange={set("companyName")} placeholder="(주)거래처 또는 매장명" />
<div className="grid grid-cols-2 gap-3">
<Field icon={<UserIcon size={16} />} label="대표자" value={form.ceoName} onChange={set("ceoName")} placeholder="홍길동" />
<Field icon={<Phone size={16} />} label="연락처" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" />
<Field icon={<Phone size={16} />} label="연락처 *" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" />
</div>
<Field icon={<MapPin size={16} />} label="주소 *" value={form.address} onChange={set("address")} placeholder="배송지 주소를 입력하세요" />
<Field icon={<FileText size={16} />} label="사업자등록번호" value={form.bizNo} onChange={set("bizNo")} placeholder="000-00-00000 (선택)" />
<button
+377 -166
View File
@@ -1,89 +1,179 @@
"use client";
import { useEffect, useState } from "react";
import { Check, Download, X, Eye } from "lucide-react";
import { useEffect, useMemo, useState, useCallback } from "react";
import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package } from "lucide-react";
import Swal from "sweetalert2";
interface Order {
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
COMPANY_NAME: string; EMAIL: string; STATUS: string;
TOTAL_TAXFREE: number; TOTAL_TAXABLE: number; TOTAL_AMOUNT: number;
TOTAL_TAXFREE: number; TOTAL_TAXABLE: number;
TOTAL_SUPPLY: number; TOTAL_VAT: number; TOTAL_AMOUNT: number;
}
interface DetailOrder extends Order {
CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string;
MEMO?: string; APPROVE_DATE?: string;
}
interface DetailLine {
SEQ: number; ITEM_NAME: string; UNIT_PRICE: number; QTY: number;
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;
STOCK_QTY: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const fmt = (n: number | string | undefined | null) =>
Number(n || 0).toLocaleString("ko-KR");
const STATUS_LABEL: Record<string, string> = {
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소",
REQUESTED: "출고요청",
APPROVED: "출고완료",
SHIPPED: "출고완료",
PAID: "입금완료",
INVOICED: "계산서발행",
CANCELLED: "취소",
};
const STATUS_COLOR: Record<string, string> = {
REQUESTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-blue-100 text-blue-700",
SHIPPED: "bg-cyan-100 text-cyan-700",
INVOICED: "bg-violet-100 text-violet-700",
PAID: "bg-emerald-100 text-emerald-700",
CANCELLED: "bg-slate-100 text-slate-500",
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
APPROVED: "bg-blue-100 text-blue-700 border-blue-200",
SHIPPED: "bg-cyan-100 text-cyan-700 border-cyan-200",
PAID: "bg-emerald-100 text-emerald-700 border-emerald-200",
INVOICED: "bg-violet-100 text-violet-700 border-violet-200",
CANCELLED: "bg-slate-100 text-slate-500 border-slate-200",
};
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [status, setStatus] = useState("");
const [detail, setDetail] = useState<{ order: Order; items: DetailLine[] } | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [activeId, setActiveId] = useState<string>("");
const [detail, setDetail] = useState<{ order: DetailOrder; items: DetailLine[] } | null>(null);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const load = async () => {
const res = await fetch("/api/m/orders/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: status || undefined }),
});
setOrders((await res.json()).RESULTLIST ?? []);
};
useEffect(() => { load(); }, []); // eslint-disable-line
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/m/orders/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: status || undefined }),
});
const j = await res.json();
const list: Order[] = j.RESULTLIST ?? [];
setOrders(list);
setSelected((prev) => new Set(Array.from(prev).filter((id) => list.some((o) => o.OBJID === id))));
if (list.length && !list.some((o) => o.OBJID === activeId)) {
setActiveId(list[0].OBJID);
} else if (!list.length) {
setActiveId("");
setDetail(null);
}
} finally {
setLoading(false);
}
}, [status, activeId]);
const view = async (o: Order) => {
const res = await fetch("/api/m/orders/detail", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: o.OBJID }),
useEffect(() => { load(); }, [load]);
// 활성 행 변경 시 상세 로드
useEffect(() => {
if (!activeId) { setDetail(null); return; }
let cancelled = false;
(async () => {
const res = await fetch("/api/m/orders/detail", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: activeId }),
});
const j = await res.json();
if (!cancelled && j.success) {
setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[] });
}
})();
return () => { cancelled = true; };
}, [activeId]);
const requestedSelectedIds = useMemo(
() => Array.from(selected).filter((id) => orders.find((o) => o.OBJID === id)?.STATUS === "REQUESTED"),
[selected, orders]
);
const toggleOne = (o: Order) => {
if (o.STATUS !== "REQUESTED") return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(o.OBJID)) next.delete(o.OBJID); else next.add(o.OBJID);
return next;
});
};
const toggleAllRequested = () => {
const reqIds = orders.filter((o) => o.STATUS === "REQUESTED").map((o) => o.OBJID);
setSelected((prev) => {
const allOn = reqIds.length > 0 && reqIds.every((id) => prev.has(id));
const next = new Set(prev);
if (allOn) reqIds.forEach((id) => next.delete(id));
else reqIds.forEach((id) => next.add(id));
return next;
});
const j = await res.json();
if (j.success) setDetail({ order: o, items: j.items });
};
const approve = async (o: Order) => {
const bulkShip = async () => {
const ids = requestedSelectedIds;
if (ids.length === 0) {
Swal.fire({ icon: "warning", title: "출고 처리할 발주를 선택하세요.", text: "체크박스로 출고요청 상태의 발주를 선택해주세요." });
return;
}
const ok = await Swal.fire({
icon: "question",
title: "발주를 승인하시겠습니까?",
html: `<b>${o.COMPANY_NAME}</b><br>합계 ₩${fmt(o.TOTAL_AMOUNT)}<br><br>승인 시 재고가 차감되고<br><b>${o.EMAIL}</b>로 거래명세표 메일이 발송됩니다.`,
showCancelButton: true, confirmButtonText: "승인", cancelButtonText: "취소",
title: `${ids.length}건 출고 처리하시겠습니까?`,
html: `선택된 발주의 재고가 차감되고,<br>각 거래처 이메일로 거래명세표 발송됩니다.`,
showCancelButton: true,
confirmButtonText: "출고",
cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
setBusy(true);
try {
const res = await fetch("/api/m/orders/approve", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: o.OBJID }),
});
const j = await res.json();
if (j.success) {
Swal.fire({
icon: j.mailSent ? "success" : "warning",
title: "승인 완료",
text: j.mailSent ? "거래명세표 메일이 발송되었습니다." : `메일 발송 실패: ${j.mailError ?? "SMTP 미설정"}`,
let ok_cnt = 0;
let fail_cnt = 0;
let mailFail = 0;
const errors: string[] = [];
for (const objid of ids) {
try {
const res = await fetch("/api/m/orders/approve", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid }),
});
load();
setDetail(null);
} else {
Swal.fire({ icon: "error", title: "승인 실패", text: j.message });
const j = await res.json();
if (j.success) {
ok_cnt++;
if (!j.mailSent) mailFail++;
} else {
fail_cnt++;
const o = orders.find((x) => x.OBJID === objid);
errors.push(`${o?.ORDER_NO ?? objid}: ${j.message ?? "실패"}`);
}
} catch {
fail_cnt++;
}
} finally { setBusy(false); }
}
setBusy(false);
setSelected(new Set());
await load();
Swal.fire({
icon: fail_cnt === 0 ? "success" : "warning",
title: `출고 처리 완료 (성공 ${ok_cnt} / 실패 ${fail_cnt})`,
html: [
ok_cnt > 0 ? `· 거래명세표 메일 ${ok_cnt - mailFail}건 발송${mailFail > 0 ? ` <span style="color:#dc2626">(${mailFail}건 메일 실패)</span>` : ""}` : "",
errors.length > 0 ? `<br><br><b>실패 내역:</b><br>${errors.join("<br>")}` : "",
].filter(Boolean).join(""),
});
};
const cancel = async (o: Order) => {
const ok = await Swal.fire({ icon: "warning", title: "발주를 취소하시겠습니까?", showCancelButton: true, confirmButtonColor: "#dc2626" });
const cancelOne = async (o: Order) => {
const ok = await Swal.fire({
icon: "warning", title: "발주를 취소(반려)하시겠습니까?",
text: o.ORDER_NO, showCancelButton: true, confirmButtonColor: "#dc2626",
});
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/orders/cancel", {
method: "POST", headers: { "Content-Type": "application/json" },
@@ -92,135 +182,256 @@ export default function AdminOrdersPage() {
if ((await res.json()).success) load();
};
const allRequestedCount = orders.filter((o) => o.STATUS === "REQUESTED").length;
const allRequestedChecked = allRequestedCount > 0 && allRequestedCount === requestedSelectedIds.length;
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-sm text-slate-500 mt-1"> ·. .</p>
<div className="space-y-3">
{/* 헤더 */}
<div className="flex items-end justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold text-slate-900"> · </h1>
<p className="text-xs text-slate-500 mt-0.5">
. [] .
</p>
</div>
<div className="flex gap-2 items-center">
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-9 px-3 rounded-lg border border-slate-200 text-sm bg-white">
<option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button onClick={load} disabled={loading} className="h-9 px-3 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm font-semibold inline-flex items-center gap-1.5 hover:bg-slate-50 disabled:opacity-50">
<RefreshCcw size={14} className={loading ? "animate-spin" : ""} />
</button>
<button
onClick={bulkShip}
disabled={busy || requestedSelectedIds.length === 0}
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5 shadow-sm"
>
<Truck size={14} /> {requestedSelectedIds.length > 0 ? ` (${requestedSelectedIds.length})` : ""}
</button>
</div>
</div>
<div className="flex gap-2">
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
<option value=""> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"></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-3 py-3"></th>
<th className="text-left px-3 py-3"></th>
<th className="text-left px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
<th className="text-center px-3 py-3"></th>
<th className="text-right px-3 py-3"></th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr><td colSpan={8} 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-3 py-3 font-semibold">{o.ORDER_NO}</td>
<td className="px-3 py-3">{o.ORDER_DATE}</td>
<td className="px-3 py-3">{o.COMPANY_NAME}</td>
<td className="px-3 py-3 text-right tabular-nums text-violet-700">{fmt(o.TOTAL_TAXFREE)}</td>
<td className="px-3 py-3 text-right tabular-nums text-rose-700">{fmt(o.TOTAL_TAXABLE)}</td>
<td className="px-3 py-3 text-right tabular-nums font-bold">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-3 py-3 text-center">
<span className={`px-2 py-1 rounded text-xs font-semibold ${STATUS_COLOR[o.STATUS]}`}>
{STATUS_LABEL[o.STATUS]}
</span>
</td>
<td className="px-3 py-3 text-right">
<button onClick={() => view(o)} className="px-2.5 h-8 rounded-md text-xs text-slate-700 hover:bg-slate-100 inline-flex items-center gap-1">
<Eye size={12} />
</button>
{o.STATUS === "REQUESTED" && (
<>
<button disabled={busy} onClick={() => approve(o)} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-emerald-700 text-white hover:bg-emerald-800 inline-flex items-center gap-1 disabled:opacity-50">
<Check size={12} />
</button>
<button onClick={() => cancel(o)} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-rose-50 text-rose-700 hover:bg-rose-100 inline-flex items-center gap-1">
<X size={12} />
</button>
</>
)}
{(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") && (
<a href={`/api/m/orders/statement/${o.OBJID}`} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-slate-50 text-slate-700 hover:bg-slate-100 inline-flex items-center gap-1">
<Download size={12} />
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 상세 모달 */}
{detail && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setDetail(null)}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg"> {detail.order.ORDER_NO}</h3>
<button onClick={() => setDetail(null)} className="text-slate-400 hover:text-slate-700"><X size={20} /></button>
</div>
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
<Info label="업체명" value={detail.order.COMPANY_NAME} />
<Info label="이메일" value={detail.order.EMAIL} />
<Info label="발주일" value={detail.order.ORDER_DATE} />
<Info label="상태" value={STATUS_LABEL[detail.order.STATUS]} />
</div>
<table className="w-full text-sm border border-slate-200">
<thead className="bg-slate-50">
{/* 2분할 레이아웃 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" style={{ minHeight: "calc(100vh - 200px)" }}>
{/* 좌측: 발주 리스트 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> ({orders.length})</span>
<span className="text-slate-400 font-normal"> {selected.size} / {allRequestedCount}</span>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="bg-slate-50 text-slate-600 sticky top-0 z-10">
<tr>
<th className="text-left px-3 py-2 text-xs"></th>
<th className="text-center px-2 py-2 text-xs w-16"></th>
<th className="text-right px-2 py-2 text-xs w-16"></th>
<th className="text-right px-2 py-2 text-xs"></th>
<th className="text-right px-2 py-2 text-xs"></th>
<th className="text-right px-2 py-2 text-xs"></th>
<th className="text-right px-2 py-2 text-xs"></th>
<th className="w-8 px-2 py-2">
<input type="checkbox" checked={allRequestedChecked} onChange={toggleAllRequested}
disabled={allRequestedCount === 0}
className="accent-emerald-600 cursor-pointer disabled:opacity-30" />
</th>
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-left px-2 py-2"></th>
<th className="text-right px-2 py-2"></th>
<th className="text-center px-2 py-2"></th>
</tr>
</thead>
<tbody>
{detail.items.map((it) => (
<tr key={it.SEQ} className="border-t border-slate-100">
<td className="px-3 py-2">{it.ITEM_NAME}</td>
<td className="text-center px-2 py-2">{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</td>
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.QTY)}</td>
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.UNIT_PRICE)}</td>
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.SUPPLY_AMOUNT)}</td>
<td className="text-right px-2 py-2 tabular-nums">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
<td className="text-right px-2 py-2 tabular-nums font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
</tr>
))}
{orders.length === 0 ? (
<tr><td colSpan={6} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "발주가 없습니다."}</td></tr>
) : orders.map((o) => {
const checked = selected.has(o.OBJID);
const active = o.OBJID === activeId;
return (
<tr
key={o.OBJID}
onClick={() => setActiveId(o.OBJID)}
className={`border-t border-slate-100 cursor-pointer ${active ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={checked}
onChange={() => toggleOne(o)}
disabled={o.STATUS !== "REQUESTED"}
className="accent-emerald-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
title={o.STATUS !== "REQUESTED" ? "출고요청 상태만 선택할 수 있습니다." : ""}
/>
</td>
<td className="px-2 py-2 font-semibold text-slate-800">{o.ORDER_NO}</td>
<td className="px-2 py-2 text-slate-600">{o.ORDER_DATE}</td>
<td className="px-2 py-2 truncate max-w-[140px]" title={o.COMPANY_NAME}>{o.COMPANY_NAME}</td>
<td className="px-2 py-2 text-right tabular-nums font-semibold">{fmt(o.TOTAL_AMOUNT)}</td>
<td className="px-2 py-2 text-center">
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[o.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
{STATUS_LABEL[o.STATUS] ?? o.STATUS}
</span>
</td>
</tr>
);
})}
</tbody>
<tfoot className="bg-slate-50">
<tr><td colSpan={6} className="px-3 py-2 text-right font-bold"> (VAT포함)</td><td className="px-2 py-2 text-right font-bold tabular-nums text-emerald-700">{fmt(detail.order.TOTAL_AMOUNT)}</td></tr>
</tfoot>
</table>
{detail.order.STATUS === "REQUESTED" && (
<div className="flex gap-2 justify-end mt-5 pt-4 border-t border-slate-100">
<button onClick={() => cancel(detail.order)} className="px-4 h-10 rounded-lg border border-rose-200 text-rose-700 text-sm font-semibold hover:bg-rose-50"></button>
<button disabled={busy} onClick={() => approve(detail.order)} className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
+
</button>
</div>
</div>
</div>
{/* 우측: 거래명세표 미리보기 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> </span>
{detail && (
<a
href={`/api/m/orders/statement/${detail.order.OBJID}`}
className="text-xs text-emerald-700 font-semibold inline-flex items-center gap-1 hover:underline"
>
<Download size={12} />
</a>
)}
</div>
<div className="flex-1 overflow-auto p-4">
{!detail ? (
<div className="h-full flex flex-col items-center justify-center text-slate-400">
<Package size={48} className="mb-3 opacity-50" />
<div className="text-sm"> .</div>
</div>
) : (
<StatementPreview order={detail.order} items={detail.items} onCancel={cancelOne} busy={busy} />
)}
</div>
</div>
</div>
</div>
);
}
function StatementPreview({
order,
items,
onCancel,
busy,
}: {
order: DetailOrder;
items: DetailLine[];
onCancel: (o: Order) => void;
busy: boolean;
}) {
const lowStock = items.filter((it) => Number(it.STOCK_QTY) < Number(it.QTY));
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>
<div className="grid grid-cols-2 gap-2 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>
</div>
<div className="border border-slate-200 rounded p-2 bg-slate-50/60">
<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} · </>}
{order.BIZ_NO && <>: {order.BIZ_NO} · </>}
{order.PHONE && <>: {order.PHONE} · </>}
{order.EMAIL && <>: {order.EMAIL}</>}
{order.ADDRESS && <div>: {order.ADDRESS}</div>}
</div>
</div>
{lowStock.length > 0 && (
<div className="border border-rose-200 bg-rose-50 rounded p-2 text-[11px] text-rose-700 flex items-start gap-2">
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
<div>
<b> {lowStock.length}</b> :
<ul className="mt-1 ml-4 list-disc">
{lowStock.map((it) => (
<li key={it.SEQ}>{it.ITEM_NAME} ( {fmt(it.QTY)} / {fmt(it.STOCK_QTY)})</li>
))}
</ul>
</div>
</div>
)}
<table className="w-full text-[11px] border border-slate-300">
<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-12"></th>
<th className="border border-slate-300 px-1.5 py-1.5 w-16"></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>
</tr>
</thead>
<tbody className="tabular-nums">
{items.map((it) => {
const lack = Number(it.STOCK_QTY) < Number(it.QTY);
return (
<tr key={it.SEQ}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{it.SEQ}</td>
<td className="border border-slate-300 px-1.5 py-1">{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 ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}>
{fmt(it.STOCK_QTY)}
</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>
</tr>
);
})}
{items.length === 0 && (
<tr><td colSpan={9} 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">
<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>
{order.STATUS === "REQUESTED" && (
<div className="flex justify-end gap-2 pt-3 border-t border-slate-200">
<button
onClick={() => onCancel(order)}
disabled={busy}
className="px-3 h-9 rounded-lg border border-rose-200 text-rose-700 text-xs font-semibold hover:bg-rose-50 disabled:opacity-50 inline-flex items-center gap-1"
>
<X size={12} />
</button>
<span className="text-[11px] text-slate-400 self-center">
[] .
</span>
</div>
)}
{(order.STATUS === "APPROVED" || order.STATUS === "SHIPPED") && (
<div className="text-[11px] text-emerald-700 pt-2 border-t border-slate-200 inline-flex items-center gap-1">
<Check size={12} /> {order.APPROVE_DATE}
</div>
)}
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return <div><div className="text-xs text-slate-500">{label}</div><div className="font-semibold">{value}</div></div>;
}
+4 -4
View File
@@ -11,16 +11,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: false, message: "잘못된 요청입니다." }, { status: 400 });
}
const { email, password, companyName, ceoName, bizNo, phone } = body;
const { email, password, companyName, ceoName, bizNo, phone, address } = body;
if (!email || !password || !companyName) {
if (!email || !password || !companyName || !phone || !address) {
return NextResponse.json(
{ success: false, message: "이메일, 비밀번호, 업체명 필수입니다." },
{ success: false, message: "이메일, 비밀번호, 업체명, 전화번호, 주소는 필수입니다." },
{ status: 400 }
);
}
const result = await signupMomoUser({ email, password, companyName, ceoName, bizNo, phone });
const result = await signupMomoUser({ email, password, companyName, ceoName, bizNo, phone, address });
if (!result.success || !result.user) {
return NextResponse.json(
{ success: false, message: result.error ?? "가입에 실패했습니다." },
+2 -1
View File
@@ -98,7 +98,8 @@ export async function POST(req: NextRequest) {
try {
const order = await queryOne<Record<string, unknown>>(
`SELECT O.objid, O.order_no, TO_CHAR(O.order_date,'YYYY-MM-DD') AS order_date,
U.user_name, U.email, NULL, NULL, U.cell_phone,
U.user_name AS company_name, U.email,
U.ceo_name, U.biz_no, U.cell_phone AS phone, U.address,
O.total_supply, O.total_vat, O.total_amount,
O.total_taxfree, O.total_taxable
FROM momo_orders O
+11 -2
View File
@@ -15,7 +15,8 @@ export async function POST(req: NextRequest) {
TO_CHAR(O.order_date,'YYYY-MM-DD') AS "ORDER_DATE",
O.customer_objid AS "CUSTOMER_OBJID",
U.user_name AS "COMPANY_NAME", U.email AS "EMAIL",
NULL AS "CEO_NAME", NULL AS "BIZ_NO", U.cell_phone AS "PHONE",
U.ceo_name AS "CEO_NAME", U.biz_no AS "BIZ_NO",
U.cell_phone AS "PHONE", U.address AS "ADDRESS",
O.status AS "STATUS", O.memo AS "MEMO",
O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT",
O.total_amount AS "TOTAL_AMOUNT",
@@ -47,7 +48,15 @@ export async function POST(req: NextRequest) {
OI.vat_amount AS "VAT_AMOUNT",
OI.total_amount AS "TOTAL_AMOUNT",
I.unit AS "UNIT",
I.image_url AS "IMAGE_URL"
I.image_url AS "IMAGE_URL",
COALESCE(
(SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON W.objid = S.wh_objid
WHERE S.item_objid = OI.item_objid
AND W.wh_type = 'STOCK'
AND COALESCE(W.is_del,'N') != 'Y'),
0
) AS "STOCK_QTY"
FROM momo_order_items OI
LEFT JOIN momo_items I ON OI.item_objid = I.objid
WHERE OI.order_objid = $1
+8 -3
View File
@@ -10,6 +10,7 @@ export interface MomoUser {
ceoName: string;
bizNo: string;
phone: string;
address: string;
role: "USER" | "ADMIN";
status: string;
userId: string;
@@ -24,6 +25,7 @@ export interface SignupInput {
ceoName?: string;
bizNo?: string;
phone?: string;
address?: string;
}
function rowToUser(r: Record<string, unknown>): MomoUser {
@@ -39,6 +41,7 @@ function rowToUser(r: Record<string, unknown>): MomoUser {
ceoName: (r.CEO_NAME as string) || (r.USER_NAME_ENG as string) || "",
bizNo: (r.BIZ_NO as string) || "",
phone: (r.CELL_PHONE as string) || (r.TEL as string) || "",
address: (r.ADDRESS as string) || "",
role,
status: (r.STATUS as string) || "active",
userId,
@@ -53,7 +56,8 @@ export async function findMomoUserByEmail(email: string): Promise<MomoUser | nul
user_name_eng AS "USER_NAME_ENG",
email AS "EMAIL", cell_phone AS "CELL_PHONE", tel AS "TEL",
user_type AS "USER_TYPE", status AS "STATUS",
biz_no AS "BIZ_NO", ceo_name AS "CEO_NAME"
biz_no AS "BIZ_NO", ceo_name AS "CEO_NAME",
address AS "ADDRESS"
FROM user_info
WHERE LOWER(user_id) = LOWER($1) OR LOWER(email) = LOWER($1)
LIMIT 1`,
@@ -107,8 +111,8 @@ export async function signupMomoUser(input: SignupInput): Promise<{ success: boo
await execute(
`INSERT INTO user_info
(user_id, user_password, user_name, email, cell_phone,
user_type, user_type_name, biz_no, ceo_name, status, regdate)
VALUES ($1, $2, $3, $1, $4, 'C', '거래처', $5, $6, 'active', NOW())`,
user_type, user_type_name, biz_no, ceo_name, address, status, regdate)
VALUES ($1, $2, $3, $1, $4, 'C', '거래처', $5, $6, $7, 'active', NOW())`,
[
email,
enc,
@@ -116,6 +120,7 @@ export async function signupMomoUser(input: SignupInput): Promise<{ success: boo
input.phone?.trim() ?? "",
input.bizNo?.trim() ?? "",
input.ceoName?.trim() ?? "",
input.address?.trim() ?? "",
]
);