6 Commits

Author SHA1 Message Date
hjjeong 5e8de47a6e Merge remote-tracking branch 'origin/main' into feat/kakao-login
# Conflicts:
#	src/app/(main)/m/orders/new/page.tsx
2026-05-06 15:01:19 +09:00
hjjeong 39465b38d9 feat(mobile): admin 메뉴 토글 + 발주 카드/리스트 뷰 + admin-panel PC 전용 안내
모바일 더보기에 admin 메뉴 전환 기능, 발주 페이지에 카드/리스트 뷰 선택,
admin-panel 페이지의 모바일 진입 가드를 함께 추가. PC 동작 영향 없음.

- /m/more: user.isAdmin 인 경우에만 [관리자 메뉴 보기] 토글 노출.
  ON 시 menu_info.menu_type=0 그룹(권한/부서/사용자/공통코드 등)으로 메뉴바 전환.
  사용자 카드 색도 amber 로 바뀌어 현재 모드를 시각적으로 표시. 다시 누르면
  사용자 메뉴(거래처 주문/마스터/매입/출고/통계)로 복귀. 모드는 localStorage
  ("momo_admin_mode") 에 저장되어 새로고침 후에도 유지.
- /m/orders/new: 카드 뷰(2열 그리드)와 리스트 뷰(가로 한 줄 + 썸네일)를 토글로
  전환. PC 트리·모바일 트리 양쪽에 동일 적용, 같은 viewMode state 공유. 사용자
  선택은 localStorage ("momo_orders_new_view_mode") 에 저장.
- /admin-panel: 모바일(md 미만) 진입 시 안내 화면(PC에서 사용해주세요 + 모바일
  메뉴 복귀 링크) 노출. md+ 에서는 기존 데스크탑 패널 그대로. /m/more 의
  관리자 메뉴 항목들이 admin-panel 로 이동할 때 모바일에서 화면 깨짐 방지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:06:53 +09:00
