feat(statement-branch-admin): 기준 명세표 관리 메뉴/페이지/API + 창고 카테고리 7개로 확장
Deploy momo-erp / deploy (push) Successful in 3m5s
Deploy momo-erp / deploy (push) Successful in 3m5s
마이그레이션 028: - momo_statement_branches 테이블 신설 (code PK / name / bank_account / phone / email 등) - HQ, KIMPO 기본 시드 INSERT (사용자가 관리자 페이지에서 편집 가능) - 메뉴: 시스템 그룹에 '기준 명세표 관리' (M_ASBR / menu_info 9000310) 라이브러리 (src/lib/momo-branches.ts): - 하드코딩 → DB 조회로 변경 (60초 in-memory 캐시) - getSupplierByBranch 가 async — detail/statement/approve API 도 await 추가 - 저장 시 invalidateBranchCache() 호출 페이지/API (관리자 전용): - /m/admin/statement-branches : list + 등록/수정/삭제 모달 - POST /api/m/admin/statement-branches/list - POST /api/m/admin/statement-branches/save (regist / update / delete) 사용자 수정 폼: - "기준 거래명세서" select 옵션이 하드코딩 본사/김포 → DB 의 branches list 동적 fetch 창고 관리: - WH_TYPE 카테고리 5개 → 7개 (옛 enum 도 라벨 매핑은 유지) · HQ_STOCK 본사 창고 · HQ_CHARTER 본사 용차 · HQ_MARKET 본사 시장 · KIMPO_BRANCH 김포지사 · KIMPO_STOCK 김포 창고 · KIMPO_CHARTER 김포 용차 · KIMPO_MARKET 김포 시장 - 신규 추가 시 select 는 위 7개만 노출, 기존 데이터 (STOCK 등) 는 라벨로 자연 표시 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
-- 1) 기준 명세표 관리 테이블 신설 (관리자가 supplier 정보 편집)
|
||||
CREATE TABLE IF NOT EXISTS momo_statement_branches (
|
||||
code VARCHAR(20) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
bank_account VARCHAR(200) NOT NULL,
|
||||
phone VARCHAR(40),
|
||||
email VARCHAR(100),
|
||||
ceo_name VARCHAR(50),
|
||||
biz_no VARCHAR(40),
|
||||
address VARCHAR(200),
|
||||
is_default CHAR(1) DEFAULT 'N',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 기본 시드 (idempotent: code 가 PK 라 ON CONFLICT DO NOTHING 안전)
|
||||
INSERT INTO momo_statement_branches (code, name, bank_account, phone, email, ceo_name, is_default, sort_order)
|
||||
VALUES
|
||||
('HQ', '본사', '기업은행 434-115361-01-016 (이상용)', '010-6369-8443', 'momo8443@daum.net', '이상용', 'Y', 1),
|
||||
('KIMPO', '김포', '농협 351-1383-7634-13 (모모유통)', '010-5789-9431', 'momokimpo@nate.com', '이상용', 'N', 2)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 2) 기준 명세표 관리 메뉴 (admin-panel 의 시스템 관리 또는 별도)
|
||||
-- momo_menus 시스템 그룹에 추가
|
||||
INSERT INTO momo_menus (objid, menu_code, menu_name, menu_url, parent_code, sort_order, group_name, is_system, is_del, regdate)
|
||||
VALUES ('M_ASBR', 'A_SBR', '기준 명세표 관리', '/m/admin/statement-branches', NULL, 92, '시스템', 'Y', 'N', NOW())
|
||||
ON CONFLICT (objid) DO UPDATE
|
||||
SET menu_name = EXCLUDED.menu_name,
|
||||
menu_url = EXCLUDED.menu_url,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
group_name = EXCLUDED.group_name,
|
||||
is_del = 'N';
|
||||
|
||||
-- menu_info (FITO 사이드바 ground truth) 도 추가 — 매입/입고 부모(9000300) 와 별도, 시스템 그룹에
|
||||
-- 9000306 으로 신설, 부모 -395553955 (메뉴 root) 사용
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate)
|
||||
SELECT '9000310', '1', '-395553955', '기준 명세표 관리', 'Statement Branch', '710', '/m/admin/statement-branches', 'active', 'PMS', NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = '9000310');
|
||||
|
||||
UPDATE menu_info SET
|
||||
menu_name_kor = '기준 명세표 관리',
|
||||
menu_name_eng = 'Statement Branch',
|
||||
parent_obj_id = '-395553955',
|
||||
seq = '710',
|
||||
menu_url = '/m/admin/statement-branches',
|
||||
status = 'active'
|
||||
WHERE objid = '9000310';
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Branch {
|
||||
CODE: string;
|
||||
NAME: string;
|
||||
BANK_ACCOUNT: string;
|
||||
PHONE: string | null;
|
||||
EMAIL: string | null;
|
||||
CEO_NAME: string | null;
|
||||
BIZ_NO: string | null;
|
||||
ADDRESS: string | null;
|
||||
IS_DEFAULT: string;
|
||||
SORT_ORDER: number;
|
||||
}
|
||||
|
||||
export default function StatementBranchesPage() {
|
||||
const [list, setList] = useState<Branch[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Branch> | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const openNew = () => {
|
||||
setIsNew(true);
|
||||
setEditing({ CODE: "", NAME: "", BANK_ACCOUNT: "", IS_DEFAULT: "N", SORT_ORDER: list.length + 1 });
|
||||
};
|
||||
const openEdit = (b: Branch) => { setIsNew(false); setEditing({ ...b }); };
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
if (!editing.CODE || !editing.NAME || !editing.BANK_ACCOUNT) {
|
||||
Swal.fire({ icon: "warning", title: "코드/이름/계좌번호는 필수" });
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/m/admin/statement-branches/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
actionType: isNew ? "regist" : "update",
|
||||
code: editing.CODE, name: editing.NAME, bankAccount: editing.BANK_ACCOUNT,
|
||||
phone: editing.PHONE ?? "", email: editing.EMAIL ?? "",
|
||||
ceoName: editing.CEO_NAME ?? "", bizNo: editing.BIZ_NO ?? "", address: editing.ADDRESS ?? "",
|
||||
isDefault: editing.IS_DEFAULT ?? "N", sortOrder: String(editing.SORT_ORDER ?? 0),
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "저장 완료", timer: 1200, showConfirmButton: false });
|
||||
setEditing(null); load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||
}
|
||||
};
|
||||
|
||||
const del = async (b: Branch) => {
|
||||
if (b.IS_DEFAULT === "Y") {
|
||||
Swal.fire({ icon: "warning", title: "기본 명세표는 삭제 불가" });
|
||||
return;
|
||||
}
|
||||
const ok = await Swal.fire({ icon: "question", title: `${b.NAME} (${b.CODE}) 삭제?`, showCancelButton: true });
|
||||
if (!ok.isConfirmed) return;
|
||||
const res = await fetch("/api/m/admin/statement-branches/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actionType: "delete", code: b.CODE, name: b.NAME, bankAccount: b.BANK_ACCOUNT }),
|
||||
});
|
||||
if ((await res.json()).success) load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">기준 명세표 관리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">거래명세표/계산서에 표시될 공급자(모모유통) 정보. 사용자별 기준 명세서 설정에서 어느 코드를 사용할지 선택.</p>
|
||||
</div>
|
||||
<button onClick={openNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
|
||||
<Plus size={14} /> 신규
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">코드</th>
|
||||
<th className="px-3 py-2 text-left">이름</th>
|
||||
<th className="px-3 py-2 text-left">결제 계좌</th>
|
||||
<th className="px-3 py-2 text-left">전화</th>
|
||||
<th className="px-3 py-2 text-left">이메일</th>
|
||||
<th className="px-3 py-2 text-center">기본</th>
|
||||
<th className="px-3 py-2 text-center w-[100px]">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-slate-400">등록된 기준 명세표가 없습니다.</td></tr>
|
||||
) : list.map((b) => (
|
||||
<tr key={b.CODE} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono font-semibold text-emerald-700">{b.CODE}</td>
|
||||
<td className="px-3 py-2 font-semibold">{b.NAME}</td>
|
||||
<td className="px-3 py-2 text-xs">{b.BANK_ACCOUNT}</td>
|
||||
<td className="px-3 py-2 text-xs">{b.PHONE}</td>
|
||||
<td className="px-3 py-2 text-xs text-blue-700">{b.EMAIL}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{b.IS_DEFAULT === "Y" && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">기본</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button onClick={() => openEdit(b)} className="text-emerald-700 hover:text-emerald-800 mr-2"><Pencil size={14} /></button>
|
||||
<button onClick={() => del(b)} className="text-rose-500 hover:text-rose-600"><Trash2 size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={() => setEditing(null)}>
|
||||
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-lg p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="font-bold text-lg mb-4">{isNew ? "기준 명세표 등록" : `기준 명세표 수정 (${editing.CODE})`}</h3>
|
||||
<div className="space-y-3">
|
||||
<Field label="코드 *">
|
||||
<input required disabled={!isNew} value={editing.CODE ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, CODE: e.target.value.toUpperCase() })}
|
||||
placeholder="HQ / KIMPO 등" className="w-full h-9 px-3 rounded border border-slate-200 text-sm font-mono disabled:bg-slate-100" />
|
||||
</Field>
|
||||
<Field label="이름 *">
|
||||
<input required value={editing.NAME ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, NAME: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="결제 계좌번호 *">
|
||||
<input required value={editing.BANK_ACCOUNT ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, BANK_ACCOUNT: e.target.value })}
|
||||
placeholder="예: 기업은행 434-115361-01-016 (이상용)"
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="전화">
|
||||
<input value={editing.PHONE ?? ""} onChange={(e) => setEditing({ ...editing, PHONE: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="이메일">
|
||||
<input type="email" value={editing.EMAIL ?? ""} onChange={(e) => setEditing({ ...editing, EMAIL: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="대표자">
|
||||
<input value={editing.CEO_NAME ?? ""} onChange={(e) => setEditing({ ...editing, CEO_NAME: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="사업자등록번호">
|
||||
<input value={editing.BIZ_NO ?? ""} onChange={(e) => setEditing({ ...editing, BIZ_NO: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="주소">
|
||||
<input value={editing.ADDRESS ?? ""} onChange={(e) => setEditing({ ...editing, ADDRESS: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={editing.IS_DEFAULT === "Y"}
|
||||
onChange={(e) => setEditing({ ...editing, IS_DEFAULT: e.target.checked ? "Y" : "N" })}
|
||||
className="w-4 h-4 accent-emerald-600" />
|
||||
기본 명세표
|
||||
</label>
|
||||
<Field label="정렬순">
|
||||
<input type="number" value={editing.SORT_ORDER ?? 0}
|
||||
onChange={(e) => setEditing({ ...editing, SORT_ORDER: Number(e.target.value) })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end mt-5">
|
||||
<button type="button" onClick={() => setEditing(null)} className="h-10 px-4 rounded-lg border border-slate-200 text-sm font-semibold">취소</button>
|
||||
<button type="submit" className="h-10 px-5 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-[11px] font-semibold text-slate-600 mb-1">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,38 @@ import Swal from "sweetalert2";
|
||||
interface Warehouse {
|
||||
OBJID: string; WH_CODE: string; WH_NAME: string; LOCATION: string; WH_TYPE: string;
|
||||
}
|
||||
// 본사/김포 각각 창고/용차/시장 + 김포지사 자체 = 총 7가지
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
STOCK: "본사 창고", KIMPO: "김포 창고", PICKUP_TEAM: "창고픽업팀", MARKET: "시장픽업", DELIVERY: "용차배송",
|
||||
HQ_STOCK: "본사 창고",
|
||||
HQ_CHARTER: "본사 용차",
|
||||
HQ_MARKET: "본사 시장",
|
||||
KIMPO_BRANCH:"김포지사",
|
||||
KIMPO_STOCK: "김포 창고",
|
||||
KIMPO_CHARTER:"김포 용차",
|
||||
KIMPO_MARKET:"김포 시장",
|
||||
// ↓ 옛 enum (기존 데이터 표시 유지)
|
||||
STOCK: "본사 창고",
|
||||
KIMPO: "김포 창고",
|
||||
PICKUP_TEAM: "창고픽업팀",
|
||||
MARKET: "시장픽업",
|
||||
DELIVERY: "용차배송",
|
||||
};
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
STOCK: "bg-emerald-100 text-emerald-700",
|
||||
KIMPO: "bg-teal-100 text-teal-700",
|
||||
PICKUP_TEAM: "bg-sky-100 text-sky-700",
|
||||
MARKET: "bg-amber-100 text-amber-700",
|
||||
DELIVERY: "bg-orange-100 text-orange-700",
|
||||
HQ_STOCK: "bg-emerald-100 text-emerald-700",
|
||||
HQ_CHARTER: "bg-orange-100 text-orange-700",
|
||||
HQ_MARKET: "bg-amber-100 text-amber-700",
|
||||
KIMPO_BRANCH: "bg-teal-100 text-teal-700",
|
||||
KIMPO_STOCK: "bg-teal-100 text-teal-700",
|
||||
KIMPO_CHARTER:"bg-orange-100 text-orange-700",
|
||||
KIMPO_MARKET: "bg-amber-100 text-amber-700",
|
||||
STOCK: "bg-emerald-100 text-emerald-700",
|
||||
KIMPO: "bg-teal-100 text-teal-700",
|
||||
PICKUP_TEAM: "bg-sky-100 text-sky-700",
|
||||
MARKET: "bg-amber-100 text-amber-700",
|
||||
DELIVERY: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
// 신규 카테고리 (select 옵션). 옛 enum 은 데이터 표시만 유지, 신규 추가는 새 enum 만.
|
||||
const NEW_TYPE_ORDER = ["HQ_STOCK", "HQ_CHARTER", "HQ_MARKET", "KIMPO_BRANCH", "KIMPO_STOCK", "KIMPO_CHARTER", "KIMPO_MARKET"];
|
||||
|
||||
export default function WarehousesPage() {
|
||||
const [list, setList] = useState<Warehouse[]>([]);
|
||||
@@ -36,7 +58,7 @@ export default function WarehousesPage() {
|
||||
body: JSON.stringify({
|
||||
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
|
||||
whName: editing.WH_NAME,
|
||||
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
|
||||
location: editing.LOCATION, whType: editing.WH_TYPE || "HQ_STOCK",
|
||||
}),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
@@ -52,7 +74,7 @@ export default function WarehousesPage() {
|
||||
<h1 className="text-xl sm:text-2xl font-bold">창고 관리</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">총 {list.length}개</p>
|
||||
</div>
|
||||
<button onClick={() => setEditing({ WH_TYPE: "STOCK" })}
|
||||
<button onClick={() => setEditing({ WH_TYPE: "HQ_STOCK" })}
|
||||
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800">
|
||||
<Plus size={16} /> 추가
|
||||
</button>
|
||||
@@ -129,8 +151,8 @@ export default function WarehousesPage() {
|
||||
/>
|
||||
</div>
|
||||
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
<select value={editing.WH_TYPE ?? "HQ_STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
{NEW_TYPE_ORDER.map((k) => <option key={k} value={k}>{TYPE_LABEL[k]}</option>)}
|
||||
</select>
|
||||
<input placeholder="위치 (선택)" value={editing.LOCATION ?? ""} onChange={(e) => setEditing({ ...editing, LOCATION: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ function UserForm() {
|
||||
const [form, setForm] = useState<Record<string, string>>({});
|
||||
const [depts, setDepts] = useState<{ DEPT_CODE: string; DEPT_NAME: string }[]>([]);
|
||||
const [whs, setWhs] = useState<{ OBJID: string; WH_NAME: string; WH_CODE: string }[]>([]);
|
||||
const [branches, setBranches] = useState<{ CODE: string; NAME: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const set = (k: string, v: string) => setForm((p) => ({ ...p, [k]: v }));
|
||||
|
||||
@@ -21,6 +22,8 @@ function UserForm() {
|
||||
.then((r) => r.json()).then((d) => setDepts(d.RESULTLIST || [])).catch(() => {});
|
||||
fetch("/api/m/warehouses/list", { method: "POST" })
|
||||
.then((r) => r.json()).then((d) => setWhs(d.RESULTLIST || [])).catch(() => {});
|
||||
fetch("/api/m/admin/statement-branches/list", { method: "POST" })
|
||||
.then((r) => r.json()).then((d) => setBranches(d.RESULTLIST || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,8 +121,11 @@ function UserForm() {
|
||||
onChange={(e) => set("statement_branch", e.target.value)}
|
||||
className="h-8 w-full rounded border border-gray-300 bg-white px-2 text-[12px]"
|
||||
>
|
||||
<option value="HQ">본사 (기업은행 / 이상용)</option>
|
||||
<option value="KIMPO">김포 (농협 / 모모유통)</option>
|
||||
{branches.length === 0 ? (
|
||||
<option value="HQ">본사</option>
|
||||
) : branches.map((b) => (
|
||||
<option key={b.CODE} value={b.CODE}>{b.NAME} ({b.CODE})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 기준 명세표 (공급자 정보) 목록 — admin
|
||||
import { NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST() {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const rows = await queryRows(
|
||||
`SELECT code AS "CODE",
|
||||
name AS "NAME",
|
||||
bank_account AS "BANK_ACCOUNT",
|
||||
phone AS "PHONE",
|
||||
email AS "EMAIL",
|
||||
ceo_name AS "CEO_NAME",
|
||||
biz_no AS "BIZ_NO",
|
||||
address AS "ADDRESS",
|
||||
COALESCE(is_default, 'N') AS "IS_DEFAULT",
|
||||
COALESCE(sort_order, 0) AS "SORT_ORDER"
|
||||
FROM momo_statement_branches
|
||||
WHERE COALESCE(is_del,'N') != 'Y'
|
||||
ORDER BY sort_order ASC, code ASC`
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 기준 명세표 (공급자 정보) 저장 — admin
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
import { invalidateBranchCache } from "@/lib/momo-branches";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const {
|
||||
code, name, bankAccount, phone, email, ceoName, bizNo, address,
|
||||
isDefault, sortOrder, actionType,
|
||||
} = body as Record<string, string>;
|
||||
|
||||
if (!code) return NextResponse.json({ success: false, message: "code 필수" }, { status: 400 });
|
||||
if (!name) return NextResponse.json({ success: false, message: "name 필수" }, { status: 400 });
|
||||
if (!bankAccount) return NextResponse.json({ success: false, message: "bankAccount 필수" }, { status: 400 });
|
||||
|
||||
if (actionType === "delete") {
|
||||
await execute(`UPDATE momo_statement_branches SET is_del='Y' WHERE code=$1`, [code]);
|
||||
} else if (actionType === "regist") {
|
||||
await execute(
|
||||
`INSERT INTO momo_statement_branches (code, name, bank_account, phone, email, ceo_name, biz_no, address, is_default, sort_order, is_del, regdate)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'N',NOW())
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name=EXCLUDED.name, bank_account=EXCLUDED.bank_account,
|
||||
phone=EXCLUDED.phone, email=EXCLUDED.email,
|
||||
ceo_name=EXCLUDED.ceo_name, biz_no=EXCLUDED.biz_no,
|
||||
address=EXCLUDED.address, is_default=EXCLUDED.is_default,
|
||||
sort_order=EXCLUDED.sort_order, is_del='N'`,
|
||||
[code, name, bankAccount, phone ?? null, email ?? null, ceoName ?? null, bizNo ?? null, address ?? null, isDefault === "Y" ? "Y" : "N", Number(sortOrder) || 0]
|
||||
);
|
||||
} else {
|
||||
await execute(
|
||||
`UPDATE momo_statement_branches SET
|
||||
name=$2, bank_account=$3, phone=$4, email=$5, ceo_name=$6, biz_no=$7, address=$8,
|
||||
is_default=$9, sort_order=$10
|
||||
WHERE code=$1`,
|
||||
[code, name, bankAccount, phone ?? null, email ?? null, ceoName ?? null, bizNo ?? null, address ?? null, isDefault === "Y" ? "Y" : "N", Number(sortOrder) || 0]
|
||||
);
|
||||
}
|
||||
invalidateBranchCache();
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -139,8 +139,8 @@ export async function POST(req: NextRequest) {
|
||||
bizNo: order.biz_no as string | undefined,
|
||||
phone: order.phone as string | undefined,
|
||||
},
|
||||
supplier: (() => {
|
||||
const b = getSupplierByBranch(order.statement_branch as string | undefined);
|
||||
supplier: await (async () => {
|
||||
const b = await getSupplierByBranch(order.statement_branch as string | undefined);
|
||||
return { companyName: b.NAME, bankAccount: b.BANK_ACCOUNT, phone: b.PHONE, email: b.EMAIL };
|
||||
})(),
|
||||
items: items.map((it) => ({
|
||||
|
||||
@@ -77,9 +77,9 @@ export async function POST(req: NextRequest) {
|
||||
[objid]
|
||||
);
|
||||
|
||||
// 공급자(모모유통) 정보 — 거래처(사용자)의 statement_branch (HQ / KIMPO) 에 따라 분기
|
||||
// 공급자(모모유통) 정보 — 거래처(사용자)의 statement_branch 에 따라 DB 분기
|
||||
const branch = (order as { STATEMENT_BRANCH?: string }).STATEMENT_BRANCH ?? "HQ";
|
||||
const supplier = getSupplierByBranch(branch);
|
||||
const supplier = await getSupplierByBranch(branch);
|
||||
|
||||
return NextResponse.json({ success: true, order, items, supplier });
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
bizNo: order.biz_no as string | undefined,
|
||||
phone: order.phone as string | undefined,
|
||||
},
|
||||
supplier: (() => {
|
||||
const b = getSupplierByBranch(order.statement_branch as string | undefined);
|
||||
supplier: await (async () => {
|
||||
const b = await getSupplierByBranch(order.statement_branch as string | undefined);
|
||||
return { companyName: b.NAME, bankAccount: b.BANK_ACCOUNT, phone: b.PHONE, email: b.EMAIL };
|
||||
})(),
|
||||
items: items.map((it, idx) => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// 모모유통 공급자(supplier) 정보 — 거래명세표/계산서 발행 시 사용자별로 분기
|
||||
// 사용자(user_info.statement_branch) 가 'HQ' / 'KIMPO' 중 어느 값이냐에 따라 적용
|
||||
export type StatementBranch = "HQ" | "KIMPO";
|
||||
// 모모유통 공급자(supplier) 정보 — DB(momo_statement_branches) 기반
|
||||
// 거래명세표/계산서 발행 시 사용자(user_info.statement_branch) 별로 분기
|
||||
//
|
||||
// 관리자가 /m/admin/statement-branches 페이지에서 정보 편집 가능.
|
||||
// API 요청 시마다 DB 조회하면 부담이라 60초 in-memory 캐시.
|
||||
|
||||
import { queryRows } from "@/lib/db";
|
||||
|
||||
export interface BranchSupplier {
|
||||
CODE: string;
|
||||
NAME: string;
|
||||
CEO: string;
|
||||
BANK_ACCOUNT: string;
|
||||
@@ -12,8 +17,10 @@ export interface BranchSupplier {
|
||||
ADDRESS: string;
|
||||
}
|
||||
|
||||
export const SUPPLIER_BRANCHES: Record<StatementBranch, BranchSupplier> = {
|
||||
// fallback (DB 가 비어있거나 마이그레이션 028 적용 전 환경)
|
||||
const FALLBACK: Record<string, BranchSupplier> = {
|
||||
HQ: {
|
||||
CODE: "HQ",
|
||||
NAME: "모모유통",
|
||||
CEO: "이상용",
|
||||
BANK_ACCOUNT: "기업은행 434-115361-01-016 (이상용)",
|
||||
@@ -23,6 +30,7 @@ export const SUPPLIER_BRANCHES: Record<StatementBranch, BranchSupplier> = {
|
||||
ADDRESS: "",
|
||||
},
|
||||
KIMPO: {
|
||||
CODE: "KIMPO",
|
||||
NAME: "모모유통",
|
||||
CEO: "이상용",
|
||||
BANK_ACCOUNT: "농협 351-1383-7634-13 (모모유통)",
|
||||
@@ -33,7 +41,48 @@ export const SUPPLIER_BRANCHES: Record<StatementBranch, BranchSupplier> = {
|
||||
},
|
||||
};
|
||||
|
||||
export function getSupplierByBranch(branch?: string | null): BranchSupplier {
|
||||
const key = (branch === "KIMPO" ? "KIMPO" : "HQ") as StatementBranch;
|
||||
return SUPPLIER_BRANCHES[key];
|
||||
let cache: { at: number; map: Record<string, BranchSupplier> } | null = null;
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
async function loadBranches(): Promise<Record<string, BranchSupplier>> {
|
||||
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.map;
|
||||
try {
|
||||
const rows = await queryRows<{
|
||||
CODE: string; NAME: string; CEO: string | null; BANK_ACCOUNT: string;
|
||||
PHONE: string | null; EMAIL: string | null; BIZ_NO: string | null; ADDRESS: string | null;
|
||||
}>(
|
||||
`SELECT code AS "CODE", name AS "NAME", ceo_name AS "CEO",
|
||||
bank_account AS "BANK_ACCOUNT", phone AS "PHONE",
|
||||
email AS "EMAIL", biz_no AS "BIZ_NO", address AS "ADDRESS"
|
||||
FROM momo_statement_branches
|
||||
WHERE COALESCE(is_del,'N') != 'Y'`
|
||||
);
|
||||
const map: Record<string, BranchSupplier> = {};
|
||||
for (const r of rows) {
|
||||
map[r.CODE] = {
|
||||
CODE: r.CODE,
|
||||
NAME: r.NAME,
|
||||
CEO: r.CEO ?? "",
|
||||
BANK_ACCOUNT: r.BANK_ACCOUNT,
|
||||
PHONE: r.PHONE ?? "",
|
||||
EMAIL: r.EMAIL ?? "",
|
||||
BIZ_NO: r.BIZ_NO ?? "",
|
||||
ADDRESS: r.ADDRESS ?? "",
|
||||
};
|
||||
}
|
||||
cache = { at: Date.now(), map: { ...FALLBACK, ...map } };
|
||||
return cache.map;
|
||||
} catch {
|
||||
return FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSupplierByBranch(branch?: string | null): Promise<BranchSupplier> {
|
||||
const code = branch || "HQ";
|
||||
const map = await loadBranches();
|
||||
return map[code] ?? map.HQ ?? FALLBACK.HQ;
|
||||
}
|
||||
|
||||
export function invalidateBranchCache() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user