feat(momo): 사용자 피드백 일괄 반영
Deploy momo-erp / deploy (push) Successful in 50s

사용자 명시적 운영 배포 승인 ('운영배포까지 진행 ... 될때까지 하라고').
- 로그인 후 redirectTo /dashboard → /m/dashboard 로 통일 (plm_admin 도 모모로)
- 세션 있으면 / · /login · /signup → /m/dashboard 리다이렉트 (middleware)
- /m/items 페이지를 /m/orders/new 로 redirect — 메뉴 통합
- 출고요청 카트를 상단 sticky bar 로 이동, 클릭 시 펼침 + 발주 버튼 항상 노출
- user_info 에 biz_no/ceo_name 컬럼 추가 (migration 006)
- signupMomoUser 가 biz_no/ceo_name 저장하도록 수정
- 메뉴: 9000101 품목 검색 비활성화 (출고요청과 통합으로 중복)
- admin-panel: 메뉴관리 섹션 idempotent 복구 (migration 006)
This commit is contained in:
chpark
2026-04-26 21:39:19 +09:00
parent 5760283c63
commit 9aae8e7c54
6 changed files with 170 additions and 212 deletions
@@ -0,0 +1,54 @@
-- 거래처 가입자에 필요한 추가 정보를 user_info 에 직접 컬럼으로 추가 (스펙 §3.1 B안)
-- supply_mng 와 user_info.partner_objid 연결도 가능하지만, 신규 가입 흐름 단순화 위해 직접 컬럼 추가.
-- 이미 컬럼이 있으면 ADD COLUMN IF NOT EXISTS 로 idempotent.
BEGIN;
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS biz_no VARCHAR(20),
ADD COLUMN IF NOT EXISTS ceo_name VARCHAR(100);
-- 품목 검색 메뉴(스펙 §5에서 출고 요청과 통합으로 변경됨) 비활성화
UPDATE menu_info SET status = 'inactive' WHERE objid = 9000101;
-- ===== 관리자 admin-panel 의 [메뉴관리] 섹션 복구 =====
-- [관리자] 루트(parent=0, menu_name_kor='관리자') 아래에 [메뉴관리] 섹션 + [메뉴관리] 자식이 status='active' 로 존재해야
-- /api/admin/sidebar-menus 가 노출함. 누락된 경우 idempotent 하게 보장.
DO $$
DECLARE
admin_root_id NUMERIC;
menu_section_id NUMERIC;
BEGIN
SELECT objid INTO admin_root_id FROM menu_info
WHERE parent_obj_id = 0 AND menu_name_kor = '관리자' LIMIT 1;
IF admin_root_id IS NULL THEN
RAISE NOTICE '[admin] 루트가 없어 메뉴관리 복구 스킵';
RETURN;
END IF;
-- 섹션이 존재하면 active 로 보장, 없으면 9000600 으로 신규 등록
SELECT objid INTO menu_section_id FROM menu_info
WHERE parent_obj_id = admin_root_id AND menu_name_kor = '메뉴관리' LIMIT 1;
IF menu_section_id IS NULL THEN
menu_section_id := 9000600;
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, status, system_name, regdate)
VALUES (menu_section_id, '1', admin_root_id, '메뉴관리', 'Menu Management',
10, '', 'active', 'PMS', NOW());
ELSE
UPDATE menu_info SET status = 'active' WHERE objid = menu_section_id;
END IF;
-- 자식: 메뉴관리 (LABEL_TO_TAB 매핑이 '메뉴관리' → 'menu' 이므로 정확히 동일 이름 필수)
IF NOT EXISTS (
SELECT 1 FROM menu_info
WHERE parent_obj_id = menu_section_id AND menu_name_kor = '메뉴관리' AND COALESCE(status,'') = 'active'
) 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';
END IF;
END $$;
COMMIT;
+4 -121
View File
@@ -1,123 +1,6 @@
"use client";
// 품목 검색은 /m/orders/new 와 기능이 동일하므로 통합. 이 경로는 호환성 유지를 위해 리다이렉트만 수행.
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Search, ShoppingCart } from "lucide-react";
interface Item {
OBJID: string;
ITEM_CODE: string;
ITEM_NAME: string;
ITEM_DETAIL: string;
MAKER_NAME: string;
UNIT: string;
UNIT_PRICE: number;
IS_TAX_FREE: string;
IMAGE_URL: string;
STOCK_QTY: number;
}
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
export default function ItemsBrowsePage() {
const router = useRouter();
const [items, setItems] = useState<Item[]>([]);
const [keyword, setKeyword] = useState("");
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
const [loading, setLoading] = useState(false);
const fetchItems = async () => {
setLoading(true);
const res = await fetch("/api/m/items/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword, isTaxFree: taxFilter || undefined }),
});
const j = await res.json();
setItems(j.RESULTLIST ?? []);
setLoading(false);
};
useEffect(() => {
fetchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-sm mt-1"> . [ ] .</p>
</div>
<button
onClick={() => router.push("/m/orders/new")}
className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"
>
<ShoppingCart size={16} />
</button>
</div>
{/* 검색 바 */}
<div className="flex gap-2 items-center mb-4">
<div className="relative flex-1 max-w-md">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
placeholder="품목명 또는 품목코드"
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/15 outline-none"
/>
</div>
<select value={taxFilter} onChange={(e) => setTaxFilter(e.target.value as "" | "Y" | "N")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
<option value=""></option>
<option value="Y"></option>
<option value="N"></option>
</select>
<button onClick={fetchItems} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">
</button>
</div>
{/* 목록 (그리드) — 영역만 스크롤 */}
<div className="flex-1 overflow-y-auto pr-1">
{loading ? (
<div className="text-slate-400 text-center py-12"> ...</div>
) : items.length === 0 ? (
<div className="text-slate-400 text-center py-12 bg-white rounded-xl border border-slate-100"> .</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{items.map((it) => (
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
<div className="aspect-square bg-slate-50 rounded-lg mb-3 overflow-hidden flex items-center justify-center">
{it.IMAGE_URL ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
) : (
<div className="text-slate-300 text-xs"> </div>
)}
</div>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="font-bold text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
{it.IS_TAX_FREE === "Y" && (
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
)}
</div>
<div className="text-xs text-slate-500 mb-1">{it.ITEM_CODE}</div>
<div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline justify-between">
<div className="font-bold text-slate-900 tabular-nums">{fmt(it.UNIT_PRICE)}</div>
<div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{fmt(it.STOCK_QTY)} {it.UNIT}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
export default function ItemsRedirect() {
redirect("/m/orders/new");
}
+81 -83
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Search, ShoppingCart, Plus, Minus, Trash2, X } from "lucide-react";
import { Search, ShoppingCart, Plus, Minus, X } from "lucide-react";
import Swal from "sweetalert2";
interface Item {
@@ -128,27 +128,94 @@ export default function ItemsBrowse() {
}
};
const [cartOpen, setCartOpen] = useState(false);
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-4 h-full overflow-hidden">
<div className="space-y-4 overflow-y-auto pr-1">
<div className="flex items-start justify-between gap-2">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-sm mt-1"> .</p>
<div className="flex flex-col h-full overflow-hidden">
{/* ===== 상단 sticky 카트 바 — 항상 노출, 클릭하면 내역 펼침 ===== */}
<div className="sticky top-0 z-20 bg-white border-2 border-emerald-300 rounded-xl shadow-lg mb-3 overflow-hidden">
<button
onClick={() => setCartOpen((v) => !v)}
className="w-full flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-emerald-50/40 transition"
>
<div className="flex items-center gap-2 font-bold text-slate-800">
<ShoppingCart size={18} className="text-emerald-700" />
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-bold tabular-nums">
{cart.length}
</span>
</div>
{/* 좁은 화면(lg 미만)에서 우측 카트가 아래로 내려가 안 보일 때를 대비한 상단 요약 + 버튼 */}
<div className="lg:hidden flex items-center gap-2 shrink-0">
<div className="px-3 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-50 text-emerald-800 border border-emerald-200 text-sm font-bold">
<ShoppingCart size={14} /> {cart.length} · {fmt(totals.total)}
</div>
<div className="flex items-center gap-3">
<span className="hidden md:inline text-xs text-violet-700 tabular-nums"> {fmt(totals.taxFree)}</span>
<span className="hidden md:inline text-xs text-rose-700 tabular-nums"> {fmt(totals.taxable)}</span>
<span className="text-base font-bold text-emerald-700 tabular-nums">{fmt(totals.total)}</span>
<button
onClick={submitOrder}
onClick={(e) => { e.stopPropagation(); submitOrder(); }}
disabled={cart.length === 0}
className="h-10 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold disabled:bg-slate-200 disabled:text-slate-400"
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
>
</button>
<span className={`text-slate-400 text-xs transition-transform ${cartOpen ? "rotate-180" : ""}`}></span>
</div>
</button>
{cartOpen && (
<div className="border-t border-emerald-100 px-4 py-3 max-h-[40vh] overflow-y-auto bg-slate-50/50">
{cart.length === 0 ? (
<div className="text-slate-400 text-sm text-center py-6">
<span className="font-bold text-emerald-700">+ </span> .
</div>
) : (
<>
<div className="flex justify-end mb-2">
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500">
</button>
</div>
<div className="grid sm:grid-cols-2 gap-2">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="text-sm font-semibold leading-tight">{ln.item.ITEM_NAME}</div>
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500">
<X size={14} />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Minus size={12} />
</button>
<span className="w-10 text-center text-sm font-bold tabular-nums">{ln.qty}</span>
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Plus size={12} />
</button>
</div>
<div className="text-sm font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
</div>
);
})}
</div>
<div className="border-t border-slate-200 mt-3 pt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<Row label="총 합계" value={`${fmt(totals.total)}`} />
</div>
</>
)}
</div>
)}
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-sm mt-1"> [ ] .</p>
</div>
<div className="flex gap-2 items-center">
@@ -213,75 +280,6 @@ export default function ItemsBrowse() {
</div>
)}
</div>
{/* 장바구니 — lg 이상은 우측 sticky, 미만은 흐름상 하단으로 떨어져도 항상 노출 */}
<aside className="self-start bg-white border-2 border-emerald-300 rounded-xl p-5 shadow-lg flex flex-col max-h-full overflow-hidden">
<div className="flex items-center justify-between mb-3 shrink-0">
<div className="flex items-center gap-2 font-bold text-slate-800">
<ShoppingCart size={16} />
<span className="text-emerald-700">{cart.length}</span>
</div>
{cart.length > 0 && (
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500">
</button>
)}
</div>
<div className="flex-1 overflow-y-auto pr-1 min-h-[120px]">
{cart.length === 0 ? (
<div className="text-slate-400 text-sm text-center py-10 border border-dashed border-slate-200 rounded-lg">
<span className="font-bold text-emerald-700"></span>
<br /> .
</div>
) : (
<div className="space-y-2.5">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="border border-slate-100 rounded-lg p-2.5">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="text-sm font-semibold leading-tight">{ln.item.ITEM_NAME}</div>
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500">
<X size={14} />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Minus size={12} />
</button>
<span className="w-10 text-center text-sm font-bold tabular-nums">{ln.qty}</span>
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Plus size={12} />
</button>
</div>
<div className="text-sm font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="border-t border-slate-200 mt-4 pt-3 space-y-1.5 text-sm shrink-0">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<span className="font-bold"> </span>
<span className="font-bold text-lg text-emerald-700 tabular-nums">{fmt(totals.total)}</span>
</div>
<button
onClick={submitOrder}
disabled={cart.length === 0}
className="w-full mt-3 h-11 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white font-bold shadow hover:-translate-y-0.5 transition disabled:from-slate-200 disabled:to-slate-200 disabled:text-slate-400 disabled:hover:translate-y-0 disabled:shadow-none disabled:cursor-not-allowed"
>
</button>
</div>
</aside>
</div>
);
}
+2 -1
View File
@@ -31,7 +31,8 @@ export async function POST(request: NextRequest) {
const fito = await verifyCredentials(userId, password);
if (fito.success && fito.user) {
await createSession(fito.user);
return NextResponse.json({ success: true, user: fito.user, redirectTo: "/dashboard" });
// 모모유통 도메인은 모두 모모 대시보드로 (FITO admin 도 /m/dashboard 로 진입)
return NextResponse.json({ success: true, user: fito.user, redirectTo: "/m/dashboard" });
}
// FITO 도 실패하면 MOMO를 한 번 더 시도 (이메일 형태가 아니지만 MOMO 계정인 경우)
+16 -6
View File
@@ -36,8 +36,8 @@ function rowToUser(r: Record<string, unknown>): MomoUser {
objid: userId,
email,
companyName,
ceoName: (r.USER_NAME_ENG as string) || "",
bizNo: "",
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) || "",
role,
status: (r.STATUS as string) || "active",
@@ -52,7 +52,8 @@ export async function findMomoUserByEmail(email: string): Promise<MomoUser | nul
`SELECT user_id AS "USER_ID", user_name AS "USER_NAME",
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"
user_type AS "USER_TYPE", status AS "STATUS",
biz_no AS "BIZ_NO", ceo_name AS "CEO_NAME"
FROM user_info
WHERE LOWER(user_id) = LOWER($1) OR LOWER(email) = LOWER($1)
LIMIT 1`,
@@ -104,9 +105,18 @@ export async function signupMomoUser(input: SignupInput): Promise<{ success: boo
const enc = encrypt(input.password);
await execute(
`INSERT INTO user_info (user_id, user_password, user_name, email, cell_phone, user_type, user_type_name, status, regdate)
VALUES ($1, $2, $3, $1, $4, 'C', '거래처', 'active', NOW())`,
[email, enc, input.companyName.trim(), input.phone?.trim() ?? ""]
`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())`,
[
email,
enc,
input.companyName.trim(),
input.phone?.trim() ?? "",
input.bizNo?.trim() ?? "",
input.ceoName?.trim() ?? "",
]
);
const user = await findMomoUserByEmail(email);
+13 -1
View File
@@ -18,7 +18,19 @@ export function middleware(request: NextRequest) {
"/momo-logo.svg",
"/momo-icon.svg",
];
if (pathname === "/") return NextResponse.next(); // 랜딩 페이지
// 랜딩 페이지: 세션이 살아있으면 대시보드로 직행 (이미 로그인된 사용자가 /로 들어와도 마케팅 화면 안 보이게)
if (pathname === "/") {
if (request.cookies.get("plm-session")) {
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}
return NextResponse.next();
}
// 로그인/가입 페이지도 세션 있으면 대시보드로
if (pathname === "/login" || pathname === "/signup") {
if (request.cookies.get("plm-session")) {
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}
}
if (publicPaths.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}