hjjeong 47a1dc5843 feat(mobile): /m/* 모바일 셸 분리 + 발주 페이지 모바일 뷰
PC는 운영서버(momotogether.com) 디자인 그대로 유지하고, 모바일 전용
UI를 별도 셸로 제공. 화면 폭(md, 768px) 기준 반응형으로 토글.

- (mobile) 라우트 그룹 신설 후 (main)/m/* 22개 페이지를 통째로 이동.
  업무 로직/page 본문은 변경 없음 (단순 경로 이동).
- (mobile)/layout.tsx: md+ 는 기존 Sidebar+Header(=PC), md 미만은
  MobileTopBar+MobileBottomNav(=모바일). children 한 번만 렌더, state 공유.
- MobileTopBar: 루트 페이지(홈/발주하기/내 발주/더보기)는 로고, 그 외엔 뒤로가기.
- MobileBottomNav: 4탭(홈/발주하기/내 발주/더보기) 모든 사용자에게 동일 노출.
  권한별 가시성은 추후 DB 권한 테이블로 처리.
- /m/more (신규): PC 사이드바와 같은 API(/api/menu/top + /api/menu)에서
  메뉴 트리 동적 로드. PC와 모바일이 같은 DB 소스를 공유.
- /m/orders/new: PC 트리(운영 디자인 그대로) + 모바일 트리(큰 터치 영역,
  하단 floating 카트, 단순화된 발주 흐름) 두 갈래. cart/state는 부모
  컴포넌트 한 곳에서 공유.
- 카트 바 토글의 nested <button> 을 <div role="button"> 으로 변경.
  Next 16 / React 19 hydration 검증 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:01:11 +09:00
hjjeong 00b173573d docs(kakao): 모모유통 측에 전달할 상세 가이드 + 메시지 템플릿 추가
비즈 앱 인증과 명의 이전을 모모유통 담당자가 직접 따라할 수 있도록 단계별 화면
경로/입력값/주의사항 정리. 그대로 복사해서 보낼 수 있는 메시지 템플릿과 자주
묻는 질문도 포함.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:13:49 +09:00
hjjeong e77ec978eb chore(env): 카카오 dev 시크릿 .env.development 추가
다른 개발자 로컬에서 즉시 테스트 가능하도록 dev 환경의 KAKAO_REST_API_KEY,
KAKAO_CLIENT_SECRET, KAKAO_REDIRECT_URI 를 트래킹. 비즈 앱 전환 시 모두 재발급
예정이라 노출 영향은 임시. 운영 .env.production 은 별도 채널로 관리.

체크리스트에 .env 추적 정책 재검토 항목 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:07:37 +09:00
hjjeong 92ad098351 feat(auth): 카카오 로그인/가입 추가 (kakao_id 매칭 + 추가정보 입력 플로우)
- user_info.kakao_id 컬럼 + 부분 unique 인덱스 (010 마이그레이션)
- OAuth 인가/콜백/완료 3-step 플로우, state CSRF + pending JWT 사용
- 신규 사용자는 /signup/kakao 에서 업체정보 입력 후 가입, 동일 이메일 일반
  가입자가 카카오 로그인 시 자동으로 kakao_id 연결
- 비즈 앱 미인증 환경에서도 동작하도록 이메일 입력 필드 조건부 노출
- 운영 전환 체크리스트(docs/KAKAO_LOGIN_CHECKLIST.md) 동봉

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:05:44 +09:00
41 changed files with 2622 additions and 463 deletions
+8
View File
@@ -17,3 +17,11 @@ SMTP_FROM=모모유통 <chpark@coa-soft.com>
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
MOMO_PHONE=010-6624-5315
DEPLOY_WEBHOOK_TOKEN=momo-deploy-2026-secure
# 카카오 OAuth (개발) — 카카오 디벨로퍼스에서 발급 후 채워넣기
# https://developers.kakao.com/console/app → 앱 키 → REST API 키
# 카카오 로그인 → 활성화 ON, Redirect URI 등록 필수
# 동의항목: 이메일(필수 동의), 닉네임(기본)
KAKAO_REST_API_KEY=1e7825e926c4e8de575ee73ecbd02398
KAKAO_CLIENT_SECRET=jva60F8UfxZtDFIYqZsbECeHl8fcz6e6
KAKAO_REDIRECT_URI=http://localhost:3000/api/auth/kakao/callback
+16
View File
@@ -0,0 +1,16 @@
-- 010_kakao_login.sql
-- 카카오 OAuth 로그인/가입 지원
-- 1) user_info.kakao_id 컬럼 추가 (카카오 user.id, 정수형이지만 64자 문자열로 저장)
-- 2) kakao_id 부분 unique 인덱스 (NULL 다수 허용, 값이 있을 때만 unique)
BEGIN;
ALTER TABLE user_info
ADD COLUMN IF NOT EXISTS kakao_id VARCHAR(64);
COMMENT ON COLUMN user_info.kakao_id IS '카카오 로그인 연동 ID (Kakao user.id). NULL = 미연동';
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_info_kakao_id
ON user_info (kakao_id)
WHERE kakao_id IS NOT NULL;
COMMIT;
+180
View File
@@ -0,0 +1,180 @@
# 카카오 로그인 — 모모유통 측 요청사항 안내
> **이 문서는 모모유통 담당자분께 전달하기 위한 안내문**입니다. 카카오 로그인 기능을 운영 환경에서 사용하기 위해, **모모유통 명의로 카카오 디벨로퍼스 앱 등록과 비즈 앱 인증**이 필요합니다.
---
## 왜 모모유통 명의가 필요한가요?
현재는 개발자의 개인 카카오 계정으로 임시 등록해 테스트 중입니다. 그러나 다음 이유로 **운영 출시 전 모모유통 명의로 이전이 필수**입니다.
1. **개인정보 처리 책임** — 사용자가 카카오로 로그인할 때 동의 화면에 "이 앱을 운영하는 사업자" 가 모모유통이어야 법적으로 맞습니다 (개인정보 보호법).
2. **이메일 권한** — 개인 앱은 카카오 이메일을 못 가져옵니다. 사업자등록증 기반의 **비즈 앱 인증** 을 받아야 이메일이 풀리고, 인증은 모모유통 명의가 필수.
3. **소유권 안정성** — 개발자가 프로젝트 떠나도 모모유통이 콘솔 접근/키 관리/검수 답변을 계속 할 수 있어야 합니다.
4. **비용 청구 책임** — 향후 카카오톡 메시지 등 유료 기능을 쓸 경우 청구가 등록자(=모모유통)에게 갑니다.
---
## 모모유통 측 작업 단계
### 0. 준비물
- **(주)모모유통 사업자등록증 PDF** (스캔본 또는 정부24 발급분)
- **회사 대표 이메일 계정** (예: `momo8443@daum.net`) — 카카오 계정 가입에 사용
-**1~3 영업일 검수 대기 시간**
### 1. 카카오 계정 만들기 (카카오톡 계정과 별도)
이미 카카오톡 쓰고 계신 개인 계정이 아닌 **회사 대표 이메일로 별도 카카오 계정**을 만드는 것을 강력 권장. 담당자 변경 시 인계가 쉽고, 개인-법인 분리도 깔끔합니다.
1. https://accounts.kakao.com/weblogin/account/signup 접속
2. 이메일: `momo8443@daum.net` (또는 회사 대표 이메일)
3. 비밀번호 설정 (회사 비밀번호 관리도구에 보관 권장)
> 이미 회사 대표 이메일로 카카오톡 계정이 있으면 그걸 그대로 쓰셔도 됩니다.
### 2. 카카오 디벨로퍼스 가입
1. https://developers.kakao.com 접속
2. 우측 상단 "**로그인**" → 위에서 만든 카카오 계정으로 로그인
3. 약관 동의 화면이 뜨면 동의
### 3. 애플리케이션(앱) 등록
1. 좌측 메뉴 "**내 애플리케이션**" → "**애플리케이션 추가하기**"
2. 입력값:
- **앱 이름**: `MOMO 유통 ERP`
- **사업자명**: `(주)모모유통`
- **카테고리**: 쇼핑 (또는 적절한 항목)
3. 저장하면 자동으로 앱 키들이 발급됨 (REST API 키 등)
### 4. 비즈 앱 전환 (사업자 인증)
이 단계가 **이메일 권한****운영용 정식 앱** 자격을 부여합니다.
1. 좌측 메뉴 "**비즈니스**" 클릭
2. "**비즈 앱 전환**" 또는 "**사업자 인증**" 버튼 클릭
3. 사업자등록증 PDF 업로드
4. 회사 정보 입력 (사업자등록번호, 대표자명, 주소 등 — 사업자등록증과 동일하게)
5. 신청 → **카카오 검수 대기 (1~3 영업일)**
6. 통과되면 콘솔 상단에 "비즈 앱" 배지가 붙습니다
### 5. 카카오 로그인 활성화
비즈 앱 검수 통과 **이전에도 진행 가능**합니다.
1. 좌측 메뉴 "**제품 설정 → 카카오 로그인 → 일반**"
2. 상단 "**활성화 설정**" 토글을 **ON** 으로
3. 같은 페이지 또는 "**제품 설정 → 카카오 로그인 → 동의항목**" 에서:
- **닉네임**: "필수 동의" 로 변경
- 동의 목적: `회원가입 시 업체명 자동 입력`
- **카카오계정(이메일)**: 비즈 앱 인증 통과 후 "필수 동의" 로 변경
- 동의 목적: `회원 식별 및 거래명세표 발송`
### 6. Redirect URI 등록 (개발자가 알려준 주소)
1. 좌측 메뉴 "**앱 설정 → 앱 → 플랫폼 키**"
2. "**Default Rest API Key**" 카드를 클릭
3. 페이지 안의 "**카카오 로그인 리다이렉트 URI**" 섹션에 두 줄 등록:
- `http://localhost:3000/api/auth/kakao/callback` (개발자 로컬 테스트용)
- `https://momotogether.com/api/auth/kakao/callback` (운영용)
4. 페이지 하단 "**저장**" 클릭
### 7. Client Secret 활성화
같은 페이지에서:
1. "**클라이언트 시크릿**" 섹션 클릭
2. "**카카오 로그인**" 옆 "**코드 발급**" → 활성화 **ON**
3. 발급된 코드는 절대 외부 노출 금지 (개발자에게 전달 시 보안 채널 사용)
### 8. 개발자를 팀원으로 초대
1. 좌측 메뉴 "**앱 설정 → 앱 → 멤버**"
2. "**멤버 초대**" 클릭
3. 개발자의 카카오 계정 (이메일 또는 카카오 ID) 입력
4. 권한: "**개발자**" 또는 "**관리자**" 선택 → 초대
---
## 개발자에게 전달할 정보
위 작업 완료 후, 아래 두 가지를 **보안 채널**(1Password, 슬랙 비공개 DM, 회사 메일 등 — **공개 채팅이나 깃허브 이슈 본문에는 절대 금지**) 로 전달:
1. **REST API 키** — 콘솔 → 앱 설정 → 앱 → 플랫폼 키 → "Default Rest API Key" 의 32자리 코드
2. **Client Secret 코드** — 콘솔 → 앱 설정 → 앱 → 플랫폼 키 → 클라이언트 시크릿 → "카카오 로그인" 의 코드
또한 팀원 초대를 보내주시면 개발자도 콘솔 접근 가능.
---
## 메시지 템플릿 (그대로 복사해서 모모유통에 전송 가능)
```text
안녕하세요. 모모유통 ERP 의 카카오 로그인 기능 준비를 위해 한 가지 부탁드릴 사항이 있습니다.
지금은 제 개인 카카오 계정으로 임시 등록해 개발/테스트 중인데, 운영 출시 전에는
모모유통 명의로 카카오 디벨로퍼스 앱을 새로 등록하시고 사업자등록증으로 비즈 앱 인증을
받으셔야 합니다. 이유는 다음과 같습니다.
1. 사용자가 카카오로 로그인할 때 동의 화면에 "이 앱을 운영하는 사업자"가 모모유통으로
표시되어야 개인정보보호법상 책임 소재가 맞습니다.
2. 카카오 이메일을 가져오려면 비즈 앱 인증이 필수이고, 인증은 사업자등록증 명의 회사로만
가능합니다. (현재 임시 앱은 인증 미보유라 사용자가 이메일을 직접 입력해야 합니다.)
3. 향후 제가 운영에서 빠지더라도 모모유통이 키 관리/검수 답변을 계속할 수 있어야 합니다.
아래 단계로 진행해주시면 감사하겠습니다.
[준비물]
- (주)모모유통 사업자등록증 PDF
- 회사 대표 이메일 (카카오 계정 가입용 — 가능하면 회사 메일로 별도 카카오 계정 생성)
[작업 순서]
1. https://developers.kakao.com 가입 (회사 대표 이메일 계정으로)
2. 애플리케이션 추가 (앱 이름: "MOMO 유통 ERP", 사업자명: "(주)모모유통")
3. 비즈니스 → 비즈 앱 전환 → 사업자등록증 업로드 → 검수 신청 (1~3영업일 소요)
4. 카카오 로그인 활성화 ON
5. Redirect URI 등록 (제가 별도 안내드릴 두 줄)
6. 클라이언트 시크릿 발급
7. 멤버 메뉴에서 저를 팀원으로 초대
자세한 화면별 안내는 첨부한 KAKAO_HANDOFF_TO_CLIENT.md 문서를 참고해주세요.
검수 통과되면 REST API 키와 Client Secret 코드를 보안 채널 (회사 메일이나 1Password
등 — 카카오톡이나 공개 메신저는 피해주세요) 로 전달 부탁드립니다. 운영 환경 적용은
제가 진행하겠습니다.
문의 사항 있으시면 언제든 연락 주세요. 감사합니다.
— 담당 개발자 드림
```
---
## 자주 묻는 질문
### Q. 비즈 앱 인증 안 받고 운영 시작은 안 되나요?
가능은 합니다. 다만 다음 제약이 있습니다.
- **카카오에서 이메일을 못 가져옴** → 사용자가 회원가입 시 이메일을 직접 입력해야 함 (현재 테스트 환경 동작과 동일)
- **앱이 "테스트 단계"** 로 표시되어 카카오 동의 화면에 경고가 뜰 수 있음
- **앱 관리자/팀원 외에는 로그인 못 함** 가능성 (앱 상태에 따라)
권장: 출시와 거의 동시에 비즈 인증 신청. 검수 1~3일이면 끝나니 큰 부담은 아닙니다.
### Q. 사업자등록증 외에 필요한 서류는?
기본은 사업자등록증 PDF 1장이면 충분합니다. 카카오가 추가 자료(통신판매업신고증 등)를 요청할 수 있는데, 그때 따로 안내가 옵니다.
### Q. 검수가 거절되면?
거절 사유가 메일로 옵니다. 흔한 이유:
- 사업자등록증 이미지 흐림 → 다시 촬영/스캔
- 입력 정보가 사업자등록증과 불일치 → 정확히 똑같이 (대표자명, 주소까지)
- 앱 이름이 부적절 → 회사명 또는 서비스명으로 변경
수정 후 재신청 가능합니다.
### Q. 검수 통과까지 개발/테스트는 어떻게?
지금 하던 임시 앱(개발자 명의) 그대로 사용합니다. 검수 끝나면 운영 `.env.production` 의 키만 신규 모모유통 앱 키로 교체하면 됩니다. 코드는 변경 없음.
+165
View File
@@ -0,0 +1,165 @@
# 카카오 로그인 — 운영 전환 체크리스트
테스트 환경에서 카카오 로그인/가입 기능 동작 확인 완료.
운영 배포 전후로 처리할 작업을 단계별로 정리.
---
## 1. 운영 DB 마이그레이션
운영 DB 에 `user_info.kakao_id` 컬럼 + 부분 unique 인덱스 추가.
```bash
# A. 자동 배포 스크립트 사용 (권장 — 멱등)
# deploy.sh 가 매 배포마다 migrate:momo 자동 실행
docker compose -f docker-compose.prod.yml exec -T momo-erp npm run migrate:momo
# B. 010 만 수동 실행 (009 가 admin 비번 초기화하는 게 부담스러우면)
psql "<운영 DATABASE_URL>" -f db/migrations/010_kakao_login.sql
```
### 검증
```sql
-- 컬럼 존재 확인
SELECT column_name FROM information_schema.columns
WHERE table_name = 'user_info' AND column_name = 'kakao_id';
-- 부분 unique 인덱스 확인
SELECT indexdef FROM pg_indexes
WHERE indexname = 'idx_user_info_kakao_id';
```
---
## 2. 카카오 앱 명의 이전 (모모유통)
**현재 상태**: 개발자 개인 카카오 계정 명의로 등록된 테스트 앱.
**목표 상태**: (주)모모유통 명의 카카오 계정으로 등록된 운영 앱 + 비즈 앱 인증.
> 모모유통 측에 전달할 상세 가이드와 메시지 템플릿: [KAKAO_HANDOFF_TO_CLIENT.md](./KAKAO_HANDOFF_TO_CLIENT.md)
### 모모유통 측에서 진행
- [ ] 회사 대표 이메일로 카카오 계정 생성 (예: `momo8443@daum.net`)
- [ ] https://developers.kakao.com 가입
- [ ] **애플리케이션 추가**
- 앱 이름: `MOMO 유통 ERP` (또는 적절한 이름)
- 사업자명: `(주)모모유통`
- [ ] **비즈니스 → 비즈 앱 전환 → 사업자등록증 업로드 → 검수 신청** (1~3 영업일 소요)
- [ ] 검수 통과 후:
- **카카오 로그인 → 동의항목**에서 `카카오계정(이메일)`**필수 동의** 로 활성화
- [ ] **앱 → 멤버** 메뉴에서 개발자 카카오 계정을 팀원으로 초대
### 신규 앱에서 등록할 것
- [ ] **카카오 로그인 → 활성화 ON**
- [ ] **앱 → 플랫폼 키 → 카카오 로그인 리다이렉트 URI 등록**
- `http://localhost:3000/api/auth/kakao/callback` (개발용)
- `https://momotogether.com/api/auth/kakao/callback` (운영용)
- [ ] **클라이언트 시크릿 활성화 + 코드 발급** (보안 권장)
- [ ] **동의항목**: 닉네임 필수 동의 + 이메일 필수 동의 (비즈 검수 통과 후)
---
## 3. 운영 환경변수 설정
운영 서버의 `.env.production` 에 카카오 환경변수 3개 추가.
```bash
# 운영 서버 SSH 접속
cd /deploy/source # 또는 ~/momo-erp/source
# .env.production 끝에 추가
cat >> .env.production <<'EOF'
KAKAO_REST_API_KEY=<신규 앱의 REST API 키>
KAKAO_CLIENT_SECRET=<신규 앱의 Client Secret>
KAKAO_REDIRECT_URI=https://momotogether.com/api/auth/kakao/callback
EOF
# 컨테이너 재시작 (env_file 다시 로드)
docker compose -f docker-compose.prod.yml up -d --force-recreate momo-erp
```
---
## 4. 노출된 키 폐기 (보안)
테스트 단계에서 채팅·로그에 노출된 키들. 운영 시작 전 카카오 콘솔에서 **재발급** 후 새 값으로 운영 적용.
대상:
- 테스트용 REST API 키: `1e7825...`
- 테스트용 Client Secret: `jva60F8UfxZtDFI...`
운영 앱은 신규 발급 키만 쓰므로, 테스트 앱은 **삭제하거나 그대로 두어도 무방** (사용 안 하면 그만).
> ⚠ 운영 앱 키는 이번에는 **채팅/이슈/PR 본문에 절대 붙여넣지 말 것**. `.env.production` 에 직접 SSH 붙여 넣기.
### `.env` 추적 정책 재검토 (선택)
현재 `.env.development` 는 git 트래킹된 상태(dev 시크릿이 히스토리에 있음). 운영 전환 시점에 다음을 함께 고려:
- `.env.development.example` (마스킹 템플릿)을 추가하고 실제 `.env.development``git rm --cached` 로 untrack
- 팀에 `.env` 공유 채널(1Password / Bitwarden / 슬랙 비공개) 합의
- 과거 히스토리의 시크릿은 `git filter-repo` 로 청소 또는 어차피 모두 재발급
---
## 5. 배포 후 동작 확인
### 신규 가입 (kakao_id 미보유)
1. `https://momotogether.com/login` → "카카오로 시작하기"
2. 카카오 동의 화면 (이메일·닉네임)
3.`/signup/kakao` 추가정보 입력
- 비즈 인증 후라면 이메일 자동 채워짐 (read-only)
- 비즈 인증 전이면 이메일도 직접 입력
4. 업체명·연락처·주소 입력 → 가입 완료 → `/m/dashboard`
### 재방문 로그인 (kakao_id 매칭)
1. 같은 카카오 계정으로 "카카오로 시작하기"
2. 동의 화면 건너뜀 (카카오 SSO + 이전 동의 기록)
3. → 즉시 `/m/dashboard` 진입
### DB 검증
```sql
SELECT user_id, user_name, email, kakao_id, regdate
FROM user_info
WHERE kakao_id IS NOT NULL
ORDER BY regdate DESC
LIMIT 10;
```
---
## 6. 자동 동작 (코드 변경 불필요)
- 비즈 앱 인증 통과 → 카카오에서 이메일 자동 수신 → `/signup/kakao` 페이지가 이메일 read-only 모드로 자동 전환 ([src/app/(auth)/signup/kakao/page.tsx](../src/app/(auth)/signup/kakao/page.tsx) 의 `needEmailInput` 분기)
- 동일 이메일 일반 가입자가 카카오 로그인 시도 → 자동으로 `kakao_id` 연결 후 로그인 ([src/app/api/auth/kakao/callback/route.ts](../src/app/api/auth/kakao/callback/route.ts))
---
## 코드 변경 요약 (참고)
| 파일 | 역할 |
|------|------|
| [db/migrations/010_kakao_login.sql](../db/migrations/010_kakao_login.sql) | `user_info.kakao_id` 컬럼 + unique 인덱스 |
| [src/lib/kakao-auth.ts](../src/lib/kakao-auth.ts) | 카카오 OAuth 헬퍼 (인가 URL · 토큰 교환 · 프로필 · DB 조회/INSERT) |
| [src/app/api/auth/kakao/start/route.ts](../src/app/api/auth/kakao/start/route.ts) | 인가 URL 리다이렉트 + state CSRF 쿠키 |
| [src/app/api/auth/kakao/callback/route.ts](../src/app/api/auth/kakao/callback/route.ts) | 콜백 — kakao_id 매칭 / 동일 이메일 자동 연결 / 신규 분기 |
| [src/app/api/auth/kakao/pending/route.ts](../src/app/api/auth/kakao/pending/route.ts) | 가입 페이지용 pending JWT 검증 |
| [src/app/api/auth/kakao/complete/route.ts](../src/app/api/auth/kakao/complete/route.ts) | 가입 완료 — INSERT + 세션 |
| [src/app/(auth)/signup/kakao/page.tsx](../src/app/(auth)/signup/kakao/page.tsx) | 추가정보 입력 페이지 |
| [src/app/(auth)/login/page.tsx](../src/app/(auth)/login/page.tsx) | 카카오 버튼 + `?kakao_error=...` 토스트 |
| [src/app/(auth)/signup/page.tsx](../src/app/(auth)/signup/page.tsx) | 카카오 버튼 |
| [src/middleware.ts](../src/middleware.ts) | `/api/auth/kakao` public path 추가 |
### 보안 설계
- **CSRF**: 인가 요청 `state` 파라미터에 16바이트 nonce, httpOnly 쿠키와 대조
- **임시 가입 정보**: jose JWT (NEXTAUTH_SECRET 서명, 10분 만료) — 클라이언트 위조 불가
- **자동 계정 연결**: 카카오 OAuth 가 이메일 소유 검증을 보장하는 경우에만
- **일반 로그인 차단**: 카카오 가입자는 `user_password=''``verifyMomoCredentials` 가 거부
+2 -5
View File
@@ -2180,7 +2180,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2190,7 +2189,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3451,7 +3450,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -7033,7 +7031,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
@@ -8079,7 +8076,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+36 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, FormEvent } from "react";
import { useState, useEffect, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin } from "lucide-react";
@@ -13,6 +13,16 @@ export default function LoginPage() {
const [showPw, setShowPw] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const url = new URL(window.location.href);
const err = url.searchParams.get("kakao_error");
if (err) {
Swal.fire({ icon: "error", title: "카카오 로그인 실패", text: err });
url.searchParams.delete("kakao_error");
window.history.replaceState({}, "", url.toString());
}
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -194,6 +204,20 @@ export default function LoginPage() {
</button>
</form>
<div className="my-6 flex items-center gap-3 text-[11px] text-slate-400 tracking-wider">
<span className="flex-1 h-px bg-slate-200" />
OR
<span className="flex-1 h-px bg-slate-200" />
</div>
<a
href="/api/auth/kakao/start"
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-[#FEE500] hover:bg-[#FDD835] text-[#3C1E1E] text-sm font-bold tracking-wide shadow-sm hover:shadow-md transition-all"
>
<KakaoIcon />
</a>
<div className="mt-8 text-center">
<p className="text-sm text-slate-500">
?{" "}
@@ -213,3 +237,14 @@ export default function LoginPage() {
</div>
);
}
function KakaoIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 1.5C4.58 1.5 1 4.32 1 7.8c0 2.27 1.52 4.27 3.81 5.4l-.96 3.5c-.08.31.27.55.54.39L8.6 14.7c.13.01.27.02.4.02 4.42 0 8-2.82 8-6.92S13.42 1.5 9 1.5z"
fill="#3C1E1E"
/>
</svg>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import { useState, useEffect, FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
import { Mail, Building2, User as UserIcon, Phone, FileText, MapPin, ArrowRight } from "lucide-react";
interface Pending {
email: string; // 카카오에서 받았으면 채워짐, 아니면 ""
nickname: string;
}
export default function KakaoSignupPage() {
const router = useRouter();
const [pending, setPending] = useState<Pending | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
const [form, setForm] = useState({
email: "",
companyName: "",
ceoName: "",
bizNo: "",
phone: "",
address: "",
});
const [loading, setLoading] = useState(false);
// 카카오에서 이메일을 못 받았으면 사용자가 직접 입력해야 함
const needEmailInput = pending !== null && !pending.email;
useEffect(() => {
fetch("/api/auth/kakao/pending", { credentials: "same-origin" })
.then(async (res) => {
const j = await res.json();
if (!res.ok || !j.success) {
setPendingError(j.message || "카카오 인증 세션이 만료되었습니다.");
return;
}
setPending({ email: j.email || "", nickname: j.nickname || "" });
setForm((f) => ({ ...f, companyName: j.nickname || "" }));
})
.catch(() => setPendingError("서버 오류가 발생했습니다."));
}, []);
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.companyName || !form.phone || !form.address) {
Swal.fire({ icon: "warning", title: "필수 항목을 입력하세요.", text: "업체명·연락처·주소는 필수입니다." });
return;
}
if (needEmailInput) {
if (!form.email) {
Swal.fire({ icon: "warning", title: "이메일을 입력하세요." });
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
Swal.fire({ icon: "warning", title: "유효한 이메일 형식이 아닙니다." });
return;
}
}
setLoading(true);
try {
const res = await fetch("/api/auth/kakao/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
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);
}
};
if (pendingError) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-6">
<div className="w-full max-w-md text-center bg-white rounded-2xl shadow-sm p-10">
<h1 className="text-xl font-bold text-slate-900 mb-3"> </h1>
<p className="text-sm text-slate-500 mb-6">{pendingError}</p>
<Link
href="/login"
className="inline-flex items-center gap-2 h-11 px-5 rounded-xl bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 transition"
>
</Link>
</div>
</div>
);
}
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">
. .
</p>
</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" />
KAKAO SIGN UP
</div>
<h1 className="text-3xl font-bold text-slate-900 mb-2"> </h1>
<p className="text-slate-500 text-sm"> .</p>
</div>
{pending && pending.email && (
<div className="mb-6 p-4 rounded-xl bg-yellow-50 border border-yellow-200">
<div className="flex items-center gap-2 text-[12px] text-yellow-900 font-semibold mb-1">
<Mail size={14} />
</div>
<div className="text-sm text-slate-800 font-medium">{pending.email}</div>
{pending.nickname && (
<div className="text-[12px] text-slate-500 mt-0.5">{pending.nickname} </div>
)}
</div>
)}
{pending && !pending.email && pending.nickname && (
<div className="mb-6 p-4 rounded-xl bg-yellow-50 border border-yellow-200">
<div className="flex items-center gap-2 text-[12px] text-yellow-900 font-semibold mb-1">
</div>
<div className="text-sm text-slate-800 font-medium">{pending.nickname} </div>
<div className="text-[12px] text-slate-500 mt-1">
.
</div>
</div>
)}
<form onSubmit={submit} className="space-y-4">
{needEmailInput && (
<Field
icon={<Mail size={16} />}
label="이메일 *"
type="email"
value={form.email}
onChange={set("email")}
placeholder="you@company.com"
autoFocus
/>
)}
<Field icon={<Building2 size={16} />} label="업체명 *" value={form.companyName} onChange={set("companyName")} placeholder="(주)거래처 또는 매장명" autoFocus={!needEmailInput} />
<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={<MapPin size={16} />} label="주소 *" value={form.address} onChange={set("address")} placeholder="배송지 주소를 입력하세요" />
<Field icon={<FileText size={16} />} label="사업자등록번호" value={form.bizNo} onChange={set("bizNo")} placeholder="000-00-00000 (선택)" />
<button
type="submit"
disabled={loading || !pending}
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>
</form>
</div>
</div>
</div>
);
}
function Field(props: {
icon: React.ReactNode;
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
type?: string;
placeholder?: 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}
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>
);
}
+25
View File
@@ -167,12 +167,37 @@ export default function SignupPage() {
<Link href="/" className="underline hover:text-slate-600"> </Link> .
</p>
</form>
<div className="my-6 flex items-center gap-3 text-[11px] text-slate-400 tracking-wider">
<span className="flex-1 h-px bg-slate-200" />
OR
<span className="flex-1 h-px bg-slate-200" />
</div>
<a
href="/api/auth/kakao/start"
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-[#FEE500] hover:bg-[#FDD835] text-[#3C1E1E] text-sm font-bold tracking-wide shadow-sm hover:shadow-md transition-all"
>
<KakaoIcon />
</a>
</div>
</div>
</div>
);
}
function KakaoIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 1.5C4.58 1.5 1 4.32 1 7.8c0 2.27 1.52 4.27 3.81 5.4l-.96 3.5c-.08.31.27.55.54.39L8.6 14.7c.13.01.27.02.4.02 4.42 0 8-2.82 8-6.92S13.42 1.5 9 1.5z"
fill="#3C1E1E"
/>
</svg>
);
}
function Field(props: {
icon: React.ReactNode;
label: string;
-455
View File
@@ -1,455 +0,0 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package } 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;
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
}
interface CartLine { item: Item; qty: number }
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; amount: number; label: string }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
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 [extras, setExtras] = useState<ExtraLine[]>([]);
const [cartOpen, setCartOpen] = useState(false);
const fetchItems = useCallback(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);
}, [keyword, taxFilter]);
useEffect(() => { fetchItems(); }, []); // eslint-disable-line
// 카트에 택배전용 품목이 있는지
const cartNeedsDelivery = useMemo(
() => cart.some((c) => c.item.REQUIRES_DELIVERY === "Y"),
[cart]
);
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
// 택배전용 품목이 카트에 들어왔는데 택배 라인이 없으면 자동 한 줄 추가
useEffect(() => {
if (cartNeedsDelivery && !hasDeliveryLine) {
setExtras((prev) => [
{ id: newKey(), kind: "DELIVERY", amount: 0, label: "택배비" },
...prev,
]);
}
}, [cartNeedsDelivery, hasDeliveryLine]);
const addToCart = (item: Item) => {
setCart((c) => {
const found = c.find((x) => x.item.OBJID === item.OBJID);
const stock = Number(item.STOCK_QTY);
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
if (found) {
if (found.qty + 1 > limit) {
Swal.fire({
icon: "warning",
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}` : `재고는 ${fmt(stock)}`}입니다.`,
});
return c;
}
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
}
return [...c, { item, qty: 1 }];
});
Swal.fire({
toast: true, position: "top-end", icon: "success",
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
showConfirmButton: false, timer: 1200, timerProgressBar: true,
});
};
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;
const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
if (newQty > limit) return x;
return { ...x, qty: newQty };
})
);
};
const setQty = (objid: string, value: number) => {
setCart((c) =>
c.map((x) => {
if (x.item.OBJID !== objid) return x;
const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
return { ...x, qty: clamped };
})
);
};
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
const addExtra = (kind: "DELIVERY" | "CHARTER") => {
setExtras((p) => [...p, { id: newKey(), kind, amount: 0, label: kind === "DELIVERY" ? "택배비" : "용차비" }]);
};
const updateExtra = (id: string, field: keyof ExtraLine, value: string | number) => {
setExtras((p) => p.map((e) => (e.id === id ? { ...e, [field]: value } as ExtraLine : e)));
};
const removeExtra = (id: string) => {
const target = extras.find((e) => e.id === id);
if (target?.kind === "DELIVERY" && cartNeedsDelivery) {
Swal.fire({ icon: "warning", title: "택배 전용 품목이 있어 택배 라인을 제거할 수 없습니다." });
return;
}
setExtras((p) => p.filter((e) => e.id !== id));
};
const totals = useMemo(() => {
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0, delivery = 0, charter = 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;
}
}
for (const ex of extras) {
const amt = Number(ex.amount) || 0;
total += amt;
const s = Math.round(amt / 1.1);
supply += s;
vat += amt - s;
taxable += s;
if (ex.kind === "DELIVERY") delivery += amt;
if (ex.kind === "CHARTER") charter += amt;
}
return { supply, vat, total, taxFree, taxable, delivery, charter };
}, [cart, extras]);
const submitOrder = async () => {
if (cart.length === 0) {
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
return;
}
if (cartNeedsDelivery && !hasDeliveryLine) {
Swal.fire({ icon: "warning", title: "택배 전용 품목이 포함되어 택배 라인이 필요합니다." });
return;
}
if (extras.some((e) => Number(e.amount) <= 0)) {
Swal.fire({ icon: "warning", title: "택배/용차 금액을 입력하세요." });
return;
}
const ok = await Swal.fire({
icon: "question",
title: "발주를 요청하시겠습니까?",
text: `합계 ₩${fmt(totals.total)} (품목 ${cart.length}, 부가 ${extras.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 })),
extras: extras.map((e) => ({ kind: e.kind, amount: Number(e.amount), label: e.label })),
}),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
setCart([]); setExtras([]);
router.push("/m/orders");
} else {
Swal.fire({ icon: "error", title: "오류", text: j.message });
}
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ===== 상단 sticky 카트 바 ===== */}
<div className="sticky top-0 z-20 bg-white border-2 border-emerald-300 rounded-xl shadow-lg mb-3 overflow-hidden">
<button
onClick={() => setCartOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 px-3 sm:px-4 py-2.5 hover:bg-emerald-50/40 transition"
>
<div className="flex items-center gap-2 font-bold text-slate-800 text-sm sm:text-base min-w-0">
<ShoppingCart size={16} className="text-emerald-700 shrink-0" />
<span className="truncate"> </span>
<span className="shrink-0 px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[11px] font-bold tabular-nums">
{cart.length + extras.length}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden md:inline text-xs text-violet-700 tabular-nums"> {fmt(totals.taxFree)}</span>
<span className="hidden md:inline text-xs text-rose-700 tabular-nums"> {fmt(totals.taxable)}</span>
<span className="text-sm sm:text-base font-bold text-emerald-700 tabular-nums">{fmt(totals.total)}</span>
<button
onClick={(e) => { e.stopPropagation(); submitOrder(); }}
disabled={cart.length === 0}
className="h-8 sm:h-9 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
>
</button>
<span className={`text-slate-400 text-xs transition-transform ${cartOpen ? "rotate-180" : ""}`}></span>
</div>
</button>
{cartOpen && (
<div className="border-t border-emerald-100 px-3 sm:px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
{/* 택배/용차 추가 버튼 */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => addExtra("DELIVERY")}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-orange-100 text-orange-700 text-xs font-bold hover:bg-orange-200"
>
<Truck size={13} /> +
</button>
<button
type="button"
onClick={() => addExtra("CHARTER")}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-sky-100 text-sky-700 text-xs font-bold hover:bg-sky-200"
>
<Package size={13} /> +
</button>
</div>
{(cart.length > 0 || extras.length > 0) && (
<button
onClick={() => { setCart([]); setExtras([]); }}
className="text-xs text-slate-400 hover:text-rose-500"
>
</button>
)}
</div>
{/* 택배/용차 라인 */}
{extras.length > 0 && (
<div className="space-y-1.5">
{extras.map((ex) => (
<div
key={ex.id}
className={`flex items-center gap-2 p-2 rounded-lg border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
{ex.kind === "DELIVERY" ? "택배" : "용차"}
</span>
<input
value={ex.label}
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
placeholder="담당자/메모"
className="flex-1 min-w-0 h-8 px-2 rounded border border-slate-200 text-xs bg-white"
/>
<input
type="number"
min={0}
value={ex.amount || ""}
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
placeholder="금액"
className="w-24 sm:w-32 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
/>
<button
onClick={() => removeExtra(ex.id)}
className="text-slate-300 hover:text-rose-500 shrink-0"
title="삭제"
>
<X size={14} />
</button>
</div>
))}
</div>
)}
{/* 품목 라인 */}
{cart.length === 0 && extras.length === 0 ? (
<div className="text-slate-400 text-sm text-center py-6">
<span className="font-bold text-emerald-700">+ </span> .
</div>
) : cart.length > 0 && (
<div className="grid sm:grid-cols-2 gap-2">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="bg-white 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 min-w-0">
<div className="truncate">{ln.item.ITEM_NAME}</div>
{ln.item.REQUIRES_DELIVERY === "Y" && (
<span className="inline-block mt-0.5 text-[9px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span>
)}
</div>
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500 shrink-0">
<X size={14} />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<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>
<input
type="number"
min={1}
value={ln.qty}
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
className="w-12 h-7 text-center text-sm font-bold tabular-nums border border-slate-200 rounded"
/>
<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 pt-2 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<Row label="총 합계" value={`${fmt(totals.total)}`} />
{totals.delivery > 0 && <Row label="택배비" value={`${fmt(totals.delivery)}`} color="orange" />}
{totals.charter > 0 && <Row label="용차비" value={`${fmt(totals.charter)}`} color="sky" />}
</div>
</div>
)}
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm: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-2 sm: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-3 sm: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 grid-cols-2 sm:grid-cols-2 xl:grid-cols-3 gap-2 sm:gap-3">
{items.map((it) => (
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition">
<div className="aspect-square bg-slate-50 rounded-lg mb-2 sm: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-1 mb-1">
<div className="font-bold text-xs sm:text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
<div className="flex flex-col gap-0.5 items-end shrink-0">
{it.IS_TAX_FREE === "Y" && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold"></span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold"></span>
)}
</div>
</div>
<div className="text-[11px] text-slate-500 mb-1.5 truncate">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline justify-between mb-1">
<div className="font-bold text-slate-900 tabular-nums text-sm">{fmt(it.UNIT_PRICE)}</div>
<div className={`text-[11px] font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{fmt(it.STOCK_QTY)}{it.UNIT}
</div>
</div>
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<div className="text-[10px] text-sky-700 mb-1">1 {fmt(it.MAX_ORDER_QTY)}</div>
)}
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="w-full mt-1.5 h-8 sm:h-9 rounded-lg bg-emerald-700 text-white text-[11px] sm: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={13} />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" | "orange" | "sky" }) {
const cls = color === "violet" ? "text-violet-700"
: color === "rose" ? "text-rose-700"
: color === "orange" ? "text-orange-700"
: color === "sky" ? "text-sky-700"
: "text-slate-700";
return (
<div className="flex justify-between">
<span className={cls}>{label}</span>
<span className="tabular-nums">{value}</span>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/store/auth-store";
import { Loading } from "@/components/ui/loading";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { MobileTopBar } from "@/components/layout/mobile-top-bar";
import { MobileBottomNav } from "@/components/layout/mobile-bottom-nav";
// 같은 /m/* 경로지만 화면 폭에 따라 다른 셸을 입힌다.
// md(768px) 이상 = PC: 기존 Sidebar + Header (관리자 와이드 뷰 — 운영 디자인 그대로)
// md 미만 = Mobile: MobileTopBar + BottomNav (대리점주/고령 사용자 친화)
// children 은 단 한 번만 렌더하고 Tailwind 반응형 클래스로 주변 셸만 토글한다.
//
// 메뉴/탭은 역할별 코드 분기 없음. 모든 사용자에게 동일하게 노출하고,
// 권한별 가시성은 추후 DB 권한 테이블로 처리한다.
export default function MobileLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, isLoading, fetchUser } = useAuthStore();
useEffect(() => {
fetchUser();
}, [fetchUser]);
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
}
}, [isLoading, user, router]);
if (isLoading) return <Loading message="불러오는 중..." />;
if (!user) return null;
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar — PC 전용 */}
<aside className="hidden md:flex shrink-0">
<Sidebar />
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top: PC 는 Header, 모바일은 MobileTopBar */}
<div className="hidden md:block shrink-0">
<Header />
</div>
<div className="md:hidden shrink-0">
<MobileTopBar />
</div>
{/*
본문 main:
- 모바일: overflow-y-auto 로 페이지 자체가 스크롤. 패딩 포함.
- PC: 기존 (main) 레이아웃과 동일 (overflow-hidden + flex flex-col min-h-0).
페이지 내부에서 sticky cart bar 등 자체 스크롤 패턴을 사용.
children 은 한 번만 렌더되며, 페이지 레벨에서 hidden md:flex / md:hidden 로 분기.
*/}
<main className="flex-1 overflow-y-auto bg-slate-50 px-4 pt-3 pb-4 md:overflow-hidden md:flex md:flex-col md:min-h-0 md:bg-transparent md:p-4">
{children}
</main>
{/* BottomNav — 모바일 전용 */}
<div className="md:hidden shrink-0">
<MobileBottomNav />
</div>
</div>
</div>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
LogOut, ChevronRight, ShieldCheck, ShieldOff,
LayoutDashboard, TrendingUp, FolderKanban, Package, ListChecks,
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
Headset, Clock, Calculator, Coins, Truck, Settings,
ClipboardCheck, Compass, GitBranch, Puzzle, Stamp, Folder,
} from "lucide-react";
import Swal from "sweetalert2";
import { useAuthStore } from "@/store/auth-store";
import { mapMenuUrl } from "@/lib/menu-url-map";
import { MENU_ICON_MAP } from "@/lib/constants";
import type { MenuItem } from "@/types";
// PC 사이드바와 동일한 아이콘 매핑 (sidebar.tsx 와 같은 로직)
const ICON_COMPONENTS: Record<string, React.ElementType> = {
LayoutDashboard, TrendingUp, FolderKanban, Package, ListChecks,
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
Headset, UserClock: Clock, Calculator, Coins, Truck, Settings,
ClipboardCheck, Compass, GitBranch, Puzzle, Stamp,
};
function getMenuIcon(menuName: string): React.ElementType {
for (const [key, iconName] of Object.entries(MENU_ICON_MAP)) {
if (menuName?.toUpperCase().includes(key.toUpperCase())) {
return ICON_COMPONENTS[iconName] || Folder;
}
}
return Folder;
}
interface MenuGroup {
objid: string;
name: string;
children: { objid: string; name: string; href: string }[];
}
const ADMIN_MODE_KEY = "momo_admin_mode";
export default function MorePage() {
const { user, logout } = useAuthStore();
const [companyName, setCompanyName] = useState<string | null>(null);
// 모드별로 메뉴 그룹을 따로 들고 토글로 노출 전환.
const [userGroups, setUserGroups] = useState<MenuGroup[]>([]);
const [adminGroups, setAdminGroups] = useState<MenuGroup[]>([]);
// 'user' | 'admin' — admin 만 토글 가능. 새로고침 후 유지를 위해 localStorage 동기화.
const [mode, setMode] = useState<"user" | "admin">("user");
const [loaded, setLoaded] = useState(false);
// 모드 초기 로드 (localStorage)
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem(ADMIN_MODE_KEY);
if (saved === "admin") setMode("admin");
}, []);
useEffect(() => {
fetch("/api/auth/profile")
.then((r) => r.json())
.then((j) => {
if (j.success) setCompanyName(j.data?.USER_NAME ?? null);
})
.catch(() => {});
}, []);
// PC 사이드바와 동일한 소스(DB MENU_INFO) 에서 메뉴 트리를 받아온다.
// "관리자" top menu 의 자식 = 시스템 관리(menu_type=0) 메뉴들 → adminGroups
// 그 외 top menu 들의 자식 = 운영 메뉴(menu_type=1) → userGroups
useEffect(() => {
const load = async () => {
const topRes = await fetch("/api/menu/top");
if (!topRes.ok) {
setLoaded(true);
return;
}
const topJson = await topRes.json();
const tops: { OBJID: string; MENU_NAME_KOR: string }[] = topJson.menus || [];
const buildGroupsFor = async (topObjId: string): Promise<MenuGroup[]> => {
const sideRes = await fetch("/api/menu", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ MENUOBJID: topObjId }),
});
if (!sideRes.ok) return [];
const sideJson = await sideRes.json();
const items = (sideJson.RESULT || []) as MenuItem[];
const parents = items.filter((i) => i.level === "1" || Number(i.level) < 2);
const children = items.filter((i) => i.level !== "1" && Number(i.level) >= 2);
const result: MenuGroup[] = [];
for (const p of parents) {
const kids = children.filter((c) => c.parentObjId === p.objid);
// 동일 이름 중복 제거 (sidebar 와 동일 규칙)
const seen = new Set<string>();
const uniqueKids = kids.filter((c) => {
if (seen.has(c.menuNameKor)) return false;
seen.add(c.menuNameKor);
return true;
});
if (uniqueKids.length === 0) continue;
result.push({
objid: p.objid,
name: p.menuNameKor,
children: uniqueKids.map((c) => ({
objid: c.objid,
name: c.menuNameKor,
href: mapMenuUrl(c.menuUrl) || "#",
})),
});
}
return result;
};
const adminTop = tops.find((m) => m.MENU_NAME_KOR === "관리자");
const userTops = tops.filter((m) => m.MENU_NAME_KOR !== "관리자");
const userResults: MenuGroup[] = [];
for (const t of userTops) {
const g = await buildGroupsFor(t.OBJID);
userResults.push(...g);
}
setUserGroups(userResults);
if (adminTop) {
// 관리자 메뉴 항목은 /admin-panel?menuId={리프 menu objid} 로 통일.
// (admin-panel 페이지가 menuId 쿼리에 따라 내부 탭 전환)
const raw = await buildGroupsFor(adminTop.OBJID);
const remapped: MenuGroup[] = raw.map((g) => ({
...g,
children: g.children.map((c) => ({
...c,
href: `/admin-panel?menuId=${c.objid}`,
})),
}));
setAdminGroups(remapped);
}
setLoaded(true);
};
load();
}, []);
const onLogout = async () => {
const ok = await Swal.fire({
icon: "question",
title: "로그아웃 하시겠어요?",
showCancelButton: true,
confirmButtonText: "로그아웃",
cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
await logout();
};
const toggleMode = () => {
setMode((prev) => {
const next = prev === "admin" ? "user" : "admin";
if (typeof window !== "undefined") {
window.localStorage.setItem(ADMIN_MODE_KEY, next);
}
return next;
});
};
const canShowAdminToggle = user?.isAdmin && adminGroups.length > 0;
const groups = mode === "admin" ? adminGroups : userGroups;
const isAdminMode = mode === "admin";
return (
<div className="space-y-5">
{/* 사용자 카드 — 관리자 모드일 땐 amber 톤으로 시각 구분 */}
<div
className={
isAdminMode
? "bg-gradient-to-br from-amber-600 to-amber-500 rounded-2xl px-5 py-6 text-white shadow-md"
: "bg-gradient-to-br from-emerald-700 to-emerald-600 rounded-2xl px-5 py-6 text-white shadow-md"
}
>
<div className="flex items-center gap-2 text-sm mb-1 opacity-90">
{isAdminMode ? (
<>
<ShieldCheck size={16} />
<span className="font-semibold tracking-wide"> </span>
</>
) : null}
</div>
<div className="text-2xl font-bold tracking-tight">
{companyName ?? user?.userName ?? "사용자"}
</div>
<div className={isAdminMode ? "text-amber-50/90 text-sm mt-1" : "text-emerald-100/90 text-sm mt-1"}>
{user?.userId}
</div>
</div>
{/*
관리자 토글 버튼 — user.isAdmin 이고 관리자 메뉴 그룹이 있을 때만 노출.
ON 일 땐 메뉴바가 관리자 메뉴(권한·사용자·기준정보 관리 등)로 교체.
*/}
{canShowAdminToggle && (
<button
onClick={toggleMode}
className={
"w-full flex items-center justify-between px-5 h-16 rounded-2xl border-2 transition shadow-sm " +
(isAdminMode
? "bg-emerald-50 border-emerald-300 active:bg-emerald-100"
: "bg-amber-50 border-amber-300 active:bg-amber-100")
}
>
<div className="flex items-center gap-3">
{isAdminMode ? (
<ShieldOff size={24} className="text-emerald-700" />
) : (
<ShieldCheck size={24} className="text-amber-600" />
)}
<div className="text-left">
<div
className={
"text-base font-extrabold " +
(isAdminMode ? "text-emerald-800" : "text-amber-800")
}
>
{isAdminMode ? "사용자 메뉴로 돌아가기" : "관리자 메뉴 보기"}
</div>
<div
className={
"text-[11px] " +
(isAdminMode ? "text-emerald-700/80" : "text-amber-700/80")
}
>
{isAdminMode
? "거래처 주문 · 마스터 · 매입 · 출고 · 통계"
: "권한 · 부서 · 사용자 · 기준정보 관리"}
</div>
</div>
</div>
<ChevronRight size={20} className={isAdminMode ? "text-emerald-500" : "text-amber-500"} />
</button>
)}
{!loaded ? (
<div className="text-slate-400 text-sm text-center py-8"> ...</div>
) : groups.length === 0 ? (
<div className="text-slate-400 text-sm text-center py-8">
{isAdminMode ? "관리자 메뉴가 없습니다." : "표시할 메뉴가 없습니다."}
</div>
) : (
groups.map((group) => (
<Section key={group.objid} title={group.name}>
{group.children.map((it) => (
<MenuItemRow
key={it.objid}
href={it.href}
label={it.name}
Icon={getMenuIcon(it.name)}
/>
))}
</Section>
))
)}
<Section title="계정">
<button
onClick={onLogout}
className="w-full flex items-center justify-between px-5 h-16 bg-white rounded-xl border border-slate-200 active:bg-rose-50 transition"
>
<div className="flex items-center gap-3">
<LogOut size={22} className="text-rose-600" />
<span className="text-base font-semibold text-rose-600"></span>
</div>
</button>
</Section>
<div className="text-center text-xs text-slate-400 pt-4 pb-2">
ERP
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-xs font-bold text-slate-500 mb-2 px-2 tracking-wider uppercase">
{title}
</div>
<div className="space-y-2">{children}</div>
</div>
);
}
function MenuItemRow({ href, label, Icon }: { href: string; label: string; Icon: React.ElementType }) {
return (
<Link
href={href}
className="flex items-center justify-between px-5 h-16 bg-white rounded-xl border border-slate-200 active:bg-emerald-50 transition"
>
<div className="flex items-center gap-3">
<Icon size={22} className="text-emerald-700" />
<span className="text-base font-semibold text-slate-800">{label}</span>
</div>
<ChevronRight size={20} className="text-slate-400" />
</Link>
);
}
+914
View File
@@ -0,0 +1,914 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Search, ShoppingCart, Plus, Minus, X, LayoutGrid, List, Truck, Package } 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;
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
}
interface CartLine { item: Item; qty: number }
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; amount: number; label: string }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
// 모바일 발주 페이지의 뷰 모드 (card/list) 사용자 선택 기억
const VIEW_MODE_KEY = "momo_orders_new_view_mode";
// PC 와 모바일에 서로 다른 디자인을 보여주지만, state(cart, items 등)는 부모에서 한 번만 관리.
// 두 트리는 hidden md:flex / md:hidden 로 분기되며, 보이는 한 쪽만 사용자가 만진다.
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 [extras, setExtras] = useState<ExtraLine[]>([]);
const [cartOpen, setCartOpen] = useState(false);
// 모바일 뷰 모드 — card(기본, 2열 그리드) / list(가로 한 줄)
const [viewMode, setViewMode] = useState<"card" | "list">("card");
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem(VIEW_MODE_KEY);
if (saved === "list" || saved === "card") setViewMode(saved);
}, []);
const changeViewMode = (m: "card" | "list") => {
setViewMode(m);
if (typeof window !== "undefined") window.localStorage.setItem(VIEW_MODE_KEY, m);
};
const fetchItems = useCallback(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);
}, [keyword, taxFilter]);
useEffect(() => { fetchItems(); }, []); // eslint-disable-line
// 카트에 택배전용 품목이 있는지
const cartNeedsDelivery = useMemo(
() => cart.some((c) => c.item.REQUIRES_DELIVERY === "Y"),
[cart]
);
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
// 택배전용 품목이 카트에 들어왔는데 택배 라인이 없으면 자동 한 줄 추가
useEffect(() => {
if (cartNeedsDelivery && !hasDeliveryLine) {
setExtras((prev) => [
{ id: newKey(), kind: "DELIVERY", amount: 0, label: "택배비" },
...prev,
]);
}
}, [cartNeedsDelivery, hasDeliveryLine]);
const addToCart = (item: Item) => {
setCart((c) => {
const found = c.find((x) => x.item.OBJID === item.OBJID);
const stock = Number(item.STOCK_QTY);
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
if (found) {
if (found.qty + 1 > limit) {
Swal.fire({
icon: "warning",
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}` : `재고는 ${fmt(stock)}`}입니다.`,
});
return c;
}
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
}
return [...c, { item, qty: 1 }];
});
Swal.fire({
toast: true, position: "top-end", icon: "success",
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
showConfirmButton: false, timer: 1200, timerProgressBar: true,
});
};
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;
const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
if (newQty > limit) return x;
return { ...x, qty: newQty };
})
);
};
const setQty = (objid: string, value: number) => {
setCart((c) =>
c.map((x) => {
if (x.item.OBJID !== objid) return x;
const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
return { ...x, qty: clamped };
})
);
};
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
const addExtra = (kind: "DELIVERY" | "CHARTER") => {
setExtras((p) => [...p, { id: newKey(), kind, amount: 0, label: kind === "DELIVERY" ? "택배비" : "용차비" }]);
};
const updateExtra = (id: string, field: keyof ExtraLine, value: string | number) => {
setExtras((p) => p.map((e) => (e.id === id ? { ...e, [field]: value } as ExtraLine : e)));
};
const removeExtra = (id: string) => {
const target = extras.find((e) => e.id === id);
if (target?.kind === "DELIVERY" && cartNeedsDelivery) {
Swal.fire({ icon: "warning", title: "택배 전용 품목이 있어 택배 라인을 제거할 수 없습니다." });
return;
}
setExtras((p) => p.filter((e) => e.id !== id));
};
const totals = useMemo(() => {
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0, delivery = 0, charter = 0, count = 0;
for (const ln of cart) {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
total += lineTotal;
count += ln.qty;
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;
}
}
for (const ex of extras) {
const amt = Number(ex.amount) || 0;
total += amt;
const s = Math.round(amt / 1.1);
supply += s;
vat += amt - s;
taxable += s;
if (ex.kind === "DELIVERY") delivery += amt;
if (ex.kind === "CHARTER") charter += amt;
}
return { supply, vat, total, taxFree, taxable, delivery, charter, count };
}, [cart, extras]);
const submitOrder = async () => {
if (cart.length === 0) {
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
return;
}
if (cartNeedsDelivery && !hasDeliveryLine) {
Swal.fire({ icon: "warning", title: "택배 전용 품목이 포함되어 택배 라인이 필요합니다." });
return;
}
if (extras.some((e) => Number(e.amount) <= 0)) {
Swal.fire({ icon: "warning", title: "택배/용차 금액을 입력하세요." });
return;
}
const ok = await Swal.fire({
icon: "question",
title: "발주를 요청하시겠습니까?",
text: `합계 ₩${fmt(totals.total)} (품목 ${cart.length}, 부가 ${extras.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 })),
extras: extras.map((e) => ({ kind: e.kind, amount: Number(e.amount), label: e.label })),
}),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
setCart([]); setExtras([]);
router.push("/m/orders");
} else {
Swal.fire({ icon: "error", title: "오류", text: j.message });
}
};
return (
<>
{/* ============================================================ */}
{/* PC 뷰 — 운영서버 momotogether.com 디자인 그대로 */}
{/* ============================================================ */}
<div className="hidden md:flex flex-col h-full overflow-hidden flex-1 min-h-0">
{/* 상단 sticky 카트 바 */}
<div className="sticky top-0 z-20 bg-white border-2 border-emerald-300 rounded-xl shadow-lg mb-3 overflow-hidden">
{/*
HTML 스펙상 <button> 안에 <button> 을 넣을 수 없어 React 19 가 hydration 에러를 띄움.
토글 영역을 <div role="button"> 로 바꿔서 동작은 동일하게 유지하되 nesting 만 풀었다.
*/}
<div
role="button"
tabIndex={0}
onClick={() => setCartOpen((v) => !v)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setCartOpen((v) => !v);
}
}}
className="w-full flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-emerald-50/40 transition cursor-pointer select-none"
>
<div className="flex items-center gap-2 font-bold text-slate-800">
<ShoppingCart size={18} className="text-emerald-700" />
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-bold tabular-nums">
{cart.length + extras.length}
</span>
</div>
<div className="flex items-center gap-3">
<span className="hidden md:inline text-xs text-violet-700 tabular-nums"> {fmt(totals.taxFree)}</span>
<span className="hidden md:inline text-xs text-rose-700 tabular-nums"> {fmt(totals.taxable)}</span>
<span className="text-base font-bold text-emerald-700 tabular-nums">{fmt(totals.total)}</span>
<button
onClick={(e) => { e.stopPropagation(); submitOrder(); }}
disabled={cart.length === 0}
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
>
</button>
<span className={`text-slate-400 text-xs transition-transform ${cartOpen ? "rotate-180" : ""}`}></span>
</div>
</div>
{cartOpen && (
<div className="border-t border-emerald-100 px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
{/* 택배/용차 추가 버튼 */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => addExtra("DELIVERY")}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-orange-100 text-orange-700 text-xs font-bold hover:bg-orange-200"
>
<Truck size={13} /> +
</button>
<button
type="button"
onClick={() => addExtra("CHARTER")}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-sky-100 text-sky-700 text-xs font-bold hover:bg-sky-200"
>
<Package size={13} /> +
</button>
</div>
{(cart.length > 0 || extras.length > 0) && (
<button
onClick={() => { setCart([]); setExtras([]); }}
className="text-xs text-slate-400 hover:text-rose-500"
>
</button>
)}
</div>
{/* 택배/용차 라인 */}
{extras.length > 0 && (
<div className="space-y-1.5">
{extras.map((ex) => (
<div
key={ex.id}
className={`flex items-center gap-2 p-2 rounded-lg border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
{ex.kind === "DELIVERY" ? "택배" : "용차"}
</span>
<input
value={ex.label}
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
placeholder="담당자/메모"
className="flex-1 min-w-0 h-8 px-2 rounded border border-slate-200 text-xs bg-white"
/>
<input
type="number"
min={0}
value={ex.amount || ""}
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
placeholder="금액"
className="w-24 sm:w-32 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
/>
<button
onClick={() => removeExtra(ex.id)}
className="text-slate-300 hover:text-rose-500 shrink-0"
title="삭제"
>
<X size={14} />
</button>
</div>
))}
</div>
)}
{/* 품목 라인 */}
{cart.length === 0 && extras.length === 0 ? (
<div className="text-slate-400 text-sm text-center py-6">
<span className="font-bold text-emerald-700">+ </span> .
</div>
) : cart.length > 0 && (
<div className="grid sm:grid-cols-2 gap-2">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="bg-white 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 min-w-0">
<div className="truncate">{ln.item.ITEM_NAME}</div>
{ln.item.REQUIRES_DELIVERY === "Y" && (
<span className="inline-block mt-0.5 text-[9px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span>
)}
</div>
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500 shrink-0">
<X size={14} />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<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>
<input
type="number"
min={1}
value={ln.qty}
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
className="w-12 h-7 text-center text-sm font-bold tabular-nums border border-slate-200 rounded"
/>
<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 pt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<Row label="총 합계" value={`${fmt(totals.total)}`} />
{totals.delivery > 0 && <Row label="택배비" value={`${fmt(totals.delivery)}`} color="orange" />}
{totals.charter > 0 && <Row label="용차비" value={`${fmt(totals.charter)}`} color="sky" />}
</div>
</div>
)}
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<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>
{/* 카드 / 리스트 뷰 토글 — PC. viewMode state 는 모바일 트리와 공유 */}
{!loading && items.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-slate-500"> {items.length}</div>
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => changeViewMode("card")}
className={
"h-8 px-3 rounded-md flex items-center gap-1.5 text-xs font-bold transition " +
(viewMode === "card"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:text-slate-700")
}
aria-pressed={viewMode === "card"}
>
<LayoutGrid size={14} strokeWidth={2.4} />
</button>
<button
onClick={() => changeViewMode("list")}
className={
"h-8 px-3 rounded-md flex items-center gap-1.5 text-xs font-bold transition " +
(viewMode === "list"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:text-slate-700")
}
aria-pressed={viewMode === "list"}
>
<List size={14} strokeWidth={2.4} />
</button>
</div>
</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>
) : viewMode === "card" ? (
<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>
<div className="flex flex-col gap-0.5 items-end shrink-0">
{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>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold"></span>
)}
</div>
</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>
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<div className="text-[10px] text-sky-700 mt-1">1 {fmt(it.MAX_ORDER_QTY)}</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>
) : (
/* PC 리스트 뷰 — 가로 한 줄. 여유 공간이 더 많으니 정보를 펼쳐 표시. */
<div className="space-y-2">
{items.map((it) => (
<div
key={it.OBJID}
className="flex items-center gap-4 bg-white border border-slate-200 rounded-xl p-3 hover:shadow-md transition"
>
<div className="w-16 h-16 rounded-lg bg-slate-50 overflow-hidden flex items-center justify-center shrink-0">
{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-[10px]"> </div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<div className="font-bold text-sm text-slate-900 truncate">{it.ITEM_NAME}</div>
{it.IS_TAX_FREE === "Y" && (
<span className="shrink-0 px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="shrink-0 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold"></span>
)}
</div>
<div className="text-xs text-slate-500 truncate">{it.MAKER_NAME || "-"}</div>
</div>
<div className="text-right shrink-0 w-32">
<div className="font-bold text-base 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>
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<div className="text-[10px] text-sky-700">1 {fmt(it.MAX_ORDER_QTY)}</div>
)}
</div>
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="h-10 px-4 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 shrink-0"
>
<Plus size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* ============================================================ */}
{/* 모바일 뷰 — 어르신 친화 (큰 글씨/터치, 하단 floating 카트) */}
{/* ============================================================ */}
<div className="md:hidden space-y-3">
<div>
<h1 className="text-xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-sm mt-1"> .</p>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search size={18} 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-12 pl-10 pr-3 rounded-xl border border-slate-200 text-base focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/15 outline-none"
/>
</div>
<button onClick={fetchItems} className="h-12 px-5 rounded-xl bg-emerald-700 text-white text-base font-bold active:bg-emerald-800">
</button>
</div>
{/* 카드 / 리스트 뷰 모드 토글 — 결과 개수와 함께 같은 줄에 배치 */}
{!loading && items.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-slate-500"> {items.length}</div>
<div className="flex bg-slate-100 rounded-xl p-1">
<button
onClick={() => changeViewMode("card")}
className={
"h-10 px-3 rounded-lg flex items-center gap-1.5 text-sm font-bold transition " +
(viewMode === "card"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 active:text-slate-700")
}
aria-pressed={viewMode === "card"}
>
<LayoutGrid size={16} strokeWidth={2.4} />
</button>
<button
onClick={() => changeViewMode("list")}
className={
"h-10 px-3 rounded-lg flex items-center gap-1.5 text-sm font-bold transition " +
(viewMode === "list"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 active:text-slate-700")
}
aria-pressed={viewMode === "list"}
>
<List size={16} strokeWidth={2.4} />
</button>
</div>
</div>
)}
{loading ? (
<div className="text-slate-400 text-center py-16"> ...</div>
) : items.length === 0 ? (
<div className="text-slate-400 text-center py-16 bg-white rounded-xl border border-slate-100">
.
</div>
) : viewMode === "card" ? (
<div className="grid grid-cols-2 gap-3 pb-32">
{items.map((it) => (
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-2xl p-3 flex flex-col">
<div className="aspect-square bg-slate-50 rounded-xl 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-1">
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-2 min-h-[2.5rem] flex-1 min-w-0">
{it.ITEM_NAME}
</div>
<div className="flex flex-col gap-0.5 items-end shrink-0">
{it.IS_TAX_FREE === "Y" && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold"></span>
)}
</div>
</div>
<div className="text-sm text-slate-500 mt-1 truncate">{it.MAKER_NAME || "-"}</div>
<div className="font-extrabold text-slate-900 tabular-nums text-lg mt-2">
{fmt(it.UNIT_PRICE)}
</div>
<div className={`text-sm font-semibold mt-1 ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{Number(it.STOCK_QTY) > 0 ? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}` : "재고 없음"}
</div>
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<div className="text-xs text-sky-700 mt-0.5">1 {fmt(it.MAX_ORDER_QTY)}</div>
)}
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="w-full mt-3 h-12 rounded-xl bg-emerald-700 text-white text-base font-bold active:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1.5"
>
<Plus size={18} strokeWidth={2.5} />
</button>
</div>
))}
</div>
) : (
/* 리스트 뷰 — 가로 한 줄, 썸네일+정보+담기 버튼 */
<div className="space-y-2 pb-32">
{items.map((it) => (
<div
key={it.OBJID}
className="flex items-center gap-3 bg-white border border-slate-200 rounded-2xl p-3"
>
<div className="w-20 h-20 rounded-xl bg-slate-50 overflow-hidden flex items-center justify-center shrink-0">
{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-[10px] text-center px-1"> </div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-1 flex-1 min-w-0">
{it.ITEM_NAME}
</div>
{it.IS_TAX_FREE === "Y" && (
<span className="shrink-0 px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="shrink-0 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-bold"></span>
)}
</div>
<div className="text-xs text-slate-500 truncate mt-0.5">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline gap-2 mt-1.5 flex-wrap">
<div className="font-extrabold text-slate-900 tabular-nums text-lg">
{fmt(it.UNIT_PRICE)}
</div>
<div
className={
"text-xs font-semibold tabular-nums " +
(Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500")
}
>
{Number(it.STOCK_QTY) > 0
? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}`
: "재고 없음"}
</div>
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<div className="text-xs text-sky-700">1 {fmt(it.MAX_ORDER_QTY)}</div>
)}
</div>
</div>
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="h-12 px-4 rounded-xl bg-emerald-700 text-white text-sm font-bold active:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1 shrink-0"
>
<Plus size={16} strokeWidth={2.5} />
</button>
</div>
))}
</div>
)}
{/* 모바일 floating 카트 바 (BottomNav 위에 위치) */}
<div className="fixed inset-x-0 bottom-16 mx-auto max-w-[480px] z-20 pb-[env(safe-area-inset-bottom)] pointer-events-none">
<div className="px-3 pointer-events-auto">
<div className="bg-white border-2 border-emerald-300 rounded-2xl shadow-2xl overflow-hidden">
<button
onClick={() => setCartOpen((v) => !v)}
className="w-full flex items-center justify-between gap-3 px-4 py-3 active:bg-emerald-50/40"
>
<div className="flex items-center gap-2 font-bold text-slate-800">
<ShoppingCart size={22} className="text-emerald-700" />
<span className="text-base"></span>
<span className="px-2.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-sm font-extrabold tabular-nums min-w-[28px] text-center">
{cart.length + extras.length}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-lg font-extrabold text-emerald-700 tabular-nums">
{fmt(totals.total)}
</span>
<span className={`text-slate-400 text-base transition-transform ${cartOpen ? "rotate-180" : ""}`}></span>
</div>
</button>
{cartOpen && (
<div className="border-t border-emerald-100 px-3 py-3 max-h-[60vh] overflow-y-auto bg-slate-50/60 space-y-3">
{/* 택배/용차 추가 버튼 + 전체 비우기 */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => addExtra("DELIVERY")}
className="inline-flex items-center gap-1 h-10 px-3 rounded-lg bg-orange-100 text-orange-700 text-sm font-bold active:bg-orange-200"
>
<Truck size={16} /> +
</button>
<button
type="button"
onClick={() => addExtra("CHARTER")}
className="inline-flex items-center gap-1 h-10 px-3 rounded-lg bg-sky-100 text-sky-700 text-sm font-bold active:bg-sky-200"
>
<Package size={16} /> +
</button>
</div>
{(cart.length > 0 || extras.length > 0) && (
<button
onClick={() => { setCart([]); setExtras([]); }}
className="text-sm text-slate-500 active:text-rose-500 px-2 py-1"
>
</button>
)}
</div>
{/* 택배/용차 라인 */}
{extras.length > 0 && (
<div className="space-y-2">
{extras.map((ex) => (
<div
key={ex.id}
className={`flex items-center gap-2 p-2 rounded-xl border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
>
<span className={`text-xs font-bold px-2 py-1 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
{ex.kind === "DELIVERY" ? "택배" : "용차"}
</span>
<input
value={ex.label}
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
placeholder="담당자/메모"
className="flex-1 min-w-0 h-10 px-2 rounded-lg border border-slate-200 text-sm bg-white"
/>
<input
type="number"
min={0}
value={ex.amount || ""}
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
placeholder="금액"
className="w-24 h-10 px-2 rounded-lg border border-slate-200 text-sm text-right tabular-nums bg-white"
/>
<button
onClick={() => removeExtra(ex.id)}
className="w-9 h-9 flex items-center justify-center text-slate-400 active:text-rose-500 shrink-0"
aria-label="삭제"
>
<X size={18} />
</button>
</div>
))}
</div>
)}
{/* 품목 라인 */}
{cart.length === 0 && extras.length === 0 ? (
<div className="text-slate-500 text-base text-center py-6">
<span className="font-bold text-emerald-700"></span> .
</div>
) : cart.length > 0 && (
<div className="space-y-2">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="bg-white border border-slate-200 rounded-xl p-3">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="text-base font-semibold leading-snug flex-1 min-w-0">
<div>{ln.item.ITEM_NAME}</div>
{ln.item.REQUIRES_DELIVERY === "Y" && (
<span className="inline-block mt-0.5 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span>
)}
</div>
<button
onClick={() => removeLine(ln.item.OBJID)}
className="w-9 h-9 -m-1 flex items-center justify-center text-slate-400 active:text-rose-500 shrink-0"
aria-label="삭제"
>
<X size={20} />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
onClick={() => updateQty(ln.item.OBJID, -1)}
className="w-11 h-11 rounded-lg bg-slate-100 active:bg-slate-200 flex items-center justify-center"
aria-label="수량 감소"
>
<Minus size={20} strokeWidth={2.5} />
</button>
<input
type="number"
min={1}
value={ln.qty}
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
className="w-14 h-11 text-center text-lg font-extrabold tabular-nums border border-slate-200 rounded-lg"
/>
<button
onClick={() => updateQty(ln.item.OBJID, 1)}
className="w-11 h-11 rounded-lg bg-slate-100 active:bg-slate-200 flex items-center justify-center"
aria-label="수량 증가"
>
<Plus size={20} strokeWidth={2.5} />
</button>
</div>
<div className="text-base font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
</div>
);
})}
</div>
)}
{/* 합계 */}
{(cart.length > 0 || extras.length > 0) && (
<div className="border-t border-slate-200 pt-2 grid grid-cols-2 gap-2 text-sm">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<Row label="총 합계" value={`${fmt(totals.total)}`} />
{totals.delivery > 0 && <Row label="택배비" value={`${fmt(totals.delivery)}`} color="orange" />}
{totals.charter > 0 && <Row label="용차비" value={`${fmt(totals.charter)}`} color="sky" />}
</div>
)}
<button
onClick={submitOrder}
disabled={cart.length === 0}
className="w-full mt-1 h-14 rounded-xl bg-emerald-700 text-white text-lg font-extrabold active:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<ShoppingCart size={20} /> ({totals.count})
</button>
</div>
)}
</div>
</div>
</div>
</div>
</>
);
}
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" | "orange" | "sky" }) {
const cls = color === "violet" ? "text-violet-700"
: color === "rose" ? "text-rose-700"
: color === "orange" ? "text-orange-700"
: color === "sky" ? "text-sky-700"
: "text-slate-700";
return (
<div className="flex justify-between">
<span className={cls}>{label}</span>
<span className="tabular-nums">{value}</span>
</div>
);
}
+33 -1
View File
@@ -121,7 +121,14 @@ export default function AdminPanelPage() {
}, []);
return (
<div className="flex h-screen bg-gray-100">
<>
{/*
모바일(md 미만)에서 진입했을 때 안내 — admin-panel 자체는 1630×950 데스크탑 팝업 전용.
PC 동작에는 영향 없음 (md 이상에서는 hidden).
*/}
<MobilePcOnlyNotice />
<div className="hidden md:flex h-screen bg-gray-100">
{/* 좌측 메뉴 (adminMenu.jsp 대응) */}
<aside className="w-[220px] bg-[#2a2a2a] text-gray-300 flex flex-col shrink-0">
<div className="px-4 py-3 border-b border-white/10">
@@ -221,6 +228,31 @@ export default function AdminPanelPage() {
)}
</main>
</div>
</>
);
}
// 모바일에서 admin-panel 진입 시 안내. md+ 에선 hidden.
function MobilePcOnlyNotice() {
return (
<div className="md:hidden min-h-screen bg-slate-50 flex flex-col items-center justify-center px-6 py-10 text-center">
<div className="w-20 h-20 rounded-full bg-amber-100 flex items-center justify-center mb-5">
<Shield size={40} className="text-amber-600" />
</div>
<h1 className="text-2xl font-extrabold text-slate-900 mb-2"> </h1>
<p className="text-slate-600 text-base leading-relaxed max-w-xs mb-1">
<span className="font-bold">PC에서만</span> .
</p>
<p className="text-slate-500 text-sm leading-relaxed max-w-xs mb-8">
PC로 ··· .
</p>
<a
href="/m/more"
className="inline-flex items-center justify-center gap-2 h-12 px-6 rounded-xl bg-emerald-700 text-white text-base font-bold active:bg-emerald-800 hover:bg-emerald-800 transition shadow-sm"
>
</a>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { SignJWT } from "jose";
import {
exchangeCodeForToken,
fetchKakaoProfile,
findMomoUserByKakaoId,
findUserInfoForKakaoLink,
linkKakaoIdToUser,
} from "@/lib/kakao-auth";
import { findMomoUserByEmail } from "@/lib/momo-auth";
import { createSession } from "@/lib/session";
import type { User } from "@/types";
const STATE_COOKIE = "kakao-oauth-state";
const PENDING_COOKIE = "kakao-signup-pending";
const PENDING_TTL_SEC = 10 * 60;
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "fito-plm-default-secret"
);
export async function GET(request: NextRequest) {
const sp = request.nextUrl.searchParams;
const code = sp.get("code");
const state = sp.get("state");
const err = sp.get("error");
// 사용자가 카카오 동의 화면에서 취소
if (err) {
return redirectWithError(request, "카카오 로그인이 취소되었습니다.");
}
if (!code || !state) {
return redirectWithError(request, "잘못된 요청입니다.");
}
const cookieStore = await cookies();
const savedState = cookieStore.get(STATE_COOKIE)?.value;
cookieStore.delete(STATE_COOKIE);
if (!savedState || savedState !== state) {
return redirectWithError(request, "카카오 인증 세션이 만료되었습니다. 다시 시도해주세요.");
}
let profile;
try {
const token = await exchangeCodeForToken(code);
profile = await fetchKakaoProfile(token);
} catch (e) {
console.error("[kakao/callback]", e);
return redirectWithError(request, "카카오 인증 처리 중 오류가 발생했습니다.");
}
// 1) kakao_id 로 기존 연동 사용자 조회 → 즉시 로그인
const linked = await findMomoUserByKakaoId(profile.kakaoId);
if (linked) {
await createSession(toSessionUser(linked));
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}
// 2) 카카오 이메일과 일치하는 기존 일반 가입자가 있으면 자동 연결 후 로그인
// (카카오 OAuth 가 이메일 소유를 검증했으므로 안전)
if (profile.email) {
const candidate = await findUserInfoForKakaoLink(profile.email);
if (candidate && !candidate.existingKakaoId) {
await linkKakaoIdToUser(candidate.userId, profile.kakaoId);
const fresh = await findMomoUserByEmail(profile.email);
if (fresh) {
await createSession(toSessionUser(fresh));
return NextResponse.redirect(new URL("/m/dashboard", request.url));
}
}
if (candidate && candidate.existingKakaoId && candidate.existingKakaoId !== profile.kakaoId) {
return redirectWithError(request, "해당 이메일은 다른 카카오 계정에 연결되어 있습니다.");
}
}
// 3) 신규 사용자 — 카카오 정보를 단기 JWT 쿠키에 담아 추가정보 입력 페이지로
// email 은 카카오에서 받았으면 채워두고, 못 받았으면 빈 문자열 (가입 페이지에서 직접 입력받음)
const pendingToken = await new SignJWT({
purpose: "kakao-signup",
kakaoId: profile.kakaoId,
email: profile.email ?? "",
nickname: profile.nickname ?? "",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${PENDING_TTL_SEC}s`)
.sign(SECRET);
cookieStore.set(PENDING_COOKIE, pendingToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: PENDING_TTL_SEC,
path: "/",
});
return NextResponse.redirect(new URL("/signup/kakao", request.url));
}
function redirectWithError(request: NextRequest, message: string): NextResponse {
const url = new URL("/login", request.url);
url.searchParams.set("kakao_error", message);
return NextResponse.redirect(url);
}
function toSessionUser(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,
};
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { signupKakaoUser } from "@/lib/kakao-auth";
import { findMomoUserByEmail } from "@/lib/momo-auth";
import { createSession } from "@/lib/session";
import type { User } from "@/types";
const PENDING_COOKIE = "kakao-signup-pending";
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "fito-plm-default-secret"
);
interface PendingPayload {
purpose: string;
kakaoId: string;
email: string;
nickname?: string;
}
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: bodyEmail, companyName, ceoName, bizNo, phone, address } = body;
if (!companyName || !phone || !address) {
return NextResponse.json(
{ success: false, message: "업체명·연락처·주소는 필수입니다." },
{ status: 400 }
);
}
const cookieStore = await cookies();
const token = cookieStore.get(PENDING_COOKIE)?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: "카카오 인증 세션이 없습니다. 다시 로그인해주세요." },
{ status: 401 }
);
}
let payload: PendingPayload;
try {
const { payload: p } = await jwtVerify(token, SECRET);
payload = p as unknown as PendingPayload;
if (payload.purpose !== "kakao-signup") throw new Error("invalid purpose");
} catch {
cookieStore.delete(PENDING_COOKIE);
return NextResponse.json(
{ success: false, message: "세션이 만료되었습니다. 카카오 로그인을 다시 시도해주세요." },
{ status: 401 }
);
}
// 이메일 결정: 카카오에서 받았으면 그걸로(신뢰), 아니면 사용자가 입력한 값
// 사용자 입력값을 받을 때 형식 검증 (카카오 검증 이메일은 형식 보장됨)
const finalEmail = (payload.email || bodyEmail || "").trim().toLowerCase();
if (!finalEmail) {
return NextResponse.json(
{ success: false, message: "이메일을 입력해주세요." },
{ status: 400 }
);
}
if (!payload.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(finalEmail)) {
return NextResponse.json(
{ success: false, message: "유효한 이메일 형식이 아닙니다." },
{ status: 400 }
);
}
// 가입 직전 이메일 중복 체크 — 사용자 입력 이메일이 다른 사람 계정과 충돌하면 거부
const dup = await findMomoUserByEmail(finalEmail);
if (dup) {
cookieStore.delete(PENDING_COOKIE);
return NextResponse.json(
{ success: false, message: "이미 가입된 이메일입니다. 다른 이메일을 입력하거나 일반 로그인을 이용하세요." },
{ status: 409 }
);
}
let user;
try {
user = await signupKakaoUser({
kakaoId: payload.kakaoId,
extra: {
email: finalEmail,
companyName,
ceoName,
bizNo,
phone,
address,
},
});
} catch (e) {
console.error("[kakao/complete]", e);
return NextResponse.json(
{ success: false, message: "가입 처리 중 오류가 발생했습니다." },
{ status: 500 }
);
}
cookieStore.delete(PENDING_COOKIE);
const sessionUser: User = {
sabun: "",
userId: user.email,
userName: user.companyName,
userNameEng: "",
userNameCn: "",
deptCode: "",
deptName: "",
positionCode: "",
positionName: "",
email: user.email,
tel: "",
cellPhone: user.phone,
userType: "MOMO",
userTypeName: user.role === "ADMIN" ? "관리자" : "거래처",
authName: user.role,
partnerCd: "",
isAdmin: user.isAdmin,
role: user.role,
objid: user.objid,
companyName: user.companyName,
};
await createSession(sessionUser);
return NextResponse.json({ success: true, user: sessionUser });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
const PENDING_COOKIE = "kakao-signup-pending";
const SECRET = new TextEncoder().encode(
process.env.NEXTAUTH_SECRET || "fito-plm-default-secret"
);
// 추가정보 입력 페이지에서 호출 — 이메일/닉네임 미리보기용
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get(PENDING_COOKIE)?.value;
if (!token) {
return NextResponse.json({ success: false, message: "카카오 인증 세션이 없습니다." }, { status: 401 });
}
try {
const { payload } = await jwtVerify(token, SECRET);
if (payload.purpose !== "kakao-signup") {
return NextResponse.json({ success: false, message: "유효하지 않은 세션입니다." }, { status: 401 });
}
return NextResponse.json({
success: true,
email: payload.email,
nickname: payload.nickname,
});
} catch {
return NextResponse.json({ success: false, message: "세션이 만료되었습니다. 다시 로그인해주세요." }, { status: 401 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import crypto from "node:crypto";
import { buildAuthorizeUrl } from "@/lib/kakao-auth";
const STATE_COOKIE = "kakao-oauth-state";
export async function GET() {
if (!process.env.KAKAO_REST_API_KEY || !process.env.KAKAO_REDIRECT_URI) {
return NextResponse.json(
{ success: false, message: "카카오 로그인이 구성되지 않았습니다. 관리자에게 문의하세요." },
{ status: 503 }
);
}
const state = crypto.randomBytes(16).toString("hex");
const cookieStore = await cookies();
cookieStore.set(STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 5 * 60,
path: "/",
});
return NextResponse.redirect(buildAuthorizeUrl(state));
}
@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, ShoppingCart, ClipboardList, Menu } from "lucide-react";
import { cn } from "@/lib/utils";
interface Tab {
href: string;
label: string;
icon: React.ElementType;
matchPrefixes?: string[];
}
// 모든 사용자 동일한 4탭. 권한별 가시성은 추후 DB 권한 테이블로 처리.
const TABS: Tab[] = [
{ href: "/m/dashboard", label: "홈", icon: Home },
{ href: "/m/orders/new", label: "발주하기", icon: ShoppingCart, matchPrefixes: ["/m/orders/new", "/m/items"] },
{ href: "/m/orders", label: "내 발주", icon: ClipboardList },
{ href: "/m/more", label: "더보기", icon: Menu, matchPrefixes: ["/m/more", "/m/admin"] },
];
export function MobileBottomNav() {
const pathname = usePathname();
const isActive = (tab: Tab) => {
if (tab.matchPrefixes) {
return tab.matchPrefixes.some((p) => pathname === p || pathname.startsWith(p + "/"));
}
return pathname === tab.href;
};
return (
<nav className="sticky bottom-0 z-30 bg-white border-t border-slate-200 pb-[env(safe-area-inset-bottom)]">
<div className="grid grid-cols-4">
{TABS.map((t) => {
const Icon = t.icon;
const active = isActive(t);
return (
<Link
key={t.href}
href={t.href}
className={cn(
"flex flex-col items-center justify-center gap-1 h-16 transition-colors",
"active:bg-slate-100",
active ? "text-emerald-700" : "text-slate-500 hover:text-slate-800"
)}
>
<Icon size={26} strokeWidth={active ? 2.4 : 2} />
<span className={cn("text-[12px] tracking-tight", active ? "font-bold" : "font-medium")}>
{t.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ChevronLeft } from "lucide-react";
const TITLE_MAP: Record<string, string> = {
"/m/dashboard": "홈",
"/m/orders/new": "발주하기",
"/m/orders": "내 발주",
"/m/more": "더보기",
"/m/admin/orders": "발주 관리",
"/m/admin/inbounds": "입고 관리",
"/m/admin/inbounds/new": "입고 등록",
"/m/admin/payments": "입금 관리",
"/m/admin/invoices": "계산서",
"/m/admin/customers": "거래처 관리",
"/m/admin/vendors": "매입처 관리",
"/m/admin/items": "품목 관리",
"/m/admin/makers": "제조사 관리",
"/m/admin/warehouses": "창고 관리",
"/m/admin/inventory": "재고 현황",
"/m/admin/inventory/history": "재고 이력",
"/m/admin/procurements": "매입 관리",
"/m/admin/procurements/new": "매입 등록",
"/m/admin/statistics": "월별 통계",
"/m/admin/statistics/daily": "일별 통계",
"/m/admin/statistics/margin": "마진 분석",
};
const ROOT_PATHS = new Set(["/m/dashboard", "/m/orders/new", "/m/orders", "/m/more"]);
export function MobileTopBar() {
const pathname = usePathname();
const router = useRouter();
const title = TITLE_MAP[pathname] ?? "모모유통";
const isRoot = ROOT_PATHS.has(pathname);
return (
<header className="sticky top-0 z-30 flex items-center h-14 px-2 bg-white border-b border-slate-200">
{isRoot ? (
<Link href="/m/dashboard" className="flex items-center gap-2 px-2 h-11">
<img src="/momo-icon.svg" alt="모모유통" className="w-8 h-8" />
<span className="text-base font-extrabold text-emerald-800 tracking-tight"></span>
</Link>
) : (
<button
onClick={() => router.back()}
className="w-11 h-11 flex items-center justify-center rounded-full hover:bg-slate-100 active:bg-slate-200 transition"
aria-label="뒤로가기"
>
<ChevronLeft size={26} className="text-slate-700" />
</button>
)}
<h1 className="flex-1 text-center text-lg font-bold text-slate-900 truncate px-2">
{isRoot ? "" : title}
</h1>
<div className="w-11 h-11" />
</header>
);
}
+176
View File
@@ -0,0 +1,176 @@
// 카카오 OAuth 2.0 (Authorization Code grant) 헬퍼
// docs: https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
import { queryOne, execute } from "./db";
import type { MomoUser } from "./momo-auth";
const KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize";
const KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
const KAKAO_USER_URL = "https://kapi.kakao.com/v2/user/me";
export interface KakaoProfile {
kakaoId: string;
email: string | null;
nickname: string | null;
}
export interface KakaoSignupExtra {
email: string; // 카카오에서 받았거나 사용자가 직접 입력한 값. user_id 로도 사용
companyName: string;
ceoName?: string;
bizNo?: string;
phone: string;
address: string;
}
function requireEnv(key: string): string {
const v = process.env[key];
if (!v) throw new Error(`환경변수 ${key} 가 설정되지 않았습니다.`);
return v;
}
export function buildAuthorizeUrl(state: string): string {
// scope 를 명시하지 않으면 카카오 콘솔의 "동의항목" 에 활성화된 항목만 자동으로 요청됨.
// 비즈 앱 미인증 상태에서 account_email 같이 권한 없는 scope 를 강제하면 KOE205 발생.
const params = new URLSearchParams({
response_type: "code",
client_id: requireEnv("KAKAO_REST_API_KEY"),
redirect_uri: requireEnv("KAKAO_REDIRECT_URI"),
state,
});
return `${KAKAO_AUTH_URL}?${params.toString()}`;
}
export async function exchangeCodeForToken(code: string): Promise<string> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: requireEnv("KAKAO_REST_API_KEY"),
redirect_uri: requireEnv("KAKAO_REDIRECT_URI"),
code,
});
const secret = process.env.KAKAO_CLIENT_SECRET;
if (secret) body.set("client_secret", secret);
const res = await fetch(KAKAO_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" },
body,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`카카오 토큰 교환 실패: ${res.status} ${txt}`);
}
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) throw new Error("카카오 access_token 미수신");
return json.access_token;
}
export async function fetchKakaoProfile(accessToken: string): Promise<KakaoProfile> {
const res = await fetch(KAKAO_USER_URL, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`카카오 사용자 조회 실패: ${res.status} ${txt}`);
}
const j = (await res.json()) as {
id: number | string;
kakao_account?: { email?: string; profile?: { nickname?: string } };
properties?: { nickname?: string };
};
const acc = j.kakao_account;
return {
kakaoId: String(j.id),
email: acc?.email?.toLowerCase() ?? null,
nickname: acc?.profile?.nickname ?? j.properties?.nickname ?? null,
};
}
// kakao_id 로 기존 연동 사용자 조회
export async function findMomoUserByKakaoId(kakaoId: string): Promise<MomoUser | null> {
const row = await queryOne<Record<string, unknown>>(
`SELECT user_id AS "USER_ID", user_name AS "USER_NAME",
user_name_eng AS "USER_NAME_ENG",
email AS "EMAIL", cell_phone AS "CELL_PHONE", tel AS "TEL",
user_type AS "USER_TYPE", status AS "STATUS",
biz_no AS "BIZ_NO", ceo_name AS "CEO_NAME",
address AS "ADDRESS"
FROM user_info
WHERE kakao_id = $1
LIMIT 1`,
[kakaoId]
);
if (!row) return null;
return rowToMomoUser(row);
}
// 이메일로 기존 일반 가입자 조회 (카카오 자동 연결용)
export async function findUserInfoForKakaoLink(
email: string
): Promise<{ userId: string; existingKakaoId: string | null } | null> {
const row = await queryOne<{ user_id: string; kakao_id: string | null }>(
`SELECT user_id, kakao_id
FROM user_info
WHERE LOWER(email) = LOWER($1) OR LOWER(user_id) = LOWER($1)
LIMIT 1`,
[email]
);
if (!row) return null;
return { userId: row.user_id, existingKakaoId: row.kakao_id };
}
export async function linkKakaoIdToUser(userId: string, kakaoId: string): Promise<void> {
await execute(`UPDATE user_info SET kakao_id = $1 WHERE user_id = $2`, [kakaoId, userId]);
}
// 카카오 가입자 생성 — 추가정보 입력 후 호출
// user_password = '' (빈 문자열) → 기존 verifyMomoCredentials 가 거부 → 일반 로그인 차단
// email 은 KakaoSignupExtra.email 에서 가져옴 (카카오 동의항목으로 받았거나 사용자가 직접 입력)
export async function signupKakaoUser(args: {
kakaoId: string;
extra: KakaoSignupExtra;
}): Promise<MomoUser> {
const email = args.extra.email.trim().toLowerCase();
// user_id 는 이메일로 통일 (기존 momo 가입과 동일 패턴)
await execute(
`INSERT INTO user_info
(user_id, user_password, user_name, email, cell_phone,
user_type, user_type_name, biz_no, ceo_name, address, status, kakao_id, regdate)
VALUES ($1, '', $2, $1, $3, 'C', '거래처', $4, $5, $6, 'active', $7, NOW())`,
[
email,
args.extra.companyName.trim(),
args.extra.phone.trim(),
args.extra.bizNo?.trim() ?? "",
args.extra.ceoName?.trim() ?? "",
args.extra.address.trim(),
args.kakaoId,
]
);
const fresh = await findMomoUserByKakaoId(args.kakaoId);
if (!fresh) throw new Error("카카오 가입 후 사용자 조회 실패");
return fresh;
}
function rowToMomoUser(r: Record<string, unknown>): MomoUser {
const userType = String(r.USER_TYPE || "").toUpperCase();
const role: "USER" | "ADMIN" = userType === "A" ? "ADMIN" : "USER";
const userId = (r.USER_ID as string) || "";
const email = (r.EMAIL as string) || userId;
const companyName = (r.USER_NAME as string) || "";
return {
objid: userId,
email,
companyName,
ceoName: (r.CEO_NAME as string) || (r.USER_NAME_ENG as string) || "",
bizNo: (r.BIZ_NO as string) || "",
phone: (r.CELL_PHONE as string) || (r.TEL as string) || "",
address: (r.ADDRESS as string) || "",
role,
status: (r.STATUS as string) || "active",
userId,
userName: companyName,
isAdmin: role === "ADMIN",
};
}
+2 -1
View File
@@ -11,6 +11,7 @@ export function middleware(request: NextRequest) {
"/api/auth/login",
"/api/auth/signup",
"/api/auth/mobile-login",
"/api/auth/kakao",
"/api/deploy/webhook",
"/_next",
"/favicon.ico",
@@ -25,7 +26,7 @@ export function middleware(request: NextRequest) {
}
return NextResponse.next();
}
// 로그인/가입 페이지도 세션 있으면 대시보드로
// 로그인/가입 페이지도 세션 있으면 대시보드로 (단 /signup/kakao 는 카카오 인증 직후 진입하므로 제외)
if (pathname === "/login" || pathname === "/signup") {
if (request.cookies.get("plm-session")) {
return NextResponse.redirect(new URL("/m/dashboard", request.url));