Compare commits
6 Commits
main
...
feat/kakao-login
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e8de47a6e | |||
| 39465b38d9 | |||
| 47a1dc5843 | |||
| 00b173573d | |||
| e77ec978eb | |||
| 92ad098351 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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` 의 키만 신규 모모유통 앱 키로 교체하면 됩니다. 코드는 변경 없음.
|
||||
@@ -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` 가 거부
|
||||
Generated
+2
-5
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user