feat(momo v0.3): 발주 제한수량/숨김품목/회원특수권한 + admin 변경 + 회원정보 수정
Deploy momo-erp / deploy (push) Successful in 1m21s
Deploy momo-erp / deploy (push) Successful in 1m21s
[인증/계정] - 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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`)에서 강제. 프론트는 시각 표시만.
|
||||
|
||||
@@ -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<Customer[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [editing, setEditing] = useState<Partial<Customer> | 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 (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">거래처 회원 관리</h2>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<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" && load()}
|
||||
placeholder="아이디·업체명·이메일 검색"
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
|
||||
조회
|
||||
</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 text-xs uppercase">
|
||||
<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-left px-3 py-3">연락처</th>
|
||||
<th className="text-left px-3 py-3">이메일</th>
|
||||
<th className="text-center px-3 py-3">상태</th>
|
||||
<th className="text-center px-3 py-3">제한해지</th>
|
||||
<th className="text-center px-3 py-3">숨김보기</th>
|
||||
<th className="text-center px-3 py-3">가입일</th>
|
||||
<th className="text-right px-3 py-3 w-[60px]">수정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={10} className="text-center py-12 text-slate-400">거래처 회원이 없습니다.</td></tr>
|
||||
) : list.map((c) => (
|
||||
<tr key={c.USER_ID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 font-mono text-xs">{c.USER_ID}</td>
|
||||
<td className="px-3 py-2 font-semibold">{c.USER_NAME}</td>
|
||||
<td className="px-3 py-2">{c.CEO_NAME || "-"}</td>
|
||||
<td className="px-3 py-2">{c.CELL_PHONE || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-600">{c.EMAIL || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${c.STATUS === "active" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{c.STATUS === "active" ? "활성" : c.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{c.UNLIMITED_QTY === "Y" ? (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">해지</span>
|
||||
) : <span className="text-slate-300">-</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{c.VIEW_HIDDEN === "Y" ? (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 font-bold">보기</span>
|
||||
) : <span className="text-slate-300">-</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-xs text-slate-500">{c.REGDATE}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => setEditing(c)} className="text-slate-400 hover:text-emerald-700 p-1">
|
||||
<Pencil 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-4" onClick={() => setEditing(null)}>
|
||||
<form
|
||||
onSubmit={onSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6 max-h-[92vh] overflow-y-auto"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-5 text-slate-800 border-b pb-3">거래처 회원 수정</h3>
|
||||
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">기본 정보</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-5">
|
||||
<Field label="아이디 (이메일)">
|
||||
<input value={editing.USER_ID ?? ""} disabled
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-slate-50 text-slate-500" />
|
||||
</Field>
|
||||
<Field label="업체명">
|
||||
<input value={editing.USER_NAME ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, USER_NAME: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="대표자">
|
||||
<input value={editing.CEO_NAME ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, CEO_NAME: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="연락처">
|
||||
<input value={editing.CELL_PHONE ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, CELL_PHONE: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="이메일">
|
||||
<input value={editing.EMAIL ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, EMAIL: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg 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-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="주소">
|
||||
<input value={editing.ADDRESS ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, ADDRESS: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="상태">
|
||||
<select
|
||||
value={editing.STATUS ?? "active"}
|
||||
onChange={(e) => setEditing({ ...editing, STATUS: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white"
|
||||
>
|
||||
<option value="active">활성</option>
|
||||
<option value="inactive">비활성</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 mt-2 border-t pt-4 flex items-center gap-2">
|
||||
<Shield size={12} className="text-amber-600" />
|
||||
특수 권한
|
||||
</p>
|
||||
<div className="space-y-3 mb-5">
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.UNLIMITED_QTY === "Y"}
|
||||
onChange={(e) => setEditing({ ...editing, UNLIMITED_QTY: e.target.checked ? "Y" : "N" })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-slate-800">제한수량 해지</div>
|
||||
<div className="text-xs text-slate-500">품목별 1회 발주 제한수량(max_order_qty)을 무시하고 재고만큼 발주 가능</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border border-slate-200 hover:bg-slate-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.VIEW_HIDDEN === "Y"}
|
||||
onChange={(e) => setEditing({ ...editing, VIEW_HIDDEN: e.target.checked ? "Y" : "N" })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-slate-800">숨김처리 보기</div>
|
||||
<div className="text-xs text-slate-500">관리자가 숨김(is_hidden=Y)으로 등록한 품목도 발주 화면에 표시</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<button type="button" onClick={() => setEditing(null)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="px-5 h-10 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 (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ interface Item {
|
||||
STATUS: string;
|
||||
STOCK_QTY: number;
|
||||
ATTRIBUTES: Record<string, unknown> | 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() {
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${it.STATUS === "ACTIVE" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{it.STATUS === "ACTIVE" ? "사용" : "중지"}
|
||||
</span>
|
||||
{it.IS_HIDDEN === "Y" && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">숨김</span>
|
||||
)}
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">≤{it.MAX_ORDER_QTY}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
|
||||
@@ -363,6 +373,38 @@ export default function AdminItemsPage() {
|
||||
<option value="INACTIVE">중지</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="발주 제한수량 (1회 최대)">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={editing.MAX_ORDER_QTY ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEditing({ ...editing, MAX_ORDER_QTY: v === "" ? null : Number(v) });
|
||||
}}
|
||||
placeholder="공란 = 제한 없음"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="숨김 처리">
|
||||
<div className="flex gap-2 h-10">
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="hidden" checked={editing.IS_HIDDEN !== "Y"}
|
||||
onChange={() => setEditing({ ...editing, IS_HIDDEN: "N" })}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
공개
|
||||
</label>
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="hidden" checked={editing.IS_HIDDEN === "Y"}
|
||||
onChange={() => setEditing({ ...editing, IS_HIDDEN: "Y" })}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
숨김
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="상세 설명">
|
||||
<textarea
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { Mail, Lock, Building2, User as UserIcon, Phone, FileText, MapPin, Save } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Profile {
|
||||
USER_ID: string;
|
||||
USER_NAME: string;
|
||||
EMAIL: string;
|
||||
CELL_PHONE: string;
|
||||
TEL: string;
|
||||
ADDRESS: string;
|
||||
CEO_NAME: string;
|
||||
BIZ_NO: string;
|
||||
USER_TYPE: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [pwForm, setPwForm] = useState({ current: "", next: "", confirm: "" });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/auth/profile");
|
||||
const j = await res.json();
|
||||
if (j.success) setProfile(j.data);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const onSaveInfo = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profile) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userName: profile.USER_NAME,
|
||||
email: profile.EMAIL,
|
||||
cellPhone: profile.CELL_PHONE,
|
||||
tel: profile.TEL,
|
||||
address: profile.ADDRESS,
|
||||
ceoName: profile.CEO_NAME,
|
||||
bizNo: profile.BIZ_NO,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) Swal.fire({ icon: "success", title: "저장되었습니다", timer: 1200, showConfirmButton: false });
|
||||
else Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangePassword = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!pwForm.current || !pwForm.next) {
|
||||
Swal.fire({ icon: "warning", title: "현재 비밀번호와 새 비밀번호를 입력하세요." });
|
||||
return;
|
||||
}
|
||||
if (pwForm.next !== pwForm.confirm) {
|
||||
Swal.fire({ icon: "warning", title: "새 비밀번호 확인이 일치하지 않습니다." });
|
||||
return;
|
||||
}
|
||||
if (pwForm.next.length < 4) {
|
||||
Swal.fire({ icon: "warning", title: "비밀번호는 4자 이상이어야 합니다." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/profile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword: pwForm.current,
|
||||
newPassword: pwForm.next,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: "비밀번호가 변경되었습니다.", timer: 1500, showConfirmButton: false });
|
||||
setPwForm({ current: "", next: "", confirm: "" });
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "변경 실패", text: j.message });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!profile) {
|
||||
return <div className="text-slate-400 text-center py-12">불러오는 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<h2 className="text-lg font-bold text-gray-800">회원정보 수정</h2>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<form onSubmit={onSaveInfo} className="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 border-b pb-2">기본 정보</h3>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field icon={<UserIcon size={14} />} label="아이디">
|
||||
<input value={profile.USER_ID} disabled
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm bg-slate-50 text-slate-500" />
|
||||
</Field>
|
||||
<Field icon={<Building2 size={14} />} label={profile.USER_TYPE === "C" ? "업체명" : "이름"}>
|
||||
<input value={profile.USER_NAME ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, USER_NAME: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<Mail size={14} />} label="이메일">
|
||||
<input type="email" value={profile.EMAIL ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, EMAIL: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<Phone size={14} />} label="휴대폰">
|
||||
<input value={profile.CELL_PHONE ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, CELL_PHONE: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<Phone size={14} />} label="전화번호">
|
||||
<input value={profile.TEL ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, TEL: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<UserIcon size={14} />} label="대표자">
|
||||
<input value={profile.CEO_NAME ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, CEO_NAME: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<FileText size={14} />} label="사업자번호">
|
||||
<input value={profile.BIZ_NO ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, BIZ_NO: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field icon={<MapPin size={14} />} label="주소">
|
||||
<input value={profile.ADDRESS ?? ""}
|
||||
onChange={(e) => setProfile({ ...profile, ADDRESS: e.target.value })}
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button type="submit" disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Save size={14} /> 정보 저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 비밀번호 변경 */}
|
||||
<form onSubmit={onChangePassword} className="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 border-b pb-2">비밀번호 변경</h3>
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<Field icon={<Lock size={14} />} label="현재 비밀번호">
|
||||
<input type="password" value={pwForm.current}
|
||||
onChange={(e) => setPwForm({ ...pwForm, current: e.target.value })}
|
||||
autoComplete="current-password"
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<Lock size={14} />} label="새 비밀번호">
|
||||
<input type="password" value={pwForm.next}
|
||||
onChange={(e) => setPwForm({ ...pwForm, next: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field icon={<Lock size={14} />} label="새 비밀번호 확인">
|
||||
<input type="password" value={pwForm.confirm}
|
||||
onChange={(e) => setPwForm({ ...pwForm, confirm: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<button type="submit" disabled={loading}
|
||||
className="inline-flex items-center gap-2 px-5 h-10 rounded-lg bg-slate-700 text-white text-sm font-bold hover:bg-slate-800 disabled:opacity-50">
|
||||
<Lock size={14} /> 비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ icon, label, children }: { icon: React.ReactNode; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">{label}</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">{icon}</span>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 회원정보 수정 — 본인 전용 (이름/연락처/주소/이메일/비밀번호 변경)
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSession, createSession } from "@/lib/session";
|
||||
import { queryOne, execute } from "@/lib/db";
|
||||
import { encrypt } from "@/lib/encrypt";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSession();
|
||||
if (!user) return NextResponse.json({ success: false }, { status: 401 });
|
||||
const userId = user.objid || user.userId;
|
||||
const row = await queryOne<Record<string, string>>(
|
||||
`SELECT user_id AS "USER_ID",
|
||||
user_name AS "USER_NAME",
|
||||
email AS "EMAIL",
|
||||
cell_phone AS "CELL_PHONE",
|
||||
tel AS "TEL",
|
||||
address AS "ADDRESS",
|
||||
ceo_name AS "CEO_NAME",
|
||||
biz_no AS "BIZ_NO",
|
||||
user_type AS "USER_TYPE"
|
||||
FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
if (!row) return NextResponse.json({ success: false, message: "사용자를 찾을 수 없습니다." }, { status: 404 });
|
||||
return NextResponse.json({ success: true, data: row });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSession();
|
||||
if (!user) return NextResponse.json({ success: false }, { status: 401 });
|
||||
const userId = user.objid || user.userId;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const {
|
||||
userName,
|
||||
email,
|
||||
cellPhone,
|
||||
tel,
|
||||
address,
|
||||
ceoName,
|
||||
bizNo,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
} = body as Record<string, string>;
|
||||
|
||||
// 비밀번호 변경 요청이면 현재 비밀번호 검증
|
||||
if (newPassword) {
|
||||
if (!currentPassword) {
|
||||
return NextResponse.json({ success: false, message: "현재 비밀번호를 입력하세요." }, { status: 400 });
|
||||
}
|
||||
if (newPassword.length < 4) {
|
||||
return NextResponse.json({ success: false, message: "새 비밀번호는 4자 이상이어야 합니다." }, { status: 400 });
|
||||
}
|
||||
const row = await queryOne<{ user_password: string }>(
|
||||
`SELECT user_password FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
if (!row || row.user_password !== encrypt(currentPassword)) {
|
||||
return NextResponse.json({ success: false, message: "현재 비밀번호가 일치하지 않습니다." }, { status: 400 });
|
||||
}
|
||||
await execute(
|
||||
`UPDATE user_info SET user_password = $2 WHERE user_id = $1`,
|
||||
[userId, encrypt(newPassword)]
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 정보 업데이트 (NULL이면 기존값 유지)
|
||||
await execute(
|
||||
`UPDATE user_info SET
|
||||
user_name = COALESCE($2, user_name),
|
||||
email = COALESCE($3, email),
|
||||
cell_phone = COALESCE($4, cell_phone),
|
||||
tel = COALESCE($5, tel),
|
||||
address = COALESCE($6, address),
|
||||
ceo_name = COALESCE($7, ceo_name),
|
||||
biz_no = COALESCE($8, biz_no)
|
||||
WHERE user_id = $1`,
|
||||
[userId,
|
||||
userName ?? null, email ?? null, cellPhone ?? null,
|
||||
tel ?? null, address ?? null, ceoName ?? null, bizNo ?? null]
|
||||
);
|
||||
|
||||
// 세션의 표시 정보 갱신 (userName, email, cellPhone)
|
||||
if (userName || email || cellPhone) {
|
||||
await createSession({
|
||||
...user,
|
||||
userName: userName ?? user.userName,
|
||||
email: email ?? user.email,
|
||||
cellPhone: cellPhone ?? user.cellPhone,
|
||||
companyName: userName ?? user.companyName,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 거래처(회원) 목록 — 관리자 전용
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { keyword, status } = body as { keyword?: string; status?: string };
|
||||
|
||||
const conditions: string[] = ["user_type = 'C'"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
if (keyword) {
|
||||
conditions.push(`(user_id ILIKE '%' || $${i} || '%' OR user_name ILIKE '%' || $${i} || '%' OR email ILIKE '%' || $${i} || '%')`);
|
||||
params.push(keyword);
|
||||
i++;
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`status = $${i++}`);
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const rows = await queryRows(
|
||||
`SELECT
|
||||
user_id AS "USER_ID",
|
||||
user_name AS "USER_NAME",
|
||||
email AS "EMAIL",
|
||||
cell_phone AS "CELL_PHONE",
|
||||
biz_no AS "BIZ_NO",
|
||||
ceo_name AS "CEO_NAME",
|
||||
address AS "ADDRESS",
|
||||
status AS "STATUS",
|
||||
COALESCE(unlimited_qty, 'N') AS "UNLIMITED_QTY",
|
||||
COALESCE(view_hidden, 'N') AS "VIEW_HIDDEN",
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE"
|
||||
FROM user_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY regdate DESC
|
||||
LIMIT 500`,
|
||||
params
|
||||
);
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 거래처(회원) 정보/권한 수정 — 관리자 전용
|
||||
// 본인 정보 수정용 엔드포인트는 별도 (/api/auth/profile)
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const {
|
||||
userId,
|
||||
userName,
|
||||
email,
|
||||
cellPhone,
|
||||
bizNo,
|
||||
ceoName,
|
||||
address,
|
||||
status,
|
||||
unlimitedQty,
|
||||
viewHidden,
|
||||
} = body as Record<string, string>;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ success: false, message: "userId 누락" }, { status: 400 });
|
||||
}
|
||||
|
||||
const unlimited = unlimitedQty === "Y" ? "Y" : "N";
|
||||
const view = viewHidden === "Y" ? "Y" : "N";
|
||||
|
||||
await execute(
|
||||
`UPDATE user_info SET
|
||||
user_name = COALESCE($2, user_name),
|
||||
email = COALESCE($3, email),
|
||||
cell_phone = COALESCE($4, cell_phone),
|
||||
biz_no = COALESCE($5, biz_no),
|
||||
ceo_name = COALESCE($6, ceo_name),
|
||||
address = COALESCE($7, address),
|
||||
status = COALESCE($8, status),
|
||||
unlimited_qty = $9,
|
||||
view_hidden = $10
|
||||
WHERE user_id = $1 AND user_type = 'C'`,
|
||||
[userId, userName ?? null, email ?? null, cellPhone ?? null, bizNo ?? null,
|
||||
ceoName ?? null, address ?? null, status ?? null, unlimited, view]
|
||||
);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { queryRows, queryOne } from "@/lib/db";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
|
||||
// 품목 목록 — USER 는 재고 있는 ACTIVE 만, ADMIN 은 전체 표시
|
||||
// 숨김(is_hidden='Y') 품목: view_hidden='Y' 권한 회원과 관리자에게만 노출
|
||||
export async function POST(req: NextRequest) {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
@@ -17,6 +18,18 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
const isUser = r.user.role === "USER";
|
||||
|
||||
// view_hidden 권한 조회 (USER 일 때만 의미 있음)
|
||||
let canViewHidden = !isUser;
|
||||
if (isUser) {
|
||||
const userId = r.user.objid || r.user.userId;
|
||||
const row = await queryOne<{ view_hidden: string }>(
|
||||
`SELECT COALESCE(view_hidden, 'N') AS view_hidden FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
canViewHidden = row?.view_hidden === "Y";
|
||||
}
|
||||
|
||||
const conditions = ["COALESCE(I.is_del, 'N') != 'Y'"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
@@ -27,6 +40,8 @@ export async function POST(req: NextRequest) {
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (!canViewHidden) conditions.push("COALESCE(I.is_hidden, 'N') != 'Y'");
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(I.item_name ILIKE '%' || $${i} || '%' OR I.item_code ILIKE '%' || $${i} || '%' OR I.item_detail ILIKE '%' || $${i} || '%')`);
|
||||
params.push(keyword);
|
||||
@@ -61,6 +76,8 @@ export async function POST(req: NextRequest) {
|
||||
I.image_url AS "IMAGE_URL",
|
||||
I.status AS "STATUS",
|
||||
I.attributes AS "ATTRIBUTES",
|
||||
I.max_order_qty AS "MAX_ORDER_QTY",
|
||||
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
|
||||
@@ -22,7 +22,11 @@ export async function POST(req: NextRequest) {
|
||||
imageUrl,
|
||||
attributes,
|
||||
status,
|
||||
maxOrderQty,
|
||||
isHidden,
|
||||
} = body;
|
||||
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
|
||||
const hidden = isHidden === "Y" ? "Y" : "N";
|
||||
|
||||
if (!itemName) {
|
||||
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
|
||||
@@ -41,13 +45,16 @@ export async function POST(req: NextRequest) {
|
||||
`INSERT INTO momo_items (
|
||||
objid, item_code, item_name, item_detail, maker_objid,
|
||||
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
|
||||
max_order_qty, is_hidden,
|
||||
is_del, regdate, regid
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,'N',NOW(),$13)`,
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,'N',NOW(),$15)`,
|
||||
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null,
|
||||
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
attributes ? JSON.stringify(attributes) : null,
|
||||
status ?? "ACTIVE", userId]
|
||||
status ?? "ACTIVE",
|
||||
maxQty, hidden,
|
||||
userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: newId, itemCode });
|
||||
}
|
||||
@@ -57,13 +64,17 @@ export async function POST(req: NextRequest) {
|
||||
`UPDATE momo_items SET
|
||||
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
|
||||
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
|
||||
attributes=$10::jsonb, status=$11, update_date=NOW(), update_id=$12
|
||||
attributes=$10::jsonb, status=$11,
|
||||
max_order_qty=$12, is_hidden=$13,
|
||||
update_date=NOW(), update_id=$14
|
||||
WHERE objid=$1`,
|
||||
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
|
||||
Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
attributes ? JSON.stringify(attributes) : null,
|
||||
status ?? "ACTIVE", userId]
|
||||
status ?? "ACTIVE",
|
||||
maxQty, hidden,
|
||||
userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: objid });
|
||||
}
|
||||
|
||||
@@ -39,10 +39,26 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 발주자(회원) 권한 — unlimited_qty='Y' 면 max_order_qty 무시
|
||||
const customerRow = await pool.query(
|
||||
`SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty FROM user_info WHERE user_id = $1`,
|
||||
[customerObjid]
|
||||
);
|
||||
const unlimitedQty = customerRow.rows[0]?.unlimited_qty === "Y";
|
||||
|
||||
const itemIds = lines.map((l) => l.itemObjid);
|
||||
const placeholders = itemIds.map((_, i) => `$${i + 1}`).join(",");
|
||||
const items = await pool.query(
|
||||
`SELECT objid, item_name, unit_price, is_tax_free FROM momo_items WHERE objid IN (${placeholders})`,
|
||||
`SELECT
|
||||
I.objid, I.item_name, I.unit_price, I.is_tax_free,
|
||||
I.max_order_qty, COALESCE(I.is_hidden, 'N') AS is_hidden,
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
WHERE S.item_objid = I.objid AND COALESCE(W.is_del,'N') != 'Y'
|
||||
), 0) AS stock_qty
|
||||
FROM momo_items I
|
||||
WHERE I.objid IN (${placeholders}) AND COALESCE(I.is_del, 'N') != 'Y'`,
|
||||
itemIds
|
||||
);
|
||||
const itemMap = new Map(items.rows.map((row) => [row.objid as string, row]));
|
||||
@@ -52,6 +68,27 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// 수량 검증: 재고 한도 + (unlimited_qty 가 아니면) max_order_qty 한도
|
||||
for (const ln of lines) {
|
||||
const it = itemMap.get(ln.itemObjid)!;
|
||||
const stock = Number(it.stock_qty ?? 0);
|
||||
if (Number(ln.qty) > stock) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `${it.item_name} — 재고(${stock})를 초과할 수 없습니다.`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
if (!unlimitedQty) {
|
||||
const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty);
|
||||
if (maxQ > 0 && Number(ln.qty) > maxQ) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `${it.item_name} — 1회 발주 제한수량(${maxQ})을 초과할 수 없습니다.`,
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderObjid = createObjectId();
|
||||
const orderNo = await genOrderNo();
|
||||
const enriched = lines.map((ln, idx) => {
|
||||
|
||||
@@ -130,9 +130,13 @@ export function Header() {
|
||||
|
||||
<div className="flex items-center gap-2 ml-2 pl-2 border-l border-gray-200">
|
||||
<User size={14} className="text-gray-400" />
|
||||
<span className="text-xs text-gray-600">
|
||||
{user?.userName} ({user?.deptName})
|
||||
</span>
|
||||
<a
|
||||
href="/profile"
|
||||
className="text-xs text-gray-600 hover:text-emerald-700 hover:underline"
|
||||
title="회원정보 수정"
|
||||
>
|
||||
{user?.userName} {user?.deptName ? `(${user.deptName})` : ""}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { queryOne } from "./db";
|
||||
import { encrypt } from "./encrypt";
|
||||
import { MASTER_PWD, SUPER_ADMIN } from "./constants";
|
||||
import { SUPER_ADMIN } from "./constants";
|
||||
import { checkNull } from "./utils";
|
||||
import type { User } from "@/types";
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function verifyCredentials(
|
||||
const encPwd = encrypt(password);
|
||||
const dbPwd = checkNull(pwdRow.user_password);
|
||||
|
||||
if (dbPwd !== encPwd && password !== MASTER_PWD) {
|
||||
if (dbPwd !== encPwd) {
|
||||
return { success: false, error: "패스워드가 일치하지 않습니다." };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ export const COMPANY_INFO = {
|
||||
ADDR: "",
|
||||
} as const;
|
||||
|
||||
export const SUPER_ADMIN = "plm_admin";
|
||||
export const ADMIN_USER_ID = "plm_admin";
|
||||
export const SUPER_ADMIN = "admin";
|
||||
export const ADMIN_USER_ID = "admin";
|
||||
|
||||
// 페이징
|
||||
export const COUNT_PER_PAGE = 20;
|
||||
@@ -29,9 +29,6 @@ export const ADMIN_COUNT_PER_PAGE = 20;
|
||||
export const AES_KEY = "ILJIAESSECRETKEY";
|
||||
export const AES_ALGORITHM = "aes-128-ecb";
|
||||
|
||||
// 마스터 비밀번호
|
||||
export const MASTER_PWD = "qlalfqjsgh11";
|
||||
|
||||
// 메뉴 아이콘 매핑 (menu.jsp에서 가져옴)
|
||||
export const MENU_ICON_MAP: Record<string, string> = {
|
||||
DASHBOARD: "LayoutDashboard",
|
||||
|
||||
Reference in New Issue
Block a user