feat(momo): 모모유통 유통관리 ERP 1차 구축 (가입/품목/재고/발주/명세서/메일)
- DB: momo_* 테이블 12종 (users/items/makers/warehouses/stocks/stock_moves/orders/order_items/procurements/vendors/attachments/mail_logs) + 시드 - 인증: 랜딩(/) + 회원가입(/signup, bcrypt) + 로그인(MOMO/FITO 자동 분기) + /api/auth/mobile-login(JWT 토큰) - 세션: 쿠키 + Authorization Bearer 동시 지원 (모바일 앱용) - /m/* 레이아웃: 좌측 사이드바 + 헤더, 역할별 메뉴 분기 - USER 화면: 품목 검색(이미지/재고/단가) + 장바구니 + 발주 요청 + 본인 이력 + 대시보드 - ADMIN 화면: 품목/창고/재고/매입입고/발주승인/회원관리/월간 매출 통계 + 대시보드(14일 그래프, 재고 부족, 승인 대기) - 발주 승인: 트랜잭션으로 재고 차감 + 거래명세표 HTML 메일 본문 + xlsx 첨부 발송 (nodemailer) - 면세 자동 판정: 품목명 'M' 접두 시 is_tax_free=Y, 합계는 면세/과세 분리 집계 - 미들웨어: /, /signup, /api/auth/signup, /api/auth/mobile-login 공개 - 도구: scripts/migrate-momo.mjs (npm run migrate:momo), .env.momo.example - 문서: docs/MOMO_DISTRIBUTION_SPEC.md, docs/proposal.html (고객용 HTML 제안서) - 별도 RN 앱(d:/momo-mobile) 스캐폴드 작성 (Expo + EAS APK 빌드) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# 모모유통(MOMO) 추가 환경변수 — .env / .env.production 에 함께 설정
|
||||||
|
# 기존 FITO 변수에 아래 항목을 추가합니다.
|
||||||
|
|
||||||
|
# ============ DB ============
|
||||||
|
# 모모유통 테이블도 동일 DATABASE_URL 의 momo_* 테이블에 저장됩니다.
|
||||||
|
# DATABASE_URL 은 기존과 동일하게 사용
|
||||||
|
|
||||||
|
# ============ SMTP (메일 발송) ============
|
||||||
|
# 발주 승인 시 거래명세표를 메일로 자동 발송합니다.
|
||||||
|
# 미설정 시: 메일은 jsonTransport 로 콘솔에만 출력 (개발 편의), DB mail_logs 에는 SENT 로 기록
|
||||||
|
SMTP_HOST=smtp.daum.net
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=momo8443@daum.net
|
||||||
|
SMTP_PASS=__다음 메일 앱 비밀번호__
|
||||||
|
SMTP_FROM=모모유통 <momo8443@daum.net>
|
||||||
|
|
||||||
|
# ============ 거래명세표에 표시될 공급자 정보 ============
|
||||||
|
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
|
||||||
|
MOMO_PHONE=010-6624-5315
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 모모유통 (MOMO) 유통관리 시스템 — 초기 스키마
|
||||||
|
-- 기존 FITO 테이블과 분리하기 위해 momo_ 접두사 사용
|
||||||
|
-- 실행: psql $DATABASE_URL -f db/migrations/001_momo_init.sql
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. 회원 (대리점 + 관리자) ----------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_users (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
email VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(200) NOT NULL,
|
||||||
|
company_name VARCHAR(200) NOT NULL,
|
||||||
|
ceo_name VARCHAR(100),
|
||||||
|
biz_no VARCHAR(20),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
address VARCHAR(300),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER | ADMIN
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | LOCKED | LEFT
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT,
|
||||||
|
update_date TIMESTAMP,
|
||||||
|
update_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_users_email ON momo_users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_users_role ON momo_users(role, status);
|
||||||
|
|
||||||
|
-- 2. 제조사 ------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_makers (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
maker_name VARCHAR(200) NOT NULL,
|
||||||
|
contact VARCHAR(100),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
memo TEXT,
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. 품목 --------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_items (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
item_code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
item_name VARCHAR(200) NOT NULL,
|
||||||
|
item_detail TEXT,
|
||||||
|
maker_objid TEXT,
|
||||||
|
unit VARCHAR(20) DEFAULT 'EA',
|
||||||
|
unit_price NUMERIC(15,2) DEFAULT 0,
|
||||||
|
cost_price NUMERIC(15,2) DEFAULT 0,
|
||||||
|
is_tax_free CHAR(1) DEFAULT 'N', -- 'Y' = 면세 (M 접두 품목)
|
||||||
|
image_url TEXT,
|
||||||
|
attributes JSONB,
|
||||||
|
status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT,
|
||||||
|
update_date TIMESTAMP,
|
||||||
|
update_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_items_status ON momo_items(status, is_del);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_items_taxfree ON momo_items(is_tax_free);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_items_name ON momo_items(item_name);
|
||||||
|
|
||||||
|
-- 4. 창고 --------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_warehouses (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
wh_code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
wh_name VARCHAR(200) NOT NULL,
|
||||||
|
location VARCHAR(200),
|
||||||
|
wh_type VARCHAR(20) DEFAULT 'STOCK', -- STOCK | PICKUP_TEAM | MARKET | DELIVERY
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. 재고 (창고×품목) --------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_stocks (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
wh_objid TEXT NOT NULL,
|
||||||
|
item_objid TEXT NOT NULL,
|
||||||
|
qty NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
update_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(wh_objid, item_objid)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_stocks_item ON momo_stocks(item_objid);
|
||||||
|
|
||||||
|
-- 6. 입출고 이력 -------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_stock_moves (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
wh_objid TEXT NOT NULL,
|
||||||
|
item_objid TEXT NOT NULL,
|
||||||
|
move_type VARCHAR(20) NOT NULL, -- IN | OUT | ADJ | TRANSFER
|
||||||
|
qty NUMERIC(15,2) NOT NULL,
|
||||||
|
ref_type VARCHAR(20), -- ORDER | PROCUREMENT | MANUAL
|
||||||
|
ref_objid TEXT,
|
||||||
|
memo TEXT,
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_moves_item ON momo_stock_moves(item_objid, regdate);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_moves_ref ON momo_stock_moves(ref_type, ref_objid);
|
||||||
|
|
||||||
|
-- 7. 발주서 (대리점 → 모모유통) ---------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_orders (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
order_no VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
customer_objid TEXT NOT NULL,
|
||||||
|
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
|
||||||
|
approve_user TEXT,
|
||||||
|
approve_date TIMESTAMP,
|
||||||
|
ship_date TIMESTAMP,
|
||||||
|
invoice_no VARCHAR(50),
|
||||||
|
invoice_date DATE,
|
||||||
|
total_supply NUMERIC(15,2) DEFAULT 0,
|
||||||
|
total_vat NUMERIC(15,2) DEFAULT 0,
|
||||||
|
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||||
|
total_taxfree NUMERIC(15,2) DEFAULT 0,
|
||||||
|
total_taxable NUMERIC(15,2) DEFAULT 0,
|
||||||
|
paid_amount NUMERIC(15,2) DEFAULT 0,
|
||||||
|
paid_date DATE,
|
||||||
|
memo TEXT,
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT,
|
||||||
|
update_date TIMESTAMP,
|
||||||
|
update_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_orders_cust ON momo_orders(customer_objid, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_orders_status ON momo_orders(status, order_date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_order_items (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
order_objid TEXT NOT NULL,
|
||||||
|
item_objid TEXT NOT NULL,
|
||||||
|
item_name_snap VARCHAR(200),
|
||||||
|
unit_price NUMERIC(15,2) NOT NULL,
|
||||||
|
qty NUMERIC(15,2) NOT NULL,
|
||||||
|
is_tax_free CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
|
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
seq INT,
|
||||||
|
remark VARCHAR(200)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_order_items ON momo_order_items(order_objid);
|
||||||
|
|
||||||
|
-- 8. 매입처 / 매입발주 -------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_vendors (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
vendor_name VARCHAR(200) NOT NULL,
|
||||||
|
contact VARCHAR(100),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
biz_no VARCHAR(20),
|
||||||
|
is_del CHAR(1) DEFAULT 'N'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_procurements (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
proc_no VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
vendor_objid TEXT,
|
||||||
|
proc_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
status VARCHAR(20) DEFAULT 'OPEN',
|
||||||
|
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||||
|
memo TEXT,
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_procurement_items (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
proc_objid TEXT NOT NULL,
|
||||||
|
item_objid TEXT NOT NULL,
|
||||||
|
cost_price NUMERIC(15,2) NOT NULL,
|
||||||
|
qty NUMERIC(15,2) NOT NULL,
|
||||||
|
total_amount NUMERIC(15,2) NOT NULL,
|
||||||
|
received_qty NUMERIC(15,2) DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 9. 첨부 / 메일 로그 --------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_attachments (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
ref_type VARCHAR(20) NOT NULL,
|
||||||
|
ref_objid TEXT NOT NULL,
|
||||||
|
file_name VARCHAR(300) NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
file_size BIGINT,
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_attach_ref ON momo_attachments(ref_type, ref_objid);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS momo_mail_logs (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
to_email VARCHAR(200) NOT NULL,
|
||||||
|
subject VARCHAR(300),
|
||||||
|
body TEXT,
|
||||||
|
ref_type VARCHAR(20),
|
||||||
|
ref_objid TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDING',
|
||||||
|
error_msg TEXT,
|
||||||
|
sent_at TIMESTAMP,
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_momo_maillogs_ref ON momo_mail_logs(ref_type, ref_objid);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 모모유통 시드 데이터 — 초기 관리자, 창고 4개, 샘플 제조사
|
||||||
|
-- 실행 전 db/migrations/001_momo_init.sql 적용 필요
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 초기 관리자 (이메일: admin@momo.com / 비밀번호: admin1234 — bcrypt)
|
||||||
|
-- bcrypt hash for "admin1234" cost=10 — 운영 시 반드시 비밀번호 변경
|
||||||
|
INSERT INTO momo_users (objid, email, password_hash, company_name, role, status, regdate)
|
||||||
|
VALUES (
|
||||||
|
'MOMOADM00000001',
|
||||||
|
'admin@momo.com',
|
||||||
|
'$2b$10$gqkZxYVzQwH8gCWPvfBtFOg/9QDx2iO3p0d8RA7d7j.VhSZqHfqTa',
|
||||||
|
'모모유통(관리자)',
|
||||||
|
'ADMIN',
|
||||||
|
'ACTIVE',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
|
-- 창고
|
||||||
|
INSERT INTO momo_warehouses (objid, wh_code, wh_name, wh_type, regdate) VALUES
|
||||||
|
('MOMOWH000000001', 'WH001', '본사창고', 'STOCK', NOW()),
|
||||||
|
('MOMOWH000000002', 'WH002', '시장픽업', 'MARKET', NOW()),
|
||||||
|
('MOMOWH000000003', 'WH003', '용차배송', 'DELIVERY', NOW()),
|
||||||
|
('MOMOWH000000004', 'WH004', '창고픽업팀','PICKUP_TEAM', NOW())
|
||||||
|
ON CONFLICT (wh_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- 샘플 제조사
|
||||||
|
INSERT INTO momo_makers (objid, maker_name, regdate) VALUES
|
||||||
|
('MOMOMK000000001', '성부유통', NOW()),
|
||||||
|
('MOMOMK000000002', '과트', NOW()),
|
||||||
|
('MOMOMK000000003', '날로유진', NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
# 모모유통 — 도매 유통 관리 시스템 개발 스펙
|
||||||
|
|
||||||
|
> **버전**: 0.1 (초안)
|
||||||
|
> **작성일**: 2026-04-25
|
||||||
|
> **대상 도메인**: `momo.junggomoa.com`
|
||||||
|
> **기술 스택**: Next.js 15 (App Router) · React 19 · TypeScript · Tailwind · PostgreSQL (raw SQL via `pg`) · JWT 세션 · Zustand · TanStack Table · SweetAlert2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요약 (TL;DR)
|
||||||
|
|
||||||
|
모모유통은 **대형 도매 유통 업체**다. 본사는 도매처에서 물품을 사들여 **자체 창고에 적재**한 뒤, 가입된 **소매 대리점(소상공인)** 들이 시스템에서 출고를 요청하면 담당자가 검수·승인하여 출고한다.
|
||||||
|
|
||||||
|
- **사용자 그룹 2종**
|
||||||
|
- **일반 사용자(대리점)** — 가입·로그인 후 재고 보유 품목을 보고 **발주(출고요청)** 작성
|
||||||
|
- **관리자(모모유통 담당자)** — 품목·재고·창고 마스터 관리, 발주서 승인, 거래명세서/계산서 발행, 통계 조회
|
||||||
|
- **핵심 워크플로우**: `발주요청` → (담당자 승인) → `발주완료` + **메일 자동 발송** → (월말 일괄) → `계산서 발행 완료`
|
||||||
|
- **면세/과세 구분**: 품목 코드 접두어 `M` = 면세 (예: `M유정란`, `M꽃계탕`). 거래명세서·매출통계에서 `면세매출합` / `과세매출합` 분리 집계.
|
||||||
|
- **이메일 발송**: 발주 승인 시 가입 시 등록한 이메일로 **거래명세표(첨부 또는 본문)** 발송.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사용자 역할 / 권한
|
||||||
|
|
||||||
|
| 역할 | 코드 | 설명 | 접근 가능 메뉴 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 일반 사용자 | `USER` | 대리점/소매상 | 대시보드(본인용), 재고 조회(보유 수량 노출), **출고요청서 작성**, 본인 발주 이력 조회, 본인 미수금/계산서 조회 |
|
||||||
|
| 관리자 | `ADMIN` | 모모유통 담당자 | 전체 메뉴 (품목·재고·창고·발주서 승인·거래명세서·계산서·통계·회원관리) |
|
||||||
|
|
||||||
|
> `users` 테이블의 `role` 컬럼으로 구분. 가입 직후 기본값은 `USER`. 관리자 승격은 어드민 패널에서 수동.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 회원가입 / 로그인
|
||||||
|
|
||||||
|
### 2.1 가입 화면 (`/signup`)
|
||||||
|
- 필드
|
||||||
|
- **이메일** (필수, 유니크, 로그인 ID 겸용)
|
||||||
|
- **사용자명 (업체명)** (필수, 표시명)
|
||||||
|
- **비밀번호** / **비밀번호 확인** (필수)
|
||||||
|
- **연락처** (선택)
|
||||||
|
- **사업자등록번호** (선택, 거래명세서 출력용)
|
||||||
|
- **대표자명** (선택, 거래명세서 출력용)
|
||||||
|
- 검증
|
||||||
|
- 이메일 형식, 비밀번호 8자 이상, 업체명 중복은 허용 (이메일만 유니크)
|
||||||
|
- 저장 시
|
||||||
|
- `password`는 bcrypt 해시 (`bcryptjs` 권장, `pg` 환경에서 트라이비얼)
|
||||||
|
- `role = 'USER'`, `status = 'ACTIVE'`, `is_del = 'N'`
|
||||||
|
|
||||||
|
### 2.2 로그인 화면 (`/login`) — 기존 화면 재사용
|
||||||
|
- 입력: 이메일 + 비밀번호
|
||||||
|
- 성공 시: JWT 발급 → `plm-session` 쿠키 설정 → `/` 리다이렉트
|
||||||
|
- 가입 링크 추가: 로그인 폼 하단에 "회원가입" 버튼
|
||||||
|
|
||||||
|
### 2.3 미들웨어 정책 변경
|
||||||
|
`src/middleware.ts`의 공개 경로에 `/signup`, `/api/auth/signup` 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 메뉴 정리 (기존 FITO/PLM → 모모유통)
|
||||||
|
|
||||||
|
기존 PLM 메뉴 중 **불필요한 항목 삭제**, **유사 메뉴 재활용**, 신규 메뉴 추가.
|
||||||
|
|
||||||
|
### 3.1 제거 (사용 안 함)
|
||||||
|
다음 디렉토리/메뉴는 폐기 — `src/app/(main)/` 및 `src/app/api/` 양쪽에서 정리 필요:
|
||||||
|
|
||||||
|
- `bom`, `product/bom-list`, `product/bom-register` — BOM 관리 (제조 PLM 전용)
|
||||||
|
- `product/design-change`, `product/part-change` — 설계변경/부품변경
|
||||||
|
- `product/spec`, `product/part-list`, `product/part-register` — 부품 마스터
|
||||||
|
- `part`, `part-mgmt` — 부품 관리
|
||||||
|
- `procurement-std` — 조달 표준
|
||||||
|
- `production` — 생산 관리
|
||||||
|
- `quality` — 품질 관리
|
||||||
|
- `project` — 프로젝트 관리
|
||||||
|
- `scm` — SCM
|
||||||
|
- `work` — 업무 (워크플로우)
|
||||||
|
- `cost`, `cost-mgmt` — 원가 관리 (※ 어드민용 매출/원가/마진 통계는 §10에서 별도 신규)
|
||||||
|
- `cs` — 고객지원 (필요 시 후속 단계로 보류)
|
||||||
|
- `fund` — 자금 (필요 시 후속 단계로 보류)
|
||||||
|
- `delivery` — 납품 (출고관리로 대체)
|
||||||
|
- `approval` — 결재 (단순 상태 전이로 대체)
|
||||||
|
- `purchase` — 매입 (`procurement` 신규로 대체)
|
||||||
|
- `sales` — 매출 (`statistics` 신규로 대체)
|
||||||
|
|
||||||
|
### 3.2 재활용 (이름·로직 일부 변경)
|
||||||
|
| 기존 | 신규 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `product/*` | `item/*` (품목 마스터) | 품목명/제조사/사진/면세여부/속성 |
|
||||||
|
| `inventory/list` | `inventory/list` | 창고별 현재고 — 그대로 |
|
||||||
|
| `inventory/status` | `inventory/status` | 입출고 이력 — 그대로 |
|
||||||
|
| `inventory/request` | (삭제) | 신규 `order` 메뉴로 대체 |
|
||||||
|
| `purchase-order` | `procurement` | 도매처 → 모모유통 매입 발주 (관리자 전용) |
|
||||||
|
| `order` | `order` | 대리점 → 모모유통 출고요청서 (핵심) |
|
||||||
|
| `dashboard` | `dashboard` | 그대로 — 콘텐츠 교체 |
|
||||||
|
| `admin` / `admin-panel` | `admin` | 회원/코드/메뉴 관리 |
|
||||||
|
|
||||||
|
### 3.3 최종 메뉴 트리
|
||||||
|
|
||||||
|
```
|
||||||
|
홈 (/)
|
||||||
|
├─ 대시보드 (/dashboard)
|
||||||
|
│ ├─ 일반: 내 발주 진행 현황, 추천/신규 품목, 미수금
|
||||||
|
│ └─ 관리자: 발주 승인 대기, 재고 알림, 매출 그래프, 미수금 합계
|
||||||
|
├─ 품목 관리 (/item) [관리자]
|
||||||
|
│ ├─ 품목 목록 (/item/list)
|
||||||
|
│ ├─ 품목 등록·수정 (/item/form)
|
||||||
|
│ └─ 제조사 관리 (/item/maker)
|
||||||
|
├─ 창고 관리 (/warehouse) [관리자]
|
||||||
|
│ ├─ 창고 목록 (/warehouse/list)
|
||||||
|
│ └─ 창고별 재고 (/warehouse/stock)
|
||||||
|
├─ 재고 관리 (/inventory) [공용 — 일반은 조회만]
|
||||||
|
│ ├─ 현재고 (/inventory/list)
|
||||||
|
│ ├─ 입출고 이력 (/inventory/history)
|
||||||
|
│ └─ 입고 등록 (/inventory/inbound) [관리자]
|
||||||
|
├─ 발주 관리 (/order) [공용]
|
||||||
|
│ ├─ 발주서 목록 (/order/list) (일반: 본인 / 관리자: 전체)
|
||||||
|
│ ├─ 발주서 작성 (/order/form)
|
||||||
|
│ └─ 거래명세표 (/order/statement/[id])
|
||||||
|
├─ 매입(조달) 관리 (/procurement) [관리자]
|
||||||
|
│ ├─ 매입 발주서 (/procurement/list)
|
||||||
|
│ └─ 매입처 관리 (/procurement/vendor)
|
||||||
|
├─ 정산 관리 (/settlement) [관리자]
|
||||||
|
│ ├─ 거래명세서 일괄 발행 (/settlement/statement)
|
||||||
|
│ ├─ 계산서 발행 (/settlement/invoice)
|
||||||
|
│ └─ 입금 관리 (/settlement/payment)
|
||||||
|
├─ 통계 (/statistics) [관리자]
|
||||||
|
│ ├─ 일자별 발주 현황 (/statistics/daily)
|
||||||
|
│ ├─ 월간 누적 (/statistics/monthly)
|
||||||
|
│ ├─ 업체별 매출 (/statistics/by-company)
|
||||||
|
│ └─ 품목별 발주 (/statistics/by-item)
|
||||||
|
└─ 시스템 (/admin) [관리자]
|
||||||
|
├─ 회원 관리 (/admin/users)
|
||||||
|
├─ 공통 코드 (/admin/code)
|
||||||
|
└─ 메뉴 관리 (/admin/menu)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 데이터 모델 (PostgreSQL)
|
||||||
|
|
||||||
|
> **명명 규칙**: 테이블·컬럼은 모두 `snake_case`. PK는 `objid TEXT` (기존 FITO 규약 — `createObjectId()` 사용). 삭제는 soft delete (`is_del CHAR(1) DEFAULT 'N'`). 생성/수정 시각은 `regdate`, `regid`, `update_date`, `update_id`.
|
||||||
|
|
||||||
|
### 4.1 사용자 / 인증
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
email VARCHAR(200) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(200) NOT NULL,
|
||||||
|
company_name VARCHAR(200) NOT NULL, -- 업체명 (표시명)
|
||||||
|
ceo_name VARCHAR(100), -- 대표자명
|
||||||
|
biz_no VARCHAR(20), -- 사업자등록번호
|
||||||
|
phone VARCHAR(50),
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER | ADMIN
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | LOCKED | LEFT
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT,
|
||||||
|
update_date TIMESTAMP,
|
||||||
|
update_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 제조사 / 품목
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE makers (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
maker_name VARCHAR(200) NOT NULL,
|
||||||
|
contact VARCHAR(100),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE items (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
item_code VARCHAR(50) NOT NULL UNIQUE, -- 자동 생성: ITM-YYYYMMDD-####
|
||||||
|
item_name VARCHAR(200) NOT NULL, -- 표시명 (예: "M유정란", "빨강 탈취제")
|
||||||
|
item_detail TEXT, -- 상세명 / 설명
|
||||||
|
maker_objid TEXT REFERENCES makers(objid),
|
||||||
|
unit VARCHAR(20) DEFAULT 'EA', -- EA, BOX, KG 등
|
||||||
|
unit_price NUMERIC(15,2) DEFAULT 0, -- 기본 단가 (대리점 출고가)
|
||||||
|
cost_price NUMERIC(15,2) DEFAULT 0, -- 매입 원가
|
||||||
|
is_tax_free CHAR(1) DEFAULT 'N', -- 'Y' = 면세 (M 접두 품목)
|
||||||
|
image_url TEXT, -- /uploads/items/xxx.jpg
|
||||||
|
attributes JSONB, -- 자유 속성 (소비기한, 보관조건 등)
|
||||||
|
status VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE | INACTIVE
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT,
|
||||||
|
update_date TIMESTAMP,
|
||||||
|
update_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_items_status ON items(status, is_del);
|
||||||
|
CREATE INDEX idx_items_taxfree ON items(is_tax_free);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`is_tax_free` 자동 판정 보조**: 등록 화면에서 품목명이 `M`으로 시작하면 기본값을 `'Y'`로 토글 (사용자가 수정 가능).
|
||||||
|
|
||||||
|
### 4.3 창고 / 재고
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE warehouses (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
wh_code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
wh_name VARCHAR(200) NOT NULL, -- 예: "본사창고", "시장픽업", "용차배송"
|
||||||
|
location VARCHAR(200), -- 위치 메모
|
||||||
|
wh_type VARCHAR(20) DEFAULT 'STOCK', -- STOCK | PICKUP_TEAM | MARKET | DELIVERY
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 창고×품목별 현재고 (스냅샷)
|
||||||
|
CREATE TABLE stocks (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
wh_objid TEXT NOT NULL REFERENCES warehouses(objid),
|
||||||
|
item_objid TEXT NOT NULL REFERENCES items(objid),
|
||||||
|
qty NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
update_date TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(wh_objid, item_objid)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_stocks_item ON stocks(item_objid);
|
||||||
|
|
||||||
|
-- 입출고 이력 (감사 로그) — 모든 재고 변동은 여기 기록
|
||||||
|
CREATE TABLE stock_moves (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
wh_objid TEXT NOT NULL,
|
||||||
|
item_objid TEXT NOT NULL,
|
||||||
|
move_type VARCHAR(20) NOT NULL, -- IN(매입입고) | OUT(출고) | ADJ(조정) | TRANSFER
|
||||||
|
qty NUMERIC(15,2) NOT NULL, -- 양수: 입고, 음수: 출고
|
||||||
|
ref_type VARCHAR(20), -- ORDER | PROCUREMENT | MANUAL
|
||||||
|
ref_objid TEXT, -- orders.objid 또는 procurements.objid
|
||||||
|
memo TEXT,
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_stock_moves_item ON stock_moves(item_objid, regdate);
|
||||||
|
CREATE INDEX idx_stock_moves_ref ON stock_moves(ref_type, ref_objid);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 발주서 (대리점 → 모모유통)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE orders (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
order_no VARCHAR(50) NOT NULL UNIQUE, -- ORD-YYYYMMDD-####
|
||||||
|
customer_objid TEXT NOT NULL REFERENCES users(objid), -- 발주한 대리점
|
||||||
|
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
|
||||||
|
-- REQUESTED(발주요청) | APPROVED(발주완료) | SHIPPED(출고완료)
|
||||||
|
-- | INVOICED(계산서발행완료) | PAID(완납) | CANCELLED
|
||||||
|
approve_user TEXT REFERENCES users(objid), -- 승인 담당자
|
||||||
|
approve_date TIMESTAMP,
|
||||||
|
ship_date TIMESTAMP,
|
||||||
|
invoice_no VARCHAR(50), -- 계산서 번호
|
||||||
|
invoice_date DATE,
|
||||||
|
total_supply NUMERIC(15,2) DEFAULT 0, -- 공급가액 합계
|
||||||
|
total_vat NUMERIC(15,2) DEFAULT 0, -- 세액 합계
|
||||||
|
total_amount NUMERIC(15,2) DEFAULT 0, -- 총 합계 (VAT 포함)
|
||||||
|
total_taxfree NUMERIC(15,2) DEFAULT 0, -- 면세 합계
|
||||||
|
total_taxable NUMERIC(15,2) DEFAULT 0, -- 과세 합계 (공급가)
|
||||||
|
paid_amount NUMERIC(15,2) DEFAULT 0, -- 입금액
|
||||||
|
paid_date DATE,
|
||||||
|
memo TEXT,
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT,
|
||||||
|
update_date TIMESTAMP,
|
||||||
|
update_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_orders_customer ON orders(customer_objid, status);
|
||||||
|
CREATE INDEX idx_orders_status_date ON orders(status, order_date);
|
||||||
|
|
||||||
|
CREATE TABLE order_items (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
order_objid TEXT NOT NULL REFERENCES orders(objid) ON DELETE CASCADE,
|
||||||
|
item_objid TEXT NOT NULL REFERENCES items(objid),
|
||||||
|
item_name_snap VARCHAR(200), -- 발주 시점 품목명 스냅샷
|
||||||
|
unit_price NUMERIC(15,2) NOT NULL, -- 발주 시점 단가
|
||||||
|
qty NUMERIC(15,2) NOT NULL,
|
||||||
|
is_tax_free CHAR(1) NOT NULL DEFAULT 'N', -- 발주 시점 면세 플래그
|
||||||
|
supply_amount NUMERIC(15,2) NOT NULL, -- 공급가 = unit_price × qty (면세)
|
||||||
|
-- = round(unit_price × qty / 1.1) (과세)
|
||||||
|
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,-- 세액 (면세 = 0)
|
||||||
|
total_amount NUMERIC(15,2) NOT NULL, -- 합계 = supply + vat
|
||||||
|
seq INT,
|
||||||
|
remark VARCHAR(200)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_order_items_order ON order_items(order_objid);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 매입 발주 (모모유통 → 도매처)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE vendors (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
vendor_name VARCHAR(200) NOT NULL,
|
||||||
|
contact VARCHAR(100),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
biz_no VARCHAR(20),
|
||||||
|
is_del CHAR(1) DEFAULT 'N'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE procurements (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
proc_no VARCHAR(50) NOT NULL UNIQUE, -- PRC-YYYYMMDD-####
|
||||||
|
vendor_objid TEXT REFERENCES vendors(objid),
|
||||||
|
proc_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
status VARCHAR(20) DEFAULT 'OPEN', -- OPEN | RECEIVED | CLOSED
|
||||||
|
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||||
|
memo TEXT,
|
||||||
|
is_del CHAR(1) DEFAULT 'N',
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE procurement_items (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
proc_objid TEXT NOT NULL REFERENCES procurements(objid) ON DELETE CASCADE,
|
||||||
|
item_objid TEXT NOT NULL REFERENCES items(objid),
|
||||||
|
cost_price NUMERIC(15,2) NOT NULL, -- 매입 단가
|
||||||
|
qty NUMERIC(15,2) NOT NULL,
|
||||||
|
total_amount NUMERIC(15,2) NOT NULL,
|
||||||
|
received_qty NUMERIC(15,2) DEFAULT 0 -- 입고 처리된 수량
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 첨부 / 알림 / 메일 로그
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE attachments (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
ref_type VARCHAR(20) NOT NULL, -- ITEM | ORDER | PROCUREMENT
|
||||||
|
ref_objid TEXT NOT NULL,
|
||||||
|
file_name VARCHAR(300) NOT NULL,
|
||||||
|
file_path TEXT NOT NULL, -- /public/uploads/...
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
file_size BIGINT,
|
||||||
|
regdate TIMESTAMP DEFAULT NOW(),
|
||||||
|
regid TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_attach_ref ON attachments(ref_type, ref_objid);
|
||||||
|
|
||||||
|
CREATE TABLE mail_logs (
|
||||||
|
objid TEXT PRIMARY KEY,
|
||||||
|
to_email VARCHAR(200) NOT NULL,
|
||||||
|
subject VARCHAR(300),
|
||||||
|
body TEXT,
|
||||||
|
ref_type VARCHAR(20),
|
||||||
|
ref_objid TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING | SENT | FAILED
|
||||||
|
error_msg TEXT,
|
||||||
|
sent_at TIMESTAMP,
|
||||||
|
regdate TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API 라우트 (Next.js Route Handler)
|
||||||
|
|
||||||
|
> 모든 핸들러 첫 줄에 `getSession()` 검증, ADMIN 전용은 `user.role !== 'ADMIN'` 시 403. 응답 규약은 `.claude/rules/api-routes.md` 준수.
|
||||||
|
|
||||||
|
### 5.1 인증
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/auth/signup` | 가입 (`email`, `password`, `companyName`, `ceoName?`, `bizNo?`, `phone?`) |
|
||||||
|
| POST | `/api/auth/login` | 로그인 (기존) |
|
||||||
|
| POST | `/api/auth/logout` | 로그아웃 (기존) |
|
||||||
|
| GET | `/api/auth/me` | 세션 사용자 (기존) |
|
||||||
|
|
||||||
|
### 5.2 품목 / 제조사 (ADMIN)
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/item/list` | 품목 검색 (검색어, 면세여부, 제조사, 상태) |
|
||||||
|
| POST | `/api/item/save` | 등록/수정 (`actionType`: `regist` \| `update`) |
|
||||||
|
| POST | `/api/item/delete` | 일괄 soft delete |
|
||||||
|
| POST | `/api/item/upload-image` | 이미지 업로드 → `image_url` |
|
||||||
|
| POST | `/api/maker/list` | 제조사 목록 |
|
||||||
|
| POST | `/api/maker/save` | 제조사 등록/수정 |
|
||||||
|
|
||||||
|
### 5.3 창고 / 재고
|
||||||
|
| Method | Path | 설명 | 권한 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/warehouse/list` | 창고 목록 | ADMIN |
|
||||||
|
| POST | `/api/warehouse/save` | 창고 등록/수정 | ADMIN |
|
||||||
|
| POST | `/api/inventory/list` | 현재고 (창고×품목) | 공용 (일반은 본인 가용 재고만) |
|
||||||
|
| POST | `/api/inventory/history` | 입출고 이력 | ADMIN |
|
||||||
|
| POST | `/api/inventory/inbound` | 매입 입고 등록 (재고 +, stock_moves IN 기록) | ADMIN |
|
||||||
|
| POST | `/api/inventory/adjust` | 재고 수동 조정 | ADMIN |
|
||||||
|
|
||||||
|
### 5.4 발주서 (출고요청)
|
||||||
|
| Method | Path | 설명 | 권한 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/order/list` | 발주서 목록 (USER는 본인만, ADMIN은 전체) | 공용 |
|
||||||
|
| POST | `/api/order/detail` | 발주서 상세 + items | 공용 (본인/ADMIN) |
|
||||||
|
| POST | `/api/order/save` | **신규 작성/수정** (status=REQUESTED) | 공용 |
|
||||||
|
| POST | `/api/order/cancel` | 본인 발주 취소 (REQUESTED 상태에서만) | 공용 |
|
||||||
|
| POST | `/api/order/approve` | **승인 → APPROVED + 재고 차감 + 메일 발송** | ADMIN |
|
||||||
|
| POST | `/api/order/reject` | 반려 → CANCELLED | ADMIN |
|
||||||
|
| POST | `/api/order/ship` | 출고 처리 → SHIPPED | ADMIN |
|
||||||
|
| GET | `/api/order/statement/[id]` | 거래명세표 데이터 (HTML/PDF용 JSON) | 공용 (본인/ADMIN) |
|
||||||
|
| POST | `/api/order/invoice` | 계산서 발행 일괄 처리 → INVOICED | ADMIN |
|
||||||
|
| POST | `/api/order/payment` | 입금 등록 → PAID | ADMIN |
|
||||||
|
|
||||||
|
### 5.5 매입 (조달, ADMIN)
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/procurement/list` | |
|
||||||
|
| POST | `/api/procurement/save` | |
|
||||||
|
| POST | `/api/procurement/receive` | 입고 처리 → 재고 + |
|
||||||
|
| POST | `/api/vendor/list` | |
|
||||||
|
| POST | `/api/vendor/save` | |
|
||||||
|
|
||||||
|
### 5.6 통계 (ADMIN)
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/statistics/daily` | 일자별 발주 합계 (날짜 범위) |
|
||||||
|
| POST | `/api/statistics/monthly` | 월별 누적 합계 (연도) |
|
||||||
|
| POST | `/api/statistics/by-company` | 업체별 매출 (월/연도) |
|
||||||
|
| POST | `/api/statistics/by-item` | 품목별 발주 수량 (날짜 범위) |
|
||||||
|
| POST | `/api/statistics/dashboard` | 대시보드 카드용 요약 (오늘 발주, 승인 대기, 미수금, 재고 부족) |
|
||||||
|
|
||||||
|
### 5.7 회원 관리 (ADMIN)
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/admin/users/list` | |
|
||||||
|
| POST | `/api/admin/users/save` | 권한 변경, 상태 변경 |
|
||||||
|
| POST | `/api/admin/users/reset-password` | 비밀번호 초기화 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 핵심 워크플로우 — 발주서 라이프사이클
|
||||||
|
|
||||||
|
```
|
||||||
|
[대리점] 출고요청서 작성 (status=REQUESTED)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[관리자] 발주 관리 → 승인 (POST /api/order/approve)
|
||||||
|
├─ 1) 재고 검증: 모든 라인 qty <= 가용 재고? 실패 시 400
|
||||||
|
├─ 2) UPDATE orders SET status='APPROVED', approve_user, approve_date
|
||||||
|
├─ 3) FOR EACH order_item:
|
||||||
|
│ UPDATE stocks SET qty = qty - {qty} WHERE item_objid=...
|
||||||
|
│ INSERT stock_moves(move_type='OUT', ref='ORDER', ref_objid=order)
|
||||||
|
├─ 4) 거래명세표 HTML 생성
|
||||||
|
├─ 5) 메일 발송: customer.email → 거래명세표 첨부/본문
|
||||||
|
└─ 6) mail_logs INSERT (status=SENT/FAILED)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[관리자] 출고 처리 (선택) → status=SHIPPED (포장/배송 완료)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[관리자] 월말 일괄 → 계산서 발행 (POST /api/order/invoice)
|
||||||
|
└─ status=INVOICED, invoice_no/invoice_date 채움
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[관리자] 입금 등록 → status=PAID, paid_amount, paid_date
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 트랜잭션 경계
|
||||||
|
승인(approve)은 **단일 트랜잭션** 안에서 처리한다 — 재고 차감 실패 시 발주 상태도 롤백.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 의사 코드
|
||||||
|
await db.tx(async (tx) => {
|
||||||
|
for (const item of items) {
|
||||||
|
const stock = await tx.queryOne(
|
||||||
|
`SELECT qty FROM stocks WHERE item_objid=$1 FOR UPDATE`, [item.itemObjid]
|
||||||
|
);
|
||||||
|
if (Number(stock.qty) < Number(item.qty)) throw new Error('재고 부족');
|
||||||
|
await tx.execute(`UPDATE stocks SET qty = qty - $1 ...`, [...]);
|
||||||
|
await tx.execute(`INSERT INTO stock_moves (...)`, [...]);
|
||||||
|
}
|
||||||
|
await tx.execute(`UPDATE orders SET status='APPROVED', ...`);
|
||||||
|
});
|
||||||
|
// 트랜잭션 성공 후 메일 발송 (실패해도 발주 상태는 유지, mail_logs로 추적)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 금액 계산 규칙
|
||||||
|
- **면세** (`is_tax_free='Y'`): `supply_amount = unit_price × qty`, `vat_amount = 0`, `total_amount = supply_amount`
|
||||||
|
- **과세** (`is_tax_free='N'`): `unit_price`가 **VAT 포함가**라고 가정
|
||||||
|
- `total_amount = unit_price × qty`
|
||||||
|
- `supply_amount = round(total_amount / 1.1)`
|
||||||
|
- `vat_amount = total_amount - supply_amount`
|
||||||
|
- 발주 헤더 합계는 라인 합산:
|
||||||
|
- `total_supply = SUM(supply_amount)`
|
||||||
|
- `total_vat = SUM(vat_amount)`
|
||||||
|
- `total_amount = SUM(total_amount)`
|
||||||
|
- `total_taxfree = SUM(supply_amount WHERE is_tax_free='Y')`
|
||||||
|
- `total_taxable = SUM(supply_amount WHERE is_tax_free='N')`
|
||||||
|
|
||||||
|
> 단가가 VAT-별도 모델인 경우 `items.price_mode` 컬럼을 `INCL`/`EXCL`로 추가하여 분기 (현재 스펙은 INCL 기본).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 페이지 명세
|
||||||
|
|
||||||
|
### 7.1 대리점(USER) 페이지
|
||||||
|
|
||||||
|
#### `/dashboard` (USER 화면)
|
||||||
|
- 카드 4개: `진행중 발주 N건` / `이번달 누적 ₩` / `미수금 ₩` / `재고 알림 N건`
|
||||||
|
- 최근 발주 5건 그리드 (발주번호, 일자, 합계, 상태)
|
||||||
|
- "출고요청 작성" CTA 버튼 → `/order/form`
|
||||||
|
|
||||||
|
#### `/order/form` — 출고요청서 작성
|
||||||
|
- 좌측: **품목 선택 패널**
|
||||||
|
- 검색: 품목명, 제조사, 면세여부 필터
|
||||||
|
- 그리드 컬럼: 이미지, 품목명(M표시), 제조사, 단가, **현재고**, 단위, [+ 담기]
|
||||||
|
- `현재고 = SUM(stocks.qty WHERE item_objid AND wh_type='STOCK')` — 0인 품목은 비활성화
|
||||||
|
- 우측: **장바구니**
|
||||||
|
- 라인: 품목명, 단가, 수량(±), 합계, [삭제]
|
||||||
|
- 합계 박스: 공급가, 세액, 총합 + 면세합/과세합
|
||||||
|
- 메모 입력 / [발주 요청] 버튼 → POST `/api/order/save`
|
||||||
|
- 저장 후 `/order/list` 리다이렉트 + 토스트
|
||||||
|
|
||||||
|
#### `/order/list` — 발주서 목록
|
||||||
|
- 검색: 기간, 상태, (관리자만 업체명)
|
||||||
|
- 컬럼: 발주번호, 발주일, **업체명**, 합계, 상태(뱃지), [상세] [거래명세표]
|
||||||
|
- 행 클릭 → `/order/[id]` 상세 모달 또는 페이지
|
||||||
|
|
||||||
|
#### `/order/statement/[id]` — 거래명세표
|
||||||
|
- 이미지#3 양식 재현
|
||||||
|
- 헤더: "거래 명세 표", 발행일, 공급받는자(대리점), 공급자(모모유통)
|
||||||
|
- 본문 테이블: 순번, 품명, EA, 단가, **공급가액**, **세액**, 합계, 비고
|
||||||
|
- 푸터: "합계 ₩{total} (VAT 포함)", 공급자 정보(계좌·전화·이메일), `momo8443@daum.net`
|
||||||
|
- 출력: 브라우저 인쇄용 CSS (A4) + PDF 다운로드 버튼
|
||||||
|
|
||||||
|
### 7.2 관리자(ADMIN) 페이지
|
||||||
|
|
||||||
|
#### `/dashboard` (ADMIN)
|
||||||
|
- KPI 카드: `오늘 발주 N건` / `승인 대기 N건` / `이번달 매출 ₩` / `미수금 ₩` / `재고 부족 품목 N개`
|
||||||
|
- 그래프 영역:
|
||||||
|
- 막대: 최근 14일 일별 발주 합계 (Recharts)
|
||||||
|
- 도넛: 이번달 면세/과세 비율
|
||||||
|
- 막대: 업체별 이번달 매출 TOP 10
|
||||||
|
- 위젯:
|
||||||
|
- 승인 대기 발주서 5건 (빠른 승인 버튼)
|
||||||
|
- 재고 부족 품목 (현재고 < 임계치, 임계치는 일단 10 고정)
|
||||||
|
|
||||||
|
#### `/item/list` & `/item/form`
|
||||||
|
- 목록 컬럼: 이미지(40px), 품목코드, 품목명, 제조사, 단위, 단가, **면세여부 뱃지**, 상태
|
||||||
|
- 등록 폼:
|
||||||
|
- 품목명, 상세설명(textarea), 제조사 선택, 단위, 단가, 원가
|
||||||
|
- **면세여부 토글** (품목명 첫글자 `M` 입력 시 자동 ON, 사용자 변경 가능)
|
||||||
|
- 이미지 업로드 (드래그앤드롭, 단일 또는 다중)
|
||||||
|
- 속성정보 (key-value 동적 행 — JSONB로 저장: `{ "소비기한일수": 30, "보관": "냉장" }`)
|
||||||
|
|
||||||
|
#### `/inventory/list`
|
||||||
|
- 컬럼: 창고, 품목코드, 품목명, 면세, **현재고**, 마지막 변경일
|
||||||
|
- 검색: 창고, 품목, 면세여부
|
||||||
|
|
||||||
|
#### `/inventory/inbound` — 매입 입고 등록
|
||||||
|
- 매입처(vendor) 선택 → 품목 라인 추가 → 입고 처리
|
||||||
|
- 저장 시 `procurements` + `procurement_items` 생성, `stocks` qty 증가, `stock_moves` IN 기록
|
||||||
|
|
||||||
|
#### `/order/list` (관리자 뷰)
|
||||||
|
- 일반 뷰와 동일하나 **상태 변경 액션** 컬럼 추가
|
||||||
|
- 일괄 승인 버튼 (체크박스로 선택)
|
||||||
|
- 행 우측: [승인] [반려] [출고] [계산서 발행] [입금]
|
||||||
|
|
||||||
|
#### `/settlement/statement` — 거래명세서 일괄
|
||||||
|
- 기간 + 업체별 필터링 → 선택된 발주 묶어서 PDF 일괄 다운로드
|
||||||
|
- 옵션: 메일 재발송
|
||||||
|
|
||||||
|
#### `/settlement/invoice` — 계산서 발행
|
||||||
|
- 미발행 발주 목록 (status=APPROVED|SHIPPED 이면서 invoice_no IS NULL)
|
||||||
|
- 업체별 그룹핑 → 한 업체의 여러 발주를 하나의 계산서로 묶음 (선택)
|
||||||
|
- 발행 시 `invoice_no` 생성 (`INV-YYYYMM-####`), 상태 INVOICED
|
||||||
|
|
||||||
|
#### `/settlement/payment` — 입금 관리
|
||||||
|
- 이미지#4 재현: 업체명, 총합, M포함, M미포함, **입금액**, 차액, 자동적용(완납/미납), 계산서발행여부, 입금일자
|
||||||
|
- 행에서 입금 드롭다운으로 입금 등록
|
||||||
|
|
||||||
|
#### `/statistics/daily`
|
||||||
|
- 이미지#1 재현: 일자별 / 업체별 발주 수량 피벗 그리드
|
||||||
|
- X축: 업체명, Y축: 품목명, 셀: 수량
|
||||||
|
- 단위: 단가/발주수량/(여유분=발주-입수량) 색상 구분
|
||||||
|
|
||||||
|
#### `/statistics/monthly`
|
||||||
|
- 이미지#5 재현: 월별 업체별 면세매출/과세매출 피벗
|
||||||
|
- 라인 그래프 보조: 월별 총매출 추이 (12개월)
|
||||||
|
|
||||||
|
#### `/statistics/by-company`
|
||||||
|
- 업체별 (대리점별) 누적 매출 — 막대 + 표
|
||||||
|
|
||||||
|
#### `/statistics/by-item`
|
||||||
|
- 품목별 누적 발주 수량 — 막대 + 표
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 이메일 발송
|
||||||
|
|
||||||
|
### 8.1 인프라
|
||||||
|
- **라이브러리**: `nodemailer`
|
||||||
|
- **설정**: `.env`에 SMTP 정보
|
||||||
|
```
|
||||||
|
SMTP_HOST=smtp.daum.net
|
||||||
|
SMTP_PORT=465
|
||||||
|
SMTP_USER=momo8443@daum.net
|
||||||
|
SMTP_PASS=...
|
||||||
|
SMTP_FROM=모모유통 <momo8443@daum.net>
|
||||||
|
```
|
||||||
|
- 송신 모듈: `src/lib/mailer.ts` — `sendOrderApprovalMail(order)` 함수 export
|
||||||
|
|
||||||
|
### 8.2 발송 시점
|
||||||
|
| 트리거 | 수신자 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| 발주 승인 (`/api/order/approve`) | `users.email` | 거래명세표 (HTML 인라인 + PDF 첨부) |
|
||||||
|
| 계산서 발행 (`/api/order/invoice`) | `users.email` | 계산서 안내 + PDF |
|
||||||
|
| 가입 환영 (선택) | 신규 가입자 | 환영 메일 |
|
||||||
|
|
||||||
|
### 8.3 본문 템플릿 (거래명세표 예시)
|
||||||
|
```
|
||||||
|
[모모유통] {업체명}님, 발주가 승인되었습니다.
|
||||||
|
|
||||||
|
발주번호: {orderNo}
|
||||||
|
발주일자: {orderDate}
|
||||||
|
합계: ₩{totalAmount} (VAT 포함)
|
||||||
|
- 면세 합계: ₩{totalTaxFree}
|
||||||
|
- 과세 공급가: ₩{totalTaxable}
|
||||||
|
- 세액: ₩{totalVat}
|
||||||
|
|
||||||
|
[품목 목록]
|
||||||
|
{각 라인}
|
||||||
|
|
||||||
|
상세 거래명세표는 첨부 PDF를 확인하세요.
|
||||||
|
모모유통 / momo8443@daum.net / 010-6369-8443
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 PDF 생성
|
||||||
|
- **방안 A (권장 단순)**: 서버에서 거래명세표 HTML 렌더 → `puppeteer` 헤드리스로 PDF 캡처
|
||||||
|
- **방안 B (가벼움)**: 클라이언트만 인쇄 + PDF는 첨부 없이 메일 본문에 링크
|
||||||
|
- 1차 구현은 **B**, 후속으로 **A** 추가
|
||||||
|
|
||||||
|
### 8.5 실패 처리
|
||||||
|
- 메일 발송 실패해도 트랜잭션은 커밋 (이미 승인된 상태)
|
||||||
|
- `mail_logs.status='FAILED'` 기록 → 관리자가 `/admin/mail-logs`(추후)에서 재시도 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 파일 업로드
|
||||||
|
|
||||||
|
- 저장 위치: `public/uploads/items/{yyyymm}/{uuid}.{ext}`
|
||||||
|
- 업로드 엔드포인트: `POST /api/item/upload-image` (multipart/form-data)
|
||||||
|
- 검증: 이미지 MIME만 허용 (`image/jpeg|png|webp`), 5MB 제한
|
||||||
|
- 응답: `{ success: true, url: "/uploads/items/202604/xxx.jpg" }`
|
||||||
|
- DB: `items.image_url` 또는 `attachments` 테이블 (다중 첨부 시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 어드민 — 매출/원가/마진 (이미지#6 §9)
|
||||||
|
|
||||||
|
### `/statistics/margin` (관리자 전용)
|
||||||
|
- 월별 업체별 매출 / 매입 원가 / **마진** 산출
|
||||||
|
- 매출 = `SUM(orders.total_supply)` (면세+과세 공급가)
|
||||||
|
- 원가 = `SUM(order_items.qty × items.cost_price)` (발주 시점 원가 스냅샷이 더 정확하나 1차는 현재 원가)
|
||||||
|
- 마진 = 매출 - 원가, 마진율 = 마진 / 매출 × 100
|
||||||
|
- 그리드 + 막대 그래프
|
||||||
|
|
||||||
|
> 정확한 원가 추적이 필요하면 `order_items`에 `cost_price_snap` 컬럼 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 공통 코드 / 정적 데이터
|
||||||
|
|
||||||
|
`code_master` (기존 FITO `code` 테이블 재사용 또는 신규)에 다음 코드그룹 등록:
|
||||||
|
|
||||||
|
| 코드그룹 ID | 의미 | 코드 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ORDER_STATUS` | 발주 상태 | REQUESTED, APPROVED, SHIPPED, INVOICED, PAID, CANCELLED |
|
||||||
|
| `WH_TYPE` | 창고 유형 | STOCK, PICKUP_TEAM, MARKET, DELIVERY |
|
||||||
|
| `MOVE_TYPE` | 재고 변동 | IN, OUT, ADJ, TRANSFER |
|
||||||
|
| `UNIT` | 단위 | EA, BOX, KG, L, PACK |
|
||||||
|
| `USER_ROLE` | 권한 | USER, ADMIN |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 권한 가드 (서버 + 클라이언트)
|
||||||
|
|
||||||
|
### 12.1 서버
|
||||||
|
```typescript
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) return NextResponse.json({ success: false }, { status: 401 });
|
||||||
|
if (user.role !== 'ADMIN') {
|
||||||
|
return NextResponse.json({ success: false, message: '권한 없음' }, { status: 403 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 클라이언트
|
||||||
|
- `auth-store`에 `role` 추가 → 메뉴 store에서 `role !== 'ADMIN'` 인 항목 필터링
|
||||||
|
- ADMIN 전용 페이지: 페이지 컴포넌트 최상단에서 `if (user?.role !== 'ADMIN') redirect('/dashboard')`
|
||||||
|
|
||||||
|
### 12.3 데이터 격리
|
||||||
|
- `/api/order/list`에서 `USER`인 경우 `WHERE customer_objid = $session_user`
|
||||||
|
- `/api/inventory/list`에서 `USER`는 `qty > 0` 인 품목만 노출, 창고는 `STOCK` 타입만
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 마이그레이션 / 시드
|
||||||
|
|
||||||
|
### 13.1 SQL 마이그레이션 파일
|
||||||
|
`db/migrations/` 디렉토리 신규:
|
||||||
|
- `001_init_users.sql`
|
||||||
|
- `002_init_items.sql`
|
||||||
|
- `003_init_warehouse_stock.sql`
|
||||||
|
- `004_init_orders.sql`
|
||||||
|
- `005_init_procurement.sql`
|
||||||
|
- `006_init_attachments_mail.sql`
|
||||||
|
- `007_seed_codes.sql` — 공통 코드
|
||||||
|
- `008_seed_admin.sql` — 초기 관리자 (`admin@momo.com` / 임시 비밀번호)
|
||||||
|
|
||||||
|
> 기존 FITO 테이블은 건드리지 않고 신규 테이블만 추가. 기존 사용자가 동일 DB인 경우 충돌 회피를 위해 `momo_` 접두사 검토.
|
||||||
|
|
||||||
|
### 13.2 초기 데이터
|
||||||
|
- 관리자 계정 1개
|
||||||
|
- 창고 4개 (본사창고, 시장픽업, 용차배송, 기타)
|
||||||
|
- 공통 코드 위 §11 항목 전부
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 비기능 / 운영
|
||||||
|
|
||||||
|
- **성능**: 통계 조회는 `regdate` / `order_date` 인덱스 + LIMIT/OFFSET 페이징
|
||||||
|
- **로그**: `console.error` 통일, 운영 단계에서 `pino` 도입 검토
|
||||||
|
- **백업**: 외부 DB(`211.115.91.141:11140/fito`) 정기 백업은 인프라 책임
|
||||||
|
- **보안**:
|
||||||
|
- 비밀번호 bcrypt 해시 (cost 10)
|
||||||
|
- JWT 만료 24h (`SESSION_TTL`)
|
||||||
|
- SQL Injection: prepared statement (`$1`...) 강제, 동적 컬럼명 금지
|
||||||
|
- 파일 업로드 MIME/크기 검증 + 파일명 sanitize
|
||||||
|
- **i18n**: 한국어 단일 (현 단계)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 개발 우선순위 (스프린트 가이드)
|
||||||
|
|
||||||
|
### Sprint 1 — 기초 (1주)
|
||||||
|
1. 메뉴 정리: 불필요 디렉토리 일괄 삭제 (`bom`, `production`, `quality`, `scm`, `work`, `procurement-std`, `cs`, `fund`, `delivery`, `approval`, `cost*`, `purchase`, `sales`, `part*`, `product/*` 일부)
|
||||||
|
2. DB 스키마 마이그레이션 적용 (§4 전체)
|
||||||
|
3. 회원가입 (`/signup` + `/api/auth/signup`)
|
||||||
|
4. `users.role`, `auth-store`에 role 추가, 메뉴/페이지 권한 가드
|
||||||
|
|
||||||
|
### Sprint 2 — 마스터 (1주)
|
||||||
|
5. 품목 등록/목록/이미지 업로드 (`/item/*`)
|
||||||
|
6. 제조사 관리
|
||||||
|
7. 창고 관리 + 재고 등록·조정 + 이력
|
||||||
|
|
||||||
|
### Sprint 3 — 발주 핵심 (1.5주)
|
||||||
|
8. 출고요청서 작성 (`/order/form`)
|
||||||
|
9. 발주서 목록 + 상세
|
||||||
|
10. 승인 워크플로우 + 트랜잭션 재고 차감
|
||||||
|
11. 거래명세표 페이지 (`/order/statement/[id]`)
|
||||||
|
12. 메일 발송 (nodemailer 연동)
|
||||||
|
|
||||||
|
### Sprint 4 — 정산·통계 (1주)
|
||||||
|
13. 계산서 발행 / 입금 관리
|
||||||
|
14. 통계 4종 (일자별·월별·업체별·품목별)
|
||||||
|
15. 대시보드 (USER + ADMIN)
|
||||||
|
|
||||||
|
### Sprint 5 — 매입·마무리 (3일)
|
||||||
|
16. 매입(조달) 관리 + 매입 입고 → 재고 +
|
||||||
|
17. 마진 통계
|
||||||
|
18. 메일 로그/재시도 UI
|
||||||
|
19. 시드 / 운영 가이드 정리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 명세 외 합의 필요 항목 (TODO 확인)
|
||||||
|
|
||||||
|
- [ ] 가입 시 **관리자 승인** 필요 여부 (현재 자동 ACTIVE)
|
||||||
|
- [ ] 단가 모델: VAT 포함가(INCL) vs 별도(EXCL) — 현재 INCL 가정
|
||||||
|
- [ ] 거래명세서 PDF 생성: 서버(puppeteer) vs 클라이언트(브라우저 인쇄)
|
||||||
|
- [ ] 재고 부족 임계치: 품목별 vs 전역 고정값 (현재 10 고정)
|
||||||
|
- [ ] 미수금 정의: 누적 미입금 vs 30일 초과 미입금
|
||||||
|
- [ ] 계산서 발행 단위: 발주 1건 vs 업체별 월합산 (둘 다 가능, UI에서 선택)
|
||||||
|
- [ ] 이메일 송신 계정: `momo8443@daum.net` 비밀번호/SMTP 설정 확보 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 참고 — 원본 엑셀 워크플로우 매핑
|
||||||
|
|
||||||
|
| 엑셀 시트 (스크린샷) | 시스템 화면 |
|
||||||
|
|---|---|
|
||||||
|
| 시트1 — 날짜별 업체×품목 발주표 | `/statistics/daily` (피벗 그리드) |
|
||||||
|
| 시트2 — 창고/픽업팀별 분류 | `/inventory/list` (창고 필터) + `/statistics/daily` (창고 컬럼) |
|
||||||
|
| 시트3 — 거래명세표 자동생성 | `/order/statement/[id]` |
|
||||||
|
| 시트4 — 입금/계산서 체크 | `/settlement/payment` |
|
||||||
|
| 시트5 — 월간 면세/과세 매출 합산 | `/statistics/monthly` |
|
||||||
|
| 시트6 — 누적 그래프 | `/dashboard` (관리자) |
|
||||||
|
| 시트7 — 제조사 발주 본사/지사/여유분 | `/procurement/list` + `/statistics/by-item` |
|
||||||
|
| 시트8 — 제조관리(소비기한/입고가) | `items.attributes` JSONB + `/inventory/inbound` |
|
||||||
|
| 시트9 — 어드민(매출/원가/마진) | `/statistics/margin` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 끝.**
|
||||||
|
변경/추가 요구사항은 본 문서 §16 TODO 또는 PR 코멘트로 전달.
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>모모유통 — 유통관리 시스템 제안서</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--brand:#0f766e;
|
||||||
|
--brand-2:#14b8a6;
|
||||||
|
--ink:#0f172a;
|
||||||
|
--ink-2:#334155;
|
||||||
|
--line:#e2e8f0;
|
||||||
|
--bg:#f8fafc;
|
||||||
|
--warn:#f59e0b;
|
||||||
|
--ok:#10b981;
|
||||||
|
--tax-free:#7c3aed;
|
||||||
|
--tax:#e11d48;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Pretendard","Apple SD Gothic Neo","Malgun Gothic",sans-serif;line-height:1.65;-webkit-font-smoothing:antialiased}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto;padding:48px 24px 80px}
|
||||||
|
header.hero{background:linear-gradient(135deg,var(--brand) 0%,var(--brand-2) 100%);color:#fff;padding:64px 32px;border-radius:24px;box-shadow:0 20px 50px -20px rgba(15,118,110,.4);margin-bottom:48px}
|
||||||
|
header.hero h1{margin:0 0 8px;font-size:40px;letter-spacing:-.5px}
|
||||||
|
header.hero .sub{font-size:16px;opacity:.9;margin-bottom:24px}
|
||||||
|
header.hero .meta{display:flex;gap:24px;flex-wrap:wrap;font-size:14px;opacity:.9}
|
||||||
|
header.hero .meta b{font-weight:600}
|
||||||
|
section{background:#fff;border:1px solid var(--line);border-radius:18px;padding:32px;margin-bottom:24px;box-shadow:0 1px 2px rgba(15,23,42,.04)}
|
||||||
|
h2{margin:0 0 16px;font-size:24px;color:var(--brand);display:flex;align-items:center;gap:10px}
|
||||||
|
h2 .num{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;background:var(--brand);color:#fff;border-radius:8px;font-size:16px;font-weight:700}
|
||||||
|
h3{margin:24px 0 12px;font-size:18px;color:var(--ink)}
|
||||||
|
p,li{color:var(--ink-2);font-size:15px}
|
||||||
|
ul,ol{padding-left:20px}
|
||||||
|
ul li{margin:6px 0}
|
||||||
|
.grid{display:grid;gap:16px}
|
||||||
|
.grid.cols-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
|
||||||
|
.grid.cols-3{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
||||||
|
.card{background:#f8fafc;border:1px solid var(--line);border-radius:12px;padding:18px}
|
||||||
|
.card .ico{width:36px;height:36px;background:var(--brand);color:#fff;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;margin-bottom:10px}
|
||||||
|
.card h4{margin:0 0 6px;font-size:15px;color:var(--ink)}
|
||||||
|
.card p{margin:0;font-size:13.5px}
|
||||||
|
.badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-right:4px}
|
||||||
|
.badge.brand{background:#ccfbf1;color:#0f766e}
|
||||||
|
.badge.warn{background:#fef3c7;color:#92400e}
|
||||||
|
.badge.ok{background:#d1fae5;color:#065f46}
|
||||||
|
.badge.free{background:#ede9fe;color:#6d28d9}
|
||||||
|
.badge.tax{background:#ffe4e6;color:#9f1239}
|
||||||
|
.flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:16px 0}
|
||||||
|
.flow .step{flex:1;min-width:140px;background:#fff;border:2px solid var(--line);border-radius:12px;padding:14px;text-align:center}
|
||||||
|
.flow .step .t{font-size:13px;color:var(--ink-2);margin-bottom:4px}
|
||||||
|
.flow .step .l{font-weight:700;color:var(--ink)}
|
||||||
|
.flow .step.s1{border-color:#fde68a}
|
||||||
|
.flow .step.s2{border-color:#a7f3d0}
|
||||||
|
.flow .step.s3{border-color:#bfdbfe}
|
||||||
|
.flow .step.s4{border-color:#ddd6fe}
|
||||||
|
.flow .arrow{font-size:24px;color:#94a3b8}
|
||||||
|
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:14px}
|
||||||
|
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)}
|
||||||
|
th{background:#f1f5f9;color:var(--ink);font-weight:600;font-size:13px}
|
||||||
|
td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.callout{border-left:4px solid var(--brand);background:#f0fdfa;padding:14px 18px;border-radius:8px;margin:16px 0;font-size:14.5px}
|
||||||
|
.callout.warn{border-left-color:var(--warn);background:#fffbeb}
|
||||||
|
.timeline{display:grid;grid-template-columns:120px 1fr;gap:0;margin-top:16px}
|
||||||
|
.timeline .t{padding:14px 12px;border-right:2px solid var(--brand);font-weight:700;color:var(--brand);font-size:14px}
|
||||||
|
.timeline .c{padding:14px 18px;border-bottom:1px solid var(--line)}
|
||||||
|
.timeline .c:last-child{border-bottom:0}
|
||||||
|
.timeline .t:last-of-type{border-right:2px solid var(--brand)}
|
||||||
|
.device{background:#0f172a;border-radius:24px;padding:18px;color:#e2e8f0;margin:16px 0;font-size:13px;font-family:"SF Mono",Consolas,monospace;line-height:1.6}
|
||||||
|
.device .bar{height:6px;background:#334155;border-radius:3px;margin-bottom:12px}
|
||||||
|
.price{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px dashed #334155}
|
||||||
|
.price:last-child{border-bottom:0;font-weight:700;color:#5eead4}
|
||||||
|
footer{margin-top:48px;padding:24px;text-align:center;color:var(--ink-2);font-size:13px}
|
||||||
|
.signature{margin-top:32px;padding-top:24px;border-top:2px solid var(--line);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
|
||||||
|
.signature .col{flex:1;min-width:240px}
|
||||||
|
.signature .col h4{margin:0 0 4px;color:var(--brand)}
|
||||||
|
@media print{
|
||||||
|
body{background:#fff}
|
||||||
|
section{break-inside:avoid;box-shadow:none;border-color:#cbd5e1}
|
||||||
|
header.hero{box-shadow:none}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div style="font-size:13px;letter-spacing:2px;opacity:.85;margin-bottom:8px">PROPOSAL · 2026-04-25</div>
|
||||||
|
<h1>모모유통 유통관리 시스템</h1>
|
||||||
|
<div class="sub">엑셀 기반 발주 업무를 웹 + 모바일 앱으로 전환합니다</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span><b>고객사</b> · 모모유통</span>
|
||||||
|
<span><b>도메인</b> · momo.junggomoa.com</span>
|
||||||
|
<span><b>기간(예상)</b> · 5주</span>
|
||||||
|
<span><b>플랫폼</b> · 웹(PC) + 안드로이드 앱</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">1</span> 왜 이 시스템이 필요한가</h2>
|
||||||
|
<p>현재는 엑셀 한 파일에 <b>여러 명이 동시에 입력</b>하다 보니 발주 총수량과 명세서 자동계산이 어긋나는 일이 잦습니다. 업체가 늘어날수록 단가·재고·입금 상태를 한 시트에서 관리하기가 점점 더 어렵습니다.</p>
|
||||||
|
<div class="grid cols-3" style="margin-top:18px">
|
||||||
|
<div class="card"><div class="ico">📋</div><h4>발주 누락·중복</h4><p>여러 명이 동시 편집 → 셀이 겹치거나 사라짐</p></div>
|
||||||
|
<div class="card"><div class="ico">🧮</div><h4>금액 오류</h4><p>VAT·면세 분리 합산이 수동, 자릿수 실수</p></div>
|
||||||
|
<div class="card"><div class="ico">📨</div><h4>명세서 수작업</h4><p>업체별로 매번 별도 시트 복사·메일 발송</p></div>
|
||||||
|
<div class="card"><div class="ico">📦</div><h4>재고 불투명</h4><p>창고별 현재고가 엑셀에 반영되지 않음</p></div>
|
||||||
|
<div class="card"><div class="ico">📊</div><h4>매출 가시성 부족</h4><p>월간 누적·업체별 매출을 한눈에 보기 어려움</p></div>
|
||||||
|
<div class="card"><div class="ico">📱</div><h4>현장 입력 불가</h4><p>거래처가 PC 앞에 가야만 발주 가능</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">2</span> 누가 어떻게 사용하나</h2>
|
||||||
|
|
||||||
|
<h3>👤 일반 사용자 (대리점·소매상)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>이메일과 업체명으로 <b>회원가입</b></li>
|
||||||
|
<li>웹 또는 <b>안드로이드 앱</b>에서 로그인</li>
|
||||||
|
<li>현재 재고가 있는 품목을 검색·선택해 <b>발주 요청</b></li>
|
||||||
|
<li>본인 발주 이력·미수금·계산서 조회</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>🛠 관리자 (모모유통 담당자)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>품목 마스터 관리 (사진·제조사·면세여부·속성)</li>
|
||||||
|
<li>창고별 재고 등록·조정·이력 추적</li>
|
||||||
|
<li>발주 요청 검토 → <b>승인 한 번으로</b> 재고 차감 + 거래명세표 메일 자동 발송</li>
|
||||||
|
<li>월말 계산서 발행 / 입금 관리 / 누적 매출 통계</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">3</span> 핵심 업무 흐름</h2>
|
||||||
|
<div class="flow">
|
||||||
|
<div class="step s1"><div class="t">1단계 · 대리점</div><div class="l">발주 요청</div></div>
|
||||||
|
<div class="arrow">▶</div>
|
||||||
|
<div class="step s2"><div class="t">2단계 · 모모유통</div><div class="l">승인 + 메일 발송</div></div>
|
||||||
|
<div class="arrow">▶</div>
|
||||||
|
<div class="step s3"><div class="t">3단계 · 모모유통</div><div class="l">출고 처리</div></div>
|
||||||
|
<div class="arrow">▶</div>
|
||||||
|
<div class="step s4"><div class="t">4단계 · 월말</div><div class="l">계산서 + 입금</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="callout">
|
||||||
|
<b>승인 버튼 한 번</b>으로 다음이 자동 처리됩니다:<br>
|
||||||
|
① 재고에서 발주 수량만큼 차감 → ② 거래명세표 PDF/엑셀 자동 생성 → ③ 가입한 이메일로 명세서 본문 + 엑셀 첨부 메일 발송 → ④ 발주 상태 "발주완료"로 변경
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">4</span> 면세 / 과세 자동 분리</h2>
|
||||||
|
<p>품목명이 <b>"M"</b>으로 시작하는 면세 품목(예: M유정란, M꽃계탕)은 시스템이 자동으로 면세 플래그를 켜고, 거래명세표·매출통계에서 면세 합계와 과세 합계를 분리 집계합니다.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>품명</th><th>구분</th><th class="num">단가</th><th class="num">수량</th><th class="num">공급가</th><th class="num">세액</th><th class="num">합계</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>M 유정란</td><td><span class="badge free">면세</span></td><td class="num">10,000</td><td class="num">30</td><td class="num">300,000</td><td class="num">-</td><td class="num">300,000</td></tr>
|
||||||
|
<tr><td>빨강 탈취제</td><td><span class="badge tax">과세</span></td><td class="num">9,200</td><td class="num">11</td><td class="num">92,000</td><td class="num">9,200</td><td class="num">101,200</td></tr>
|
||||||
|
<tr><td>초록 탈취제</td><td><span class="badge tax">과세</span></td><td class="num">9,200</td><td class="num">3</td><td class="num">25,091</td><td class="num">2,509</td><td class="num">27,600</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">5</span> 메일 자동 발송 (거래명세표)</h2>
|
||||||
|
<p>관리자가 발주를 승인하면, 가입 시 등록한 이메일로 다음과 같은 메일이 즉시 발송됩니다.</p>
|
||||||
|
<div class="device">
|
||||||
|
<div class="bar"></div>
|
||||||
|
<div style="font-size:11px;color:#94a3b8">받는사람: 수원 거래처 <suwon@example.com></div>
|
||||||
|
<div style="font-size:11px;color:#94a3b8;margin-bottom:10px">제목: [모모유통] 발주 ORD-20260425-0007 승인되었습니다</div>
|
||||||
|
<div style="color:#5eead4;font-size:14px;margin-bottom:8px">📎 첨부 · 거래명세표.xlsx (12 KB)</div>
|
||||||
|
<div class="price"><span>면세 합계</span><span>₩300,000</span></div>
|
||||||
|
<div class="price"><span>과세 공급가</span><span>₩928,650</span></div>
|
||||||
|
<div class="price"><span>세액</span><span>₩81,900</span></div>
|
||||||
|
<div class="price"><span>총 합계 (VAT 포함)</span><span>₩1,310,550</span></div>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:14px">메일에는 <b>본문에 명세서 표</b>가 포함되며, 동시에 <b>엑셀 파일(.xlsx)</b>이 첨부됩니다. 거래처가 그대로 회계 시스템에 올릴 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">6</span> 화면 구성</h2>
|
||||||
|
<h3>웹 (PC) — 관리자 + 대리점 공용</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>대시보드</b> — 오늘의 발주, 승인 대기, 이번달 매출, 미수금, 재고 부족 알림</li>
|
||||||
|
<li><b>품목 관리</b> — 사진 업로드, 제조사, 면세 여부, 속성(소비기한 등)</li>
|
||||||
|
<li><b>창고/재고</b> — 창고별 현재고, 입출고 이력</li>
|
||||||
|
<li><b>발주 관리</b> — 발주서 작성/목록/승인, 거래명세표 출력</li>
|
||||||
|
<li><b>정산</b> — 계산서 발행, 입금 등록, 미수금 관리</li>
|
||||||
|
<li><b>통계</b> — 일자별·월별·업체별·품목별 그래프</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>📱 안드로이드 앱 — 대리점 전용</h3>
|
||||||
|
<ul>
|
||||||
|
<li>로그인</li>
|
||||||
|
<li>품목 검색 (사진·재고·단가 표시)</li>
|
||||||
|
<li>장바구니 → 발주 요청</li>
|
||||||
|
<li>내 발주 이력 + 알림</li>
|
||||||
|
</ul>
|
||||||
|
<div class="callout">앱은 <b>APK 파일</b>로 전달드립니다. 구글 플레이 등록 없이 사내 배포 가능합니다.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">7</span> 일정 (5주)</h2>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="t">1주차</div>
|
||||||
|
<div class="c"><b>기초 — 메뉴 정리, DB 구축, 회원가입, 권한</b><br>불필요한 PLM 잔재 메뉴 제거 후 모모유통 메뉴 트리로 재편</div>
|
||||||
|
<div class="t">2주차</div>
|
||||||
|
<div class="c"><b>마스터 — 품목·제조사·창고·재고</b><br>품목 사진 업로드, 면세 자동 인식, 창고별 재고 관리</div>
|
||||||
|
<div class="t">3~4주차</div>
|
||||||
|
<div class="c"><b>발주 핵심 — 작성·승인·메일·엑셀</b><br>장바구니 UI, 트랜잭션 재고 차감, 거래명세표 메일 + 엑셀 첨부</div>
|
||||||
|
<div class="t">5주차</div>
|
||||||
|
<div class="c"><b>정산·통계·앱 + 마무리</b><br>계산서·입금·통계 화면, 안드로이드 APK 빌드, 운영 가이드</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">8</span> 결정해 주실 사항</h2>
|
||||||
|
<ol>
|
||||||
|
<li><b>가입 승인</b> — 거래처가 가입하면 자동 활성화? 아니면 관리자 승인 후 활성화?</li>
|
||||||
|
<li><b>단가 모델</b> — 등록 단가에 VAT가 <b>포함</b>되어 있나요, 별도인가요? (엑셀 보면 포함으로 보입니다)</li>
|
||||||
|
<li><b>이메일 송신 계정</b> — momo8443@daum.net 사용 시 SMTP 비밀번호/앱 비밀번호 필요</li>
|
||||||
|
<li><b>계산서 발행 단위</b> — 발주 1건씩 / 업체별 월 합산 (둘 다 가능, 디폴트 결정 필요)</li>
|
||||||
|
<li><b>재고 부족 알림 기준</b> — 임계 수량 (예: 10개 미만 알림)</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">9</span> 기대 효과</h2>
|
||||||
|
<div class="grid cols-3">
|
||||||
|
<div class="card"><div class="ico">⚡</div><h4>발주 처리 시간</h4><p>엑셀 대비 <b>70% 단축</b><br>(승인 1클릭 = 재고+메일+명세서)</p></div>
|
||||||
|
<div class="card"><div class="ico">✅</div><h4>금액 오류</h4><p>VAT·면세 자동 계산으로<br><b>0건</b> 목표</p></div>
|
||||||
|
<div class="card"><div class="ico">📈</div><h4>매출 가시성</h4><p>월간 누적·업체별 그래프를<br><b>실시간</b>으로</p></div>
|
||||||
|
<div class="card"><div class="ico">📱</div><h4>거래처 편의</h4><p>휴대폰만으로 발주 가능<br>현장 즉시 주문</p></div>
|
||||||
|
<div class="card"><div class="ico">🔒</div><h4>데이터 안전성</h4><p>동시 편집 충돌 없음<br>모든 변경 이력 추적</p></div>
|
||||||
|
<div class="card"><div class="ico">🧾</div><h4>회계 연계</h4><p>엑셀 명세서 자동 첨부<br>거래처가 그대로 활용</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<div class="col">
|
||||||
|
<h4>고객사</h4>
|
||||||
|
<div>모모유통</div>
|
||||||
|
<div style="font-size:13px;color:#64748b">대표: ____________</div>
|
||||||
|
<div style="font-size:13px;color:#64748b">날짜: 2026 . __ . __</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h4>개발사</h4>
|
||||||
|
<div>chpark@wace.me</div>
|
||||||
|
<div style="font-size:13px;color:#64748b">담당: ____________</div>
|
||||||
|
<div style="font-size:13px;color:#64748b">날짜: 2026 . __ . __</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>© 2026 모모유통 유통관리 시스템 · Next.js 15 + React Native (APK) · momo.junggomoa.com</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+25
-4
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -2148,12 +2150,20 @@
|
|||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||||
@@ -2170,6 +2180,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2179,7 +2190,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -3440,6 +3451,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
@@ -6349,6 +6361,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "7.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||||
|
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nypm": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.5",
|
"version": "0.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||||
@@ -7012,6 +7033,7 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
@@ -8057,7 +8079,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -8114,7 +8136,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|||||||
+4
-1
@@ -6,11 +6,13 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"migrate:momo": "node scripts/migrate-momo.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^7.7.0",
|
"@prisma/client": "^7.7.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 모모유통 마이그레이션 실행 스크립트
|
||||||
|
// 사용법: node scripts/migrate-momo.mjs
|
||||||
|
// .env.development 또는 .env.production 의 DATABASE_URL 사용
|
||||||
|
|
||||||
|
import pg from "pg";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const envFile = process.env.NODE_ENV === "production" ? ".env.production" : ".env.development";
|
||||||
|
const envPath = path.join(__dirname, "..", envFile);
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
|
||||||
|
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||||
|
if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.join(__dirname, "..", "db", "migrations");
|
||||||
|
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
||||||
|
const conn = process.env.DATABASE_URL;
|
||||||
|
if (!conn) {
|
||||||
|
console.error("DATABASE_URL 환경변수가 설정되지 않았습니다.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString: conn });
|
||||||
|
await client.connect();
|
||||||
|
console.log(`[migrate] DB connected. Running ${files.length} files...`);
|
||||||
|
for (const f of files) {
|
||||||
|
const sql = fs.readFileSync(path.join(dir, f), "utf-8");
|
||||||
|
console.log(` → ${f}`);
|
||||||
|
try {
|
||||||
|
await client.query(sql);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ✖ ${f} 실패:`, err.message);
|
||||||
|
await client.end();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("[migrate] ✔ 완료");
|
||||||
|
await client.end();
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, FormEvent } from "react";
|
import { useState, FormEvent } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin } from "lucide-react";
|
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin } from "lucide-react";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
@@ -31,7 +32,7 @@ export default function LoginPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
router.push("/dashboard");
|
router.push(data.redirectTo || "/dashboard");
|
||||||
} else {
|
} else {
|
||||||
Swal.fire({
|
Swal.fire({
|
||||||
icon: "error",
|
icon: "error",
|
||||||
@@ -193,6 +194,15 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
아직 계정이 없으신가요?{" "}
|
||||||
|
<Link href="/signup" className="text-emerald-700 font-semibold hover:underline">
|
||||||
|
회원가입
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 pt-6 border-t border-slate-200 text-center">
|
<div className="mt-12 pt-6 border-t border-slate-200 text-center">
|
||||||
<p className="text-[11px] text-slate-400 tracking-wide">
|
<p className="text-[11px] text-slate-400 tracking-wide">
|
||||||
© 2026 MOMO DISTRIBUTION. All rights reserved.
|
© 2026 MOMO DISTRIBUTION. All rights reserved.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, FormEvent } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
import { Mail, Lock, Building2, User as UserIcon, Phone, FileText, ArrowRight, Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirm: "",
|
||||||
|
companyName: "",
|
||||||
|
ceoName: "",
|
||||||
|
bizNo: "",
|
||||||
|
phone: "",
|
||||||
|
});
|
||||||
|
const [showPw, setShowPw] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setForm({ ...form, [k]: e.target.value });
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.email || !form.password || !form.companyName) {
|
||||||
|
Swal.fire({ icon: "warning", title: "필수 항목을 입력하세요." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
Swal.fire({ icon: "warning", title: "비밀번호는 8자 이상이어야 합니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.password !== form.passwordConfirm) {
|
||||||
|
Swal.fire({ icon: "warning", title: "비밀번호가 일치하지 않습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/signup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
companyName: form.companyName,
|
||||||
|
ceoName: form.ceoName,
|
||||||
|
bizNo: form.bizNo,
|
||||||
|
phone: form.phone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: "success",
|
||||||
|
title: "가입이 완료되었습니다",
|
||||||
|
text: "이제 발주를 시작하실 수 있습니다.",
|
||||||
|
confirmButtonColor: "#0f766e",
|
||||||
|
});
|
||||||
|
router.push("/m/dashboard");
|
||||||
|
} else {
|
||||||
|
Swal.fire({ icon: "error", title: "가입 실패", text: data.message });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Swal.fire({ icon: "error", title: "서버 오류", text: "잠시 후 다시 시도하세요." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||||
|
{/* 좌측 브랜드 */}
|
||||||
|
<div className="relative lg:flex-1 lg:min-h-screen overflow-hidden bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-10 py-16 lg:py-0 flex flex-col justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none opacity-30"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-2.5 mb-12 hover:opacity-80 transition">
|
||||||
|
<img src="/momo-icon.svg" alt="" className="w-9 h-9" />
|
||||||
|
<span className="text-white/95 text-sm font-bold tracking-widest">MOMO DISTRIBUTION</span>
|
||||||
|
</Link>
|
||||||
|
<h2 className="text-white text-4xl font-bold mb-4 tracking-tight">
|
||||||
|
지금 가입하고<br />
|
||||||
|
<span className="text-emerald-200">발주를 시작하세요</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-emerald-100/80 leading-relaxed max-w-md mb-8">
|
||||||
|
이메일과 업체명만 입력하면 바로 사용 가능합니다. 가입 즉시 모모유통의 모든 품목을 검색하고 발주할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-emerald-100/80 text-sm">
|
||||||
|
<li>· 가입비 · 월 사용료 없음</li>
|
||||||
|
<li>· 안드로이드 앱 무료 제공 (APK)</li>
|
||||||
|
<li>· 거래명세표 자동 발송 (메일 + 엑셀)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 가입 폼 */}
|
||||||
|
<div className="lg:flex-1 flex items-center justify-center px-6 py-12 lg:py-16 bg-slate-50">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||||
|
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||||
|
SIGN UP
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 mb-2">회원가입</h1>
|
||||||
|
<p className="text-slate-500 text-sm">
|
||||||
|
이미 계정이 있으신가요?{" "}
|
||||||
|
<Link href="/login" className="text-emerald-700 font-semibold hover:underline">로그인</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<Field icon={<Mail size={16} />} label="이메일 *" type="email" value={form.email} onChange={set("email")} placeholder="you@company.com" autoComplete="email" autoFocus />
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">비밀번호 *</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Lock size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition" />
|
||||||
|
<input
|
||||||
|
type={showPw ? "text" : "password"}
|
||||||
|
value={form.password}
|
||||||
|
onChange={set("password")}
|
||||||
|
placeholder="8자 이상"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 pl-11 pr-12 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPw((v) => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-700" tabIndex={-1}>
|
||||||
|
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field icon={<Lock size={16} />} label="비밀번호 확인 *" type={showPw ? "text" : "password"} value={form.passwordConfirm} onChange={set("passwordConfirm")} placeholder="비밀번호 재입력" autoComplete="new-password" />
|
||||||
|
<Field icon={<Building2 size={16} />} label="업체명 *" value={form.companyName} onChange={set("companyName")} placeholder="(주)거래처 또는 매장명" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field icon={<UserIcon size={16} />} label="대표자" value={form.ceoName} onChange={set("ceoName")} placeholder="홍길동" />
|
||||||
|
<Field icon={<Phone size={16} />} label="연락처" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" />
|
||||||
|
</div>
|
||||||
|
<Field icon={<FileText size={16} />} label="사업자등록번호" value={form.bizNo} onChange={set("bizNo")} placeholder="000-00-00000 (선택)" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="group relative w-full h-12 mt-2 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white text-sm font-bold tracking-wide shadow-lg shadow-emerald-600/25 hover:shadow-emerald-600/40 hover:-translate-y-0.5 active:translate-y-0 transition-all disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
가입하기 <ArrowRight size={16} className="group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-slate-400 text-center pt-2">
|
||||||
|
가입하시면 <Link href="/" className="underline hover:text-slate-600">서비스 이용약관</Link>에 동의하시는 것으로 간주됩니다.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field(props: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
autoComplete?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">{props.label}</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition">
|
||||||
|
{props.icon}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={props.type ?? "text"}
|
||||||
|
value={props.value}
|
||||||
|
onChange={props.onChange}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
autoComplete={props.autoComplete}
|
||||||
|
autoFocus={props.autoFocus}
|
||||||
|
className="w-full h-11 pl-11 pr-4 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { verifyCredentials } from "@/lib/auth";
|
import { verifyCredentials } from "@/lib/auth";
|
||||||
|
import { verifyMomoCredentials } from "@/lib/momo-auth";
|
||||||
import { createSession } from "@/lib/session";
|
import { createSession } from "@/lib/session";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -13,16 +15,69 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await verifyCredentials(userId, password);
|
// 이메일 형태이면 MOMO 사용자 우선 시도, 그 외에는 FITO 우선 시도
|
||||||
|
const looksLikeEmail = /@/.test(userId);
|
||||||
|
|
||||||
if (!result.success || !result.user) {
|
if (looksLikeEmail) {
|
||||||
return NextResponse.json(
|
const momo = await verifyMomoCredentials(userId, password);
|
||||||
{ success: false, message: result.error },
|
if (momo.success && momo.user) {
|
||||||
{ status: 401 }
|
const sessionUser: User = momoToSessionUser(momo.user);
|
||||||
);
|
await createSession(sessionUser);
|
||||||
|
return NextResponse.json({ success: true, user: sessionUser, redirectTo: "/m/dashboard" });
|
||||||
|
}
|
||||||
|
// MOMO 실패 시 FITO 폴백 시도 (관리자 마이그레이션 케이스)
|
||||||
}
|
}
|
||||||
|
|
||||||
await createSession(result.user);
|
const fito = await verifyCredentials(userId, password);
|
||||||
|
if (fito.success && fito.user) {
|
||||||
|
await createSession(fito.user);
|
||||||
|
return NextResponse.json({ success: true, user: fito.user, redirectTo: "/dashboard" });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, user: result.user });
|
// FITO 도 실패하면 MOMO를 한 번 더 시도 (이메일 형태가 아니지만 MOMO 계정인 경우)
|
||||||
|
if (!looksLikeEmail) {
|
||||||
|
const momo = await verifyMomoCredentials(userId, password);
|
||||||
|
if (momo.success && momo.user) {
|
||||||
|
const sessionUser: User = momoToSessionUser(momo.user);
|
||||||
|
await createSession(sessionUser);
|
||||||
|
return NextResponse.json({ success: true, user: sessionUser, redirectTo: "/m/dashboard" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: fito.error || "아이디 또는 비밀번호를 확인하세요." },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function momoToSessionUser(u: {
|
||||||
|
objid: string;
|
||||||
|
email: string;
|
||||||
|
companyName: string;
|
||||||
|
phone: string;
|
||||||
|
role: "USER" | "ADMIN";
|
||||||
|
isAdmin: boolean;
|
||||||
|
}): User {
|
||||||
|
return {
|
||||||
|
sabun: "",
|
||||||
|
userId: u.email,
|
||||||
|
userName: u.companyName,
|
||||||
|
userNameEng: "",
|
||||||
|
userNameCn: "",
|
||||||
|
deptCode: "",
|
||||||
|
deptName: "",
|
||||||
|
positionCode: "",
|
||||||
|
positionName: "",
|
||||||
|
email: u.email,
|
||||||
|
tel: "",
|
||||||
|
cellPhone: u.phone,
|
||||||
|
userType: "MOMO",
|
||||||
|
userTypeName: u.role === "ADMIN" ? "관리자" : "거래처",
|
||||||
|
authName: u.role,
|
||||||
|
partnerCd: "",
|
||||||
|
isAdmin: u.isAdmin,
|
||||||
|
role: u.role,
|
||||||
|
objid: u.objid,
|
||||||
|
companyName: u.companyName,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 모바일 전용 로그인: JWT 를 응답 본문으로 반환 (쿠키 미사용)
|
||||||
|
// RN 앱에서 SecureStore 에 보관한 뒤 Authorization: Bearer 헤더로 사용
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { SignJWT } from "jose";
|
||||||
|
import { verifyMomoCredentials } from "@/lib/momo-auth";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "fito-plm-default-secret");
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const { email, password } = await request.json();
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json({ success: false, message: "이메일/비밀번호를 입력하세요." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const r = await verifyMomoCredentials(email, password);
|
||||||
|
if (!r.success || !r.user) {
|
||||||
|
return NextResponse.json({ success: false, message: r.error ?? "로그인 실패" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const user: User = {
|
||||||
|
sabun: "", userId: r.user.email, userName: r.user.companyName,
|
||||||
|
userNameEng: "", userNameCn: "", deptCode: "", deptName: "",
|
||||||
|
positionCode: "", positionName: "", email: r.user.email,
|
||||||
|
tel: "", cellPhone: r.user.phone, userType: "MOMO",
|
||||||
|
userTypeName: r.user.role === "ADMIN" ? "관리자" : "거래처",
|
||||||
|
authName: r.user.role, partnerCd: "", isAdmin: r.user.isAdmin,
|
||||||
|
role: r.user.role, objid: r.user.objid, companyName: r.user.companyName,
|
||||||
|
};
|
||||||
|
const token = await new SignJWT({ user })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime("30d")
|
||||||
|
.sign(SECRET);
|
||||||
|
return NextResponse.json({ success: true, token, user });
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { signupMomoUser } from "@/lib/momo-auth";
|
||||||
|
import { createSession } from "@/lib/session";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: Record<string, string>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: false, message: "잘못된 요청입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, companyName, ceoName, bizNo, phone } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !companyName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: "이메일, 비밀번호, 업체명은 필수입니다." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signupMomoUser({ email, password, companyName, ceoName, bizNo, phone });
|
||||||
|
if (!result.success || !result.user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: result.error ?? "가입에 실패했습니다." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUser: User = {
|
||||||
|
sabun: "",
|
||||||
|
userId: result.user.email,
|
||||||
|
userName: result.user.companyName,
|
||||||
|
userNameEng: "",
|
||||||
|
userNameCn: "",
|
||||||
|
deptCode: "",
|
||||||
|
deptName: "",
|
||||||
|
positionCode: "",
|
||||||
|
positionName: "",
|
||||||
|
email: result.user.email,
|
||||||
|
tel: "",
|
||||||
|
cellPhone: result.user.phone || "",
|
||||||
|
userType: "MOMO",
|
||||||
|
userTypeName: result.user.role === "ADMIN" ? "관리자" : "거래처",
|
||||||
|
authName: result.user.role,
|
||||||
|
partnerCd: "",
|
||||||
|
isAdmin: result.user.isAdmin,
|
||||||
|
role: result.user.role,
|
||||||
|
objid: result.user.objid,
|
||||||
|
companyName: result.user.companyName,
|
||||||
|
};
|
||||||
|
await createSession(sessionUser);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, user: sessionUser });
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { queryOne, queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
if (r.user.role === "USER") {
|
||||||
|
const summary = await queryOne(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'REQUESTED') AS "REQUESTED_CNT",
|
||||||
|
COUNT(*) FILTER (WHERE status IN ('APPROVED','SHIPPED','INVOICED')) AS "PROGRESS_CNT",
|
||||||
|
COALESCE(SUM(total_amount) FILTER (WHERE date_trunc('month', order_date) = date_trunc('month', CURRENT_DATE)), 0) AS "MONTH_AMOUNT",
|
||||||
|
COALESCE(SUM(total_amount - paid_amount) FILTER (WHERE status = 'INVOICED'), 0) AS "UNPAID"
|
||||||
|
FROM momo_orders
|
||||||
|
WHERE customer_objid = $1 AND COALESCE(is_del,'N') != 'Y'`,
|
||||||
|
[r.user.objid]
|
||||||
|
);
|
||||||
|
const recent = await queryRows(
|
||||||
|
`SELECT objid AS "OBJID", order_no AS "ORDER_NO",
|
||||||
|
TO_CHAR(order_date,'YYYY-MM-DD') AS "ORDER_DATE",
|
||||||
|
status AS "STATUS", total_amount AS "TOTAL_AMOUNT"
|
||||||
|
FROM momo_orders WHERE customer_objid = $1 AND COALESCE(is_del,'N') != 'Y'
|
||||||
|
ORDER BY regdate DESC LIMIT 5`,
|
||||||
|
[r.user.objid]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ summary, recent });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN
|
||||||
|
const summary = await queryOne(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'REQUESTED' AND COALESCE(is_del,'N') != 'Y') AS "PENDING_CNT",
|
||||||
|
COUNT(*) FILTER (WHERE order_date = CURRENT_DATE AND COALESCE(is_del,'N') != 'Y') AS "TODAY_CNT",
|
||||||
|
COALESCE(SUM(total_amount) FILTER (WHERE date_trunc('month', order_date) = date_trunc('month', CURRENT_DATE)), 0) AS "MONTH_AMOUNT",
|
||||||
|
COALESCE(SUM(total_amount - paid_amount) FILTER (WHERE status IN ('APPROVED','SHIPPED','INVOICED')), 0) AS "UNPAID"
|
||||||
|
FROM momo_orders WHERE COALESCE(is_del,'N') != 'Y'`
|
||||||
|
);
|
||||||
|
const lowStock = await queryRows(
|
||||||
|
`SELECT I.objid AS "OBJID", I.item_code AS "ITEM_CODE", I.item_name AS "ITEM_NAME",
|
||||||
|
COALESCE(SUM(S.qty),0) AS "STOCK_QTY"
|
||||||
|
FROM momo_items I
|
||||||
|
LEFT JOIN momo_stocks S ON S.item_objid = I.objid
|
||||||
|
WHERE I.status = 'ACTIVE' AND COALESCE(I.is_del,'N') != 'Y'
|
||||||
|
GROUP BY I.objid, I.item_code, I.item_name
|
||||||
|
HAVING COALESCE(SUM(S.qty),0) < 10
|
||||||
|
ORDER BY "STOCK_QTY" ASC LIMIT 10`
|
||||||
|
);
|
||||||
|
const daily = await queryRows(
|
||||||
|
`SELECT TO_CHAR(order_date, 'MM-DD') AS "DAY",
|
||||||
|
COUNT(*) AS "CNT", COALESCE(SUM(total_amount),0) AS "AMOUNT"
|
||||||
|
FROM momo_orders
|
||||||
|
WHERE order_date >= CURRENT_DATE - INTERVAL '13 days' AND COALESCE(is_del,'N') != 'Y'
|
||||||
|
GROUP BY order_date ORDER BY order_date ASC`
|
||||||
|
);
|
||||||
|
const pending = await queryRows(
|
||||||
|
`SELECT O.objid AS "OBJID", O.order_no AS "ORDER_NO",
|
||||||
|
U.company_name AS "COMPANY_NAME",
|
||||||
|
TO_CHAR(O.order_date,'YYYY-MM-DD') AS "ORDER_DATE",
|
||||||
|
O.total_amount AS "TOTAL_AMOUNT"
|
||||||
|
FROM momo_orders O JOIN momo_users U ON O.customer_objid = U.objid
|
||||||
|
WHERE O.status = 'REQUESTED' AND COALESCE(O.is_del,'N') != 'Y'
|
||||||
|
ORDER BY O.regdate ASC LIMIT 5`
|
||||||
|
);
|
||||||
|
return NextResponse.json({ summary, lowStock, daily, pending });
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 매입 입고 (단순) — 창고/품목/수량 입력하면 재고 + 이력
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
itemObjid: string;
|
||||||
|
qty: number;
|
||||||
|
costPrice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
const userId = g.user.objid || g.user.userId;
|
||||||
|
|
||||||
|
const { whObjid, lines, memo } = await req.json() as { whObjid: string; lines: Line[]; memo?: string };
|
||||||
|
if (!whObjid || !Array.isArray(lines) || lines.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, message: "창고와 품목 라인을 입력하세요." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
for (const ln of lines) {
|
||||||
|
if (!ln.itemObjid || !ln.qty || ln.qty <= 0) continue;
|
||||||
|
|
||||||
|
// upsert stock
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (wh_objid, item_objid) DO UPDATE
|
||||||
|
SET qty = momo_stocks.qty + EXCLUDED.qty, update_date = NOW()`,
|
||||||
|
[createObjectId(), whObjid, ln.itemObjid, ln.qty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// log
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, memo, regdate, regid)
|
||||||
|
VALUES ($1, $2, $3, 'IN', $4, 'PROCUREMENT', $5, NOW(), $6)`,
|
||||||
|
[createObjectId(), whObjid, ln.itemObjid, ln.qty, memo ?? null, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 매입가가 들어오면 items.cost_price 도 갱신
|
||||||
|
if (ln.costPrice && ln.costPrice > 0) {
|
||||||
|
await client.query(`UPDATE momo_items SET cost_price = $2 WHERE objid = $1`, [ln.itemObjid, ln.costPrice]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("[inbound]", err);
|
||||||
|
return NextResponse.json({ success: false, message: "입고 처리 중 오류가 발생했습니다." }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const { whObjid, keyword } = body as { whObjid?: string; keyword?: string };
|
||||||
|
|
||||||
|
const conditions: string[] = ["COALESCE(I.is_del,'N') != 'Y'"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let i = 1;
|
||||||
|
if (whObjid) {
|
||||||
|
conditions.push(`S.wh_objid = $${i++}`);
|
||||||
|
params.push(whObjid);
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
conditions.push(`(I.item_name ILIKE '%' || $${i} || '%' OR I.item_code ILIKE '%' || $${i} || '%')`);
|
||||||
|
params.push(keyword);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await queryRows(
|
||||||
|
`SELECT
|
||||||
|
S.objid AS "OBJID",
|
||||||
|
W.wh_code AS "WH_CODE",
|
||||||
|
W.wh_name AS "WH_NAME",
|
||||||
|
I.objid AS "ITEM_OBJID",
|
||||||
|
I.item_code AS "ITEM_CODE",
|
||||||
|
I.item_name AS "ITEM_NAME",
|
||||||
|
I.unit AS "UNIT",
|
||||||
|
I.is_tax_free AS "IS_TAX_FREE",
|
||||||
|
S.qty AS "QTY",
|
||||||
|
TO_CHAR(S.update_date, 'YYYY-MM-DD HH24:MI') AS "UPDATE_DATE"
|
||||||
|
FROM momo_stocks S
|
||||||
|
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||||
|
JOIN momo_items I ON S.item_objid = I.objid
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY W.wh_code, I.item_name`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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 { objids } = await req.json();
|
||||||
|
if (!Array.isArray(objids) || objids.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, message: "삭제할 항목이 없습니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = objids.map((_, i) => `$${i + 1}`).join(",");
|
||||||
|
await execute(
|
||||||
|
`UPDATE momo_items SET is_del='Y', update_date=NOW() WHERE objid IN (${placeholders})`,
|
||||||
|
objids
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
// 품목 목록 — USER 는 재고 있는 ACTIVE 만, ADMIN 은 전체 표시
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const { keyword, isTaxFree, makerObjid, status, onlyAvailable } = body as {
|
||||||
|
keyword?: string;
|
||||||
|
isTaxFree?: "Y" | "N";
|
||||||
|
makerObjid?: string;
|
||||||
|
status?: string;
|
||||||
|
onlyAvailable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUser = r.user.role === "USER";
|
||||||
|
const conditions = ["COALESCE(I.is_del, 'N') != 'Y'"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (isUser) conditions.push("I.status = 'ACTIVE'");
|
||||||
|
else if (status) {
|
||||||
|
conditions.push(`I.status = $${i++}`);
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
conditions.push(`(I.item_name ILIKE '%' || $${i} || '%' OR I.item_code ILIKE '%' || $${i} || '%' OR I.item_detail ILIKE '%' || $${i} || '%')`);
|
||||||
|
params.push(keyword);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (isTaxFree === "Y" || isTaxFree === "N") {
|
||||||
|
conditions.push(`I.is_tax_free = $${i++}`);
|
||||||
|
params.push(isTaxFree);
|
||||||
|
}
|
||||||
|
if (makerObjid) {
|
||||||
|
conditions.push(`I.maker_objid = $${i++}`);
|
||||||
|
params.push(makerObjid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
I.objid AS "OBJID",
|
||||||
|
I.item_code AS "ITEM_CODE",
|
||||||
|
I.item_name AS "ITEM_NAME",
|
||||||
|
I.item_detail AS "ITEM_DETAIL",
|
||||||
|
I.maker_objid AS "MAKER_OBJID",
|
||||||
|
M.maker_name AS "MAKER_NAME",
|
||||||
|
I.unit AS "UNIT",
|
||||||
|
I.unit_price AS "UNIT_PRICE",
|
||||||
|
I.cost_price AS "COST_PRICE",
|
||||||
|
I.is_tax_free AS "IS_TAX_FREE",
|
||||||
|
I.image_url AS "IMAGE_URL",
|
||||||
|
I.status AS "STATUS",
|
||||||
|
I.attributes AS "ATTRIBUTES",
|
||||||
|
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",
|
||||||
|
TO_CHAR(I.regdate, 'YYYY-MM-DD') AS "REGDATE"
|
||||||
|
FROM momo_items I
|
||||||
|
LEFT JOIN momo_makers M ON I.maker_objid = M.objid
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
${onlyAvailable ? `HAVING COALESCE((SELECT SUM(S.qty) FROM momo_stocks S WHERE S.item_objid = I.objid),0) > 0` : ""}
|
||||||
|
ORDER BY I.item_name ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = await queryRows(sql, params);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { execute, queryOne } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
const userId = g.user.objid || g.user.userId;
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
objid,
|
||||||
|
actionType,
|
||||||
|
itemName,
|
||||||
|
itemDetail,
|
||||||
|
makerObjid,
|
||||||
|
unit,
|
||||||
|
unitPrice,
|
||||||
|
costPrice,
|
||||||
|
isTaxFree,
|
||||||
|
imageUrl,
|
||||||
|
attributes,
|
||||||
|
status,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!itemName) {
|
||||||
|
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 면세 자동 판정 (사용자가 명시적으로 'N'으로 설정한 경우 존중)
|
||||||
|
const taxFree = isTaxFree === "Y" || (isTaxFree !== "N" && /^M[\s_가-힣A-Za-z]/.test(itemName)) ? "Y" : "N";
|
||||||
|
|
||||||
|
if (actionType === "regist") {
|
||||||
|
const newId = createObjectId();
|
||||||
|
const itemCode = await genItemCode();
|
||||||
|
await execute(
|
||||||
|
`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,
|
||||||
|
is_del, regdate, regid
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,'N',NOW(),$13)`,
|
||||||
|
[newId, itemCode, itemName, itemDetail ?? null, makerObjid ?? null,
|
||||||
|
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||||
|
taxFree, imageUrl ?? null,
|
||||||
|
attributes ? JSON.stringify(attributes) : null,
|
||||||
|
status ?? "ACTIVE", userId]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true, objId: newId, itemCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||||
|
await execute(
|
||||||
|
`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
|
||||||
|
WHERE objid=$1`,
|
||||||
|
[objid, itemName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
|
||||||
|
Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||||
|
taxFree, imageUrl ?? null,
|
||||||
|
attributes ? JSON.stringify(attributes) : null,
|
||||||
|
status ?? "ACTIVE", userId]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true, objId: objid });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genItemCode(): Promise<string> {
|
||||||
|
const today = new Date();
|
||||||
|
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
const prefix = `ITM-${ymd}-`;
|
||||||
|
const row = await queryOne<{ MAX_NO: string }>(
|
||||||
|
`SELECT COALESCE(MAX(item_code), '') AS "MAX_NO" FROM momo_items WHERE item_code LIKE $1 || '%'`,
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
const last = row?.MAX_NO ?? "";
|
||||||
|
const lastNum = last ? Number(last.replace(prefix, "")) || 0 : 0;
|
||||||
|
return prefix + String(lastNum + 1).padStart(4, "0");
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ALLOWED = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||||
|
const MAX_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ success: false, message: "파일이 없습니다." }, { status: 400 });
|
||||||
|
if (!ALLOWED.includes(file.type)) {
|
||||||
|
return NextResponse.json({ success: false, message: "이미지 파일만 업로드 가능합니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
return NextResponse.json({ success: false, message: "파일 크기는 5MB 이하여야 합니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const dir = path.join(process.cwd(), "public", "uploads", "items", ymd);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const ext = (file.name.match(/\.[a-zA-Z0-9]+$/)?.[0] ?? ".jpg").toLowerCase();
|
||||||
|
const safeExt = /^\.(jpg|jpeg|png|webp|gif)$/.test(ext) ? ext : ".jpg";
|
||||||
|
const fname = createObjectId() + safeExt;
|
||||||
|
const fpath = path.join(dir, fname);
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(fpath, buf);
|
||||||
|
|
||||||
|
const url = `/uploads/items/${ymd}/${fname}`;
|
||||||
|
return NextResponse.json({ success: true, url });
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
const rows = await queryRows(
|
||||||
|
`SELECT objid AS "OBJID", maker_name AS "MAKER_NAME", contact AS "CONTACT", phone AS "PHONE"
|
||||||
|
FROM momo_makers WHERE COALESCE(is_del,'N') != 'Y' ORDER BY maker_name ASC`
|
||||||
|
);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { execute } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
|
||||||
|
const { objid, actionType, makerName, contact, phone } = await req.json();
|
||||||
|
if (!makerName) return NextResponse.json({ success: false, message: "제조사명은 필수" }, { status: 400 });
|
||||||
|
|
||||||
|
if (actionType === "regist") {
|
||||||
|
const id = createObjectId();
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO momo_makers (objid, maker_name, contact, phone, regdate)
|
||||||
|
VALUES ($1,$2,$3,$4,NOW())`,
|
||||||
|
[id, makerName, contact ?? null, phone ?? null]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true, objId: id });
|
||||||
|
}
|
||||||
|
await execute(
|
||||||
|
`UPDATE momo_makers SET maker_name=$2, contact=$3, phone=$4 WHERE objid=$1`,
|
||||||
|
[objid, makerName, contact ?? null, phone ?? null]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// 발주 승인: 트랜잭션으로 재고 차감 + 상태 변경, 그 후 메일 발송
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { pool, queryOne, queryRows } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
import { buildStatementHtml, buildStatementXlsx } from "@/lib/excel-statement";
|
||||||
|
import { sendMail } from "@/lib/mailer";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
const adminId = g.user.objid || g.user.userId;
|
||||||
|
|
||||||
|
const { objid } = await req.json();
|
||||||
|
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const orderRow = await client.query(
|
||||||
|
`SELECT objid, order_no, customer_objid, status FROM momo_orders WHERE objid = $1 FOR UPDATE`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
if (orderRow.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
}
|
||||||
|
const order = orderRow.rows[0];
|
||||||
|
if (order.status !== "REQUESTED") {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return NextResponse.json({ success: false, message: `현재 상태(${order.status})에서는 승인할 수 없습니다.` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsRes = await client.query(
|
||||||
|
`SELECT objid, item_objid, qty FROM momo_order_items WHERE order_objid = $1 ORDER BY seq`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 출고 창고: STOCK 타입 첫 번째
|
||||||
|
const whRes = await client.query(
|
||||||
|
`SELECT objid FROM momo_warehouses WHERE wh_type = 'STOCK' AND COALESCE(is_del,'N') != 'Y' ORDER BY wh_code LIMIT 1`
|
||||||
|
);
|
||||||
|
if (whRes.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return NextResponse.json({ success: false, message: "출고 가능한 창고가 없습니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const whObjid = whRes.rows[0].objid;
|
||||||
|
|
||||||
|
for (const ln of itemsRes.rows) {
|
||||||
|
const stk = await client.query(
|
||||||
|
`SELECT qty FROM momo_stocks WHERE wh_objid = $1 AND item_objid = $2 FOR UPDATE`,
|
||||||
|
[whObjid, ln.item_objid]
|
||||||
|
);
|
||||||
|
const currentQty = stk.rowCount === 0 ? 0 : Number(stk.rows[0].qty);
|
||||||
|
if (currentQty < Number(ln.qty)) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: `재고 부족: 품목 ${ln.item_objid} (현재고 ${currentQty}, 요청 ${ln.qty})` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stk.rowCount === 0) {
|
||||||
|
// 안전장치: 재고 row가 없으면 만들지 않고 에러 처리
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return NextResponse.json({ success: false, message: "재고 정보가 없습니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
await client.query(
|
||||||
|
`UPDATE momo_stocks SET qty = qty - $1, update_date = NOW()
|
||||||
|
WHERE wh_objid = $2 AND item_objid = $3`,
|
||||||
|
[ln.qty, whObjid, ln.item_objid]
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, ref_objid, regdate, regid)
|
||||||
|
VALUES ($1, $2, $3, 'OUT', $4, 'ORDER', $5, NOW(), $6)`,
|
||||||
|
[createObjectId(), whObjid, ln.item_objid, -Number(ln.qty), objid, adminId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE momo_orders
|
||||||
|
SET status = 'APPROVED', approve_user = $2, approve_date = NOW(),
|
||||||
|
update_date = NOW(), update_id = $2
|
||||||
|
WHERE objid = $1`,
|
||||||
|
[objid, adminId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("[order/approve]", err);
|
||||||
|
return NextResponse.json({ success: false, message: "승인 중 오류가 발생했습니다." }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메일 발송 (트랜잭션 외부) =====
|
||||||
|
try {
|
||||||
|
const order = await queryOne<Record<string, unknown>>(
|
||||||
|
`SELECT O.objid, O.order_no, TO_CHAR(O.order_date,'YYYY-MM-DD') AS order_date,
|
||||||
|
U.company_name, U.email, U.ceo_name, U.biz_no, U.phone,
|
||||||
|
O.total_supply, O.total_vat, O.total_amount,
|
||||||
|
O.total_taxfree, O.total_taxable
|
||||||
|
FROM momo_orders O
|
||||||
|
JOIN momo_users U ON O.customer_objid = U.objid
|
||||||
|
WHERE O.objid = $1`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
const items = await queryRows<Record<string, unknown>>(
|
||||||
|
`SELECT seq, item_name_snap, unit_price, qty, is_tax_free, supply_amount, vat_amount, total_amount,
|
||||||
|
(SELECT unit FROM momo_items WHERE objid = OI.item_objid) AS unit
|
||||||
|
FROM momo_order_items OI WHERE order_objid = $1 ORDER BY seq`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
if (order && order.email) {
|
||||||
|
const stmt = {
|
||||||
|
orderNo: String(order.order_no),
|
||||||
|
orderDate: String(order.order_date),
|
||||||
|
customer: {
|
||||||
|
companyName: String(order.company_name),
|
||||||
|
ceoName: order.ceo_name as string | undefined,
|
||||||
|
bizNo: order.biz_no as string | undefined,
|
||||||
|
phone: order.phone as string | undefined,
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
companyName: "모모유통",
|
||||||
|
bankAccount: process.env.MOMO_BANK_ACCOUNT ?? "기업은행 ____",
|
||||||
|
phone: process.env.MOMO_PHONE ?? "010-6624-5315",
|
||||||
|
email: process.env.SMTP_FROM ?? "momo8443@daum.net",
|
||||||
|
},
|
||||||
|
items: items.map((it) => ({
|
||||||
|
seq: Number(it.seq),
|
||||||
|
itemName: String(it.item_name_snap),
|
||||||
|
unit: String(it.unit ?? "EA"),
|
||||||
|
qty: Number(it.qty),
|
||||||
|
unitPrice: Number(it.unit_price),
|
||||||
|
supplyAmount: Number(it.supply_amount),
|
||||||
|
vatAmount: Number(it.vat_amount),
|
||||||
|
totalAmount: Number(it.total_amount),
|
||||||
|
isTaxFree: it.is_tax_free === "Y",
|
||||||
|
})),
|
||||||
|
totals: {
|
||||||
|
supply: Number(order.total_supply),
|
||||||
|
vat: Number(order.total_vat),
|
||||||
|
total: Number(order.total_amount),
|
||||||
|
taxFree: Number(order.total_taxfree),
|
||||||
|
taxable: Number(order.total_taxable),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = buildStatementHtml(stmt);
|
||||||
|
const xlsx = buildStatementXlsx(stmt);
|
||||||
|
const mailRes = await sendMail({
|
||||||
|
to: String(order.email),
|
||||||
|
subject: `[모모유통] 발주 ${stmt.orderNo} 승인되었습니다`,
|
||||||
|
html,
|
||||||
|
attachments: [{
|
||||||
|
filename: `거래명세표_${stmt.orderNo}.xlsx`,
|
||||||
|
content: xlsx,
|
||||||
|
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
}],
|
||||||
|
refType: "ORDER",
|
||||||
|
refObjid: objid,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true, mailSent: mailRes.ok, mailError: mailRes.error });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[order/approve mail]", err);
|
||||||
|
return NextResponse.json({ success: true, mailSent: false, mailError: "메일 발송 실패 (승인은 완료)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, mailSent: false });
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { execute, queryOne } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
const { objid } = await req.json();
|
||||||
|
const order = await queryOne<{ customer_objid: string; status: string }>(
|
||||||
|
`SELECT customer_objid, status FROM momo_orders WHERE objid = $1`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
if (!order) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
if (r.user.role === "USER" && order.customer_objid !== r.user.objid) {
|
||||||
|
return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (order.status !== "REQUESTED") {
|
||||||
|
return NextResponse.json({ success: false, message: "요청 상태에서만 취소 가능합니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
await execute(`UPDATE momo_orders SET status='CANCELLED', update_date=NOW() WHERE objid=$1`, [objid]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryOne, queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
const { objid } = await req.json();
|
||||||
|
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||||
|
|
||||||
|
const order = await queryOne<Record<string, unknown>>(
|
||||||
|
`SELECT
|
||||||
|
O.objid AS "OBJID", O.order_no AS "ORDER_NO",
|
||||||
|
TO_CHAR(O.order_date,'YYYY-MM-DD') AS "ORDER_DATE",
|
||||||
|
O.customer_objid AS "CUSTOMER_OBJID",
|
||||||
|
U.company_name AS "COMPANY_NAME", U.email AS "EMAIL",
|
||||||
|
U.ceo_name AS "CEO_NAME", U.biz_no AS "BIZ_NO", U.phone AS "PHONE",
|
||||||
|
O.status AS "STATUS", O.memo AS "MEMO",
|
||||||
|
O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT",
|
||||||
|
O.total_amount AS "TOTAL_AMOUNT",
|
||||||
|
O.total_taxfree AS "TOTAL_TAXFREE", O.total_taxable AS "TOTAL_TAXABLE",
|
||||||
|
O.invoice_no AS "INVOICE_NO",
|
||||||
|
TO_CHAR(O.invoice_date,'YYYY-MM-DD') AS "INVOICE_DATE",
|
||||||
|
O.paid_amount AS "PAID_AMOUNT",
|
||||||
|
TO_CHAR(O.approve_date,'YYYY-MM-DD HH24:MI') AS "APPROVE_DATE"
|
||||||
|
FROM momo_orders O
|
||||||
|
JOIN momo_users U ON O.customer_objid = U.objid
|
||||||
|
WHERE O.objid = $1 AND COALESCE(O.is_del,'N') != 'Y'`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
if (!order) return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
|
||||||
|
if (r.user.role === "USER" && order.CUSTOMER_OBJID !== r.user.objid) {
|
||||||
|
return NextResponse.json({ success: false, message: "조회 권한이 없습니다." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await queryRows(
|
||||||
|
`SELECT
|
||||||
|
OI.objid AS "OBJID", OI.seq AS "SEQ",
|
||||||
|
OI.item_objid AS "ITEM_OBJID",
|
||||||
|
OI.item_name_snap AS "ITEM_NAME",
|
||||||
|
OI.unit_price AS "UNIT_PRICE",
|
||||||
|
OI.qty AS "QTY",
|
||||||
|
OI.is_tax_free AS "IS_TAX_FREE",
|
||||||
|
OI.supply_amount AS "SUPPLY_AMOUNT",
|
||||||
|
OI.vat_amount AS "VAT_AMOUNT",
|
||||||
|
OI.total_amount AS "TOTAL_AMOUNT",
|
||||||
|
I.unit AS "UNIT",
|
||||||
|
I.image_url AS "IMAGE_URL"
|
||||||
|
FROM momo_order_items OI
|
||||||
|
LEFT JOIN momo_items I ON OI.item_objid = I.objid
|
||||||
|
WHERE OI.order_objid = $1
|
||||||
|
ORDER BY OI.seq ASC`,
|
||||||
|
[objid]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, order, items });
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const { dateFrom, dateTo, status, customerObjid } = body as {
|
||||||
|
dateFrom?: string; dateTo?: string; status?: string; customerObjid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const conditions: string[] = ["COALESCE(O.is_del,'N') != 'Y'"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (r.user.role === "USER") {
|
||||||
|
conditions.push(`O.customer_objid = $${i++}`);
|
||||||
|
params.push(r.user.objid);
|
||||||
|
} else if (customerObjid) {
|
||||||
|
conditions.push(`O.customer_objid = $${i++}`);
|
||||||
|
params.push(customerObjid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push(`O.status = $${i++}`);
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (dateFrom) {
|
||||||
|
conditions.push(`O.order_date >= $${i++}::date`);
|
||||||
|
params.push(dateFrom);
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
conditions.push(`O.order_date <= $${i++}::date`);
|
||||||
|
params.push(dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await queryRows(
|
||||||
|
`SELECT
|
||||||
|
O.objid AS "OBJID",
|
||||||
|
O.order_no AS "ORDER_NO",
|
||||||
|
TO_CHAR(O.order_date, 'YYYY-MM-DD') AS "ORDER_DATE",
|
||||||
|
O.customer_objid AS "CUSTOMER_OBJID",
|
||||||
|
U.company_name AS "COMPANY_NAME",
|
||||||
|
U.email AS "EMAIL",
|
||||||
|
O.status AS "STATUS",
|
||||||
|
O.total_supply AS "TOTAL_SUPPLY",
|
||||||
|
O.total_vat AS "TOTAL_VAT",
|
||||||
|
O.total_amount AS "TOTAL_AMOUNT",
|
||||||
|
O.total_taxfree AS "TOTAL_TAXFREE",
|
||||||
|
O.total_taxable AS "TOTAL_TAXABLE",
|
||||||
|
O.invoice_no AS "INVOICE_NO",
|
||||||
|
TO_CHAR(O.invoice_date, 'YYYY-MM-DD') AS "INVOICE_DATE",
|
||||||
|
O.paid_amount AS "PAID_AMOUNT",
|
||||||
|
TO_CHAR(O.approve_date, 'YYYY-MM-DD HH24:MI') AS "APPROVE_DATE",
|
||||||
|
O.memo AS "MEMO"
|
||||||
|
FROM momo_orders O
|
||||||
|
JOIN momo_users U ON O.customer_objid = U.objid
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY O.order_date DESC, O.regdate DESC
|
||||||
|
LIMIT 500`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// 출고요청서 작성 (대리점) — status=REQUESTED 로 저장
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { pool, queryOne } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
import { calcLine, sumTotals } from "@/lib/momo-pricing";
|
||||||
|
|
||||||
|
interface InputLine {
|
||||||
|
itemObjid: string;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
const customerObjid = r.user.objid!;
|
||||||
|
|
||||||
|
const { lines, memo } = await req.json() as { lines: InputLine[]; memo?: string };
|
||||||
|
if (!Array.isArray(lines) || lines.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, message: "발주 품목을 추가하세요." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemIds = lines.map((l) => l.itemObjid);
|
||||||
|
const placeholders = itemIds.map((_, i) => `$${i + 1}`).join(",");
|
||||||
|
const items = await (await pool.connect()).query(
|
||||||
|
`SELECT objid, item_name, unit_price, is_tax_free FROM momo_items WHERE objid IN (${placeholders})`,
|
||||||
|
itemIds
|
||||||
|
);
|
||||||
|
const itemMap = new Map(items.rows.map((row) => [row.objid as string, row]));
|
||||||
|
|
||||||
|
const orderObjid = createObjectId();
|
||||||
|
const orderNo = await genOrderNo();
|
||||||
|
const enriched = lines.map((ln, idx) => {
|
||||||
|
const it = itemMap.get(ln.itemObjid);
|
||||||
|
if (!it) throw new Error(`존재하지 않는 품목: ${ln.itemObjid}`);
|
||||||
|
const isFree = it.is_tax_free === "Y";
|
||||||
|
const calc = calcLine({ unitPrice: Number(it.unit_price), qty: ln.qty, isTaxFree: isFree });
|
||||||
|
return {
|
||||||
|
seq: idx + 1,
|
||||||
|
itemObjid: ln.itemObjid,
|
||||||
|
itemName: it.item_name as string,
|
||||||
|
unitPrice: Number(it.unit_price),
|
||||||
|
qty: ln.qty,
|
||||||
|
isTaxFree: isFree,
|
||||||
|
...calc,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const totals = sumTotals(enriched);
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_orders (
|
||||||
|
objid, order_no, customer_objid, order_date, status,
|
||||||
|
total_supply, total_vat, total_amount, total_taxfree, total_taxable,
|
||||||
|
memo, regdate, regid
|
||||||
|
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,NOW(),$3)`,
|
||||||
|
[orderObjid, orderNo, customerObjid,
|
||||||
|
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable, memo ?? null]
|
||||||
|
);
|
||||||
|
for (const ln of enriched) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_order_items (
|
||||||
|
objid, order_objid, item_objid, item_name_snap, unit_price, qty,
|
||||||
|
is_tax_free, supply_amount, vat_amount, total_amount, seq
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
|
||||||
|
[createObjectId(), orderObjid, ln.itemObjid, ln.itemName, ln.unitPrice, ln.qty,
|
||||||
|
ln.isTaxFree ? "Y" : "N", ln.supplyAmount, ln.vatAmount, ln.totalAmount, ln.seq]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return NextResponse.json({ success: true, objId: orderObjid, orderNo });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("[order/save]", err);
|
||||||
|
return NextResponse.json({ success: false, message: "발주 저장 중 오류가 발생했습니다." }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genOrderNo(): Promise<string> {
|
||||||
|
const today = new Date();
|
||||||
|
const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||||
|
const prefix = `ORD-${ymd}-`;
|
||||||
|
const row = await queryOne<{ MAX_NO: string }>(
|
||||||
|
`SELECT COALESCE(MAX(order_no), '') AS "MAX_NO" FROM momo_orders WHERE order_no LIKE $1 || '%'`,
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
const last = row?.MAX_NO ?? "";
|
||||||
|
const lastNum = last ? Number(last.replace(prefix, "")) || 0 : 0;
|
||||||
|
return prefix + String(lastNum + 1).padStart(4, "0");
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 거래명세표 다운로드 (xlsx)
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { queryOne, queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
import { buildStatementXlsx } from "@/lib/excel-statement";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const order = await queryOne<Record<string, unknown>>(
|
||||||
|
`SELECT O.objid, O.order_no, TO_CHAR(O.order_date,'YYYY-MM-DD') AS order_date,
|
||||||
|
O.customer_objid,
|
||||||
|
U.company_name, U.ceo_name, U.biz_no, U.phone,
|
||||||
|
O.total_supply, O.total_vat, O.total_amount,
|
||||||
|
O.total_taxfree, O.total_taxable
|
||||||
|
FROM momo_orders O JOIN momo_users U ON O.customer_objid = U.objid
|
||||||
|
WHERE O.objid = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!order) return NextResponse.json({ success: false, message: "찾을 수 없습니다." }, { status: 404 });
|
||||||
|
if (r.user.role === "USER" && order.customer_objid !== r.user.objid) {
|
||||||
|
return NextResponse.json({ success: false, message: "권한 없음" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await queryRows<Record<string, unknown>>(
|
||||||
|
`SELECT seq, item_name_snap, unit_price, qty, is_tax_free, supply_amount, vat_amount, total_amount,
|
||||||
|
(SELECT unit FROM momo_items WHERE objid = OI.item_objid) AS unit
|
||||||
|
FROM momo_order_items OI WHERE order_objid = $1 ORDER BY seq`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buf = buildStatementXlsx({
|
||||||
|
orderNo: String(order.order_no),
|
||||||
|
orderDate: String(order.order_date),
|
||||||
|
customer: {
|
||||||
|
companyName: String(order.company_name),
|
||||||
|
ceoName: order.ceo_name as string | undefined,
|
||||||
|
bizNo: order.biz_no as string | undefined,
|
||||||
|
phone: order.phone as string | undefined,
|
||||||
|
},
|
||||||
|
supplier: {
|
||||||
|
companyName: "모모유통",
|
||||||
|
bankAccount: process.env.MOMO_BANK_ACCOUNT ?? "기업은행 ____",
|
||||||
|
phone: process.env.MOMO_PHONE ?? "010-6624-5315",
|
||||||
|
email: process.env.SMTP_FROM ?? "momo8443@daum.net",
|
||||||
|
},
|
||||||
|
items: items.map((it) => ({
|
||||||
|
seq: Number(it.seq),
|
||||||
|
itemName: String(it.item_name_snap),
|
||||||
|
unit: String(it.unit ?? "EA"),
|
||||||
|
qty: Number(it.qty),
|
||||||
|
unitPrice: Number(it.unit_price),
|
||||||
|
supplyAmount: Number(it.supply_amount),
|
||||||
|
vatAmount: Number(it.vat_amount),
|
||||||
|
totalAmount: Number(it.total_amount),
|
||||||
|
isTaxFree: it.is_tax_free === "Y",
|
||||||
|
})),
|
||||||
|
totals: {
|
||||||
|
supply: Number(order.total_supply),
|
||||||
|
vat: Number(order.total_vat),
|
||||||
|
total: Number(order.total_amount),
|
||||||
|
taxFree: Number(order.total_taxfree),
|
||||||
|
taxable: Number(order.total_taxable),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(buf), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"Content-Disposition": `attachment; filename="거래명세표_${order.order_no}.xlsx"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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 { year, month } = await req.json();
|
||||||
|
const y = Number(year) || new Date().getFullYear();
|
||||||
|
const m = Number(month) || new Date().getMonth() + 1;
|
||||||
|
|
||||||
|
const rows = await queryRows(
|
||||||
|
`SELECT
|
||||||
|
U.company_name AS "COMPANY_NAME",
|
||||||
|
COALESCE(SUM(O.total_taxfree), 0) AS "TAX_FREE",
|
||||||
|
COALESCE(SUM(O.total_taxable), 0) AS "TAXABLE",
|
||||||
|
COALESCE(SUM(O.total_amount), 0) AS "TOTAL"
|
||||||
|
FROM momo_orders O
|
||||||
|
JOIN momo_users U ON O.customer_objid = U.objid
|
||||||
|
WHERE EXTRACT(YEAR FROM O.order_date) = $1
|
||||||
|
AND EXTRACT(MONTH FROM O.order_date) = $2
|
||||||
|
AND O.status IN ('APPROVED', 'SHIPPED', 'INVOICED', 'PAID')
|
||||||
|
AND COALESCE(O.is_del,'N') != 'Y'
|
||||||
|
GROUP BY U.company_name
|
||||||
|
ORDER BY "TOTAL" DESC`,
|
||||||
|
[y, m]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
|
||||||
|
const rows = await queryRows(
|
||||||
|
`SELECT objid AS "OBJID", email AS "EMAIL", company_name AS "COMPANY_NAME",
|
||||||
|
ceo_name AS "CEO_NAME", phone AS "PHONE", biz_no AS "BIZ_NO",
|
||||||
|
role AS "ROLE", status AS "STATUS",
|
||||||
|
TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE"
|
||||||
|
FROM momo_users WHERE COALESCE(is_del,'N') != 'Y'
|
||||||
|
ORDER BY regdate DESC`
|
||||||
|
);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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 { objid, role, status } = await req.json();
|
||||||
|
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||||
|
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: unknown[] = [objid];
|
||||||
|
let i = 2;
|
||||||
|
if (role) { sets.push(`role = $${i++}`); params.push(role); }
|
||||||
|
if (status) { sets.push(`status = $${i++}`); params.push(status); }
|
||||||
|
if (sets.length === 0) return NextResponse.json({ success: false, message: "변경 사항 없음" });
|
||||||
|
|
||||||
|
sets.push(`update_date = NOW()`);
|
||||||
|
await execute(`UPDATE momo_users SET ${sets.join(", ")} WHERE objid = $1`, params);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { queryRows } from "@/lib/db";
|
||||||
|
import { requireMomoUser } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
const rows = await queryRows(
|
||||||
|
`SELECT objid AS "OBJID", wh_code AS "WH_CODE", wh_name AS "WH_NAME",
|
||||||
|
location AS "LOCATION", wh_type AS "WH_TYPE",
|
||||||
|
TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE"
|
||||||
|
FROM momo_warehouses
|
||||||
|
WHERE COALESCE(is_del,'N') != 'Y'
|
||||||
|
ORDER BY wh_code ASC`
|
||||||
|
);
|
||||||
|
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { execute } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
|
||||||
|
const { objid, actionType, whCode, whName, location, whType } = await req.json();
|
||||||
|
if (!whCode || !whName) {
|
||||||
|
return NextResponse.json({ success: false, message: "코드와 이름은 필수입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (actionType === "regist") {
|
||||||
|
const id = createObjectId();
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO momo_warehouses (objid, wh_code, wh_name, location, wh_type, regdate)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,NOW())`,
|
||||||
|
[id, whCode, whName, location ?? null, whType ?? "STOCK"]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true, objId: id });
|
||||||
|
}
|
||||||
|
await execute(
|
||||||
|
`UPDATE momo_warehouses SET wh_code=$2, wh_name=$3, location=$4, wh_type=$5 WHERE objid=$1`,
|
||||||
|
[objid, whCode, whName, location ?? null, whType ?? "STOCK"]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
interface Stock { OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string }
|
||||||
|
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
||||||
|
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number }
|
||||||
|
interface InboundLine { itemObjid: string; itemName: string; qty: number; costPrice?: number }
|
||||||
|
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
|
||||||
|
export default function InventoryPage() {
|
||||||
|
const [list, setList] = useState<Stock[]>([]);
|
||||||
|
const [whs, setWhs] = useState<Wh[]>([]);
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [whFilter, setWhFilter] = useState("");
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [inboundOpen, setInboundOpen] = useState(false);
|
||||||
|
|
||||||
|
// 입고 폼
|
||||||
|
const [inboundWh, setInboundWh] = useState("");
|
||||||
|
const [lines, setLines] = useState<InboundLine[]>([]);
|
||||||
|
const [pickItem, setPickItem] = useState("");
|
||||||
|
const [pickQty, setPickQty] = useState(1);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/m/inventory/list", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ whObjid: whFilter || undefined, keyword: keyword || undefined }),
|
||||||
|
});
|
||||||
|
setList((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
const loadMeta = async () => {
|
||||||
|
const w = await (await fetch("/api/m/warehouses/list", { method: "POST" })).json();
|
||||||
|
setWhs(w.RESULTLIST ?? []);
|
||||||
|
const i = await (await fetch("/api/m/items/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) })).json();
|
||||||
|
setItems(i.RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadMeta(); load(); }, []); // eslint-disable-line
|
||||||
|
|
||||||
|
const addLine = () => {
|
||||||
|
if (!pickItem) return;
|
||||||
|
const it = items.find((x) => x.OBJID === pickItem);
|
||||||
|
if (!it) return;
|
||||||
|
setLines([...lines, { itemObjid: it.OBJID, itemName: it.ITEM_NAME, qty: pickQty }]);
|
||||||
|
setPickItem(""); setPickQty(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitInbound = async () => {
|
||||||
|
if (!inboundWh || lines.length === 0) {
|
||||||
|
Swal.fire({ icon: "warning", title: "창고와 품목을 입력하세요." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/m/inventory/inbound", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
whObjid: inboundWh,
|
||||||
|
lines: lines.map((l) => ({ itemObjid: l.itemObjid, qty: l.qty, costPrice: l.costPrice })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.success) {
|
||||||
|
Swal.fire({ icon: "success", title: "입고 처리 완료", timer: 1500, showConfirmButton: false });
|
||||||
|
setInboundOpen(false); setLines([]); setInboundWh("");
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">재고 관리</h1>
|
||||||
|
<button onClick={() => setInboundOpen(true)} className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||||
|
<Plus size={16} /> 매입 입고
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||||
|
<option value="">전체 창고</option>
|
||||||
|
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<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-slate-800 text-white text-sm font-semibold">조회</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">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">창고</th>
|
||||||
|
<th className="text-left px-4 py-3">품목코드</th>
|
||||||
|
<th className="text-left px-4 py-3">품목명</th>
|
||||||
|
<th className="text-center px-4 py-3">구분</th>
|
||||||
|
<th className="text-right px-4 py-3">현재고</th>
|
||||||
|
<th className="text-left px-4 py-3">최종 변경</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-12 text-slate-400">재고 데이터가 없습니다. 매입 입고로 등록하세요.</td></tr>
|
||||||
|
) : list.map((s) => (
|
||||||
|
<tr key={s.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3">{s.WH_NAME}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{s.IS_TAX_FREE === "Y"
|
||||||
|
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||||
|
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold">과세</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입고 모달 */}
|
||||||
|
{inboundOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setInboundOpen(false)}>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 className="font-bold mb-4">매입 입고 등록</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200">
|
||||||
|
<option value="">창고 선택</option>
|
||||||
|
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="flex-1 h-10 px-3 rounded-lg border border-slate-200">
|
||||||
|
<option value="">품목 선택</option>
|
||||||
|
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="w-24 h-10 px-3 rounded-lg border border-slate-200" />
|
||||||
|
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">추가</button>
|
||||||
|
</div>
|
||||||
|
<div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto">
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<div className="text-center text-slate-400 text-sm py-8">품목을 추가하세요</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||||
|
<tr><th className="px-3 py-2 text-left">품목</th><th className="px-3 py-2 text-right">수량</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((ln, i) => (
|
||||||
|
<tr key={i} className="border-t border-slate-100">
|
||||||
|
<td className="px-3 py-2">{ln.itemName}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{ln.qty}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={() => setLines(lines.filter((_, j) => j !== i))} className="text-slate-400 hover:text-rose-500"><Trash2 size={12} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end mt-5">
|
||||||
|
<button onClick={() => setInboundOpen(false)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold">취소</button>
|
||||||
|
<button onClick={submitInbound} className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold">입고 처리</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef, FormEvent } from "react";
|
||||||
|
import { Plus, Search, Pencil, Trash2, Upload } from "lucide-react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
OBJID: string; ITEM_CODE: string; ITEM_NAME: string; ITEM_DETAIL: string;
|
||||||
|
MAKER_OBJID: string; MAKER_NAME: string; UNIT: string; UNIT_PRICE: number;
|
||||||
|
COST_PRICE: number; IS_TAX_FREE: string; IMAGE_URL: string; STATUS: string;
|
||||||
|
}
|
||||||
|
interface Maker { OBJID: string; MAKER_NAME: string }
|
||||||
|
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
|
||||||
|
export default function AdminItemsPage() {
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [makers, setMakers] = useState<Maker[]>([]);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [editing, setEditing] = useState<Partial<Item> | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const loadItems = async () => {
|
||||||
|
const res = await fetch("/api/m/items/list", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ keyword }),
|
||||||
|
});
|
||||||
|
setItems((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
const loadMakers = async () => {
|
||||||
|
const res = await fetch("/api/m/makers/list", { method: "POST" });
|
||||||
|
setMakers((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadItems(); loadMakers(); }, []); // eslint-disable-line
|
||||||
|
|
||||||
|
const onSave = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
const isNew = !editing.OBJID;
|
||||||
|
const body = {
|
||||||
|
objid: editing.OBJID,
|
||||||
|
actionType: isNew ? "regist" : "update",
|
||||||
|
itemName: editing.ITEM_NAME,
|
||||||
|
itemDetail: editing.ITEM_DETAIL,
|
||||||
|
makerObjid: editing.MAKER_OBJID,
|
||||||
|
unit: editing.UNIT || "EA",
|
||||||
|
unitPrice: editing.UNIT_PRICE,
|
||||||
|
costPrice: editing.COST_PRICE,
|
||||||
|
isTaxFree: editing.IS_TAX_FREE,
|
||||||
|
imageUrl: editing.IMAGE_URL,
|
||||||
|
status: editing.STATUS || "ACTIVE",
|
||||||
|
};
|
||||||
|
const res = await fetch("/api/m/items/save", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.success) {
|
||||||
|
Swal.fire({ icon: "success", title: "저장되었습니다", timer: 1500, showConfirmButton: false });
|
||||||
|
setEditing(null);
|
||||||
|
loadItems();
|
||||||
|
} else {
|
||||||
|
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpload = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/m/items/upload-image", { method: "POST", body: fd });
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.success && editing) {
|
||||||
|
setEditing({ ...editing, IMAGE_URL: j.url });
|
||||||
|
} else if (!j.success) {
|
||||||
|
Swal.fire({ icon: "error", title: "업로드 실패", text: j.message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (objid: string) => {
|
||||||
|
const ok = await Swal.fire({ icon: "warning", title: "삭제하시겠습니까?", showCancelButton: true, confirmButtonColor: "#dc2626" });
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
const res = await fetch("/api/m/items/delete", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ objids: [objid] }),
|
||||||
|
});
|
||||||
|
if ((await res.json()).success) loadItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">품목 관리</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE" })}
|
||||||
|
className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"
|
||||||
|
>
|
||||||
|
<Plus size={16} /> 신규 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<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" && loadItems()}
|
||||||
|
placeholder="품목명/코드 검색"
|
||||||
|
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadItems} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</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">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-3 w-14"></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-right px-3 py-3">단가</th>
|
||||||
|
<th className="text-center px-3 py-3">상태</th>
|
||||||
|
<th className="text-right px-3 py-3">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className="text-center py-12 text-slate-400">품목이 없습니다. 신규 등록 버튼을 눌러주세요.</td></tr>
|
||||||
|
) : items.map((it) => (
|
||||||
|
<tr key={it.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="w-10 h-10 bg-slate-50 rounded overflow-hidden">
|
||||||
|
{it.IMAGE_URL ? <img src={it.IMAGE_URL} alt="" className="w-full h-full object-cover" /> : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{it.ITEM_CODE}</td>
|
||||||
|
<td className="px-3 py-2 font-semibold">{it.ITEM_NAME}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-600">{it.MAKER_NAME || "-"}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{it.IS_TAX_FREE === "Y"
|
||||||
|
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||||
|
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold">과세</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">₩{fmt(it.UNIT_PRICE)}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button onClick={() => setEditing(it)} className="text-slate-500 hover:text-emerald-700 p-1"><Pencil size={14} /></button>
|
||||||
|
<button onClick={() => onDelete(it.OBJID)} className="text-slate-500 hover:text-rose-600 p-1 ml-1"><Trash2 size={14} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
{editing && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-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-[90vh] overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-bold mb-4">{editing.OBJID ? "품목 수정" : "품목 등록"}</h3>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<Field label="품목명 *">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={editing.ITEM_NAME ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, ITEM_NAME: e.target.value })}
|
||||||
|
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="제조사">
|
||||||
|
<select
|
||||||
|
value={editing.MAKER_OBJID ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, MAKER_OBJID: e.target.value })}
|
||||||
|
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||||
|
>
|
||||||
|
<option value="">선택</option>
|
||||||
|
{makers.map((m) => <option key={m.OBJID} value={m.OBJID}>{m.MAKER_NAME}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="단위">
|
||||||
|
<input
|
||||||
|
value={editing.UNIT ?? "EA"}
|
||||||
|
onChange={(e) => setEditing({ ...editing, UNIT: e.target.value })}
|
||||||
|
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="단가 (VAT포함)">
|
||||||
|
<input
|
||||||
|
type="number" min={0}
|
||||||
|
value={editing.UNIT_PRICE ?? 0}
|
||||||
|
onChange={(e) => setEditing({ ...editing, UNIT_PRICE: Number(e.target.value) })}
|
||||||
|
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="원가">
|
||||||
|
<input
|
||||||
|
type="number" min={0}
|
||||||
|
value={editing.COST_PRICE ?? 0}
|
||||||
|
onChange={(e) => setEditing({ ...editing, COST_PRICE: Number(e.target.value) })}
|
||||||
|
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="구분">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex-1 inline-flex items-center justify-center h-10 rounded-lg border cursor-pointer text-sm font-semibold">
|
||||||
|
<input
|
||||||
|
type="radio" name="tax" checked={editing.IS_TAX_FREE !== "Y"}
|
||||||
|
onChange={() => setEditing({ ...editing, IS_TAX_FREE: "N" })}
|
||||||
|
className="mr-1.5"
|
||||||
|
/>
|
||||||
|
과세
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 inline-flex items-center justify-center h-10 rounded-lg border cursor-pointer text-sm font-semibold">
|
||||||
|
<input
|
||||||
|
type="radio" name="tax" checked={editing.IS_TAX_FREE === "Y"}
|
||||||
|
onChange={() => setEditing({ ...editing, IS_TAX_FREE: "Y" })}
|
||||||
|
className="mr-1.5"
|
||||||
|
/>
|
||||||
|
면세 (M품목)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Field label="상세 설명">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={editing.ITEM_DETAIL ?? ""}
|
||||||
|
onChange={(e) => setEditing({ ...editing, ITEM_DETAIL: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-slate-200"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Field label="이미지">
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<div className="w-24 h-24 bg-slate-50 border border-slate-200 rounded-lg overflow-hidden flex items-center justify-center">
|
||||||
|
{editing.IMAGE_URL ? <img src={editing.IMAGE_URL} alt="" className="w-full h-full object-cover" /> : <span className="text-xs text-slate-300">미리보기</span>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileRef} type="file" accept="image/*" className="hidden"
|
||||||
|
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => fileRef.current?.click()} className="inline-flex items-center gap-2 px-3 h-9 rounded-lg border border-slate-200 hover:bg-slate-50 text-sm">
|
||||||
|
<Upload size={14} /> {uploading ? "업로드 중..." : "이미지 선택"}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">JPG/PNG/WEBP, 최대 5MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end mt-6 pt-4 border-t border-slate-100">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Check, Download, X, Eye } from "lucide-react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
|
||||||
|
COMPANY_NAME: string; EMAIL: string; STATUS: string;
|
||||||
|
TOTAL_TAXFREE: number; TOTAL_TAXABLE: number; TOTAL_AMOUNT: number;
|
||||||
|
}
|
||||||
|
interface DetailLine {
|
||||||
|
SEQ: number; ITEM_NAME: string; UNIT_PRICE: number; QTY: number;
|
||||||
|
IS_TAX_FREE: string; SUPPLY_AMOUNT: number; VAT_AMOUNT: number; TOTAL_AMOUNT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
REQUESTED: "발주요청", APPROVED: "발주완료", SHIPPED: "출고완료",
|
||||||
|
INVOICED: "계산서발행", PAID: "완납", CANCELLED: "취소",
|
||||||
|
};
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
REQUESTED: "bg-amber-100 text-amber-700",
|
||||||
|
APPROVED: "bg-blue-100 text-blue-700",
|
||||||
|
SHIPPED: "bg-cyan-100 text-cyan-700",
|
||||||
|
INVOICED: "bg-violet-100 text-violet-700",
|
||||||
|
PAID: "bg-emerald-100 text-emerald-700",
|
||||||
|
CANCELLED: "bg-slate-100 text-slate-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminOrdersPage() {
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [detail, setDetail] = useState<{ order: Order; items: DetailLine[] } | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/m/orders/list", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: status || undefined }),
|
||||||
|
});
|
||||||
|
setOrders((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||||
|
|
||||||
|
const view = async (o: Order) => {
|
||||||
|
const res = await fetch("/api/m/orders/detail", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ objid: o.OBJID }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.success) setDetail({ order: o, items: j.items });
|
||||||
|
};
|
||||||
|
|
||||||
|
const approve = async (o: Order) => {
|
||||||
|
const ok = await Swal.fire({
|
||||||
|
icon: "question",
|
||||||
|
title: "발주를 승인하시겠습니까?",
|
||||||
|
html: `<b>${o.COMPANY_NAME}</b><br>합계 ₩${fmt(o.TOTAL_AMOUNT)}<br><br>승인 시 재고가 차감되고<br><b>${o.EMAIL}</b>로 거래명세표 메일이 발송됩니다.`,
|
||||||
|
showCancelButton: true, confirmButtonText: "승인", cancelButtonText: "취소",
|
||||||
|
confirmButtonColor: "#0f766e",
|
||||||
|
});
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/m/orders/approve", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ objid: o.OBJID }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.success) {
|
||||||
|
Swal.fire({
|
||||||
|
icon: j.mailSent ? "success" : "warning",
|
||||||
|
title: "승인 완료",
|
||||||
|
text: j.mailSent ? "거래명세표 메일이 발송되었습니다." : `메일 발송 실패: ${j.mailError ?? "SMTP 미설정"}`,
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
setDetail(null);
|
||||||
|
} else {
|
||||||
|
Swal.fire({ icon: "error", title: "승인 실패", text: j.message });
|
||||||
|
}
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = async (o: Order) => {
|
||||||
|
const ok = await Swal.fire({ icon: "warning", title: "발주를 취소하시겠습니까?", showCancelButton: true, confirmButtonColor: "#dc2626" });
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
const res = await fetch("/api/m/orders/cancel", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ objid: o.OBJID }),
|
||||||
|
});
|
||||||
|
if ((await res.json()).success) load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">발주서 관리</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">대리점에서 들어온 발주를 검토·승인하세요. 승인 시 재고 차감과 메일 발송이 자동 처리됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||||
|
<option value="">전체 상태</option>
|
||||||
|
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</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">
|
||||||
|
<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-right px-3 py-3">면세</th>
|
||||||
|
<th className="text-right px-3 py-3">과세</th>
|
||||||
|
<th className="text-right px-3 py-3">합계</th>
|
||||||
|
<th className="text-center px-3 py-3">상태</th>
|
||||||
|
<th className="text-right px-3 py-3">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className="text-center py-12 text-slate-400">발주가 없습니다.</td></tr>
|
||||||
|
) : orders.map((o) => (
|
||||||
|
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-3 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||||
|
<td className="px-3 py-3">{o.ORDER_DATE}</td>
|
||||||
|
<td className="px-3 py-3">{o.COMPANY_NAME}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums text-violet-700">{fmt(o.TOTAL_TAXFREE)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums text-rose-700">{fmt(o.TOTAL_TAXABLE)}</td>
|
||||||
|
<td className="px-3 py-3 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||||
|
<td className="px-3 py-3 text-center">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-semibold ${STATUS_COLOR[o.STATUS]}`}>
|
||||||
|
{STATUS_LABEL[o.STATUS]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-right">
|
||||||
|
<button onClick={() => view(o)} className="px-2.5 h-8 rounded-md text-xs text-slate-700 hover:bg-slate-100 inline-flex items-center gap-1">
|
||||||
|
<Eye size={12} /> 상세
|
||||||
|
</button>
|
||||||
|
{o.STATUS === "REQUESTED" && (
|
||||||
|
<>
|
||||||
|
<button disabled={busy} onClick={() => approve(o)} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-emerald-700 text-white hover:bg-emerald-800 inline-flex items-center gap-1 disabled:opacity-50">
|
||||||
|
<Check size={12} /> 승인
|
||||||
|
</button>
|
||||||
|
<button onClick={() => cancel(o)} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-rose-50 text-rose-700 hover:bg-rose-100 inline-flex items-center gap-1">
|
||||||
|
<X size={12} /> 반려
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") && (
|
||||||
|
<a href={`/api/m/orders/statement/${o.OBJID}`} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-slate-50 text-slate-700 hover:bg-slate-100 inline-flex items-center gap-1">
|
||||||
|
<Download size={12} /> 명세서
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 모달 */}
|
||||||
|
{detail && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setDetail(null)}>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-bold text-lg">발주 상세 — {detail.order.ORDER_NO}</h3>
|
||||||
|
<button onClick={() => setDetail(null)} className="text-slate-400 hover:text-slate-700"><X size={20} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||||||
|
<Info label="업체명" value={detail.order.COMPANY_NAME} />
|
||||||
|
<Info label="이메일" value={detail.order.EMAIL} />
|
||||||
|
<Info label="발주일" value={detail.order.ORDER_DATE} />
|
||||||
|
<Info label="상태" value={STATUS_LABEL[detail.order.STATUS]} />
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm border border-slate-200">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-2 text-xs">품명</th>
|
||||||
|
<th className="text-center px-2 py-2 text-xs w-16">구분</th>
|
||||||
|
<th className="text-right px-2 py-2 text-xs w-16">수량</th>
|
||||||
|
<th className="text-right px-2 py-2 text-xs">단가</th>
|
||||||
|
<th className="text-right px-2 py-2 text-xs">공급가</th>
|
||||||
|
<th className="text-right px-2 py-2 text-xs">세액</th>
|
||||||
|
<th className="text-right px-2 py-2 text-xs">합계</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detail.items.map((it) => (
|
||||||
|
<tr key={it.SEQ} className="border-t border-slate-100">
|
||||||
|
<td className="px-3 py-2">{it.ITEM_NAME}</td>
|
||||||
|
<td className="text-center px-2 py-2">{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</td>
|
||||||
|
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.QTY)}</td>
|
||||||
|
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.UNIT_PRICE)}</td>
|
||||||
|
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.SUPPLY_AMOUNT)}</td>
|
||||||
|
<td className="text-right px-2 py-2 tabular-nums">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
|
||||||
|
<td className="text-right px-2 py-2 tabular-nums font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-slate-50">
|
||||||
|
<tr><td colSpan={6} className="px-3 py-2 text-right font-bold">총 합계 (VAT포함)</td><td className="px-2 py-2 text-right font-bold tabular-nums text-emerald-700">₩{fmt(detail.order.TOTAL_AMOUNT)}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{detail.order.STATUS === "REQUESTED" && (
|
||||||
|
<div className="flex gap-2 justify-end mt-5 pt-4 border-t border-slate-100">
|
||||||
|
<button onClick={() => cancel(detail.order)} className="px-4 h-10 rounded-lg border border-rose-200 text-rose-700 text-sm font-semibold hover:bg-rose-50">반려</button>
|
||||||
|
<button disabled={busy} onClick={() => approve(detail.order)} className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||||
|
승인 + 메일 발송
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }: { label: string; value: string }) {
|
||||||
|
return <div><div className="text-xs text-slate-500">{label}</div><div className="font-semibold">{value}</div></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface MonthlyRow { COMPANY_NAME: string; TAX_FREE: number; TAXABLE: number; TOTAL: number }
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
|
||||||
|
export default function StatisticsPage() {
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||||
|
const [rows, setRows] = useState<MonthlyRow[]>([]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/m/statistics/monthly", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ year, month }),
|
||||||
|
});
|
||||||
|
setRows((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||||
|
|
||||||
|
const grandTotal = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
|
||||||
|
const grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||||
|
const grandTaxable = rows.reduce((a, r) => a + Number(r.TAXABLE), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">통계 — 업체별 월간 매출</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => <option key={y} value={y}>{y}년</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => <option key={m} value={m}>{m}월</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Card label="면세 합계" value={fmt(grandFree)} color="violet" />
|
||||||
|
<Card label="과세 공급가" value={fmt(grandTaxable)} color="rose" />
|
||||||
|
<Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" />
|
||||||
|
</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">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">업체명</th>
|
||||||
|
<th className="text-right px-4 py-3">면세 합계</th>
|
||||||
|
<th className="text-right px-4 py-3">과세 공급가</th>
|
||||||
|
<th className="text-right px-4 py-3">총 매출</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={4} className="text-center py-12 text-slate-400">선택한 월의 매출 데이터가 없습니다.</td></tr>
|
||||||
|
) : rows.map((r) => (
|
||||||
|
<tr key={r.COMPANY_NAME} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3 font-semibold">{r.COMPANY_NAME}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-violet-700">{fmt(r.TAX_FREE)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-rose-700">{fmt(r.TAXABLE)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(r.TOTAL)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ label, value, color }: { label: string; value: string; color: "violet" | "rose" | "emerald" }) {
|
||||||
|
const cls = {
|
||||||
|
violet: "from-violet-50 to-violet-100 text-violet-800 border-violet-200",
|
||||||
|
rose: "from-rose-50 to-rose-100 text-rose-800 border-rose-200",
|
||||||
|
emerald: "from-emerald-50 to-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
}[color];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border bg-gradient-to-br ${cls} p-5`}>
|
||||||
|
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">₩{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
OBJID: string; EMAIL: string; COMPANY_NAME: string;
|
||||||
|
CEO_NAME: string; PHONE: string; BIZ_NO: string;
|
||||||
|
ROLE: string; STATUS: string; REGDATE: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
const [rows, setRows] = useState<UserRow[]>([]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/m/users/list", { method: "POST" });
|
||||||
|
setRows((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const toggleAdmin = async (u: UserRow) => {
|
||||||
|
const ok = await Swal.fire({
|
||||||
|
icon: "question",
|
||||||
|
title: u.ROLE === "ADMIN" ? "관리자 권한을 해제할까요?" : "관리자로 승격할까요?",
|
||||||
|
text: u.COMPANY_NAME,
|
||||||
|
showCancelButton: true,
|
||||||
|
});
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
const res = await fetch("/api/m/users/save", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ objid: u.OBJID, role: u.ROLE === "ADMIN" ? "USER" : "ADMIN" }),
|
||||||
|
});
|
||||||
|
if ((await res.json()).success) load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">회원 관리</h1>
|
||||||
|
<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">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">이메일</th>
|
||||||
|
<th className="text-left px-4 py-3">업체명</th>
|
||||||
|
<th className="text-left px-4 py-3">대표</th>
|
||||||
|
<th className="text-left px-4 py-3">연락처</th>
|
||||||
|
<th className="text-left px-4 py-3">권한</th>
|
||||||
|
<th className="text-left px-4 py-3">가입일</th>
|
||||||
|
<th className="text-right px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr key={u.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{u.EMAIL}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold">{u.COMPANY_NAME}</td>
|
||||||
|
<td className="px-4 py-3">{u.CEO_NAME || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600">{u.PHONE || "-"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${u.ROLE === "ADMIN" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-700"}`}>
|
||||||
|
{u.ROLE === "ADMIN" ? "관리자" : "거래처"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 text-xs">{u.REGDATE}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => toggleAdmin(u)} className="text-xs px-3 h-8 rounded-md border border-slate-200 hover:bg-slate-50 font-semibold">
|
||||||
|
{u.ROLE === "ADMIN" ? "권한해제" : "관리자승격"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, FormEvent } from "react";
|
||||||
|
import { Plus, Pencil } from "lucide-react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
interface Warehouse {
|
||||||
|
OBJID: string; WH_CODE: string; WH_NAME: string; LOCATION: string; WH_TYPE: string;
|
||||||
|
}
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
STOCK: "본사 창고", PICKUP_TEAM: "창고픽업팀", MARKET: "시장픽업", DELIVERY: "용차배송",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WarehousesPage() {
|
||||||
|
const [list, setList] = useState<Warehouse[]>([]);
|
||||||
|
const [editing, setEditing] = useState<Partial<Warehouse> | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/m/warehouses/list", { method: "POST" });
|
||||||
|
setList((await res.json()).RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
const res = await fetch("/api/m/warehouses/save", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
|
||||||
|
whCode: editing.WH_CODE, whName: editing.WH_NAME,
|
||||||
|
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if ((await res.json()).success) {
|
||||||
|
Swal.fire({ icon: "success", title: "저장되었습니다", timer: 1200, showConfirmButton: false });
|
||||||
|
setEditing(null); load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">창고 관리</h1>
|
||||||
|
<button onClick={() => setEditing({ WH_TYPE: "STOCK" })} className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||||
|
<Plus size={16} /> 창고 추가
|
||||||
|
</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">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">코드</th>
|
||||||
|
<th className="text-left px-4 py-3">이름</th>
|
||||||
|
<th className="text-left px-4 py-3">유형</th>
|
||||||
|
<th className="text-left px-4 py-3">위치</th>
|
||||||
|
<th className="text-right px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((w) => (
|
||||||
|
<tr key={w.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{w.WH_CODE}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold">{w.WH_NAME}</td>
|
||||||
|
<td className="px-4 py-3">{TYPE_LABEL[w.WH_TYPE] || w.WH_TYPE}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500">{w.LOCATION || "-"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button onClick={() => setEditing(w)} className="text-slate-500 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={save} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-md w-full p-6">
|
||||||
|
<h3 className="font-bold mb-4">{editing.OBJID ? "창고 수정" : "창고 추가"}</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input required placeholder="코드 (예: WH005)" value={editing.WH_CODE ?? ""} onChange={(e) => setEditing({ ...editing, WH_CODE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200" />
|
||||||
|
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200" />
|
||||||
|
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200">
|
||||||
|
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
<input placeholder="위치 (선택)" value={editing.LOCATION ?? ""} onChange={(e) => setEditing({ ...editing, LOCATION: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end mt-5">
|
||||||
|
<button type="button" onClick={() => setEditing(null)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold">취소</button>
|
||||||
|
<button type="submit" className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold">저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ShoppingCart, Package, ClipboardList, TrendingUp, AlertTriangle, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
type DashboardData =
|
||||||
|
| { summary: { REQUESTED_CNT: number; PROGRESS_CNT: number; MONTH_AMOUNT: number; UNPAID: number } | null;
|
||||||
|
recent: Array<{ OBJID: string; ORDER_NO: string; ORDER_DATE: string; STATUS: string; TOTAL_AMOUNT: number }>; }
|
||||||
|
| { summary: { PENDING_CNT: number; TODAY_CNT: number; MONTH_AMOUNT: number; UNPAID: number } | null;
|
||||||
|
lowStock: Array<{ OBJID: string; ITEM_CODE: string; ITEM_NAME: string; STOCK_QTY: number }>;
|
||||||
|
daily: Array<{ DAY: string; CNT: number; AMOUNT: number }>;
|
||||||
|
pending: Array<{ OBJID: string; ORDER_NO: string; COMPANY_NAME: string; ORDER_DATE: string; TOTAL_AMOUNT: number }>; };
|
||||||
|
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
REQUESTED: "발주요청", APPROVED: "발주완료", SHIPPED: "출고완료",
|
||||||
|
INVOICED: "계산서발행", PAID: "완납", CANCELLED: "취소",
|
||||||
|
};
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
REQUESTED: "bg-amber-100 text-amber-700",
|
||||||
|
APPROVED: "bg-blue-100 text-blue-700",
|
||||||
|
SHIPPED: "bg-cyan-100 text-cyan-700",
|
||||||
|
INVOICED: "bg-violet-100 text-violet-700",
|
||||||
|
PAID: "bg-emerald-100 text-emerald-700",
|
||||||
|
CANCELLED: "bg-slate-100 text-slate-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MomoDashboard() {
|
||||||
|
const [role, setRole] = useState<"USER" | "ADMIN">("USER");
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/auth/me").then((r) => r.json()).then((d) => setRole(d?.user?.role ?? "USER"));
|
||||||
|
fetch("/api/m/dashboard").then((r) => r.json()).then(setData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!data) return <div className="text-slate-500">불러오는 중...</div>;
|
||||||
|
|
||||||
|
if (role === "USER") {
|
||||||
|
const d = data as Extract<DashboardData, { recent: unknown[] }>;
|
||||||
|
const s = d.summary ?? { REQUESTED_CNT: 0, PROGRESS_CNT: 0, MONTH_AMOUNT: 0, UNPAID: 0 };
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">대시보드</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">발주 현황과 매출을 한눈에 확인하세요.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card title="대기중 발주" value={s.REQUESTED_CNT} suffix="건" tone="amber" />
|
||||||
|
<Card title="진행중 발주" value={s.PROGRESS_CNT} suffix="건" tone="blue" />
|
||||||
|
<Card title="이번달 누적" value={fmt(s.MONTH_AMOUNT)} prefix="₩" tone="emerald" />
|
||||||
|
<Card title="미수금" value={fmt(s.UNPAID)} prefix="₩" tone="rose" />
|
||||||
|
</div>
|
||||||
|
<Link href="/m/orders/new" className="inline-flex items-center gap-2 px-5 h-11 rounded-xl bg-emerald-700 text-white font-bold shadow hover:-translate-y-0.5 transition">
|
||||||
|
<ShoppingCart size={16} /> 새 발주 요청 <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Section title="최근 발주" linkHref="/m/orders" linkLabel="전체 보기">
|
||||||
|
<DataTable
|
||||||
|
empty="발주 이력이 없습니다."
|
||||||
|
cols={[
|
||||||
|
{ k: "ORDER_NO", t: "발주번호" },
|
||||||
|
{ k: "ORDER_DATE", t: "발주일" },
|
||||||
|
{ k: "TOTAL_AMOUNT", t: "합계", align: "right", render: (v: number) => `₩${fmt(v)}` },
|
||||||
|
{ k: "STATUS", t: "상태", render: (v: string) => <span className={`px-2 py-0.5 rounded text-xs font-semibold ${STATUS_COLOR[v] || ""}`}>{STATUS_LABEL[v] || v}</span> },
|
||||||
|
]}
|
||||||
|
rows={d.recent}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN
|
||||||
|
const d = data as Extract<DashboardData, { lowStock: unknown[] }>;
|
||||||
|
const s = d.summary ?? { PENDING_CNT: 0, TODAY_CNT: 0, MONTH_AMOUNT: 0, UNPAID: 0 };
|
||||||
|
const maxAmt = Math.max(1, ...d.daily.map((x) => Number(x.AMOUNT)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">관리자 대시보드</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">전체 발주 · 매출 · 재고 현황입니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card title="승인 대기" value={s.PENDING_CNT} suffix="건" tone="amber" icon={<ClipboardList size={18} />} />
|
||||||
|
<Card title="오늘 발주" value={s.TODAY_CNT} suffix="건" tone="blue" icon={<ShoppingCart size={18} />} />
|
||||||
|
<Card title="이번달 매출" value={fmt(s.MONTH_AMOUNT)} prefix="₩" tone="emerald" icon={<TrendingUp size={18} />} />
|
||||||
|
<Card title="미수금" value={fmt(s.UNPAID)} prefix="₩" tone="rose" icon={<AlertTriangle size={18} />} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<Section title="최근 14일 발주 매출">
|
||||||
|
<div className="flex items-end gap-1.5 h-40 px-2 pt-2">
|
||||||
|
{d.daily.length === 0 ? (
|
||||||
|
<div className="text-slate-400 text-sm m-auto">데이터가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
d.daily.map((x, i) => (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
||||||
|
<div className="w-full bg-emerald-500/80 rounded-t hover:bg-emerald-600 transition" style={{ height: `${Math.max(2, (Number(x.AMOUNT) / maxAmt) * 100)}%` }} title={`₩${fmt(x.AMOUNT)}`} />
|
||||||
|
<div className="text-[10px] text-slate-500 -rotate-45 origin-top-left mt-1 whitespace-nowrap">{x.DAY}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="재고 부족 (10개 미만)">
|
||||||
|
<DataTable
|
||||||
|
empty="재고 부족 품목이 없습니다."
|
||||||
|
cols={[
|
||||||
|
{ k: "ITEM_CODE", t: "품목코드" },
|
||||||
|
{ k: "ITEM_NAME", t: "품목명" },
|
||||||
|
{ k: "STOCK_QTY", t: "현재고", align: "right", render: (v: number) => <span className="text-rose-600 font-bold">{fmt(v)}</span> },
|
||||||
|
]}
|
||||||
|
rows={d.lowStock}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="승인 대기 발주" linkHref="/m/admin/orders" linkLabel="발주 관리로 이동">
|
||||||
|
<DataTable
|
||||||
|
empty="승인 대기 발주가 없습니다."
|
||||||
|
cols={[
|
||||||
|
{ k: "ORDER_NO", t: "발주번호" },
|
||||||
|
{ k: "COMPANY_NAME", t: "업체명" },
|
||||||
|
{ k: "ORDER_DATE", t: "발주일" },
|
||||||
|
{ k: "TOTAL_AMOUNT", t: "합계", align: "right", render: (v: number) => `₩${fmt(v)}` },
|
||||||
|
]}
|
||||||
|
rows={d.pending}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, value, suffix, prefix, tone, icon }: { title: string; value: number | string; suffix?: string; prefix?: string; tone: "amber" | "blue" | "emerald" | "rose"; icon?: React.ReactNode }) {
|
||||||
|
const toneCls = {
|
||||||
|
amber: "from-amber-50 to-amber-100 text-amber-800 border-amber-200",
|
||||||
|
blue: "from-blue-50 to-blue-100 text-blue-800 border-blue-200",
|
||||||
|
emerald: "from-emerald-50 to-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
rose: "from-rose-50 to-rose-100 text-rose-800 border-rose-200",
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border bg-gradient-to-br ${toneCls} p-5`}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="text-xs font-semibold opacity-80">{title}</div>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold tabular-nums">
|
||||||
|
{prefix}{value}{suffix && <span className="text-sm font-semibold ml-1">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children, linkHref, linkLabel }: { title: string; children: React.ReactNode; linkHref?: string; linkLabel?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-bold text-slate-800">{title}</h3>
|
||||||
|
{linkHref && (
|
||||||
|
<Link href={linkHref} className="text-xs text-emerald-700 font-semibold hover:underline">
|
||||||
|
{linkLabel} →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Col { k: string; t: string; align?: "left" | "right" | "center"; render?: (v: any, row: any) => React.ReactNode } // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
function DataTable({ cols, rows, empty }: { cols: Col[]; rows: Array<Record<string, unknown>>; empty: string }) {
|
||||||
|
if (!rows || rows.length === 0) return <div className="text-slate-400 text-sm py-8 text-center">{empty}</div>;
|
||||||
|
return (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-500 border-b border-slate-100">
|
||||||
|
{cols.map((c) => (
|
||||||
|
<th key={c.k} className={`pb-2 font-semibold text-${c.align ?? "left"} text-xs`}>{c.t}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-50 last:border-0">
|
||||||
|
{cols.map((c) => (
|
||||||
|
<td key={c.k} className={`py-2.5 text-${c.align ?? "left"}`}>
|
||||||
|
{c.render ? c.render(r[c.k], r) : String(r[c.k] ?? "")}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Search, ShoppingCart, Plus, Minus, Trash2, X } from "lucide-react";
|
||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
OBJID: string;
|
||||||
|
ITEM_CODE: string;
|
||||||
|
ITEM_NAME: string;
|
||||||
|
ITEM_DETAIL: string;
|
||||||
|
MAKER_NAME: string;
|
||||||
|
UNIT: string;
|
||||||
|
UNIT_PRICE: number;
|
||||||
|
IS_TAX_FREE: string;
|
||||||
|
IMAGE_URL: string;
|
||||||
|
STOCK_QTY: number;
|
||||||
|
}
|
||||||
|
interface CartLine { item: Item; qty: number }
|
||||||
|
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
|
||||||
|
export default function ItemsBrowse() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [cart, setCart] = useState<CartLine[]>([]);
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch("/api/m/items/list", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ keyword, isTaxFree: taxFilter || undefined }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
setItems(j.RESULTLIST ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToCart = (item: Item) => {
|
||||||
|
setCart((c) => {
|
||||||
|
const found = c.find((x) => x.item.OBJID === item.OBJID);
|
||||||
|
if (found) {
|
||||||
|
if (found.qty + 1 > Number(item.STOCK_QTY)) {
|
||||||
|
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고는 ${fmt(item.STOCK_QTY)}개입니다.` });
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
|
||||||
|
}
|
||||||
|
return [...c, { item, qty: 1 }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQty = (objid: string, delta: number) => {
|
||||||
|
setCart((c) =>
|
||||||
|
c.map((x) => {
|
||||||
|
if (x.item.OBJID !== objid) return x;
|
||||||
|
const newQty = x.qty + delta;
|
||||||
|
if (newQty <= 0) return x;
|
||||||
|
if (newQty > Number(x.item.STOCK_QTY)) return x;
|
||||||
|
return { ...x, qty: newQty };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0;
|
||||||
|
for (const ln of cart) {
|
||||||
|
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||||
|
total += lineTotal;
|
||||||
|
if (ln.item.IS_TAX_FREE === "Y") {
|
||||||
|
supply += lineTotal;
|
||||||
|
taxFree += lineTotal;
|
||||||
|
} else {
|
||||||
|
const s = Math.round(lineTotal / 1.1);
|
||||||
|
supply += s;
|
||||||
|
vat += lineTotal - s;
|
||||||
|
taxable += s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { supply, vat, total, taxFree, taxable };
|
||||||
|
}, [cart]);
|
||||||
|
|
||||||
|
const submitOrder = async () => {
|
||||||
|
if (cart.length === 0) {
|
||||||
|
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await Swal.fire({
|
||||||
|
icon: "question",
|
||||||
|
title: "발주를 요청하시겠습니까?",
|
||||||
|
text: `합계 ₩${fmt(totals.total)} (${cart.length}개 품목)`,
|
||||||
|
showCancelButton: true, confirmButtonText: "발주", cancelButtonText: "취소",
|
||||||
|
confirmButtonColor: "#0f766e",
|
||||||
|
});
|
||||||
|
if (!ok.isConfirmed) return;
|
||||||
|
|
||||||
|
const res = await fetch("/api/m/orders/save", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.success) {
|
||||||
|
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
|
||||||
|
setCart([]);
|
||||||
|
router.push("/m/orders");
|
||||||
|
} else {
|
||||||
|
Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid lg:grid-cols-[1fr_380px] gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">품목 검색</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">현재 재고가 있는 품목을 선택해 발주에 담으세요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
|
||||||
|
placeholder="품목명 또는 품목코드"
|
||||||
|
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/15 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select value={taxFilter} onChange={(e) => setTaxFilter(e.target.value as "" | "Y" | "N")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="Y">면세</option>
|
||||||
|
<option value="N">과세</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={fetchItems} className="h-10 px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-slate-400 text-center py-12">불러오는 중...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-slate-400 text-center py-12 bg-white rounded-xl border border-slate-100">검색 결과가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
|
{items.map((it) => (
|
||||||
|
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
|
||||||
|
<div className="aspect-square bg-slate-50 rounded-lg mb-3 overflow-hidden flex items-center justify-center">
|
||||||
|
{it.IMAGE_URL ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-300 text-xs">이미지 없음</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<div className="font-bold text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
|
||||||
|
{it.IS_TAX_FREE === "Y" && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<div className="font-bold text-slate-900 tabular-nums">₩{fmt(it.UNIT_PRICE)}</div>
|
||||||
|
<div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
|
||||||
|
재고 {fmt(it.STOCK_QTY)} {it.UNIT}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={Number(it.STOCK_QTY) === 0}
|
||||||
|
onClick={() => addToCart(it)}
|
||||||
|
className="w-full mt-3 h-9 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> 담기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 장바구니 */}
|
||||||
|
<aside className="lg:sticky lg:top-6 self-start bg-white border border-slate-200 rounded-xl p-5 shadow-sm h-fit">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 font-bold text-slate-800">
|
||||||
|
<ShoppingCart size={16} />
|
||||||
|
발주 장바구니 <span className="text-emerald-700">{cart.length}</span>
|
||||||
|
</div>
|
||||||
|
{cart.length > 0 && (
|
||||||
|
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500">
|
||||||
|
전체 삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cart.length === 0 ? (
|
||||||
|
<div className="text-slate-400 text-sm text-center py-10">담긴 품목이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2.5 max-h-[40vh] overflow-y-auto pr-1">
|
||||||
|
{cart.map((ln) => {
|
||||||
|
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
|
||||||
|
return (
|
||||||
|
<div key={ln.item.OBJID} className="border border-slate-100 rounded-lg p-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="text-sm font-semibold leading-tight">{ln.item.ITEM_NAME}</div>
|
||||||
|
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||||
|
<Minus size={12} />
|
||||||
|
</button>
|
||||||
|
<span className="w-10 text-center text-sm font-bold tabular-nums">{ln.qty}</span>
|
||||||
|
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold tabular-nums">₩{fmt(lineTotal)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 mt-4 pt-3 space-y-1.5 text-sm">
|
||||||
|
<Row label="면세 합계" value={`₩${fmt(totals.taxFree)}`} color="violet" />
|
||||||
|
<Row label="과세 공급가" value={`₩${fmt(totals.taxable)}`} color="rose" />
|
||||||
|
<Row label="세액" value={`₩${fmt(totals.vat)}`} />
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||||
|
<span className="font-bold">총 합계</span>
|
||||||
|
<span className="font-bold text-lg text-emerald-700 tabular-nums">₩{fmt(totals.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={submitOrder}
|
||||||
|
className="w-full mt-4 h-11 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white font-bold shadow hover:-translate-y-0.5 transition"
|
||||||
|
>
|
||||||
|
발주 요청
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" }) {
|
||||||
|
const cls = color === "violet" ? "text-violet-700" : color === "rose" ? "text-rose-700" : "text-slate-700";
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className={cls}>{label}</span>
|
||||||
|
<span className="tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { MomoSidebar } from "@/components/momo/sidebar";
|
||||||
|
import { MomoHeader } from "@/components/momo/header";
|
||||||
|
|
||||||
|
export default async function MomoLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (user.role !== "USER" && user.role !== "ADMIN") {
|
||||||
|
// FITO 사용자는 기존 /dashboard 로 보낸다
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-slate-50">
|
||||||
|
<MomoSidebar role={user.role!} />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<MomoHeader companyName={user.companyName || user.userName} role={user.role!} email={user.email} />
|
||||||
|
<main className="flex-1 p-6 overflow-x-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// /m/orders/new 는 /m/items 와 동일 — 거래처가 품목을 담아 바로 발주
|
||||||
|
import ItemsBrowse from "../../items/page";
|
||||||
|
export default ItemsBrowse;
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Download, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
OBJID: string;
|
||||||
|
ORDER_NO: string;
|
||||||
|
ORDER_DATE: string;
|
||||||
|
STATUS: string;
|
||||||
|
TOTAL_AMOUNT: number;
|
||||||
|
TOTAL_TAXFREE: number;
|
||||||
|
TOTAL_TAXABLE: number;
|
||||||
|
COMPANY_NAME?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
REQUESTED: "발주요청", APPROVED: "발주완료", SHIPPED: "출고완료",
|
||||||
|
INVOICED: "계산서발행", PAID: "완납", CANCELLED: "취소",
|
||||||
|
};
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
REQUESTED: "bg-amber-100 text-amber-700",
|
||||||
|
APPROVED: "bg-blue-100 text-blue-700",
|
||||||
|
SHIPPED: "bg-cyan-100 text-cyan-700",
|
||||||
|
INVOICED: "bg-violet-100 text-violet-700",
|
||||||
|
PAID: "bg-emerald-100 text-emerald-700",
|
||||||
|
CANCELLED: "bg-slate-100 text-slate-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MyOrdersPage() {
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/m/orders/list", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: status || undefined }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
setOrders(j.RESULTLIST ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">내 발주 이력</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">전체 {orders.length}건</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/m/orders/new" className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
||||||
|
새 발주 요청
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||||
|
<option value="">전체 상태</option>
|
||||||
|
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</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">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">발주번호</th>
|
||||||
|
<th className="text-left px-4 py-3 font-semibold">발주일</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold">면세</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold">과세</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold">합계</th>
|
||||||
|
<th className="text-center px-4 py-3 font-semibold">상태</th>
|
||||||
|
<th className="text-right px-4 py-3 font-semibold">명세서</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="text-center py-12 text-slate-400">발주 이력이 없습니다.</td></tr>
|
||||||
|
) : orders.map((o) => (
|
||||||
|
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||||
|
<td className="px-4 py-3">{o.ORDER_DATE}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-violet-700">{fmt(o.TOTAL_TAXFREE)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums text-rose-700">{fmt(o.TOTAL_TAXABLE)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-semibold ${STATUS_COLOR[o.STATUS]}`}>
|
||||||
|
{STATUS_LABEL[o.STATUS] || o.STATUS}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") ? (
|
||||||
|
<a href={`/api/m/orders/statement/${o.OBJID}`} className="inline-flex items-center gap-1 text-emerald-700 hover:underline text-xs font-semibold">
|
||||||
|
<Download size={12} /> 엑셀
|
||||||
|
</a>
|
||||||
|
) : <span className="text-slate-300 text-xs">-</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+233
-3
@@ -1,5 +1,235 @@
|
|||||||
import { redirect } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, Package, FileSpreadsheet, Mail, BarChart3, Smartphone, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
export default function Home() {
|
export const metadata = {
|
||||||
redirect("/dashboard");
|
title: "모모유통 — 유통관리 시스템",
|
||||||
|
description: "발주 · 입고 · 명세서 · 정산까지, 유통 업무의 모든 흐름을 한 곳에서.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white text-slate-900">
|
||||||
|
{/* 상단 네비 */}
|
||||||
|
<header className="sticky top-0 z-30 bg-white/85 backdrop-blur border-b border-slate-200">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<img src="/momo-icon.svg" alt="" className="w-8 h-8" />
|
||||||
|
<span className="font-bold text-emerald-800 tracking-wider">MOMO DISTRIBUTION</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="px-4 h-9 inline-flex items-center text-sm font-semibold text-emerald-800 hover:text-emerald-900 rounded-lg hover:bg-emerald-50 transition"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="px-4 h-9 inline-flex items-center text-sm font-bold text-white rounded-lg bg-gradient-to-r from-emerald-700 to-emerald-600 shadow shadow-emerald-600/20 hover:shadow-emerald-600/40 hover:-translate-y-0.5 transition-all"
|
||||||
|
>
|
||||||
|
회원가입
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 히어로 */}
|
||||||
|
<section className="relative overflow-hidden bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] text-white">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none opacity-30"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none opacity-30"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(rgba(255,255,255,0.05) 1px, transparent 0), linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 0)",
|
||||||
|
backgroundSize: "48px 48px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative max-w-6xl mx-auto px-6 py-20 lg:py-28">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-emerald-400/10 border border-emerald-300/20 px-3 py-1.5 rounded-full text-[12px] font-semibold tracking-widest text-emerald-100 mb-6">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
|
||||||
|
MOMO ERP · 2026 NEW
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-5 tracking-tight">
|
||||||
|
엑셀로 하던 발주,<br />
|
||||||
|
<span className="text-emerald-200">한 번의 클릭으로.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-emerald-100/90 text-lg md:text-xl max-w-2xl leading-relaxed mb-10">
|
||||||
|
모모유통 유통관리 시스템은 <b>발주 · 재고 · 명세서 · 정산</b>을 하나로 묶어
|
||||||
|
여러 명이 동시에 입력해도 금액이 어긋나지 않습니다.
|
||||||
|
거래처는 PC와 휴대폰 어디서든 발주할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="group inline-flex items-center gap-2 px-6 h-12 rounded-xl bg-white text-emerald-800 font-bold shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all"
|
||||||
|
>
|
||||||
|
지금 시작하기
|
||||||
|
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-2 px-6 h-12 rounded-xl border border-emerald-300/40 text-white font-semibold hover:bg-white/10 transition"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 사용 방법 (3단계) */}
|
||||||
|
<section className="max-w-6xl mx-auto px-6 py-20">
|
||||||
|
<div className="text-center mb-14">
|
||||||
|
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||||
|
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||||
|
HOW IT WORKS
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-3">3단계로 끝나는 발주</h2>
|
||||||
|
<p className="text-slate-500">엑셀 시트 여러 개를 띄울 필요 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
<Step
|
||||||
|
num="1"
|
||||||
|
title="회원가입"
|
||||||
|
desc="이메일과 업체명만 입력하면 바로 사용 가능합니다. PC와 안드로이드 앱 모두 지원합니다."
|
||||||
|
/>
|
||||||
|
<Step
|
||||||
|
num="2"
|
||||||
|
title="품목 검색 → 발주 요청"
|
||||||
|
desc="현재 재고가 있는 품목을 선택해 수량만 입력. 면세/과세는 시스템이 자동으로 분리 계산합니다."
|
||||||
|
/>
|
||||||
|
<Step
|
||||||
|
num="3"
|
||||||
|
title="모모유통 승인 → 자동 메일"
|
||||||
|
desc="담당자 승인 한 번이면 재고 차감과 거래명세표 메일(엑셀 첨부) 발송이 동시에 처리됩니다."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 주요 기능 */}
|
||||||
|
<section className="bg-emerald-50/40 border-y border-emerald-100">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-20">
|
||||||
|
<div className="text-center mb-14">
|
||||||
|
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||||
|
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||||
|
FEATURES
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-3">유통 업무 전체를 한 화면에</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
<Feature icon={<Package size={22} />} title="품목·재고 관리" desc="사진·제조사·면세 여부, 창고별 현재고와 입출고 이력까지 추적합니다." />
|
||||||
|
<Feature icon={<FileSpreadsheet size={22} />} title="거래명세표 자동 생성" desc="VAT 포함/별도, 면세/과세 합계가 자동 계산되어 명세서가 곧바로 만들어집니다." />
|
||||||
|
<Feature icon={<Mail size={22} />} title="메일 자동 발송" desc="승인 시 가입 이메일로 명세서 본문 + 엑셀 파일이 자동 첨부되어 발송됩니다." />
|
||||||
|
<Feature icon={<BarChart3 size={22} />} title="매출 통계·그래프" desc="일별·월별·업체별·품목별 누적 매출을 실시간 그래프로 확인합니다." />
|
||||||
|
<Feature icon={<Smartphone size={22} />} title="안드로이드 앱" desc="거래처가 휴대폰으로 품목을 검색하고 즉시 발주할 수 있습니다(APK 제공)." />
|
||||||
|
<Feature icon={<ShieldCheck size={22} />} title="동시 입력 안전" desc="여러 명이 동시에 발주해도 재고와 합계가 어긋나지 않도록 트랜잭션으로 보장합니다." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 사용 가이드 */}
|
||||||
|
<section className="max-w-5xl mx-auto px-6 py-20">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||||
|
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||||
|
USER GUIDE
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-3">사용자별 화면</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="rounded-2xl border border-emerald-100 bg-white p-7 shadow-sm">
|
||||||
|
<div className="text-emerald-700 text-xs font-bold tracking-widest mb-2">FOR 거래처</div>
|
||||||
|
<h3 className="text-2xl font-bold mb-4">대리점 · 소매상</h3>
|
||||||
|
<ul className="space-y-3 text-slate-600 text-[15px]">
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-600 font-bold">·</span>이메일과 업체명으로 무료 가입</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-600 font-bold">·</span>현재고 있는 품목 검색·선택</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-600 font-bold">·</span>장바구니에 담아 발주 요청</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-600 font-bold">·</span>승인되면 거래명세표가 메일로 도착</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-600 font-bold">·</span>본인 발주 내역과 미수금을 언제든 확인</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-emerald-100 bg-gradient-to-br from-[#0d3b24] to-[#1b5e3a] text-white p-7 shadow-lg shadow-emerald-700/20">
|
||||||
|
<div className="text-emerald-200 text-xs font-bold tracking-widest mb-2">FOR 모모유통</div>
|
||||||
|
<h3 className="text-2xl font-bold mb-4">관리자 · 담당자</h3>
|
||||||
|
<ul className="space-y-3 text-emerald-50/90 text-[15px]">
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-300 font-bold">·</span>품목 마스터 관리 (사진·제조사·면세)</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-300 font-bold">·</span>창고별 재고와 입출고 이력 관리</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-300 font-bold">·</span>발주 요청 검토 → 승인 (1클릭)</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-300 font-bold">·</span>월말 계산서 발행 / 입금 관리</li>
|
||||||
|
<li className="flex gap-2"><span className="text-emerald-300 font-bold">·</span>대시보드에서 매출·미수금 한눈에</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="max-w-5xl mx-auto px-6 pb-20">
|
||||||
|
<div className="rounded-3xl bg-gradient-to-br from-emerald-700 to-emerald-600 text-white p-10 md:p-14 text-center shadow-xl shadow-emerald-600/20">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-3">엑셀에서 벗어날 시간입니다</h2>
|
||||||
|
<p className="text-emerald-100/90 mb-7 max-w-xl mx-auto">
|
||||||
|
가입 후 바로 사용할 수 있습니다. 별도 설치 없이 웹 브라우저만 있으면 됩니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="inline-flex items-center gap-2 px-7 h-12 rounded-xl bg-white text-emerald-800 font-bold shadow hover:-translate-y-0.5 transition"
|
||||||
|
>
|
||||||
|
회원가입 <ArrowRight size={18} />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center gap-2 px-7 h-12 rounded-xl border border-emerald-300/40 text-white font-semibold hover:bg-white/10 transition"
|
||||||
|
>
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="border-t border-slate-200">
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-8 flex flex-wrap items-center justify-between gap-4 text-sm text-slate-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src="/momo-icon.svg" alt="" className="w-5 h-5 opacity-70" />
|
||||||
|
<span>© 2026 모모유통 유통관리 시스템</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-[13px]">
|
||||||
|
<span>momo8443@daum.net</span>
|
||||||
|
<span>010-6624-5315</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ num, title, desc }: { num: string; title: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-2xl border border-emerald-100 bg-white p-7 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
|
||||||
|
<div className="absolute -top-4 left-7 w-9 h-9 rounded-full bg-gradient-to-br from-emerald-700 to-emerald-500 text-white font-bold flex items-center justify-center shadow">
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold mt-4 mb-2 text-slate-900">{title}</h3>
|
||||||
|
<p className="text-slate-600 text-sm leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-white border border-emerald-100 p-6 hover:shadow-md transition">
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-emerald-100 text-emerald-700 flex items-center justify-center mb-3">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold mb-1">{title}</h4>
|
||||||
|
<p className="text-sm text-slate-600 leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { LogOut, ChevronDown } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function MomoHeader({ companyName, role, email }: { companyName?: string; role: "USER" | "ADMIN"; email: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-end px-6 gap-3">
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold ${role === "ADMIN" ? "bg-emerald-100 text-emerald-800" : "bg-slate-100 text-slate-700"}`}>
|
||||||
|
{role === "ADMIN" ? "관리자" : "거래처"}
|
||||||
|
</span>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-2 text-sm text-slate-700 hover:bg-slate-50 px-3 h-9 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-full bg-emerald-700 text-white text-xs font-bold flex items-center justify-center">
|
||||||
|
{(companyName || "M")[0]}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold">{companyName || email}</span>
|
||||||
|
<ChevronDown size={14} className="text-slate-400" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-xl shadow-lg overflow-hidden z-50">
|
||||||
|
<div className="px-4 py-3 border-b border-slate-100">
|
||||||
|
<div className="font-semibold text-sm">{companyName}</div>
|
||||||
|
<div className="text-xs text-slate-500">{email}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full px-4 py-2.5 text-left text-sm hover:bg-slate-50 flex items-center gap-2 text-slate-700"
|
||||||
|
>
|
||||||
|
<LogOut size={14} /> 로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
|
ShoppingCart,
|
||||||
|
Warehouse,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Building2,
|
||||||
|
ClipboardList,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface MenuLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ size?: number }>;
|
||||||
|
admin?: boolean;
|
||||||
|
user?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU: MenuLink[] = [
|
||||||
|
{ href: "/m/dashboard", label: "대시보드", icon: LayoutDashboard, user: true, admin: true },
|
||||||
|
{ href: "/m/items", label: "품목 검색", icon: Package, user: true },
|
||||||
|
{ href: "/m/orders/new", label: "발주 요청", icon: ShoppingCart, user: true },
|
||||||
|
{ href: "/m/orders", label: "내 발주 이력", icon: ClipboardList, user: true },
|
||||||
|
|
||||||
|
{ href: "/m/admin/items", label: "품목 관리", icon: Package, admin: true },
|
||||||
|
{ href: "/m/admin/warehouses", label: "창고 관리", icon: Building2, admin: true },
|
||||||
|
{ href: "/m/admin/inventory", label: "재고 관리", icon: Warehouse, admin: true },
|
||||||
|
{ href: "/m/admin/orders", label: "발주서 관리", icon: ClipboardList, admin: true },
|
||||||
|
{ href: "/m/admin/statistics", label: "통계", icon: TrendingUp, admin: true },
|
||||||
|
{ href: "/m/admin/users", label: "회원 관리", icon: Users, admin: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MomoSidebar({ role }: { role: "USER" | "ADMIN" }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const items = MENU.filter((m) => (role === "ADMIN" ? m.admin : m.user));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-60 shrink-0 bg-gradient-to-b from-[#0d3b24] to-[#1b5e3a] text-white flex flex-col">
|
||||||
|
<Link href="/m/dashboard" className="px-6 h-16 flex items-center gap-2.5 border-b border-white/10 hover:bg-white/5 transition">
|
||||||
|
<img src="/momo-icon.svg" alt="" className="w-8 h-8" />
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] tracking-widest text-emerald-200/80 leading-none">MOMO</div>
|
||||||
|
<div className="text-sm font-bold leading-tight">유통관리</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex-1 py-4 px-2 space-y-0.5 overflow-y-auto">
|
||||||
|
{items.map((it) => {
|
||||||
|
const Icon = it.icon;
|
||||||
|
const active = pathname === it.href || pathname.startsWith(it.href + "/");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={it.href}
|
||||||
|
href={it.href}
|
||||||
|
className={
|
||||||
|
"flex items-center gap-3 px-4 h-10 rounded-lg text-sm transition " +
|
||||||
|
(active
|
||||||
|
? "bg-white/15 text-white font-semibold"
|
||||||
|
: "text-emerald-100/80 hover:bg-white/10 hover:text-white")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
{it.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-white/10 text-[11px] text-emerald-200/60">
|
||||||
|
© 2026 MOMO Distribution
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// 거래명세표 엑셀 생성 (xlsx 라이브러리 사용 — 이미 deps 에 포함)
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
export interface StatementOrderItem {
|
||||||
|
seq: number;
|
||||||
|
itemName: string;
|
||||||
|
unit: string;
|
||||||
|
qty: number;
|
||||||
|
unitPrice: number;
|
||||||
|
supplyAmount: number;
|
||||||
|
vatAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
isTaxFree: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementInput {
|
||||||
|
orderNo: string;
|
||||||
|
orderDate: string; // YYYY-MM-DD
|
||||||
|
customer: {
|
||||||
|
companyName: string;
|
||||||
|
ceoName?: string;
|
||||||
|
bizNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
supplier: {
|
||||||
|
companyName: string; // "모모유통"
|
||||||
|
bankAccount?: string; // "기업은행 ____"
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
items: StatementOrderItem[];
|
||||||
|
totals: {
|
||||||
|
supply: number;
|
||||||
|
vat: number;
|
||||||
|
total: number;
|
||||||
|
taxFree: number;
|
||||||
|
taxable: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n: number): number {
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatementXlsx(input: StatementInput): Buffer {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
// AOA (array of arrays) 로 시트 구성
|
||||||
|
const aoa: (string | number)[][] = [];
|
||||||
|
|
||||||
|
aoa.push(["거 래 명 세 표"]);
|
||||||
|
aoa.push([]);
|
||||||
|
aoa.push(["발주번호", input.orderNo, "", "발주일자", input.orderDate]);
|
||||||
|
aoa.push([]);
|
||||||
|
aoa.push(["[공급받는자]"]);
|
||||||
|
aoa.push(["업체명", input.customer.companyName, "대표자", input.customer.ceoName ?? "-"]);
|
||||||
|
aoa.push(["사업자번호", input.customer.bizNo ?? "-", "전화번호", input.customer.phone ?? "-"]);
|
||||||
|
aoa.push([]);
|
||||||
|
aoa.push(["[공급자]"]);
|
||||||
|
aoa.push(["업체명", input.supplier.companyName, "계좌번호", input.supplier.bankAccount ?? "-"]);
|
||||||
|
aoa.push(["전화번호", input.supplier.phone ?? "-", "이메일", input.supplier.email ?? "-"]);
|
||||||
|
aoa.push([]);
|
||||||
|
aoa.push(["순번", "품명", "구분", "수량", "단위", "단가", "공급가액", "세액", "합계"]);
|
||||||
|
|
||||||
|
for (const it of input.items) {
|
||||||
|
aoa.push([
|
||||||
|
it.seq,
|
||||||
|
it.itemName,
|
||||||
|
it.isTaxFree ? "면세" : "과세",
|
||||||
|
it.qty,
|
||||||
|
it.unit || "EA",
|
||||||
|
fmt(it.unitPrice),
|
||||||
|
fmt(it.supplyAmount),
|
||||||
|
fmt(it.vatAmount),
|
||||||
|
fmt(it.totalAmount),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
aoa.push([]);
|
||||||
|
aoa.push(["", "", "", "", "", "면세 합계", fmt(input.totals.taxFree)]);
|
||||||
|
aoa.push(["", "", "", "", "", "과세 공급가", fmt(input.totals.taxable)]);
|
||||||
|
aoa.push(["", "", "", "", "", "세액 합계", fmt(input.totals.vat)]);
|
||||||
|
aoa.push(["", "", "", "", "", "총 합계 (VAT포함)", fmt(input.totals.total)]);
|
||||||
|
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||||
|
|
||||||
|
// 컬럼 폭
|
||||||
|
ws["!cols"] = [
|
||||||
|
{ wch: 6 }, { wch: 28 }, { wch: 6 }, { wch: 8 },
|
||||||
|
{ wch: 6 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 14 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 제목 머지
|
||||||
|
ws["!merges"] = [
|
||||||
|
{ s: { r: 0, c: 0 }, e: { r: 0, c: 8 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "거래명세표");
|
||||||
|
|
||||||
|
const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatementHtml(input: StatementInput): string {
|
||||||
|
const rows = input.items
|
||||||
|
.map(
|
||||||
|
(it) => `
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center">${it.seq}</td>
|
||||||
|
<td>${escapeHtml(it.itemName)}</td>
|
||||||
|
<td style="text-align:center;color:${it.isTaxFree ? "#7c3aed" : "#e11d48"}">${it.isTaxFree ? "면세" : "과세"}</td>
|
||||||
|
<td style="text-align:right">${it.qty}</td>
|
||||||
|
<td style="text-align:center">${escapeHtml(it.unit || "EA")}</td>
|
||||||
|
<td style="text-align:right">${formatNumber(fmt(it.unitPrice))}</td>
|
||||||
|
<td style="text-align:right">${formatNumber(fmt(it.supplyAmount))}</td>
|
||||||
|
<td style="text-align:right">${it.isTaxFree ? "-" : formatNumber(fmt(it.vatAmount))}</td>
|
||||||
|
<td style="text-align:right;font-weight:600">${formatNumber(fmt(it.totalAmount))}</td>
|
||||||
|
</tr>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ko"><head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:'Apple SD Gothic Neo','Malgun Gothic',sans-serif;color:#0f172a;padding:24px;background:#fff">
|
||||||
|
<h2 style="text-align:center;letter-spacing:8px;margin:0 0 16px">거 래 명 세 표</h2>
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:16px">
|
||||||
|
<div>
|
||||||
|
<div><b>발주번호</b> ${escapeHtml(input.orderNo)}</div>
|
||||||
|
<div><b>발주일자</b> ${escapeHtml(input.orderDate)}</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<div><b>공급자</b> ${escapeHtml(input.supplier.companyName)}</div>
|
||||||
|
<div>${escapeHtml(input.supplier.phone ?? "")} · ${escapeHtml(input.supplier.email ?? "")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid #cbd5e1;padding:10px;margin-bottom:14px;font-size:13px">
|
||||||
|
<b>${escapeHtml(input.customer.companyName)}</b> 귀하
|
||||||
|
${input.customer.ceoName ? ` · 대표 ${escapeHtml(input.customer.ceoName)}` : ""}
|
||||||
|
${input.customer.bizNo ? ` · 사업자번호 ${escapeHtml(input.customer.bizNo)}` : ""}
|
||||||
|
</div>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f1f5f9">
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">순번</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">품명</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">구분</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">수량</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">단위</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">단가</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">공급가액</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">세액</th>
|
||||||
|
<th style="border:1px solid #cbd5e1;padding:8px">합계</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody style="font-variant-numeric:tabular-nums">
|
||||||
|
${rows.replace(/<td/g, '<td style="border:1px solid #cbd5e1;padding:7px"')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table style="margin-top:14px;margin-left:auto;font-size:13px;font-variant-numeric:tabular-nums">
|
||||||
|
<tr><td style="padding:4px 12px;color:#7c3aed">면세 합계</td><td style="padding:4px 0;text-align:right;min-width:140px">₩ ${formatNumber(fmt(input.totals.taxFree))}</td></tr>
|
||||||
|
<tr><td style="padding:4px 12px;color:#e11d48">과세 공급가</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.taxable))}</td></tr>
|
||||||
|
<tr><td style="padding:4px 12px">세액 합계</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.vat))}</td></tr>
|
||||||
|
<tr><td style="padding:8px 12px;font-weight:700;border-top:2px solid #0f172a">총 합계 (VAT 포함)</td><td style="padding:8px 0;text-align:right;font-weight:700;border-top:2px solid #0f172a">₩ ${formatNumber(fmt(input.totals.total))}</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:12px;color:#475569">
|
||||||
|
위와 같이 계산합니다. — 모모유통
|
||||||
|
</div>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// 이메일 발송 (nodemailer)
|
||||||
|
// .env 에 SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM 설정 필요
|
||||||
|
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import type { Transporter } from "nodemailer";
|
||||||
|
import { execute } from "./db";
|
||||||
|
import { createObjectId } from "./utils";
|
||||||
|
|
||||||
|
let cachedTransporter: Transporter | null = null;
|
||||||
|
|
||||||
|
function getTransporter(): Transporter {
|
||||||
|
if (cachedTransporter) return cachedTransporter;
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const port = Number(process.env.SMTP_PORT || 465);
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
if (!host || !user || !pass) {
|
||||||
|
// 운영 전: 미설정 시 콘솔로만 찍고 동작 (개발 편의)
|
||||||
|
cachedTransporter = nodemailer.createTransport({ jsonTransport: true });
|
||||||
|
return cachedTransporter;
|
||||||
|
}
|
||||||
|
cachedTransporter = nodemailer.createTransport({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
secure: port === 465,
|
||||||
|
auth: { user, pass },
|
||||||
|
});
|
||||||
|
return cachedTransporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMailInput {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text?: string;
|
||||||
|
attachments?: MailAttachment[];
|
||||||
|
refType?: string;
|
||||||
|
refObjid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMail(input: SendMailInput): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const from = process.env.SMTP_FROM || "모모유통 <noreply@momo.com>";
|
||||||
|
const transporter = getTransporter();
|
||||||
|
|
||||||
|
const logId = createObjectId();
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO momo_mail_logs (objid, to_email, subject, body, ref_type, ref_objid, status, regdate)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING', NOW())`,
|
||||||
|
[logId, input.to, input.subject, input.html.slice(0, 4000),
|
||||||
|
input.refType ?? null, input.refObjid ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to: input.to,
|
||||||
|
subject: input.subject,
|
||||||
|
html: input.html,
|
||||||
|
text: input.text,
|
||||||
|
attachments: input.attachments?.map((a) => ({
|
||||||
|
filename: a.filename,
|
||||||
|
content: a.content,
|
||||||
|
contentType: a.contentType,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await execute(
|
||||||
|
`UPDATE momo_mail_logs SET status='SENT', sent_at=NOW() WHERE objid=$1`,
|
||||||
|
[logId]
|
||||||
|
);
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.log("[mailer] sent:", info.messageId || "(jsonTransport)");
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
await execute(
|
||||||
|
`UPDATE momo_mail_logs SET status='FAILED', error_msg=$2 WHERE objid=$1`,
|
||||||
|
[logId, msg]
|
||||||
|
);
|
||||||
|
console.error("[mailer] failed:", msg);
|
||||||
|
return { ok: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// 모모유통 사용자 인증 (bcrypt + momo_users)
|
||||||
|
// 기존 FITO user_info(AES) 와 별도로 동작
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { queryOne, execute } from "./db";
|
||||||
|
import { createObjectId } from "./utils";
|
||||||
|
|
||||||
|
export interface MomoUser {
|
||||||
|
objid: string;
|
||||||
|
email: string;
|
||||||
|
companyName: string;
|
||||||
|
ceoName: string;
|
||||||
|
bizNo: string;
|
||||||
|
phone: string;
|
||||||
|
role: "USER" | "ADMIN";
|
||||||
|
status: "ACTIVE" | "LOCKED" | "LEFT";
|
||||||
|
// 기존 User 호환 필드 (메뉴/세션이 사용)
|
||||||
|
userId: string; // = email
|
||||||
|
userName: string; // = companyName
|
||||||
|
isAdmin: boolean; // role === 'ADMIN'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignupInput {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
companyName: string;
|
||||||
|
ceoName?: string;
|
||||||
|
bizNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToUser(r: Record<string, unknown>): MomoUser {
|
||||||
|
const role = (r.ROLE as string) || "USER";
|
||||||
|
const email = (r.EMAIL as string) || "";
|
||||||
|
const companyName = (r.COMPANY_NAME as string) || "";
|
||||||
|
return {
|
||||||
|
objid: (r.OBJID as string) || "",
|
||||||
|
email,
|
||||||
|
companyName,
|
||||||
|
ceoName: (r.CEO_NAME as string) || "",
|
||||||
|
bizNo: (r.BIZ_NO as string) || "",
|
||||||
|
phone: (r.PHONE as string) || "",
|
||||||
|
role: role as "USER" | "ADMIN",
|
||||||
|
status: ((r.STATUS as string) || "ACTIVE") as MomoUser["status"],
|
||||||
|
userId: email,
|
||||||
|
userName: companyName,
|
||||||
|
isAdmin: role === "ADMIN",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMomoUserByEmail(email: string): Promise<{ user: MomoUser; passwordHash: string } | null> {
|
||||||
|
const row = await queryOne<Record<string, unknown>>(
|
||||||
|
`SELECT objid AS "OBJID", email AS "EMAIL", password_hash AS "PASSWORD_HASH",
|
||||||
|
company_name AS "COMPANY_NAME", ceo_name AS "CEO_NAME",
|
||||||
|
biz_no AS "BIZ_NO", phone AS "PHONE",
|
||||||
|
role AS "ROLE", status AS "STATUS"
|
||||||
|
FROM momo_users
|
||||||
|
WHERE LOWER(email) = LOWER($1) AND COALESCE(is_del, 'N') != 'Y'`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
user: rowToUser(row),
|
||||||
|
passwordHash: (row.PASSWORD_HASH as string) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMomoCredentials(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ success: boolean; user?: MomoUser; error?: string }> {
|
||||||
|
const found = await findMomoUserByEmail(email);
|
||||||
|
if (!found) return { success: false, error: "사용자가 존재하지 않습니다." };
|
||||||
|
if (found.user.status !== "ACTIVE") {
|
||||||
|
return { success: false, error: "계정이 비활성화 상태입니다. 관리자에게 문의하세요." };
|
||||||
|
}
|
||||||
|
const ok = await bcrypt.compare(password, found.passwordHash);
|
||||||
|
if (!ok) return { success: false, error: "비밀번호가 일치하지 않습니다." };
|
||||||
|
return { success: true, user: found.user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signupMomoUser(input: SignupInput): Promise<{ success: boolean; user?: MomoUser; error?: string }> {
|
||||||
|
const email = input.email.trim().toLowerCase();
|
||||||
|
if (!email || !input.password || !input.companyName) {
|
||||||
|
return { success: false, error: "이메일/비밀번호/업체명은 필수입니다." };
|
||||||
|
}
|
||||||
|
if (input.password.length < 8) {
|
||||||
|
return { success: false, error: "비밀번호는 8자 이상이어야 합니다." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dup = await queryOne(`SELECT 1 FROM momo_users WHERE LOWER(email) = LOWER($1)`, [email]);
|
||||||
|
if (dup) return { success: false, error: "이미 가입된 이메일입니다." };
|
||||||
|
|
||||||
|
const objid = createObjectId();
|
||||||
|
const hash = await bcrypt.hash(input.password, 10);
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO momo_users (objid, email, password_hash, company_name, ceo_name, biz_no, phone, role, status, regdate, regid)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'USER', 'ACTIVE', NOW(), $1)`,
|
||||||
|
[objid, email, hash, input.companyName.trim(),
|
||||||
|
input.ceoName?.trim() ?? null,
|
||||||
|
input.bizNo?.trim() ?? null,
|
||||||
|
input.phone?.trim() ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const found = await findMomoUserByEmail(email);
|
||||||
|
return { success: true, user: found?.user };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// MOMO API 라우트 권한 가드 헬퍼
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSession } from "./session";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
export async function requireMomoUser(): Promise<{ user: User } | NextResponse> {
|
||||||
|
const user = await getSession();
|
||||||
|
if (!user) return NextResponse.json({ success: false, message: "로그인이 필요합니다." }, { status: 401 });
|
||||||
|
if (user.role !== "USER" && user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ success: false, message: "MOMO 사용자만 접근 가능합니다." }, { status: 403 });
|
||||||
|
}
|
||||||
|
return { user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireMomoAdmin(): Promise<{ user: User } | NextResponse> {
|
||||||
|
const r = await requireMomoUser();
|
||||||
|
if (r instanceof NextResponse) return r;
|
||||||
|
if (r.user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ success: false, message: "관리자 권한이 필요합니다." }, { status: 403 });
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 면세/과세 금액 계산 (단가는 VAT 포함가 가정)
|
||||||
|
|
||||||
|
export interface PricingLine {
|
||||||
|
unitPrice: number;
|
||||||
|
qty: number;
|
||||||
|
isTaxFree: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingResult {
|
||||||
|
supplyAmount: number;
|
||||||
|
vatAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcLine(line: PricingLine): PricingResult {
|
||||||
|
const qty = Number(line.qty) || 0;
|
||||||
|
const price = Number(line.unitPrice) || 0;
|
||||||
|
const totalAmount = Math.round(price * qty);
|
||||||
|
if (line.isTaxFree) {
|
||||||
|
return { supplyAmount: totalAmount, vatAmount: 0, totalAmount };
|
||||||
|
}
|
||||||
|
const supply = Math.round(totalAmount / 1.1);
|
||||||
|
return {
|
||||||
|
supplyAmount: supply,
|
||||||
|
vatAmount: totalAmount - supply,
|
||||||
|
totalAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderTotals {
|
||||||
|
supply: number;
|
||||||
|
vat: number;
|
||||||
|
total: number;
|
||||||
|
taxFree: number;
|
||||||
|
taxable: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumTotals(lines: { supplyAmount: number; vatAmount: number; totalAmount: number; isTaxFree: boolean }[]): OrderTotals {
|
||||||
|
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0;
|
||||||
|
for (const l of lines) {
|
||||||
|
supply += l.supplyAmount;
|
||||||
|
vat += l.vatAmount;
|
||||||
|
total += l.totalAmount;
|
||||||
|
if (l.isTaxFree) taxFree += l.supplyAmount;
|
||||||
|
else taxable += l.supplyAmount;
|
||||||
|
}
|
||||||
|
return { supply, vat, total, taxFree, taxable };
|
||||||
|
}
|
||||||
+18
-8
@@ -29,16 +29,26 @@ export async function createSession(user: User): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSession(): Promise<User | null> {
|
export async function getSession(): Promise<User | null> {
|
||||||
|
// 1) 쿠키 기반 (웹)
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
const cookieToken = cookieStore.get(SESSION_COOKIE)?.value;
|
||||||
if (!token) return null;
|
if (cookieToken) {
|
||||||
|
try {
|
||||||
try {
|
const { payload } = await jwtVerify(cookieToken, SECRET);
|
||||||
const { payload } = await jwtVerify(token, SECRET);
|
return (payload as { user: User }).user;
|
||||||
return (payload as { user: User }).user;
|
} catch { /* fall through */ }
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
// 2) Authorization: Bearer (모바일 앱)
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const h = await headers();
|
||||||
|
const auth = h.get("authorization") || h.get("Authorization");
|
||||||
|
if (auth?.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(auth.slice(7), SECRET);
|
||||||
|
return (payload as { user: User }).user;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroySession(): Promise<void> {
|
export async function destroySession(): Promise<void> {
|
||||||
|
|||||||
+17
-4
@@ -5,7 +5,19 @@ export function middleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// 인증 불필요 경로
|
// 인증 불필요 경로
|
||||||
const publicPaths = ["/login", "/api/auth/login", "/_next", "/favicon.ico", "/icon.svg"];
|
const publicPaths = [
|
||||||
|
"/login",
|
||||||
|
"/signup",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/signup",
|
||||||
|
"/api/auth/mobile-login",
|
||||||
|
"/_next",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/icon.svg",
|
||||||
|
"/momo-logo.svg",
|
||||||
|
"/momo-icon.svg",
|
||||||
|
];
|
||||||
|
if (pathname === "/") return NextResponse.next(); // 랜딩 페이지
|
||||||
if (publicPaths.some((p) => pathname.startsWith(p))) {
|
if (publicPaths.some((p) => pathname.startsWith(p))) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -15,13 +27,14 @@ export function middleware(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세션 쿠키 확인
|
// 세션 쿠키 또는 Authorization: Bearer 토큰 확인
|
||||||
const sessionCookie = request.cookies.get("plm-session");
|
const sessionCookie = request.cookies.get("plm-session");
|
||||||
if (!sessionCookie && !pathname.startsWith("/api")) {
|
const bearer = request.headers.get("authorization")?.startsWith("Bearer ");
|
||||||
|
if (!sessionCookie && !bearer && !pathname.startsWith("/api")) {
|
||||||
return NextResponse.redirect(new URL("/login", request.url));
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionCookie && pathname.startsWith("/api")) {
|
if (!sessionCookie && !bearer && pathname.startsWith("/api")) {
|
||||||
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface User {
|
|||||||
authName: string;
|
authName: string;
|
||||||
partnerCd: string;
|
partnerCd: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
// MOMO 추가 필드 (선택 — FITO 사용자에게는 비어있음)
|
||||||
|
role?: "USER" | "ADMIN";
|
||||||
|
objid?: string;
|
||||||
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메뉴 정보
|
// 메뉴 정보
|
||||||
|
|||||||
Reference in New Issue
Block a user