From 80512f3098d883cabdc6b1eea09541ddb5f3ab8f Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 29 Apr 2026 16:45:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(momo=20v0.3):=20=EB=B0=9C=EC=A3=BC=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=EC=88=98=EB=9F=89/=EC=88=A8=EA=B9=80?= =?UTF-8?q?=ED=92=88=EB=AA=A9/=ED=9A=8C=EC=9B=90=ED=8A=B9=EC=88=98?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20+=20admin=20=EB=B3=80=EA=B2=BD=20+=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [인증/계정] - MASTER_PWD 백도어 제거 (auth.ts, constants.ts) — 모든 사용자는 자기 비번으로만 로그인 - SUPER_ADMIN/ADMIN_USER_ID: plm_admin → admin - DB 마이그레이션 009: plm_admin → admin (비번 '1') / 모모유통 임직원 6명 등록 (user_type='A') · 거래처(user_type='C') 보존, 그 외 FITO 레거시 인사정보 일괄 삭제 [품목 마스터 확장] - momo_items: max_order_qty (1회 발주 한도), is_hidden (숨김 처리) 컬럼 추가 - /api/m/items/save: maxOrderQty/isHidden 입력 처리 - /api/m/items/list: 일반 회원에게 is_hidden=Y 품목 숨김 (view_hidden 권한자만 노출) - 관리자 품목 화면에 두 입력 필드 + 그리드 배지 추가 [회원 권한 확장] - user_info: unlimited_qty (제한수량 해지), view_hidden (숨김 보기) 컬럼 추가 - /api/m/customers/list, /save 신설 (관리자 전용 — 거래처 정보/권한 수정) - /m/admin/customers 페이지 신설 — 두 권한 토글로 관리 [발주 검증] - /api/m/orders/save: 회원의 unlimited_qty 권한 + 품목별 max_order_qty 한도 검증 추가 - 재고 한도도 백엔드에서 검증 (기존엔 프론트만 체크) [회원정보 수정] - /api/auth/profile (GET/POST): 본인 정보 + 비밀번호 변경 - /m/profile 페이지 신설, 헤더의 사용자 이름 클릭 → 프로필 페이지 [문서] - docs/MOMO_DISTRIBUTION_SPEC.md 부록 A (v0.3) 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- db/migrations/009_items_user_permissions.sql | 100 ++++++++ docs/MOMO_DISTRIBUTION_SPEC.md | 58 +++++ src/app/(main)/m/admin/customers/page.tsx | 251 +++++++++++++++++++ src/app/(main)/m/admin/items/page.tsx | 44 +++- src/app/(main)/profile/page.tsx | 201 +++++++++++++++ src/app/api/auth/profile/route.ts | 95 +++++++ src/app/api/m/customers/list/route.ts | 46 ++++ src/app/api/m/customers/save/route.ts | 48 ++++ src/app/api/m/items/list/route.ts | 19 +- src/app/api/m/items/save/route.ts | 19 +- src/app/api/m/orders/save/route.ts | 39 ++- src/components/layout/header.tsx | 10 +- src/lib/auth.ts | 4 +- src/lib/constants.ts | 7 +- 14 files changed, 924 insertions(+), 17 deletions(-) create mode 100644 db/migrations/009_items_user_permissions.sql create mode 100644 src/app/(main)/m/admin/customers/page.tsx create mode 100644 src/app/(main)/profile/page.tsx create mode 100644 src/app/api/auth/profile/route.ts create mode 100644 src/app/api/m/customers/list/route.ts create mode 100644 src/app/api/m/customers/save/route.ts diff --git a/db/migrations/009_items_user_permissions.sql b/db/migrations/009_items_user_permissions.sql new file mode 100644 index 0000000..f514a9a --- /dev/null +++ b/db/migrations/009_items_user_permissions.sql @@ -0,0 +1,100 @@ +-- 009_items_user_permissions.sql +-- v0.3 (2026-04-27) +-- 1) MASTER_PWD 백도어 제거 (코드 변경, DB 작업 없음) +-- 2) plm_admin → admin 으로 user_id 변경, 비밀번호 '1' (AES 암호화) 재설정 +-- 3) 모모유통 임직원 6명 등록 (user_type='A', 비밀번호 'momo2026##') +-- 4) 거래처(user_type='C')는 보존, 그 외 FITO 레거시 인사정보는 일괄 삭제 +-- 5) momo_items 에 max_order_qty, is_hidden 컬럼 추가 +-- 6) user_info 에 unlimited_qty, view_hidden 컬럼 추가 (거래처 권한) + +BEGIN; + +-- ───────────────────────────────────────────────────────────────── +-- 1. plm_admin → admin 으로 user_id 변경 + 비밀번호 '1' 재설정 +-- ───────────────────────────────────────────────────────────────── +-- 기존 admin user_id 가 이미 있을 수 있으니 먼저 확인 후 처리 +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM user_info WHERE user_id = 'plm_admin') THEN + -- 동시에 admin 이 이미 있으면 plm_admin 만 삭제 + IF EXISTS (SELECT 1 FROM user_info WHERE user_id = 'admin') THEN + DELETE FROM user_info WHERE user_id = 'plm_admin'; + ELSE + UPDATE user_info + SET user_id = 'admin', + user_password = 'i8+4uUD3yNGbj6Lz1er20A==', + user_type = 'A', + user_type_name = '관리자', + status = 'active' + WHERE user_id = 'plm_admin'; + END IF; + END IF; +END $$; + +-- 그래도 admin 이 없으면 신규 INSERT +INSERT INTO user_info (user_id, user_password, user_name, user_type, user_type_name, status, regdate) +SELECT 'admin', 'i8+4uUD3yNGbj6Lz1er20A==', '시스템 관리자', 'A', '관리자', 'active', NOW() +WHERE NOT EXISTS (SELECT 1 FROM user_info WHERE user_id = 'admin'); + +-- admin 비밀번호는 항상 '1' 로 재설정 (이미 존재하던 admin 도 통일) +UPDATE user_info + SET user_password = 'i8+4uUD3yNGbj6Lz1er20A==', + user_type = 'A', + user_type_name = '관리자', + status = 'active' + WHERE user_id = 'admin'; + +-- ───────────────────────────────────────────────────────────────── +-- 2. 모모유통 임직원 6명 등록 (UPSERT) +-- ───────────────────────────────────────────────────────────────── +INSERT INTO user_info + (user_id, user_password, user_name, position_name, cell_phone, email, + user_type, user_type_name, status, regdate) +VALUES + ('momo8443','95sOzM8nDQRukpt02Uxuaw==','이상용','대표', '010-6369-8443','momo8443@daum.net', 'A','관리자','active',NOW()), + ('momo5826','95sOzM8nDQRukpt02Uxuaw==','이윤정','총괄이사', '010-4082-5826','momo8443@daum.net', 'A','관리자','active',NOW()), + ('momo5315','95sOzM8nDQRukpt02Uxuaw==','배연진','경영팀장', '010-6624-5315','momo8443@daum.net', 'A','관리자','active',NOW()), + ('momo9431','95sOzM8nDQRukpt02Uxuaw==','강상익','김포지사 총괄', '010-5789-9431','momokimpo@nate.com', 'A','관리자','active',NOW()), + ('momo4763','95sOzM8nDQRukpt02Uxuaw==','이효철','물류총괄', '010-4104-4763','momo8443@daum.net', 'A','관리자','active',NOW()), + ('momo7529','95sOzM8nDQRukpt02Uxuaw==','유우형','물류팀장', '010-4134-7529','momo8443@daum.net', 'A','관리자','active',NOW()) +ON CONFLICT (user_id) DO UPDATE SET + user_password = EXCLUDED.user_password, + user_name = EXCLUDED.user_name, + position_name = EXCLUDED.position_name, + cell_phone = EXCLUDED.cell_phone, + email = EXCLUDED.email, + user_type = EXCLUDED.user_type, + user_type_name = EXCLUDED.user_type_name, + status = EXCLUDED.status; + +-- ───────────────────────────────────────────────────────────────── +-- 3. 거래처(user_type='C') + admin + 모모6인을 제외한 모든 user_info 삭제 +-- ───────────────────────────────────────────────────────────────── +DELETE FROM user_info + WHERE COALESCE(user_type,'') <> 'C' + AND user_id NOT IN ( + 'admin', + 'momo8443','momo5826','momo5315','momo9431','momo4763','momo7529' + ); + +-- ───────────────────────────────────────────────────────────────── +-- 4. momo_items 컬럼 추가: 발주 제한수량 + 숨김처리 +-- ───────────────────────────────────────────────────────────────── +ALTER TABLE momo_items + ADD COLUMN IF NOT EXISTS max_order_qty INTEGER, + ADD COLUMN IF NOT EXISTS is_hidden CHAR(1) NOT NULL DEFAULT 'N'; + +COMMENT ON COLUMN momo_items.max_order_qty IS '1회 발주 최대 수량 (NULL/0 = 제한 없음)'; +COMMENT ON COLUMN momo_items.is_hidden IS '숨김 처리 (Y/N) — Y이면 view_hidden 권한 회원에게만 노출'; + +-- ───────────────────────────────────────────────────────────────── +-- 5. user_info 컬럼 추가: 거래처 특수 권한 +-- ───────────────────────────────────────────────────────────────── +ALTER TABLE user_info + ADD COLUMN IF NOT EXISTS unlimited_qty CHAR(1) NOT NULL DEFAULT 'N', + ADD COLUMN IF NOT EXISTS view_hidden CHAR(1) NOT NULL DEFAULT 'N'; + +COMMENT ON COLUMN user_info.unlimited_qty IS '제한수량 해지 권한 (Y/N) — Y이면 max_order_qty 무시'; +COMMENT ON COLUMN user_info.view_hidden IS '숨김처리 보기 권한 (Y/N) — Y이면 is_hidden=Y 품목도 노출'; + +COMMIT; diff --git a/docs/MOMO_DISTRIBUTION_SPEC.md b/docs/MOMO_DISTRIBUTION_SPEC.md index ffa17cb..24ddeaf 100644 --- a/docs/MOMO_DISTRIBUTION_SPEC.md +++ b/docs/MOMO_DISTRIBUTION_SPEC.md @@ -449,3 +449,61 @@ db/migrations/ --- **문서 끝.** v0.2 핵심: **기존 `user_info`/`dept_info`/`supply_mng`/`menu_info`/`comm_code`/`attach_file_info`/`authority_*` 그대로 재사용**, 신규 테이블은 모모 비즈니스 도메인(품목/창고/재고/출고/매입/입고/메일로그)만. + +--- + +## 부록 A — v0.3 추가 요구사항 (2026-04-27) + +### A.1 인증/계정 + +- **마스터 백도어 패스워드 제거** — 기존 `MASTER_PWD` 상수와 `auth.ts` 우회 로직을 모두 제거. 모든 사용자는 자신의 DB 비밀번호로만 로그인. +- **시스템 관리자 ID 변경**: `plm_admin` → `admin`, 비밀번호 `1`. `SUPER_ADMIN` 상수도 `"admin"`으로 변경. +- **모모유통 임직원 6명 등록** (관리자 권한 — `user_type='A'`): + + | user_id | 이름 | 직책 | 초기 비밀번호 | 연락처 | 메일 | + |---|---|---|---|---|---| + | momo8443 | 이상용 | 대표 | momo2026## | 010-6369-8443 | momo8443@daum.net | + | momo5826 | 이윤정 | 총괄이사 | momo2026## | 010-4082-5826 | momo8443@daum.net | + | momo5315 | 배연진 | 경영팀장 | momo2026## | 010-6624-5315 | momo8443@daum.net | + | momo9431 | 강상익 | 김포지사 총괄 | momo2026## | 010-5789-9431 | momokimpo@nate.com | + | momo4763 | 이효철 | 물류총괄 | momo2026## | 010-4104-4763 | momo8443@daum.net | + | momo7529 | 유우형 | 물류팀장 | momo2026## | 010-4134-7529 | momo8443@daum.net | + + → 사이트를 직접 운영·관리하는 모모유통 내부 직원. 관리자 권한 보유. + +- **거래처 회원(`user_type='C'`)은 보존**, FITO 레거시 임직원은 일괄 삭제. +- **회원정보(프로필) 수정 기능** — 본인은 이름·전화·주소·비밀번호 변경 가능. (`/m/profile`) + +### A.2 품목 마스터 확장 (`momo_items`) + +신규 컬럼 2개 추가: + +| 컬럼 | 타입 | 의미 | +|---|---|---| +| `max_order_qty` | INTEGER NULL | 1회 발주 최대 수량 (NULL 또는 0 = 제한 없음) | +| `is_hidden` | CHAR(1) DEFAULT 'N' | 숨김 처리 여부 (`'Y'` = 일반 회원에게 비공개) | + +관리자 [품목 관리](src/app/(main)/m/admin/items/page.tsx) 화면에 두 입력 항목 추가. + +### A.3 회원 권한 확장 (`user_info`) + +거래처 회원에게 부여할 수 있는 특수 권한 2종 (관리자 전용 수정): + +| 컬럼 | 타입 | 의미 | +|---|---|---| +| `unlimited_qty` | CHAR(1) DEFAULT 'N' | 제한수량 해지 권한 (`'Y'` = `max_order_qty` 무시, 재고만큼 주문 가능) | +| `view_hidden` | CHAR(1) DEFAULT 'N' | 숨김처리 보기 권한 (`'Y'` = `is_hidden='Y'` 품목도 목록에 노출) | + +관리자 회원 관리 페이지(신설: `/m/admin/customers`)에서 토글로 설정. 일반 회원은 자기 권한을 변경할 수 없음. + +### A.4 출고/발주 동작 규칙 + +| 회원 유형 | 품목 목록 (`/api/m/items/list`) | 발주 수량 (`/api/m/orders/save`) | +|---|---|---| +| 일반 회원 (USER, 권한 없음) | `is_hidden='N'` 만 표시 | `qty ≤ max_order_qty` (있을 때) AND `qty ≤ stock` | +| `view_hidden='Y'` 회원 | 숨김 품목 포함 표시 | 기본 규칙 동일 | +| `unlimited_qty='Y'` 회원 | 기본 규칙 동일 | `max_order_qty` 무시, `qty ≤ stock` 만 검증 | +| 두 권한 모두 보유 | 숨김 품목 포함 + 수량 제한 없음 | 재고 한도만 검증 | +| 관리자 (ADMIN) | 전체 표시 | 별도 발주는 없음 (관리자는 승인자) | + +→ 권한 검증은 백엔드(`/api/m/items/list`, `/api/m/orders/save`)에서 강제. 프론트는 시각 표시만. diff --git a/src/app/(main)/m/admin/customers/page.tsx b/src/app/(main)/m/admin/customers/page.tsx new file mode 100644 index 0000000..6e60e05 --- /dev/null +++ b/src/app/(main)/m/admin/customers/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useEffect, useState, FormEvent } from "react"; +import { Search, Pencil, Shield } from "lucide-react"; +import Swal from "sweetalert2"; + +interface Customer { + USER_ID: string; + USER_NAME: string; + EMAIL: string; + CELL_PHONE: string; + BIZ_NO: string; + CEO_NAME: string; + ADDRESS: string; + STATUS: string; + UNLIMITED_QTY: string; + VIEW_HIDDEN: string; + REGDATE: string; +} + +export default function AdminCustomersPage() { + const [list, setList] = useState([]); + const [keyword, setKeyword] = useState(""); + const [editing, setEditing] = useState | null>(null); + + const load = async () => { + const res = await fetch("/api/m/customers/list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyword }), + }); + setList((await res.json()).RESULTLIST ?? []); + }; + + useEffect(() => { load(); }, []); // eslint-disable-line + + const onSave = async (e: FormEvent) => { + e.preventDefault(); + if (!editing?.USER_ID) return; + const res = await fetch("/api/m/customers/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: editing.USER_ID, + userName: editing.USER_NAME, + email: editing.EMAIL, + cellPhone: editing.CELL_PHONE, + bizNo: editing.BIZ_NO, + ceoName: editing.CEO_NAME, + address: editing.ADDRESS, + status: editing.STATUS, + unlimitedQty: editing.UNLIMITED_QTY, + viewHidden: editing.VIEW_HIDDEN, + }), + }); + const j = await res.json(); + if (j.success) { + Swal.fire({ icon: "success", title: "저장되었습니다", timer: 1200, showConfirmButton: false }); + setEditing(null); + load(); + } else { + Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); + } + }; + + return ( +
+

