From 72786bfc98c7706db53f72fe376f2066d5a4fb0f Mon Sep 17 00:00:00 2001 From: chpark Date: Sun, 26 Apr 2026 22:32:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(momo):=20=EC=B6=9C=EA=B3=A0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=202=EB=B6=84=ED=95=A0=20UI=20+=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=A3=BC=EC=86=8C=20+=20=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=EC=B2=98=20=EC=A0=95=EB=B3=B4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원가입 폼·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) --- db/migrations/006_momo_user_info_extend.sql | 17 +- db/migrations/007_user_info_address.sql | 7 + src/app/(auth)/signup/page.tsx | 11 +- src/app/(main)/m/admin/orders/page.tsx | 543 ++++++++++++++------ src/app/api/auth/signup/route.ts | 8 +- src/app/api/m/orders/approve/route.ts | 3 +- src/app/api/m/orders/detail/route.ts | 13 +- src/lib/momo-auth.ts | 11 +- 8 files changed, 429 insertions(+), 184 deletions(-) create mode 100644 db/migrations/007_user_info_address.sql diff --git a/db/migrations/006_momo_user_info_extend.sql b/db/migrations/006_momo_user_info_extend.sql index 3d6c6d5..d66ab9b 100644 --- a/db/migrations/006_momo_user_info_extend.sql +++ b/db/migrations/006_momo_user_info_extend.sql @@ -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 $$; diff --git a/db/migrations/007_user_info_address.sql b/db/migrations/007_user_info_address.sql new file mode 100644 index 0000000..5a9bf11 --- /dev/null +++ b/db/migrations/007_user_info_address.sql @@ -0,0 +1,7 @@ +-- 회원가입 주소 입력 항목 추가 (스펙 §1: 이메일/업체명/전화번호/주소 필수) +BEGIN; + +ALTER TABLE user_info + ADD COLUMN IF NOT EXISTS address VARCHAR(300); + +COMMIT; diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 9b37056..7bdaa4d 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -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() { } label="업체명 *" value={form.companyName} onChange={set("companyName")} placeholder="(주)거래처 또는 매장명" />
} label="대표자" value={form.ceoName} onChange={set("ceoName")} placeholder="홍길동" /> - } label="연락처" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" /> + } label="연락처 *" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" />
+ } label="주소 *" value={form.address} onChange={set("address")} placeholder="배송지 주소를 입력하세요" /> } label="사업자등록번호" value={form.bizNo} onChange={set("bizNo")} placeholder="000-00-00000 (선택)" /> + + -
- - -
- -
- - - - - - - - - - - - - - - {orders.length === 0 ? ( - - ) : orders.map((o) => ( - - - - - - - - - - - ))} - -
발주번호발주일업체면세과세합계상태작업
발주가 없습니다.
{o.ORDER_NO}{o.ORDER_DATE}{o.COMPANY_NAME}{fmt(o.TOTAL_TAXFREE)}{fmt(o.TOTAL_TAXABLE)}₩{fmt(o.TOTAL_AMOUNT)} - - {STATUS_LABEL[o.STATUS]} - - - - {o.STATUS === "REQUESTED" && ( - <> - - - - )} - {(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") && ( - - 명세서 - - )} -
-
- - {/* 상세 모달 */} - {detail && ( -
setDetail(null)}> -
e.stopPropagation()} className="bg-white rounded-xl max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto"> -
-

발주 상세 — {detail.order.ORDER_NO}

- -
-
- - - - -
- - + {/* 2분할 레이아웃 */} +
+ {/* 좌측: 발주 리스트 */} +
+
+ 발주 리스트 ({orders.length}건) + 선택 {selected.size}건 / 출고가능 {allRequestedCount}건 +
+
+
+ - - - - - - - + + + + + + - {detail.items.map((it) => ( - - - - - - - - - - ))} + {orders.length === 0 ? ( + + ) : orders.map((o) => { + const checked = selected.has(o.OBJID); + const active = o.OBJID === activeId; + return ( + setActiveId(o.OBJID)} + className={`border-t border-slate-100 cursor-pointer ${active ? "bg-emerald-50/60" : "hover:bg-slate-50"}`} + > + + + + + + + + ); + })} - - -
품명구분수량단가공급가세액합계 + + 발주번호발주일업체합계상태
{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)}
{loading ? "조회 중..." : "발주가 없습니다."}
e.stopPropagation()}> + toggleOne(o)} + disabled={o.STATUS !== "REQUESTED"} + className="accent-emerald-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" + title={o.STATUS !== "REQUESTED" ? "출고요청 상태만 선택할 수 있습니다." : ""} + /> + {o.ORDER_NO}{o.ORDER_DATE}{o.COMPANY_NAME}₩{fmt(o.TOTAL_AMOUNT)} + + {STATUS_LABEL[o.STATUS] ?? o.STATUS} + +
총 합계 (VAT포함)₩{fmt(detail.order.TOTAL_AMOUNT)}
- {detail.order.STATUS === "REQUESTED" && ( -
- - -
+
+
+ + {/* 우측: 거래명세표 미리보기 */} +
+
+ 거래명세표 미리보기 + {detail && ( + + 엑셀 다운로드 + )}
+
+ {!detail ? ( +
+ +
왼쪽에서 발주를 선택하세요.
+
+ ) : ( + + )} +
+
+ + + ); +} + +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 ( +
+
+

거 래 명 세 표

+
+ +
+
+
발주번호 · {order.ORDER_NO}
+
발주일자 · {order.ORDER_DATE}
+
현재상태 · {STATUS_LABEL[order.STATUS] ?? order.STATUS}
+
+
+
공급자 · 모모유통
+
대표: 한신숙
+
+
+ +
+
{order.COMPANY_NAME} 귀하
+
+ {order.CEO_NAME && <>대표: {order.CEO_NAME} · } + {order.BIZ_NO && <>사업자번호: {order.BIZ_NO} · } + {order.PHONE && <>전화: {order.PHONE} · } + {order.EMAIL && <>이메일: {order.EMAIL}} + {order.ADDRESS &&
주소: {order.ADDRESS}
} +
+
+ + {lowStock.length > 0 && ( +
+ +
+ 재고 부족 {lowStock.length}건 — 출고 시 거부됩니다: +
    + {lowStock.map((it) => ( +
  • {it.ITEM_NAME} (요청 {fmt(it.QTY)} / 현재고 {fmt(it.STOCK_QTY)})
  • + ))} +
+
+
+ )} + + + + + + + + + + + + + + + + + {items.map((it) => { + const lack = Number(it.STOCK_QTY) < Number(it.QTY); + return ( + + + + + + + + + + + + ); + })} + {items.length === 0 && ( + + )} + +
#품명구분현재고수량단가공급가세액합계
{it.SEQ}{it.ITEM_NAME} + {it.IS_TAX_FREE === "Y" ? "면세" : "과세"} + + {fmt(it.STOCK_QTY)} + {fmt(it.QTY)}{fmt(it.UNIT_PRICE)}{fmt(it.SUPPLY_AMOUNT)}{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}{fmt(it.TOTAL_AMOUNT)}
품목이 없습니다.
+ + + + + + + + + + + +
면세 합계₩ {fmt(order.TOTAL_TAXFREE)}
과세 공급가₩ {fmt(order.TOTAL_TAXABLE)}
세액 합계₩ {fmt(order.TOTAL_VAT)}
총 합계 (VAT포함)₩ {fmt(order.TOTAL_AMOUNT)}
+ + {order.STATUS === "REQUESTED" && ( +
+ + + ※ 출고는 왼쪽 리스트에서 체크박스 선택 후 상단 [출고] 버튼으로 처리하세요. + +
+ )} + {(order.STATUS === "APPROVED" || order.STATUS === "SHIPPED") && ( +
+ 출고 완료 — {order.APPROVE_DATE}
)}
); } - -function Info({ label, value }: { label: string; value: string }) { - return
{label}
{value}
; -} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index 20a640a..c9e88aa 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -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 ?? "가입에 실패했습니다." }, diff --git a/src/app/api/m/orders/approve/route.ts b/src/app/api/m/orders/approve/route.ts index 8d49058..d704766 100644 --- a/src/app/api/m/orders/approve/route.ts +++ b/src/app/api/m/orders/approve/route.ts @@ -98,7 +98,8 @@ export async function POST(req: NextRequest) { try { const order = await queryOne>( `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 diff --git a/src/app/api/m/orders/detail/route.ts b/src/app/api/m/orders/detail/route.ts index fbe31b3..e1b6331 100644 --- a/src/app/api/m/orders/detail/route.ts +++ b/src/app/api/m/orders/detail/route.ts @@ -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 diff --git a/src/lib/momo-auth.ts b/src/lib/momo-auth.ts index 37fde1c..a7bb24d 100644 --- a/src/lib/momo-auth.ts +++ b/src/lib/momo-auth.ts @@ -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): MomoUser { @@ -39,6 +41,7 @@ function rowToUser(r: Record): 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