feat(momo v0.3): 발주 제한수량/숨김품목/회원특수권한 + admin 변경 + 회원정보 수정
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:
chpark
2026-04-29 16:45:35 +09:00
parent db50153494
commit 80512f3098
14 changed files with 924 additions and 17 deletions
@@ -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;
+58
View File
@@ -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`)에서 강제. 프론트는 시각 표시만.
+251
View File
@@ -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>
);
}
+43 -1
View File
@@ -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
+201
View File
@@ -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>
);
}
+95
View File
@@ -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 });
}
+46
View File
@@ -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 });
}
+48
View File
@@ -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 });
}
+18 -1
View File
@@ -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
+15 -4
View File
@@ -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 });
}
+38 -1
View File
@@ -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) => {
+7 -3
View File
@@ -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
View File
@@ -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: "패스워드가 일치하지 않습니다." };
}
+2 -5
View File
@@ -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",