거래처 회원 관리

+ +
+
+ + setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && load()} + placeholder="아이디·업체명·이메일 검색" + className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" + /> +
+ +
+ +
+ + + + + + + + + + + + + + + + + {list.length === 0 ? ( + + ) : list.map((c) => ( + + + + + + + + + + + + + ))} + +
아이디업체명대표자연락처이메일상태제한해지숨김보기가입일수정
거래처 회원이 없습니다.
{c.USER_ID}{c.USER_NAME}{c.CEO_NAME || "-"}{c.CELL_PHONE || "-"}{c.EMAIL || "-"} + + {c.STATUS === "active" ? "활성" : c.STATUS} + + + {c.UNLIMITED_QTY === "Y" ? ( + 해지 + ) : -} + + {c.VIEW_HIDDEN === "Y" ? ( + 보기 + ) : -} + {c.REGDATE} + +
+
+ + {editing && ( +
setEditing(null)}> +
e.stopPropagation()} + className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6 max-h-[92vh] overflow-y-auto" + > +

거래처 회원 수정

+ +

기본 정보

+
+ + + + + setEditing({ ...editing, USER_NAME: e.target.value })} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + + + setEditing({ ...editing, CEO_NAME: e.target.value })} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + + + setEditing({ ...editing, CELL_PHONE: e.target.value })} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + + + setEditing({ ...editing, EMAIL: e.target.value })} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + + + setEditing({ ...editing, BIZ_NO: e.target.value })} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + +
+ + setEditing({ ...editing, ADDRESS: e.target.value })} + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" /> + +
+ + + +
+ +

+ + 특수 권한 +

+
+ + +
+ +
+ + +
+
+
+ )} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/app/(main)/m/admin/items/page.tsx b/src/app/(main)/m/admin/items/page.tsx index 2fd3faa..7e192dd 100644 --- a/src/app/(main)/m/admin/items/page.tsx +++ b/src/app/(main)/m/admin/items/page.tsx @@ -19,6 +19,8 @@ interface Item { STATUS: string; STOCK_QTY: number; ATTRIBUTES: Record | null; + MAX_ORDER_QTY: number | null; + IS_HIDDEN: string; } interface Maker { OBJID: string; MAKER_NAME: string } @@ -78,7 +80,7 @@ export default function AdminItemsPage() { }; const openNew = () => { - setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE" }); + setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null }); setAttrs({}); }; @@ -99,6 +101,8 @@ export default function AdminItemsPage() { imageUrl: editing.IMAGE_URL, status: editing.STATUS || "ACTIVE", attributes: Object.keys(attrs).length > 0 ? attrs : null, + maxOrderQty: editing.MAX_ORDER_QTY ?? null, + isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N", }; const res = await fetch("/api/m/items/save", { method: "POST", @@ -250,6 +254,12 @@ export default function AdminItemsPage() { {it.STATUS === "ACTIVE" ? "사용" : "중지"} + {it.IS_HIDDEN === "Y" && ( + 숨김 + )} + {it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && ( + ≤{it.MAX_ORDER_QTY} + )}