Compare commits
209 Commits
feat/pwa-apk
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ed746e71a2 | |||
| 36edafcf16 | |||
| adcc7e3b48 | |||
| 2afb5fecdd | |||
| 4f2543686a | |||
| 77f2ef2cd5 | |||
| d6f80c187b | |||
| c69e811f46 | |||
| 83ac3a5456 | |||
| b7bc6b2bbf | |||
| 7bcfe9ce34 | |||
| 5bd07526e4 | |||
| b11d0df704 | |||
| ec6bf2922f | |||
| 2be9792263 | |||
| 5f1983b0f6 | |||
| 5d668716f1 | |||
| 294e33b5b3 | |||
| 3786800f12 | |||
| be86d22a6f | |||
| f2e7c03507 | |||
| 0dd392136b | |||
| 46a6d4697e | |||
| 72b6b6873e | |||
| 76167f0ae5 | |||
| ef298b381c | |||
| 06b406ba6a | |||
| a897f12116 | |||
| bbadd546ed | |||
| 1061332fbd | |||
| d30c8ad8d3 | |||
| 46eba2996f | |||
| 4933655c26 | |||
| 9bd81d5fbc | |||
| e4fcfd453d | |||
| 63d83b5004 | |||
| 83478fd3e1 | |||
| 8bc7bc50c0 | |||
| e4b64af3da | |||
| f9c7e55eb0 | |||
| 199ffb56d9 | |||
| 1181940bb8 | |||
| 6ac6807b1b | |||
| 62d2c43e73 | |||
| 04b59e41a8 | |||
| ecc14561e6 | |||
| 8e49fab63f | |||
| 93d6f0fc3f | |||
| cbea0f4b9f | |||
| d0c602dda3 | |||
| 5b6eb2d7d9 | |||
| 745f0037ae | |||
| 0ee120f628 | |||
| 8f26ed496d | |||
| 72227883a0 | |||
| 5ef56ef63d | |||
| e088b3549b | |||
| 612786e754 | |||
| 4b1334fabb | |||
| 51c929f703 | |||
| c5ff736cc9 | |||
| 92297145a8 | |||
| 5716686fb2 | |||
| 8b064ea120 | |||
| 9eb13439f1 | |||
| 7a252a3749 | |||
| ecea7f6a55 | |||
| 3955638d9d | |||
| 86c65df97b | |||
| f55d02a774 | |||
| 30fb668cbb | |||
| 3bfb4f31e2 | |||
| 252bab500b | |||
| 87c3fdfb65 | |||
| 89503ebf03 | |||
| 21c8bf5ab5 | |||
| 85ac9db997 | |||
| 34b64a5a17 | |||
| b5302c52d2 | |||
| 1b0d652282 | |||
| 83cb93cb76 | |||
| bbd4f84a12 | |||
| a06a5d551e | |||
| a40bb609e3 | |||
| 1a209ceb29 | |||
| af6726f2b6 | |||
| 2209863ab8 | |||
| 474cf79632 | |||
| e1618fa9d2 | |||
| bdccaa05c1 | |||
| 8e29a1f9da | |||
| 585b7d4577 | |||
| 9b36ae64a5 | |||
| 6be1633a31 | |||
| a7fa932f9f | |||
| 0aa8ce9025 | |||
| e3e4919933 | |||
| 1396ac2ed7 | |||
| 2a84b74488 | |||
| baa2b72169 | |||
| 84ef9e5179 | |||
| 6407954fc1 | |||
| bbabccf70e | |||
| 1049e9b776 | |||
| 756924354b | |||
| b34121b597 | |||
| 5ba9b9f04e | |||
| 49352feb4a | |||
| 25aa33c499 | |||
| 6ddeca316c | |||
| 1eba9aab32 | |||
| d95a736701 | |||
| 7a712c164e | |||
| 45af622afb | |||
| 2419ded4ac | |||
| b58d7e6821 | |||
| 80f490e8d6 | |||
| 326b790e4a | |||
| 461164c397 | |||
| 209b47c7f2 | |||
| dc05d48c82 | |||
| 73317166ab | |||
| 9e4d506939 | |||
| adff1347c9 | |||
| 3c73c5a47a | |||
| 4a6a5fe6dc | |||
| f73c486c4f | |||
| 86b90e2d5a | |||
| d36d256f27 | |||
| 0e041676b7 | |||
| 0139282231 | |||
| aeafeb9daf | |||
| 29641ed978 | |||
| 373e1962f0 | |||
| b7c7a4d395 | |||
| c7d7bdfaea | |||
| d6b81da946 | |||
| c9dea94bc2 | |||
| 7604027155 | |||
| 51f6bd653b | |||
| a02015641c | |||
| a120803799 | |||
| 71cf966781 | |||
| 17ae2b80d7 | |||
| 08549146be | |||
| e37d6eaa13 | |||
| ea21dced45 | |||
| 470fa4884d | |||
| 280495d741 | |||
| fac0f0d83e | |||
| 527cfddc1b | |||
| 789909991a | |||
| d25db4a023 | |||
| 8d8bb17345 | |||
| 3e2d8572f1 | |||
| 7977ffff19 | |||
| bb21be260f | |||
| 7b5951c227 | |||
| 9fd1160b38 | |||
| b568a8858a | |||
| 3505148994 | |||
| 88686d0461 | |||
| 13d02cac6a | |||
| f29024744d | |||
| e2c5c5b396 | |||
| f4b1e31f7f | |||
| 5294554384 | |||
| b204f14265 | |||
| 34ee374796 | |||
| 053a21c30e | |||
| 665a560486 | |||
| 3a7d17b3e5 | |||
| 2d5b94a026 | |||
| 9a086dae50 | |||
| 4661981da5 | |||
| d86a1154a9 | |||
| 6ea1f13003 | |||
| 485aea4d4f | |||
| 5fce695f09 | |||
| 2d2f32f4f8 | |||
| 1fde88bcd8 | |||
| 3d5a283955 | |||
| 91313351f9 | |||
| 94fc425ef3 | |||
| 0bfe85dc69 | |||
| 2fffc42575 | |||
| 29852110dc | |||
| 5778b845d1 | |||
| 0c380d94a0 | |||
| 2b3c4acdae | |||
| 19e3cf9048 | |||
| 6ad57356a0 | |||
| 75f37d8eaf | |||
| ec2e79d517 | |||
| 6b401071a4 | |||
| 7d18285ac6 | |||
| bf2339c242 | |||
| b781722614 | |||
| 9293029631 | |||
| b8d0200831 | |||
| 7151a401d4 | |||
| a8049f57a6 | |||
| 80d2240a23 | |||
| 9cd9e5c0fd | |||
| 083188332c | |||
| 91ab88a359 | |||
| bfb9470c85 | |||
| 1e0a2640e9 | |||
| 0e9378a638 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
DATABASE_URL="postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution"
|
||||
DATABASE_URL="postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="2b1f94cca798f49ff62822b01617503b019d118df9d249ee61f835a7dca1946e"
|
||||
NEXT_PUBLIC_APP_NAME="유통관리 ERP"
|
||||
|
||||
@@ -17,3 +17,10 @@ SMTP_FROM=모모유통 <momo8443@daum.net>
|
||||
# ============ 거래명세표에 표시될 공급자 정보 ============
|
||||
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
|
||||
MOMO_PHONE=010-6624-5315
|
||||
|
||||
# ============ 웹 푸시(PWA 알림) ============
|
||||
# 미설정 시 코드 하드코딩 기본 VAPID 키 사용. 운영에서 키를 교체하려면 아래 지정.
|
||||
# 생성: npx web-push generate-vapid-keys
|
||||
# VAPID_PUBLIC_KEY=__public__
|
||||
# VAPID_PRIVATE_KEY=__private__
|
||||
# VAPID_SUBJECT=mailto:admin@momotogether.com
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
name: Deploy momo-erp
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install sshpass
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq sshpass openssh-client || \
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq sshpass openssh-client
|
||||
|
||||
- name: Deploy via SSH (password auth)
|
||||
run: |
|
||||
set -e # 배포 단계 실패하면 즉시 워크플로우 fail (헬스체크에 의존하지 않음)
|
||||
export SSHPASS='qlalfqjsgh11'
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H 183.99.177.40 >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
sshpass -e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
chpark@183.99.177.40 'bash -s' <<'REMOTE_SCRIPT'
|
||||
set -e # 원격 명령도 fail 즉시 중단
|
||||
DEPLOY_DIR="$HOME/momo-erp/source"
|
||||
mkdir -p "$HOME/momo-erp"
|
||||
|
||||
if [ -d "$DEPLOY_DIR/.git" ]; then
|
||||
cd "$DEPLOY_DIR"
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git clone https://git.junggomoa.com/chpark/distribution_erp.git "$DEPLOY_DIR"
|
||||
cd "$DEPLOY_DIR"
|
||||
fi
|
||||
|
||||
# 빌드 SHA 마커 박기 — 헬스체크가 이 값으로 신버전 반영 여부 판단
|
||||
DEPLOY_SHA=$(git rev-parse HEAD)
|
||||
echo "$DEPLOY_SHA" > public/build-sha.txt
|
||||
echo "▶ 배포 대상 SHA: $DEPLOY_SHA"
|
||||
|
||||
# .env.production 갱신 (SMTP/MOMO 포함)
|
||||
cat > .env.production <<'ENVEOF'
|
||||
DATABASE_URL=postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution
|
||||
NEXTAUTH_URL=https://momotogether.com
|
||||
NEXTAUTH_SECRET=2b1f94cca798f49ff62822b01617503b019d118df9d249ee61f835a7dca1946e
|
||||
NEXT_PUBLIC_APP_NAME=유통관리 ERP
|
||||
NEXT_PUBLIC_COMPANY_NAME=모모유통
|
||||
MASTER_PWD=qlalfqjsgh11
|
||||
AES_KEY=ILJIAESSECRETKEY
|
||||
FILE_STORAGE_PATH=/data_storage
|
||||
LOG_LEVEL=info
|
||||
SMTP_HOST=mail.coa-soft.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=chpark@coa-soft.com
|
||||
SMTP_PASS=1321Qkrckd!!!!!!
|
||||
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
|
||||
ENVEOF
|
||||
|
||||
# 빌드는 먼저, 그 다음 down + up 으로 swap (--force-recreate 가 가끔 이름 충돌 일으킴)
|
||||
docker compose -f docker-compose.prod.yml build momo-erp
|
||||
docker compose -f docker-compose.prod.yml down --remove-orphans
|
||||
docker compose -f docker-compose.prod.yml up -d momo-erp
|
||||
|
||||
# 옛 momo-erp 이미지(latest 태그가 새 빌드로 갱신되며 dangling 이 된 옛 sha)는 prune.
|
||||
# -f 만 사용 (dangling 만). 다른 프로젝트의 사용 중 이미지는 건드리지 않음.
|
||||
docker image prune -f 2>&1 | tail -3 || true
|
||||
|
||||
# 마이그레이션 (idempotent) — 컨테이너 안에 db/migrations + scripts/migrate-momo.mjs 가
|
||||
# standalone 번들에 포함되어 있어야 동작 (next.config.ts outputFileTracingIncludes).
|
||||
# 컨테이너 시도 후 실패하면 호스트 측 docker run 으로 폴백 (소스 마운트 사용).
|
||||
if docker compose -f docker-compose.prod.yml exec -T momo-erp node scripts/migrate-momo.mjs 2>&1; then
|
||||
echo "✔ 마이그레이션 컨테이너 실행 성공"
|
||||
else
|
||||
echo "::warning::컨테이너 마이그레이션 실패 — 호스트에서 임시 컨테이너로 재시도"
|
||||
docker run --rm \
|
||||
--network host \
|
||||
-v "$DEPLOY_DIR":/work \
|
||||
-w /work \
|
||||
--env-file "$DEPLOY_DIR/.env.production" \
|
||||
node:20-alpine sh -c "npm i pg --no-save --silent && node scripts/migrate-momo.mjs" \
|
||||
|| echo "::error::마이그레이션 모두 실패 — 수동 실행 필요"
|
||||
fi
|
||||
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
echo "✔ 배포 완료"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
- name: Healthcheck (build-sha.txt 일치 검증)
|
||||
run: |
|
||||
EXPECTED="${GITHUB_SHA}"
|
||||
echo "▶ 기대 SHA: $EXPECTED"
|
||||
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
sleep 10
|
||||
REMOTE=$(curl -sS -m 5 -L "https://momotogether.com/build-sha.txt?_=$(date +%s)" 2>/dev/null | tr -d '[:space:]' || true)
|
||||
echo " ${i}/12: 운영 SHA=${REMOTE:-(없음)}"
|
||||
if [ -n "$REMOTE" ] && [ "$REMOTE" = "$EXPECTED" ]; then
|
||||
echo "::notice::✔ 운영에 신버전(${REMOTE:0:8}) 반영 확인"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo "::error::헬스체크 실패: 운영의 build-sha (${REMOTE:-없음})가 기대 SHA(${EXPECTED:0:8})와 다름 — 빌드/재시작 실패 가능"
|
||||
exit 1
|
||||
+15
@@ -24,6 +24,13 @@
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# 안드로이드 서명 키/번들 — 절대 커밋 금지 (Play Store 서명 키 노출 = 앱 도용 위험)
|
||||
*.keystore
|
||||
*.jks
|
||||
*.aab
|
||||
*.apk
|
||||
signing-key-info.txt
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
@@ -42,3 +49,11 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# Android TWA build artifacts / keystore (절대 커밋 금지)
|
||||
android/*.keystore
|
||||
android/*.jks
|
||||
android/*.aab
|
||||
android/*.apk
|
||||
android/app/
|
||||
android/.gradle/
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
## 개요
|
||||
|
||||
`.gitea/workflows/deploy.yml` 워크플로가 `main` 브랜치 푸시 시 자동으로
|
||||
배포 서버(183.99.177.40)에 SSH 접속 → `docker compose up -d --build` 실행합니다.
|
||||
배포 서버(121.156.99.3)에 SSH 접속 → `docker compose up -d --build` 실행합니다.
|
||||
|
||||
## Gitea 시크릿 등록
|
||||
|
||||
@@ -11,10 +11,10 @@ Gitea 저장소 → **Settings → Actions → Secrets** 에 다음 시크릿을
|
||||
|
||||
| 시크릿 이름 | 값 (예시) |
|
||||
|-------------|----------|
|
||||
| `DEPLOY_HOST` | `183.99.177.40` |
|
||||
| `DEPLOY_HOST` | `121.156.99.3` |
|
||||
| `DEPLOY_USER` | `chpark` |
|
||||
| `DEPLOY_SSH_KEY` | SSH 개인키 전체 (BEGIN/END 포함) |
|
||||
| `DATABASE_URL` | `postgresql://postgres:qlalfqjsgh11@183.99.177.40:5432/distribution` |
|
||||
| `DATABASE_URL` | `postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution` |
|
||||
| `NEXTAUTH_URL` | `https://momotogether.com` |
|
||||
| `NEXTAUTH_SECRET` | 임의의 32바이트 hex (현재 .env.production 값 재사용 가능) |
|
||||
| `MASTER_PWD` | `qlalfqjsgh11` |
|
||||
@@ -26,7 +26,7 @@ Gitea 저장소 → **Settings → Actions → Secrets** 에 다음 시크릿을
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "gitea-deploy" -f ~/.ssh/momo_deploy -N ""
|
||||
# 공개키를 배포 서버에 등록
|
||||
ssh-copy-id -i ~/.ssh/momo_deploy.pub chpark@183.99.177.40
|
||||
ssh-copy-id -i ~/.ssh/momo_deploy.pub chpark@121.156.99.3
|
||||
# 개인키를 Gitea Secret `DEPLOY_SSH_KEY` 에 붙여넣기
|
||||
cat ~/.ssh/momo_deploy
|
||||
```
|
||||
@@ -41,7 +41,7 @@ cat ~/.ssh/momo_deploy
|
||||
|
||||
긴급 시:
|
||||
```bash
|
||||
ssh chpark@183.99.177.40
|
||||
ssh chpark@121.156.99.3
|
||||
cd ~/momo-erp/source
|
||||
git pull
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 기술 스택
|
||||
- **Frontend**: Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS
|
||||
- **Backend**: Next.js API Routes (Node.js)
|
||||
- **Database**: PostgreSQL (외부 공용 서버 `211.115.91.141:11140/fito`, raw SQL via `pg`)
|
||||
- **Database**: PostgreSQL (외부 공용 서버 `121.156.99.3:5432/distribution`, raw SQL via `pg`)
|
||||
- **인증**: JWT (jose) + Cookie 기반 세션
|
||||
- **상태관리**: Zustand
|
||||
- **UI**: SweetAlert2, Lucide Icons, Custom DataGrid (TanStack React Table)
|
||||
@@ -76,4 +76,4 @@ npm run dev # 개발 서버 (localhost:3000)
|
||||
|
||||
- Docker Compose dev/prod 분리
|
||||
- Traefik 리버스 프록시 + `fito.wace.me` 서브도메인
|
||||
- DB는 외부 `211.115.91.141:11140/fito` 공유 (컨테이너 내부 DB 없음)
|
||||
- DB는 외부 `121.156.99.3:5432/distribution` 공유 (컨테이너 내부 DB 없음)
|
||||
|
||||
+8
-1
@@ -26,9 +26,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# webhook 자기재배포에 필요한 CLI: git (소스 동기), docker + compose (이미지 빌드/swap)
|
||||
# docker socket 은 docker-compose.prod.yml 에서 host 의 /var/run/docker.sock 으로 마운트됨
|
||||
RUN apk add --no-cache git docker-cli docker-cli-compose
|
||||
|
||||
# 비루트 사용자 (보안)
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
# docker socket 접근 권한: 운영 호스트 /var/run/docker.sock 의 GID(988) 와 동일한 그룹 생성 후 nextjs 가입
|
||||
addgroup -g 988 dockerhost && \
|
||||
addgroup nextjs dockerhost
|
||||
|
||||
# standalone 번들 복사
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
|
||||
@@ -1,70 +1,178 @@
|
||||
# FITO — (주)피토 PLM (Next.js)
|
||||
# 모모유통 ERP (Distribution ERP)
|
||||
|
||||
기존 Java/Spring MVC + JSP + MyBatis 기반 FITO PLM을 Next.js 15 + Node.js로 컨버전한 시스템.
|
||||
식자재/도소매 **유통·물류 업무 통합 관리 시스템**.
|
||||
거래처가 발주를 넣고, 본사가 출고·정산하고, 매입·입고·재고·세금계산서까지 한 화면에서 처리한다.
|
||||
|
||||
- 원본: [/Users/jhj/FITO](../FITO) (Java 7 + Spring 3.2.4 + MyBatis 3.2.3 + JSP)
|
||||
- DB: 외부 PostgreSQL `211.115.91.141:11140/fito` (기존 스키마 그대로 사용)
|
||||
- 이전 이력: `woosung-nextjs`에서 피벗. 스냅샷 태그 `woosung-v1-snapshot`.
|
||||
- 운영 도메인: **https://momotogether.com**
|
||||
- Android 앱: **`com.momotogether.app`** (TWA, Play 스토어 등록용 AAB / 사이드로드 APK)
|
||||
- 코드 저장소(원격): `git.junggomoa.com/chpark/distribution_erp`
|
||||
- 사용자: (주)모모유통 본사·김포지사 + 계약 거래처 전용 — 일반 소비자용 아님
|
||||
|
||||
## 개발 시작
|
||||
---
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 거래처(구매자) 화면
|
||||
- **출고 요청** — 재고 있는 품목 선택 → 장바구니 → 발주 요청. 택배전용·용차비·환불 라인 자동/수동 추가. 상시판매·기간한정 품목 분리.
|
||||
- **내 출고 이력** — 본인 발주 진행상태 추적.
|
||||
- **푸시 알림 토글** — PWA 설치 후 새 상품 등록 시 알림 수신.
|
||||
- **회원정보 수정** — 본인 정보·기준 명세표 변경.
|
||||
|
||||
### 본사/지사 운영
|
||||
- **출고 처리** — 발주 검토 → 거래명세표 자동 생성(이미지/엑셀/메일 발송) → 재고 자동 차감.
|
||||
- **입금 관리** — 출고 후 미수금 추적·입금 등록.
|
||||
- **계산서·전자세금계산서** — 과세/면세 자동 분리. 국세청 ESERO 연동 어댑터(현재는 DB 기록).
|
||||
- **매입 발주** — 공급업체별 발주서 작성·메일 발송. 발주지사(HQ/KIMPO) 선택.
|
||||
- **매입 입금관리** — 진행상태와 결재상태(입금완료/미입금)를 **분리 관리**. 부분입고 시 입고금액 기준 입금 처리.
|
||||
- **입고 처리** — 정상/불량 분리 입고, 유통기한·물류팀 최종완료자 체크리스트.
|
||||
- **재고 관리** — 창고별 실시간 재고, 입출고 이력, 창고 간 이동, 유통기한 임박 알림.
|
||||
|
||||
### 마스터·통계·관리
|
||||
- **품목 마스터** — 가격·원가·과세/면세·택배전용·1회 발주한도·판매기간(또는 상시판매) + 일괄 적용.
|
||||
- **공급업체/거래처/창고/계산서 기준정보** 관리.
|
||||
- **푸시알림 게시판** — 관리자가 공지(이미지+본문)를 작성하고 선택한 구독자에게 푸시 발송. 사용자가 알림 탭하면 공지 페이지로 이동.
|
||||
- **통계** — 대시보드, 월간/일자별 매출, 원가·마진, 거래처×일자 매출, 지사 수수료, 창고 이동 통계.
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **풀스택 단일 Next.js 프로젝트** — 프론트(React 19, App Router) + 백엔드(API Routes, Node.js) 한 저장소.
|
||||
- **TypeScript** strict mode, **Tailwind CSS**.
|
||||
- **DB**: 외부 PostgreSQL `121.156.99.3:5432/distribution` — raw SQL(`pg`).
|
||||
- **인증**: JWT(jose) + HTTP Cookie 세션 + AES-128-ECB(비밀번호).
|
||||
- **PWA**: `manifest.json` + Service Worker(`public/sw.js`) — 푸시 핸들러·알림 위임(badge·icon).
|
||||
- **푸시**: `web-push`(VAPID) — `momo_push_subscriptions` 에 endpoint 저장, 발송은 `lib/push.ts`.
|
||||
- **거래명세표 캡처**: `html-to-image` (이미지 공유/저장).
|
||||
- **상태관리**: Zustand (auth/menu/theme).
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── (auth)/login 로그인
|
||||
│ ├── (main)/
|
||||
│ │ ├── m/orders/new 거래처 — 출고요청
|
||||
│ │ ├── m/orders 거래처 — 내 출고이력
|
||||
│ │ ├── m/notices/[id] 공지 도달 페이지 (푸시 클릭 시)
|
||||
│ │ ├── m/admin/orders 출고 처리
|
||||
│ │ ├── m/admin/payments 입금 관리
|
||||
│ │ ├── m/admin/invoices 계산서 발행
|
||||
│ │ ├── m/admin/einvoices 전자세금계산서
|
||||
│ │ ├── m/admin/procurements매입 발주서
|
||||
│ │ ├── m/admin/proc-payments 매입 입금관리
|
||||
│ │ ├── m/admin/inbounds 입고 처리
|
||||
│ │ ├── m/admin/inventory 재고 관리
|
||||
│ │ ├── m/admin/items 품목 마스터
|
||||
│ │ ├── m/admin/notices 푸시알림 게시판
|
||||
│ │ ├── m/admin/vendors 공급업체 관리
|
||||
│ │ ├── m/admin/warehouses 창고 관리
|
||||
│ │ ├── m/admin/statistics 통계 대시보드
|
||||
│ │ └── profile 회원정보 수정
|
||||
│ └── api/m/ 업무 API (orders/items/inbounds/push/notices …)
|
||||
├── lib/
|
||||
│ ├── db.ts PostgreSQL Pool (queryRows/queryOne/execute)
|
||||
│ ├── auth.ts 인증 + 세션
|
||||
│ ├── push.ts web-push 발송 + 구독 관리
|
||||
│ ├── notices.ts 공지 테이블 자동 생성
|
||||
│ ├── momo-proc.ts 매입 진행/결재 분리 마이그레이션
|
||||
│ └── capture-share.ts 이미지 캡처/공유
|
||||
├── components/
|
||||
│ ├── layout/ Header / Sidebar
|
||||
│ ├── grid/ DataGrid (TanStack Table)
|
||||
│ ├── ui/ 버튼/입력/SearchableSelect 등
|
||||
│ └── push-optin.tsx 푸시 알림 켜기/끄기 토글 (localStorage 영속)
|
||||
└── store/ Zustand (auth/menu/theme)
|
||||
|
||||
public/
|
||||
├── sw.js Service Worker — fetch 캐시 + push/notificationclick
|
||||
├── manifest.json PWA 매니페스트
|
||||
├── icon-{192,512}.png PWA 아이콘 (모모 로고)
|
||||
├── badge-96.png 알림 상태바 단색 배지
|
||||
└── .well-known/assetlinks.json TWA Digital Asset Links
|
||||
|
||||
android/
|
||||
├── twa-manifest.json Bubblewrap TWA 빌드 설정 (notification delegation ON)
|
||||
└── README.md APK/AAB 빌드 가이드 (PWABuilder / Bubblewrap)
|
||||
```
|
||||
|
||||
각 디렉토리별 상세는 `*/CLAUDE.md` 참고.
|
||||
|
||||
---
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
npm run dev # http://localhost:3000
|
||||
npm run build # 운영 빌드 검증
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 환경변수
|
||||
### 환경변수 (`.env.development`)
|
||||
|
||||
`.env.development`의 DB 접속 정보를 확인. 필수 키:
|
||||
| 키 | 설명 |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL 접속 (예: `postgresql://momo_app:****@121.156.99.3:5432/distribution`) |
|
||||
| `NEXTAUTH_URL` | 로컬: `http://localhost:3000` |
|
||||
| `NEXTAUTH_SECRET` | JWT 서명 시크릿 |
|
||||
| `AES_KEY` | 16바이트 — 비밀번호 AES 키 (기존 데이터 호환 필요) |
|
||||
| `MASTER_PWD` | 마스터 비밀번호 (개발 편의) |
|
||||
| `SMTP_HOST/USER/PASS/FROM` | 거래명세표·계산서 메일 발송 |
|
||||
| `MOMO_BANK_ACCOUNT`, `MOMO_PHONE` | 거래명세표 공급자 정보 (기준명세표 미설정 폴백) |
|
||||
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | (선택) 웹푸시 VAPID — 미설정 시 `lib/push.ts` 의 기본키 사용 |
|
||||
|
||||
- `DATABASE_URL` — 외부 PostgreSQL 접속
|
||||
- `NEXTAUTH_SECRET` — JWT 서명 키
|
||||
- `MASTER_PWD` — 마스터 비밀번호 (개발 편의용)
|
||||
- `AES_KEY` — 비밀번호 AES 암호화 키 (기존 Java 호환)
|
||||
`.env.momo.example` 참고.
|
||||
|
||||
## 배포 표준
|
||||
---
|
||||
|
||||
- Docker Compose (dev/prod 분리) — 기존 FITO(Java) 배포환경 재사용
|
||||
- Traefik 리버스 프록시 + `fito.wace.me` 도메인 (entrypoints: web, websecure / certresolver: le)
|
||||
- 외부 네트워크 `toktork_server_default`
|
||||
- DB는 외부 서버 공유 (`211.115.91.141:11140/fito`) — 컨테이너 내부 DB 없음
|
||||
## 배포 — Gitea Actions 자동 배포
|
||||
|
||||
### `start.sh` 배포 스크립트 (권장)
|
||||
`main` 브랜치에 push 하면 [`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml) 이 자동 실행되어 운영 서버에서 `git pull → docker compose build → up -d` 까지 수행.
|
||||
|
||||
```bash
|
||||
# 첫 배포 (서버에서)
|
||||
cp .env.production.example .env.production
|
||||
vi .env.production # DATABASE_URL, NEXTAUTH_SECRET, AES_KEY 등 입력
|
||||
- 운영 서버: `121.156.99.3` (SSH, chpark)
|
||||
- Compose 파일: [`docker-compose.prod.yml`](docker-compose.prod.yml)
|
||||
- 컨테이너명: `momo-erp` / 이미지: `momo-erp:latest`
|
||||
- 리버스 프록시: Traefik (`traefik-net` 외부 네트워크, Let's Encrypt 자동발급)
|
||||
- 호스트: `momotogether.com`, `www.momotogether.com`
|
||||
- 영구 스토리지: named volume `momo_data_storage` ↔ `/data_storage` (업로드 이미지)
|
||||
- DB: 외부 공유 — 컨테이너 내부 DB 없음
|
||||
|
||||
./start.sh prod # git pull → build → 기동 → Traefik 라우팅 확인
|
||||
> ⚠️ **수동 SSH 빌드 금지** — 자동 워크플로우와 충돌. 배포 검증은 `build-sha.txt` 또는 `docker logs momo-erp` 로 확인.
|
||||
|
||||
# 이후 배포 (git commit 후)
|
||||
./start.sh prod # 자동 git pull + 재빌드
|
||||
---
|
||||
|
||||
# 기타 운영
|
||||
./start.sh logs prod # 실시간 로그
|
||||
./start.sh restart prod # 재시작 (git pull 포함)
|
||||
./start.sh stop prod # 중지
|
||||
./start.sh status prod # 컨테이너 상태
|
||||
./start.sh build prod # no-cache 재빌드
|
||||
./start.sh clean prod # 전체 삭제 (확인 필요)
|
||||
```
|
||||
## Android 앱 (TWA)
|
||||
|
||||
스크립트는 start.sh 자체가 업데이트되면 새 버전으로 **자동 재실행**하므로 안전합니다.
|
||||
`com.momotogether.app` — `momotogether.com` 을 감싸는 Trusted Web Activity.
|
||||
|
||||
### 로컬 개발
|
||||
- **알림 위임(notification delegation) ON** — 웹 푸시가 "삼성 인터넷" 등 브라우저가 아니라 **모모유통 앱 이름·아이콘**으로 표시됨.
|
||||
- **AAB**: Play 스토어 업로드용
|
||||
- **APK**: 사이드로드(직접 설치) 테스트용
|
||||
- 빌드 가이드: [`android/README.md`](android/README.md) (PWABuilder 또는 Bubblewrap)
|
||||
- ⚠️ 기존 서명키 재사용 필수 — 키가 바뀌면 `assetlinks.json` 지문이 안 맞아 위임 깨짐.
|
||||
|
||||
```bash
|
||||
./start.sh # docker 기반 (localhost:3643, hot reload)
|
||||
npm run dev # docker 없이 Node 직접 (localhost:3000)
|
||||
```
|
||||
서비스워커 갱신 / 페이지 콘텐츠 변경은 **APK 재빌드 불필요** — 서버 배포만으로 앱에도 반영됨.
|
||||
|
||||
### 인프라 정보
|
||||
---
|
||||
|
||||
- 컨테이너명: `plm-fito-next` (prod) / `plm-fito-next-dev` (dev)
|
||||
- 도메인: `https://fito.wace.me`
|
||||
- 내부 포트: 3000 (Traefik이 외부 80/443 → 3000)
|
||||
- 파일 저장: 호스트 `./data_storage` (레포 상대경로) ↔ 컨테이너 `/data_storage`
|
||||
- 이미지: Next.js `output: "standalone"` 기반 multi-stage build
|
||||
## 코딩 컨벤션
|
||||
|
||||
상세 구성은 [CLAUDE.md](CLAUDE.md) 참고.
|
||||
상세 규칙은 [`.claude/rules/`](.claude/rules/) 와 디렉토리별 `CLAUDE.md` 참고.
|
||||
|
||||
- **SQL alias 대문자 유지**: `SELECT col AS "OBJID"` (큰따옴표 필수, 없으면 PG 가 소문자 반환)
|
||||
- **삭제 플래그**: `COALESCE(is_del,'N') != 'Y'`
|
||||
- **objid 타입 변환**: `objid::text AS "OBJID"`
|
||||
- **API 응답**: 목록 `{ RESULTLIST, TOTAL_CNT }`, 단건 `{ success, data }`, 저장 `{ success, objId? }`, 오류 `{ success:false, message }` + 적절한 status code
|
||||
- **인증 가드**: 모든 API 라우트 첫 줄에 `getSession()` / `requireMomoAdmin()` / `requireMomoUser()`
|
||||
- **신규 컬럼**: `ensureColumns` 패턴으로 라우트 첫 호출 시 자동 ALTER (운영 무중단)
|
||||
- **푸시 알림 대상**: 일반 발송은 모든 구독자 / 거래처 전용은 `sendPush(payload, undefined, { generalOnly: true })`
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
내부용. 외부 배포·재사용 금지.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 모모유통 ERP — Android(TWA) 빌드
|
||||
|
||||
웹앱(momotogether.com)을 감싸는 **TWA(Trusted Web Activity)** 패키지 빌드 설정입니다.
|
||||
패키지명 `com.momotogether.app` — `public/.well-known/assetlinks.json` 의 지문과 짝을 이룹니다.
|
||||
|
||||
## 스토어 업로드 형식
|
||||
- **Google Play 스토어 → AAB** (필수)
|
||||
- 직접 배포(사이드로드) → APK
|
||||
|
||||
## ⚠️ 서명키(중요)
|
||||
반드시 **기존에 쓰던 keystore 그대로** 사용해야 합니다.
|
||||
다른 키로 서명하면 `assetlinks.json` 지문이 안 맞아 주소창이 다시 뜨고 **알림 위임(푸시)** 이 깨집니다.
|
||||
- 기존 지문: `2A:55:B2:9E:03:51:2B:DE:28:E2:A4:34:15:9C:23:1F:21:B6:C0:43:9C:10:3B:6C:E2:D5:46:F7:AF:42:C3:97`
|
||||
- Play App Signing 을 쓰는 경우, Play Console 의 **앱 서명 인증서 SHA-256** 도 assetlinks 에 포함되어야 합니다.
|
||||
|
||||
## 방법 A — PWABuilder (권장, 로컬 툴 불필요)
|
||||
1. https://www.pwabuilder.com 에서 `https://momotogether.com` 입력
|
||||
2. **Package For Stores → Android Package**
|
||||
3. 옵션에서 **Notification delegation(알림 위임) 켜기** ✅ (이게 켜져야 "삼성 인터넷" 대신 앱 이름으로 알림이 옵니다)
|
||||
4. **기존 서명키 업로드**(처음 만들 때 받은 keystore) — 새로 만들지 말 것
|
||||
5. **versionCode 를 현재 게시 버전보다 +1** 로
|
||||
6. 생성된 **.aab** 를 Play Console 에 새 버전으로 업로드
|
||||
|
||||
## 방법 B — Bubblewrap CLI (이 폴더의 twa-manifest.json 사용)
|
||||
JDK 17 + Android SDK 필요.
|
||||
```bash
|
||||
npm i -g @bubblewrap/cli
|
||||
cd android
|
||||
# 기존 keystore 를 ./android.keystore 로 복사 (signingKey.path 와 일치)
|
||||
bubblewrap build # app-release-bundle.aab(스토어용) + app-release-signed.apk(사이드로드용) 생성
|
||||
```
|
||||
- `twa-manifest.json` 의 `enableNotifications: true` 가 알림 위임 활성화 키입니다.
|
||||
- 새 버전 낼 때마다 `appVersionCode` 를 올리세요.
|
||||
|
||||
## 설치 후 (사용자)
|
||||
홈화면 설치 → 첫 실행 시 **알림 권한 허용** → 출고요청 화면의 **알림 스위치 ON**.
|
||||
그러면 새 품목 등록 시 "모모유통" 이름·아이콘으로 푸시가 옵니다.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"packageId": "com.momotogether.app",
|
||||
"host": "momotogether.com",
|
||||
"name": "모모유통 ERP",
|
||||
"launcherName": "모모ERP",
|
||||
"display": "standalone",
|
||||
"themeColor": "#1f2937",
|
||||
"themeColorDark": "#1f2937",
|
||||
"navigationColor": "#1f2937",
|
||||
"navigationColorDark": "#1f2937",
|
||||
"navigationDividerColor": "#1f2937",
|
||||
"navigationDividerColorDark": "#1f2937",
|
||||
"backgroundColor": "#ffffff",
|
||||
"enableNotifications": true,
|
||||
"startUrl": "/m/login",
|
||||
"iconUrl": "https://momotogether.com/icon-512.png",
|
||||
"maskableIconUrl": "https://momotogether.com/icon-512.png",
|
||||
"monochromeIconUrl": "https://momotogether.com/badge-96.png",
|
||||
"shortcuts": [],
|
||||
"generatorApp": "bubblewrap-cli",
|
||||
"webManifestUrl": "https://momotogether.com/manifest.json",
|
||||
"fallbackType": "customtabs",
|
||||
"features": {},
|
||||
"alphaDependencies": { "enabled": false },
|
||||
"enableSiteSettingsShortcut": true,
|
||||
"isChromeOSOnly": false,
|
||||
"isMetaQuest": false,
|
||||
"fullScopeUrl": "https://momotogether.com/",
|
||||
"minSdkVersion": 21,
|
||||
"orientation": "portrait",
|
||||
"fingerprints": [],
|
||||
"additionalTrustedOrigins": [],
|
||||
"retainedBundles": [],
|
||||
"appVersion": "1.0.1",
|
||||
"appVersionCode": 2,
|
||||
"signingKey": {
|
||||
"path": "./android.keystore",
|
||||
"alias": "android"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
echo "CREATE ROLE pro_search; CREATE ROLE search_user;" | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"
|
||||
-582482
File diff suppressed because one or more lines are too long
@@ -1,209 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- 모모유통 (MOMO) 유통관리 시스템 — 초기 스키마
|
||||
-- 기존 FITO 테이블과 분리하기 위해 momo_ 접두사 사용
|
||||
-- 실행: psql $DATABASE_URL -f db/migrations/001_momo_init.sql
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 회원 (대리점 + 관리자) ----------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_users (
|
||||
objid TEXT PRIMARY KEY,
|
||||
email VARCHAR(200) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(200) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
ceo_name VARCHAR(100),
|
||||
biz_no VARCHAR(20),
|
||||
phone VARCHAR(50),
|
||||
address VARCHAR(300),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER | ADMIN
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | LOCKED | LEFT
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_users_email ON momo_users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_users_role ON momo_users(role, status);
|
||||
|
||||
-- 2. 제조사 ------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_makers (
|
||||
objid TEXT PRIMARY KEY,
|
||||
maker_name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
|
||||
-- 3. 품목 --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
item_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
item_name VARCHAR(200) NOT NULL,
|
||||
item_detail TEXT,
|
||||
maker_objid TEXT,
|
||||
unit VARCHAR(20) DEFAULT 'EA',
|
||||
unit_price NUMERIC(15,2) DEFAULT 0,
|
||||
cost_price NUMERIC(15,2) DEFAULT 0,
|
||||
is_tax_free CHAR(1) DEFAULT 'N', -- 'Y' = 면세 (M 접두 품목)
|
||||
image_url TEXT,
|
||||
attributes JSONB,
|
||||
status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_status ON momo_items(status, is_del);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_taxfree ON momo_items(is_tax_free);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_name ON momo_items(item_name);
|
||||
|
||||
-- 4. 창고 --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_warehouses (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
wh_name VARCHAR(200) NOT NULL,
|
||||
location VARCHAR(200),
|
||||
wh_type VARCHAR(20) DEFAULT 'STOCK', -- STOCK | PICKUP_TEAM | MARKET | DELIVERY
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5. 재고 (창고×품목) --------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_stocks (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
qty NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
update_date TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(wh_objid, item_objid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_stocks_item ON momo_stocks(item_objid);
|
||||
|
||||
-- 6. 입출고 이력 -------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_stock_moves (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
move_type VARCHAR(20) NOT NULL, -- IN | OUT | ADJ | TRANSFER
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
ref_type VARCHAR(20), -- ORDER | PROCUREMENT | MANUAL
|
||||
ref_objid TEXT,
|
||||
memo TEXT,
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_moves_item ON momo_stock_moves(item_objid, regdate);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_moves_ref ON momo_stock_moves(ref_type, ref_objid);
|
||||
|
||||
-- 7. 발주서 (대리점 → 모모유통) ---------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_orders (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_no VARCHAR(50) NOT NULL UNIQUE,
|
||||
customer_objid TEXT NOT NULL,
|
||||
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
|
||||
approve_user TEXT,
|
||||
approve_date TIMESTAMP,
|
||||
ship_date TIMESTAMP,
|
||||
invoice_no VARCHAR(50),
|
||||
invoice_date DATE,
|
||||
total_supply NUMERIC(15,2) DEFAULT 0,
|
||||
total_vat NUMERIC(15,2) DEFAULT 0,
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
total_taxfree NUMERIC(15,2) DEFAULT 0,
|
||||
total_taxable NUMERIC(15,2) DEFAULT 0,
|
||||
paid_amount NUMERIC(15,2) DEFAULT 0,
|
||||
paid_date DATE,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_orders_cust ON momo_orders(customer_objid, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_orders_status ON momo_orders(status, order_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_order_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
item_name_snap VARCHAR(200),
|
||||
unit_price NUMERIC(15,2) NOT NULL,
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
is_tax_free CHAR(1) NOT NULL DEFAULT 'N',
|
||||
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
seq INT,
|
||||
remark VARCHAR(200)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_order_items ON momo_order_items(order_objid);
|
||||
|
||||
-- 8. 매입처 / 매입발주 -------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_vendors (
|
||||
objid TEXT PRIMARY KEY,
|
||||
vendor_name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
biz_no VARCHAR(20),
|
||||
is_del CHAR(1) DEFAULT 'N'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_procurements (
|
||||
objid TEXT PRIMARY KEY,
|
||||
proc_no VARCHAR(50) NOT NULL UNIQUE,
|
||||
vendor_objid TEXT,
|
||||
proc_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'OPEN',
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_procurement_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
proc_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
cost_price NUMERIC(15,2) NOT NULL,
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
total_amount NUMERIC(15,2) NOT NULL,
|
||||
received_qty NUMERIC(15,2) DEFAULT 0
|
||||
);
|
||||
|
||||
-- 9. 첨부 / 메일 로그 --------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_attachments (
|
||||
objid TEXT PRIMARY KEY,
|
||||
ref_type VARCHAR(20) NOT NULL,
|
||||
ref_objid TEXT NOT NULL,
|
||||
file_name VARCHAR(300) NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
mime_type VARCHAR(100),
|
||||
file_size BIGINT,
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_attach_ref ON momo_attachments(ref_type, ref_objid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_mail_logs (
|
||||
objid TEXT PRIMARY KEY,
|
||||
to_email VARCHAR(200) NOT NULL,
|
||||
subject VARCHAR(300),
|
||||
body TEXT,
|
||||
ref_type VARCHAR(20),
|
||||
ref_objid TEXT,
|
||||
status VARCHAR(20) DEFAULT 'PENDING',
|
||||
error_msg TEXT,
|
||||
sent_at TIMESTAMP,
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_maillogs_ref ON momo_mail_logs(ref_type, ref_objid);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,37 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- 모모유통 시드 데이터 — 초기 관리자, 창고 4개, 샘플 제조사
|
||||
-- 실행 전 db/migrations/001_momo_init.sql 적용 필요
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 초기 관리자 (이메일: admin@momo.com / 비밀번호: admin1234 — bcrypt)
|
||||
-- bcrypt hash for "admin1234" cost=10 — 운영 시 반드시 비밀번호 변경
|
||||
INSERT INTO momo_users (objid, email, password_hash, company_name, role, status, regdate)
|
||||
VALUES (
|
||||
'MOMOADM00000001',
|
||||
'admin@momo.com',
|
||||
'$2b$10$gqkZxYVzQwH8gCWPvfBtFOg/9QDx2iO3p0d8RA7d7j.VhSZqHfqTa',
|
||||
'모모유통(관리자)',
|
||||
'ADMIN',
|
||||
'ACTIVE',
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- 창고
|
||||
INSERT INTO momo_warehouses (objid, wh_code, wh_name, wh_type, regdate) VALUES
|
||||
('MOMOWH000000001', 'WH001', '본사창고', 'STOCK', NOW()),
|
||||
('MOMOWH000000002', 'WH002', '시장픽업', 'MARKET', NOW()),
|
||||
('MOMOWH000000003', 'WH003', '용차배송', 'DELIVERY', NOW()),
|
||||
('MOMOWH000000004', 'WH004', '창고픽업팀','PICKUP_TEAM', NOW())
|
||||
ON CONFLICT (wh_code) DO NOTHING;
|
||||
|
||||
-- 샘플 제조사
|
||||
INSERT INTO momo_makers (objid, maker_name, regdate) VALUES
|
||||
('MOMOMK000000001', '성부유통', NOW()),
|
||||
('MOMOMK000000002', '과트', NOW()),
|
||||
('MOMOMK000000003', '날로유진', NOW())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,63 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- 모모유통 v2 — 매입발주/입고/정산 메뉴 분리
|
||||
-- - 매입발주 입고 시 불량/파손 수량 분리
|
||||
-- - 출고관리 상태값 재정의 (REQUESTED → APPROVED(=출고완료) → PAID → INVOICED)
|
||||
-- - 매입처에 추가 정보
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 매입발주 입고 라인에 정상/불량/파손 수량 분리
|
||||
ALTER TABLE momo_procurement_items
|
||||
ADD COLUMN IF NOT EXISTS received_normal NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS received_defect NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS defect_memo VARCHAR(500);
|
||||
|
||||
-- 입고 처리 헤더 (1매입발주 → N입고)
|
||||
CREATE TABLE IF NOT EXISTS momo_inbounds (
|
||||
objid TEXT PRIMARY KEY,
|
||||
inbound_no VARCHAR(50) UNIQUE,
|
||||
proc_objid TEXT, -- 매입발주 참조 (없어도 단독 입고 가능)
|
||||
vendor_objid TEXT,
|
||||
wh_objid TEXT NOT NULL,
|
||||
inbound_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'COMPLETED', -- COMPLETED | CANCELLED
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_inbounds_date ON momo_inbounds(inbound_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_inbounds_proc ON momo_inbounds(proc_objid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_inbound_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
inbound_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
qty_normal NUMERIC(15,2) NOT NULL DEFAULT 0, -- 입고 정상 수량 → 재고에 +
|
||||
qty_defect NUMERIC(15,2) NOT NULL DEFAULT 0, -- 불량/파손 (재고 미반영)
|
||||
cost_price NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
defect_reason VARCHAR(200), -- 파손/유통기한임박/불량 등
|
||||
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
seq INT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_inbound_items ON momo_inbound_items(inbound_objid);
|
||||
|
||||
-- 매입처에 주소/이메일 추가
|
||||
ALTER TABLE momo_vendors
|
||||
ADD COLUMN IF NOT EXISTS email VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS address VARCHAR(300),
|
||||
ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW();
|
||||
|
||||
-- 품목에 소비기한 / 본사+지사 구분 (엑셀 요청 7,8번)
|
||||
-- attributes JSONB 에 자유 키 저장 가능. 별도 컬럼 추가는 생략.
|
||||
|
||||
-- 매입처 시드 (없으면)
|
||||
INSERT INTO momo_vendors (objid, vendor_name, contact, phone)
|
||||
VALUES
|
||||
('VND_DEFAULT_001', '도매처A (기본)', '담당자', '02-0000-0000'),
|
||||
('VND_DEFAULT_002', '도매처B (기본)', '담당자', '02-0000-0000')
|
||||
ON CONFLICT (objid) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,78 +0,0 @@
|
||||
-- 권한/메뉴 관리 (추후 ERP 확장용)
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_roles (
|
||||
objid TEXT PRIMARY KEY,
|
||||
role_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
role_name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(300),
|
||||
is_system CHAR(1) DEFAULT 'N', -- 'Y'면 시스템 기본권한 (수정/삭제 불가)
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_menus (
|
||||
objid TEXT PRIMARY KEY,
|
||||
menu_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
menu_name VARCHAR(100) NOT NULL,
|
||||
menu_url VARCHAR(200),
|
||||
icon_name VARCHAR(50),
|
||||
parent_code VARCHAR(50),
|
||||
sort_order INT DEFAULT 0,
|
||||
group_name VARCHAR(50),
|
||||
is_system CHAR(1) DEFAULT 'N',
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_role_menus (
|
||||
role_code VARCHAR(50) NOT NULL,
|
||||
menu_code VARCHAR(50) NOT NULL,
|
||||
can_read CHAR(1) DEFAULT 'Y',
|
||||
can_write CHAR(1) DEFAULT 'N',
|
||||
PRIMARY KEY (role_code, menu_code)
|
||||
);
|
||||
|
||||
-- 시스템 기본 권한 시드
|
||||
INSERT INTO momo_roles (objid, role_code, role_name, description, is_system) VALUES
|
||||
('ROLE_USER', 'USER', '거래처', '품목 검색 및 출고 요청만 가능', 'Y'),
|
||||
('ROLE_ADMIN', 'ADMIN', '관리자', '모든 메뉴 접근 + 승인/정산', 'Y')
|
||||
ON CONFLICT (role_code) DO NOTHING;
|
||||
|
||||
-- 메뉴 마스터 시드 (현재 사이드바와 동일 구조)
|
||||
INSERT INTO momo_menus (objid, menu_code, menu_name, menu_url, group_name, sort_order, is_system) VALUES
|
||||
('M_DASH', 'DASHBOARD', '대시보드', '/m/dashboard', '_top', 1, 'Y'),
|
||||
('M_ITEMS', 'ITEMS', '품목 검색', '/m/items', '주문', 10, 'Y'),
|
||||
('M_ONEW', 'ORDER_NEW', '출고 요청', '/m/orders/new', '주문', 11, 'Y'),
|
||||
('M_OLIST', 'ORDER_LIST', '내 출고 이력', '/m/orders', '주문', 12, 'Y'),
|
||||
('M_AITEM', 'A_ITEM', '품목 관리', '/m/admin/items', '마스터', 20, 'Y'),
|
||||
('M_AVEND', 'A_VENDOR', '매입처 관리', '/m/admin/vendors', '마스터', 21, 'Y'),
|
||||
('M_AWH', 'A_WH', '창고 관리', '/m/admin/warehouses', '마스터', 22, 'Y'),
|
||||
('M_AUSER', 'A_USER', '회원 관리', '/m/admin/users', '마스터', 23, 'Y'),
|
||||
('M_APROC', 'A_PROC', '매입 발주', '/m/admin/procurements','매입', 30, 'Y'),
|
||||
('M_AINB', 'A_INBOUND', '입고 처리', '/m/admin/inbounds', '매입', 31, 'Y'),
|
||||
('M_AINV', 'A_INV', '재고 관리', '/m/admin/inventory', '매입', 32, 'Y'),
|
||||
('M_AORD', 'A_ORD', '출고 관리', '/m/admin/orders', '출고/정산', 40, 'Y'),
|
||||
('M_APAY', 'A_PAY', '입금 관리', '/m/admin/payments', '출고/정산', 41, 'Y'),
|
||||
('M_AINVO', 'A_INVOICE', '계산서 발행', '/m/admin/invoices', '출고/정산', 42, 'Y'),
|
||||
('M_ASTAT', 'A_STAT_M', '월간 매출', '/m/admin/statistics', '통계', 50, 'Y'),
|
||||
('M_ASTAD', 'A_STAT_D', '일자별', '/m/admin/statistics/daily', '통계', 51, 'Y'),
|
||||
('M_ASTAR', 'A_STAT_R', '원가/마진', '/m/admin/statistics/margin', '통계', 52, 'Y'),
|
||||
('M_AROLE', 'A_ROLE', '권한 관리', '/m/admin/roles', '시스템', 90, 'Y'),
|
||||
('M_AMENU', 'A_MENU', '메뉴 관리', '/m/admin/menus', '시스템', 91, 'Y')
|
||||
ON CONFLICT (menu_code) DO NOTHING;
|
||||
|
||||
-- USER 권한에 거래처 메뉴 매핑
|
||||
INSERT INTO momo_role_menus (role_code, menu_code, can_read, can_write) VALUES
|
||||
('USER', 'DASHBOARD', 'Y','N'),
|
||||
('USER', 'ITEMS', 'Y','N'),
|
||||
('USER', 'ORDER_NEW', 'Y','Y'),
|
||||
('USER', 'ORDER_LIST', 'Y','N')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ADMIN 권한에 모든 메뉴 매핑
|
||||
INSERT INTO momo_role_menus (role_code, menu_code, can_read, can_write)
|
||||
SELECT 'ADMIN', menu_code, 'Y', 'Y' FROM momo_menus
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,53 +0,0 @@
|
||||
-- 모모유통 페이지를 FITO menu_info 의 [사용자] 그룹 아래에 대메뉴/소메뉴 2단 구조로 등록
|
||||
-- 기존 [DASHBOARD] 대메뉴 활용 + 거래처/마스터/매입/출고-정산/통계 5개 신규 대메뉴
|
||||
BEGIN;
|
||||
|
||||
-- 기존 모모 메뉴 정리
|
||||
DELETE FROM menu_info WHERE objid BETWEEN 9000000 AND 9000599;
|
||||
|
||||
-- ===== 신규 대메뉴 (parent = -395553955 [사용자]) =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000100, '1', -395553955, '거래처 주문', 'Customer Orders', 600, '', 'active', 'PMS', NOW()),
|
||||
(9000200, '1', -395553955, '마스터 관리', 'Master', 650, '', 'active', 'PMS', NOW()),
|
||||
(9000300, '1', -395553955, '매입/입고', 'Purchase', 700, '', 'active', 'PMS', NOW()),
|
||||
(9000400, '1', -395553955, '출고/정산', 'Outbound', 750, '', 'active', 'PMS', NOW()),
|
||||
(9000500, '1', -395553955, '통계', 'Statistics', 800, '', 'active', 'PMS', NOW());
|
||||
|
||||
-- ===== 기존 [DASHBOARD] 대메뉴(1837127121) 아래 자식 =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000001, '1', 1837127121, '대시보드', 'Dashboard', 1, '/m/dashboard', 'active', 'PMS', NOW());
|
||||
|
||||
-- 기존 [DASHBOARD] 대메뉴의 다른 자식(예: dashboard.do)은 비활성화
|
||||
UPDATE menu_info SET status='inactive' WHERE parent_obj_id = 1837127121 AND objid != 9000001;
|
||||
|
||||
-- ===== 거래처 주문 (9000100) =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000101, '1', 9000100, '품목 검색', 'Items', 10, '/m/items', 'active', 'PMS', NOW()),
|
||||
(9000102, '1', 9000100, '출고 요청', 'New Order', 11, '/m/orders/new', 'active', 'PMS', NOW()),
|
||||
(9000103, '1', 9000100, '내 출고 이력', 'My Orders', 12, '/m/orders', 'active', 'PMS', NOW());
|
||||
|
||||
-- ===== 마스터 관리 (9000200) =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000201, '1', 9000200, '품목 관리', 'Item Master', 10, '/m/admin/items', 'active', 'PMS', NOW()),
|
||||
(9000202, '1', 9000200, '매입처 관리', 'Vendors', 11, '/m/admin/vendors', 'active', 'PMS', NOW()),
|
||||
(9000203, '1', 9000200, '창고 관리', 'Warehouses', 12, '/m/admin/warehouses', 'active', 'PMS', NOW());
|
||||
|
||||
-- ===== 매입/입고 (9000300) =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000301, '1', 9000300, '매입 발주', 'Procurements', 10, '/m/admin/procurements', 'active', 'PMS', NOW()),
|
||||
(9000302, '1', 9000300, '입고 처리', 'Inbound', 11, '/m/admin/inbounds', 'active', 'PMS', NOW()),
|
||||
(9000303, '1', 9000300, '재고 관리', 'Inventory', 12, '/m/admin/inventory', 'active', 'PMS', NOW());
|
||||
|
||||
-- ===== 출고/정산 (9000400) =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000401, '1', 9000400, '출고 관리', 'Outbound', 10, '/m/admin/orders', 'active', 'PMS', NOW()),
|
||||
(9000402, '1', 9000400, '입금 관리', 'Payments', 11, '/m/admin/payments', 'active', 'PMS', NOW()),
|
||||
(9000403, '1', 9000400, '계산서 발행', 'Invoices', 12, '/m/admin/invoices', 'active', 'PMS', NOW());
|
||||
|
||||
-- ===== 통계 (9000500) =====
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate) VALUES
|
||||
(9000501, '1', 9000500, '월간 매출', 'Stat Monthly', 10, '/m/admin/statistics', 'active', 'PMS', NOW()),
|
||||
(9000502, '1', 9000500, '일자별 매출', 'Stat Daily', 11, '/m/admin/statistics/daily', 'active', 'PMS', NOW()),
|
||||
(9000503, '1', 9000500, '원가/마진', 'Margin', 12, '/m/admin/statistics/margin', 'active', 'PMS', NOW());
|
||||
|
||||
COMMIT;
|
||||
@@ -1,63 +0,0 @@
|
||||
-- 거래처 가입자에 필요한 추가 정보를 user_info 에 직접 컬럼으로 추가 (스펙 §3.1 B안)
|
||||
-- supply_mng 와 user_info.partner_objid 연결도 가능하지만, 신규 가입 흐름 단순화 위해 직접 컬럼 추가.
|
||||
-- 이미 컬럼이 있으면 ADD COLUMN IF NOT EXISTS 로 idempotent.
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE user_info
|
||||
ADD COLUMN IF NOT EXISTS biz_no VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS ceo_name VARCHAR(100);
|
||||
|
||||
-- 품목 검색 메뉴(스펙 §5에서 출고 요청과 통합으로 변경됨) 비활성화
|
||||
UPDATE menu_info SET status = 'inactive' WHERE objid = 9000101;
|
||||
|
||||
-- ===== 관리자 admin-panel 의 [메뉴관리] 섹션 복구 =====
|
||||
-- [관리자] 루트(parent=0, menu_name_kor='관리자') 아래에 [메뉴관리] 섹션 + [메뉴관리] 자식이 status='active' 로 존재해야
|
||||
-- /api/admin/sidebar-menus 가 노출함. 누락된 경우 idempotent 하게 보장.
|
||||
DO $$
|
||||
DECLARE
|
||||
admin_root_id NUMERIC;
|
||||
menu_section_id NUMERIC;
|
||||
BEGIN
|
||||
SELECT objid INTO admin_root_id FROM menu_info
|
||||
WHERE parent_obj_id = 0 AND menu_name_kor = '관리자' LIMIT 1;
|
||||
IF admin_root_id IS NULL THEN
|
||||
RAISE NOTICE '[admin] 루트가 없어 메뉴관리 복구 스킵';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- 섹션이 존재하면 active 로 보장, 없으면 9000600 으로 신규 등록
|
||||
SELECT objid INTO menu_section_id FROM menu_info
|
||||
WHERE parent_obj_id = admin_root_id AND menu_name_kor = '메뉴관리' LIMIT 1;
|
||||
IF menu_section_id IS NULL THEN
|
||||
menu_section_id := 9000600;
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, status, system_name, regdate)
|
||||
VALUES (menu_section_id, '1', admin_root_id, '메뉴관리', 'Menu Management',
|
||||
10, '', 'active', 'PMS', NOW());
|
||||
ELSE
|
||||
UPDATE menu_info SET status = 'active' WHERE objid = menu_section_id;
|
||||
END IF;
|
||||
|
||||
-- 자식: 메뉴관리 (LABEL_TO_TAB 매핑이 '메뉴관리' → 'menu' 이므로 정확히 동일 이름 필수)
|
||||
-- menu_info.objid 에 unique 제약이 없을 수 있으므로 ON CONFLICT 대신 EXISTS 분기로 idempotent 처리
|
||||
IF EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000601) THEN
|
||||
UPDATE menu_info
|
||||
SET parent_obj_id = menu_section_id,
|
||||
menu_name_kor = '메뉴관리',
|
||||
menu_name_eng = 'Menus',
|
||||
menu_url = '',
|
||||
status = 'active',
|
||||
system_name = 'PMS'
|
||||
WHERE objid = 9000601;
|
||||
ELSIF NOT EXISTS (
|
||||
SELECT 1 FROM menu_info
|
||||
WHERE parent_obj_id = menu_section_id AND menu_name_kor = '메뉴관리'
|
||||
) THEN
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, status, system_name, regdate)
|
||||
VALUES (9000601, '1', menu_section_id, '메뉴관리', 'Menus',
|
||||
10, '', 'active', 'PMS', NOW());
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- 회원가입 주소 입력 항목 추가 (스펙 §1: 이메일/업체명/전화번호/주소 필수)
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE user_info
|
||||
ADD COLUMN IF NOT EXISTS address VARCHAR(300);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 제조사 관리 메뉴를 마스터 관리(9000200) 아래에 추가
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000204) THEN
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, status, system_name, regdate)
|
||||
VALUES (9000204, '1', 9000200, '제조사 관리', 'Makers', 13, '/m/admin/makers', 'active', 'PMS', NOW());
|
||||
ELSE
|
||||
UPDATE menu_info
|
||||
SET parent_obj_id = 9000200, menu_name_kor = '제조사 관리',
|
||||
menu_url = '/m/admin/makers', status = 'active'
|
||||
WHERE objid = 9000204;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,97 +0,0 @@
|
||||
-- 009_items_user_permissions.sql
|
||||
-- v0.3 (2026-04-27)
|
||||
-- 1) MASTER_PWD 백도어 제거 (코드 변경, DB 작업 없음)
|
||||
-- 2) plm_admin → admin 으로 user_id 변경, 비밀번호 '1' (AES 암호화) 재설정
|
||||
-- 3) 모모유통 임직원 6명 등록 (user_type='A', 비밀번호 'momo2026##')
|
||||
-- 4) 거래처(user_type='C')는 보존, 그 외 FITO 레거시 인사정보는 일괄 삭제
|
||||
-- 5) momo_items 에 max_order_qty, is_hidden 컬럼 추가
|
||||
-- 6) user_info 에 unlimited_qty, view_hidden 컬럼 추가 (거래처 권한)
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 1. plm_admin → admin 으로 user_id 변경 + 비밀번호 '1' 재설정
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 기존 admin user_id 가 이미 있을 수 있으니 먼저 확인 후 처리
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM user_info WHERE user_id = 'plm_admin') THEN
|
||||
-- 동시에 admin 이 이미 있으면 plm_admin 만 삭제
|
||||
IF EXISTS (SELECT 1 FROM user_info WHERE user_id = 'admin') THEN
|
||||
DELETE FROM user_info WHERE user_id = 'plm_admin';
|
||||
ELSE
|
||||
UPDATE user_info
|
||||
SET user_id = 'admin',
|
||||
user_password = 'i8+4uUD3yNGbj6Lz1er20A==',
|
||||
user_type = 'A',
|
||||
user_type_name = '관리자',
|
||||
status = 'active'
|
||||
WHERE user_id = 'plm_admin';
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 그래도 admin 이 없으면 신규 INSERT
|
||||
INSERT INTO user_info (user_id, user_password, user_name, user_type, user_type_name, status, regdate)
|
||||
SELECT 'admin', 'i8+4uUD3yNGbj6Lz1er20A==', '시스템 관리자', 'A', '관리자', 'active', NOW()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM user_info WHERE user_id = 'admin');
|
||||
|
||||
-- admin 비밀번호는 항상 '1' 로 재설정 (이미 존재하던 admin 도 통일)
|
||||
UPDATE user_info
|
||||
SET user_password = 'i8+4uUD3yNGbj6Lz1er20A==',
|
||||
user_type = 'A',
|
||||
user_type_name = '관리자',
|
||||
status = 'active'
|
||||
WHERE user_id = 'admin';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 2. 모모유통 임직원 6명 등록 (UPSERT)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
INSERT INTO user_info
|
||||
(user_id, user_password, user_name, position_name, cell_phone, email,
|
||||
user_type, user_type_name, status, regdate)
|
||||
VALUES
|
||||
('momo8443','95sOzM8nDQRukpt02Uxuaw==','이상용','대표', '010-6369-8443','momo8443@daum.net', 'A','관리자','active',NOW()),
|
||||
('momo5826','95sOzM8nDQRukpt02Uxuaw==','이윤정','총괄이사', '010-4082-5826','momo8443@daum.net', 'A','관리자','active',NOW()),
|
||||
('momo5315','95sOzM8nDQRukpt02Uxuaw==','배연진','경영팀장', '010-6624-5315','momo8443@daum.net', 'A','관리자','active',NOW()),
|
||||
('momo9431','95sOzM8nDQRukpt02Uxuaw==','강상익','김포지사 총괄', '010-5789-9431','momokimpo@nate.com', 'A','관리자','active',NOW()),
|
||||
('momo4763','95sOzM8nDQRukpt02Uxuaw==','이효철','물류총괄', '010-4104-4763','momo8443@daum.net', 'A','관리자','active',NOW()),
|
||||
('momo7529','95sOzM8nDQRukpt02Uxuaw==','유우형','물류팀장', '010-4134-7529','momo8443@daum.net', 'A','관리자','active',NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
user_password = EXCLUDED.user_password,
|
||||
user_name = EXCLUDED.user_name,
|
||||
position_name = EXCLUDED.position_name,
|
||||
cell_phone = EXCLUDED.cell_phone,
|
||||
email = EXCLUDED.email,
|
||||
user_type = EXCLUDED.user_type,
|
||||
user_type_name = EXCLUDED.user_type_name,
|
||||
status = EXCLUDED.status;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 3. (DISABLED) 과거: 거래처(C) + admin + 모모6인 외 모든 사용자 삭제하던 정리 쿼리.
|
||||
-- user_type 을 'C' → 'U' 로 통합한 뒤로 'U' 거래처 134명이 매 배포마다 삭제되는
|
||||
-- 심각한 데이터 손실이 발생해서 비활성화함. 마이그레이션은 반드시 idempotent 해야 하므로
|
||||
-- destructive 구문은 두지 않는다.
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 4. momo_items 컬럼 추가: 발주 제한수량 + 숨김처리
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE momo_items
|
||||
ADD COLUMN IF NOT EXISTS max_order_qty INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS is_hidden CHAR(1) NOT NULL DEFAULT 'N';
|
||||
|
||||
COMMENT ON COLUMN momo_items.max_order_qty IS '1회 발주 최대 수량 (NULL/0 = 제한 없음)';
|
||||
COMMENT ON COLUMN momo_items.is_hidden IS '숨김 처리 (Y/N) — Y이면 view_hidden 권한 회원에게만 노출';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 5. user_info 컬럼 추가: 거래처 특수 권한
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE user_info
|
||||
ADD COLUMN IF NOT EXISTS unlimited_qty CHAR(1) NOT NULL DEFAULT 'N',
|
||||
ADD COLUMN IF NOT EXISTS view_hidden CHAR(1) NOT NULL DEFAULT 'N';
|
||||
|
||||
COMMENT ON COLUMN user_info.unlimited_qty IS '제한수량 해지 권한 (Y/N) — Y이면 max_order_qty 무시';
|
||||
COMMENT ON COLUMN user_info.view_hidden IS '숨김처리 보기 권한 (Y/N) — Y이면 is_hidden=Y 품목도 노출';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 010_delivery_charter.sql
|
||||
-- v0.4 (2026-04-27)
|
||||
-- 발주서에 택배비/용차비 라인 + 택배 전용 품목 자동 라인 지원
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. momo_items: 택배 전용 플래그
|
||||
ALTER TABLE momo_items
|
||||
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) NOT NULL DEFAULT 'N';
|
||||
COMMENT ON COLUMN momo_items.requires_delivery
|
||||
IS '택배 전용 품목 (Y) — 카트에 담기면 택배 라인이 자동으로 추가됨';
|
||||
|
||||
-- 2. momo_order_items: 라인 종류 + 라벨
|
||||
-- kind: 'ITEM'(품목) / 'DELIVERY'(택배비) / 'CHARTER'(용차비)
|
||||
ALTER TABLE momo_order_items
|
||||
ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'ITEM',
|
||||
ADD COLUMN IF NOT EXISTS extra_label VARCHAR(100);
|
||||
COMMENT ON COLUMN momo_order_items.kind
|
||||
IS 'ITEM=품목 / DELIVERY=택배비 / CHARTER=용차비';
|
||||
COMMENT ON COLUMN momo_order_items.extra_label
|
||||
IS '택배비/용차비 라인의 담당자명 또는 부가 메모';
|
||||
|
||||
-- 기존 가맹 데이터는 ITEM 으로 간주
|
||||
UPDATE momo_order_items SET kind = 'ITEM' WHERE kind IS NULL;
|
||||
|
||||
-- 3. momo_orders: 택배비/용차비 합계 (집계 편의용)
|
||||
ALTER TABLE momo_orders
|
||||
ADD COLUMN IF NOT EXISTS total_delivery NUMERIC(15,2) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS total_charter NUMERIC(15,2) DEFAULT 0;
|
||||
COMMENT ON COLUMN momo_orders.total_delivery IS '택배비 라인 합계';
|
||||
COMMENT ON COLUMN momo_orders.total_charter IS '용차비 라인 합계';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 011_extra_lines_nullable.sql
|
||||
-- v0.5 (2026-05-07)
|
||||
-- 택배(DELIVERY)/용차(CHARTER) 라인은 item_objid 가 없는 가상 라인이므로
|
||||
-- NOT NULL 제약을 풀어준다.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE momo_order_items ALTER COLUMN item_objid DROP NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN momo_order_items.item_objid
|
||||
IS '품목 OBJID. ITEM 라인은 NOT NULL, DELIVERY/CHARTER 라인은 NULL.';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,73 +0,0 @@
|
||||
-- 012_einvoices.sql
|
||||
-- v0.6 (2026-05-07)
|
||||
-- 전자세금계산서 발행 이력 테이블 — 국세청 e-세로 직접 연동 + 향후 다른 어댑터 호환
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_einvoices (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_objid TEXT, -- 연결된 발주(있으면)
|
||||
customer_objid TEXT NOT NULL, -- user_info.user_id (공급받는자)
|
||||
invoice_kind VARCHAR(20) NOT NULL DEFAULT 'TAX', -- TAX(세금계산서) / TAXFREE(계산서) / RECEIPT(영수)
|
||||
invoice_type VARCHAR(20) NOT NULL DEFAULT 'NORMAL', -- NORMAL / MODIFIED(수정) / CANCELED(취소)
|
||||
issue_method VARCHAR(20) NOT NULL DEFAULT 'NTS', -- NTS(국세청 직접) / POPBILL / MANUAL
|
||||
-- 공급자
|
||||
supplier_biz_no VARCHAR(20),
|
||||
supplier_name VARCHAR(200),
|
||||
supplier_ceo VARCHAR(100),
|
||||
supplier_address TEXT,
|
||||
supplier_business VARCHAR(100), -- 업태
|
||||
supplier_item VARCHAR(100), -- 종목
|
||||
-- 공급받는자
|
||||
buyer_biz_no VARCHAR(20),
|
||||
buyer_name VARCHAR(200),
|
||||
buyer_ceo VARCHAR(100),
|
||||
buyer_address TEXT,
|
||||
buyer_email VARCHAR(200),
|
||||
buyer_phone VARCHAR(50),
|
||||
-- 금액
|
||||
total_supply NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
total_vat NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
-- 국세청 식별자
|
||||
nts_invoice_no VARCHAR(40), -- 국세청 승인번호 (24자리)
|
||||
nts_response_code VARCHAR(10), -- 응답코드
|
||||
nts_response_msg TEXT,
|
||||
nts_sent_at TIMESTAMP, -- 국세청 전송 시각
|
||||
nts_acknowledged CHAR(1) DEFAULT 'N', -- 승인 여부
|
||||
-- 상태
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
-- DRAFT(작성중) / QUEUED(전송대기) / SENT(전송완료) / ACK(승인완료) / FAIL(실패) / CANCELED(취소)
|
||||
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
-- 원본 XML / 응답 (디버깅용)
|
||||
request_xml TEXT,
|
||||
response_xml TEXT,
|
||||
-- 메타
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_order ON momo_einvoices(order_objid);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_buyer ON momo_einvoices(customer_objid, issue_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_status ON momo_einvoices(status, issue_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_einvoices_nts ON momo_einvoices(nts_invoice_no);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_einvoice_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
einvoice_objid TEXT NOT NULL REFERENCES momo_einvoices(objid) ON DELETE CASCADE,
|
||||
seq INT NOT NULL,
|
||||
item_date DATE,
|
||||
item_name VARCHAR(200) NOT NULL,
|
||||
spec VARCHAR(100),
|
||||
qty NUMERIC(15,2),
|
||||
unit_price NUMERIC(15,2),
|
||||
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
remark VARCHAR(200)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_einvoice_items ON momo_einvoice_items(einvoice_objid, seq);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 013_einvoice_menu.sql
|
||||
-- v0.6 (2026-05-07)
|
||||
-- 전자세금계산서 메뉴 등록 (출고/정산 그룹 9000400 아래)
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000404) THEN
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, status, system_name, regdate)
|
||||
VALUES (9000404, '1', 9000400, '전자세금계산서', 'eTax Invoice',
|
||||
13, '/m/admin/einvoices', 'active', 'PMS', NOW());
|
||||
ELSE
|
||||
UPDATE menu_info
|
||||
SET parent_obj_id = 9000400,
|
||||
menu_name_kor = '전자세금계산서',
|
||||
menu_name_eng = 'eTax Invoice',
|
||||
menu_url = '/m/admin/einvoices',
|
||||
status = 'active'
|
||||
WHERE objid = 9000404;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- 014_menu_reorder.sql
|
||||
-- v0.6 (2026-05-07)
|
||||
-- 메뉴 순서 재배치 + 대시보드 위치 이동
|
||||
--
|
||||
-- 변경 후 순서:
|
||||
-- 600 거래처 주문 (9000100)
|
||||
-- 700 매입/입고 (9000300)
|
||||
-- 750 출고/정산 (9000400)
|
||||
-- 800 통계 (9000500) ← 대시보드 자식으로 포함
|
||||
-- 900 마스터 관리 (9000200) ← 마지막
|
||||
--
|
||||
-- 대시보드(9000001) 는 [DASHBOARD] 대메뉴(1837127121) → 통계(9000500) 자식으로 이동
|
||||
-- [DASHBOARD] 대메뉴 자체는 비활성화
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 대메뉴 seq 재조정 — 마스터 관리를 맨 뒤로
|
||||
UPDATE menu_info SET seq = 900 WHERE objid = 9000200; -- 마스터 관리
|
||||
-- 거래처 주문(600), 매입/입고(700), 출고/정산(750), 통계(800)는 그대로
|
||||
|
||||
-- 2. 대시보드(9000001) 를 통계(9000500) 의 첫 자식으로 이동
|
||||
UPDATE menu_info
|
||||
SET parent_obj_id = 9000500,
|
||||
seq = 5,
|
||||
menu_name_kor = '대시보드'
|
||||
WHERE objid = 9000001;
|
||||
|
||||
-- 3. 빈 [DASHBOARD] 대메뉴(1837127121) 비활성화
|
||||
UPDATE menu_info
|
||||
SET status = 'inactive'
|
||||
WHERE objid = 1837127121;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- 015_menu_reorder_v2.sql
|
||||
-- v0.6 (2026-05-07)
|
||||
-- 대메뉴 순서: 거래처 주문 → 출고/정산 → 매입/입고 → 마스터 관리 → 통계
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE menu_info SET seq = 600 WHERE objid = 9000100; -- 거래처 주문
|
||||
UPDATE menu_info SET seq = 650 WHERE objid = 9000400; -- 출고/정산
|
||||
UPDATE menu_info SET seq = 700 WHERE objid = 9000300; -- 매입/입고
|
||||
UPDATE menu_info SET seq = 750 WHERE objid = 9000200; -- 마스터 관리
|
||||
UPDATE menu_info SET seq = 800 WHERE objid = 9000500; -- 통계
|
||||
|
||||
COMMIT;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 016_vendor_extend.sql
|
||||
-- v0.7 (2026-05-07)
|
||||
-- 매입처 → 공급업체 명칭 변경 + 품목에 공급업체 연결 + 공급업체 정보 보강
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 공급업체(momo_vendors) 컬럼 보강
|
||||
ALTER TABLE momo_vendors
|
||||
ADD COLUMN IF NOT EXISTS email VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS memo TEXT,
|
||||
ADD COLUMN IF NOT EXISTS regdate TIMESTAMP DEFAULT NOW();
|
||||
|
||||
COMMENT ON TABLE momo_vendors IS '공급업체 — 발주를 보낼 도매처/제조처';
|
||||
COMMENT ON COLUMN momo_vendors.email IS '발주서 메일 발송 받을 주소';
|
||||
COMMENT ON COLUMN momo_vendors.address IS '공급업체 주소';
|
||||
|
||||
-- 2. 품목 ↔ 공급업체 연결 컬럼
|
||||
ALTER TABLE momo_items
|
||||
ADD COLUMN IF NOT EXISTS vendor_objid TEXT;
|
||||
COMMENT ON COLUMN momo_items.vendor_objid IS '주 공급업체 (momo_vendors.objid). 매입 발주 시 자동 채움';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_vendor ON momo_items(vendor_objid);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- 017_menu_rename_vendor.sql
|
||||
-- v0.7 (2026-05-07)
|
||||
-- 메뉴 명칭 변경: "매입처 관리" → "공급업체 관리"
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE menu_info
|
||||
SET menu_name_kor = '공급업체 관리',
|
||||
menu_name_eng = 'Vendors'
|
||||
WHERE objid = 9000202;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- 018_pivot_menu.sql
|
||||
-- v0.7 (2026-05-07)
|
||||
-- 통계 그룹에 "거래처×일자 매출 (피벗)" 메뉴 추가
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000504) THEN
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_url, status, system_name, regdate)
|
||||
VALUES (9000504, '1', 9000500, '거래처×일자 매출', 'Pivot Stats',
|
||||
8, '/m/admin/statistics/pivot', 'active', 'PMS', NOW());
|
||||
ELSE
|
||||
UPDATE menu_info
|
||||
SET parent_obj_id = 9000500,
|
||||
menu_name_kor = '거래처×일자 매출',
|
||||
menu_name_eng = 'Pivot Stats',
|
||||
menu_url = '/m/admin/statistics/pivot',
|
||||
status = 'active'
|
||||
WHERE objid = 9000504;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,67 +0,0 @@
|
||||
-- 019_proc_terms.sql
|
||||
-- v0.8 (2026-05-08)
|
||||
-- 1) 매입 발주서 납품조건 4필드 추가
|
||||
-- 2) 기존 공급업체 데이터 삭제 + 샘플 10개 신규 등록
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 1. momo_procurements 납품조건 컬럼
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE momo_procurements
|
||||
ADD COLUMN IF NOT EXISTS delivery_place TEXT,
|
||||
ADD COLUMN IF NOT EXISTS delivery_period TEXT,
|
||||
ADD COLUMN IF NOT EXISTS payment_terms TEXT,
|
||||
ADD COLUMN IF NOT EXISTS freight_terms TEXT;
|
||||
|
||||
COMMENT ON COLUMN momo_procurements.delivery_place IS '납품장소';
|
||||
COMMENT ON COLUMN momo_procurements.delivery_period IS '납품기간';
|
||||
COMMENT ON COLUMN momo_procurements.payment_terms IS '대금지불 조건';
|
||||
COMMENT ON COLUMN momo_procurements.freight_terms IS '운임부담';
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- 2. 공급업체(supply_mng) 초기화 + 샘플 10개
|
||||
-- supply_mng.objid 는 numeric/bigint — 시퀀스가 있을 수도/없을 수도 있어
|
||||
-- DO 블록 안에서 MAX(objid)+1 로 안전하게 부여한다.
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- 담당자 테이블 정리 (테이블이 있으면)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'supply_charger') THEN
|
||||
DELETE FROM supply_charger
|
||||
WHERE supply_objid::text IN (SELECT objid::text FROM supply_mng);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 기존 공급업체 모두 삭제
|
||||
DELETE FROM supply_mng;
|
||||
|
||||
-- 샘플 10개 — MAX(objid)+1 ~ +10 으로 부여
|
||||
DO $$
|
||||
DECLARE
|
||||
base_id BIGINT;
|
||||
samples TEXT[][] := ARRAY[
|
||||
ARRAY['VND-001', '(주)아바텍', '김영수', '02-1234-5678', '101-81-12345', 'avatec@example.com', '서울시 강남구 테헤란로 123'],
|
||||
ARRAY['VND-002', '대성식품', '이상민', '031-987-6543','129-86-54321', 'daesung@example.com', '경기도 의왕시 벌모루길 46'],
|
||||
ARRAY['VND-003', '(주)고기파는농부', '박정훈', '02-555-1212', '215-87-66721', 'meatfarmer@example.com', '서울시 송파구 문정동 88-2'],
|
||||
ARRAY['VND-004', '광이진천 농장', '최수진', '043-532-1010','317-91-12340', 'gwang2@example.com', '충북 진천군 진천읍 광혜원로 12'],
|
||||
ARRAY['VND-005', '단과일', '강동현', '063-211-3344','404-86-77890', 'danfruit@example.com', '전북 전주시 완산구 단풍로 5'],
|
||||
ARRAY['VND-006', '봉담수산', '윤소라', '031-220-7788','129-86-22301', 'bongdam@example.com', '경기도 화성시 봉담읍 와우안길 33'],
|
||||
ARRAY['VND-007', '명일동유기농', '이지호', '02-441-2233', '220-81-33445', 'myungil@example.com', '서울시 강동구 명일로 100'],
|
||||
ARRAY['VND-008', '울산단과일', '오민재', '052-733-9988','610-81-44567', 'ulsanfruit@example.com', '울산시 남구 삼산로 150'],
|
||||
ARRAY['VND-009', '농부의아침', '한세영', '031-333-4444','215-87-55667', 'morning@example.com', '경기도 양주시 백석읍 호명로 22'],
|
||||
ARRAY['VND-010', '초록마을 도매', '정혜민', '02-1577-7234','110-86-99887', 'choroc@example.com', '서울시 마포구 양화로 45']
|
||||
];
|
||||
i INT;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(objid::bigint), 0) INTO base_id FROM supply_mng;
|
||||
FOR i IN 1..10 LOOP
|
||||
INSERT INTO supply_mng
|
||||
(objid, supply_code, supply_name, charge_user_name, supply_tel_no, reg_no, email, supply_address, status, reg_id, reg_date)
|
||||
VALUES
|
||||
(base_id + i, samples[i][1], samples[i][2], samples[i][3], samples[i][4], samples[i][5], samples[i][6], samples[i][7], 'active', 'admin', NOW());
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- 권한그룹 ↔ 메뉴 매핑 테이블
|
||||
-- 권한 관리 화면에서 그룹별로 노출 메뉴를 체크박스로 매핑하기 위함
|
||||
|
||||
CREATE TABLE IF NOT EXISTS authority_sub_menu (
|
||||
objid numeric PRIMARY KEY,
|
||||
master_objid numeric NOT NULL,
|
||||
menu_objid numeric NOT NULL,
|
||||
writer varchar(100),
|
||||
regdate timestamp without time zone DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_authority_sub_menu_master ON authority_sub_menu(master_objid);
|
||||
CREATE INDEX IF NOT EXISTS idx_authority_sub_menu_menu ON authority_sub_menu(menu_objid);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_authority_sub_menu_pair ON authority_sub_menu(master_objid, menu_objid);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 거래처(또는 일반 사용자)의 기준 창고 매핑
|
||||
-- 출고 승인 시 이 창고에서 재고가 차감된다. NULL 이면 기본 STOCK 창고 사용
|
||||
ALTER TABLE user_info
|
||||
ADD COLUMN IF NOT EXISTS default_wh_objid numeric NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_info_default_wh ON user_info(default_wh_objid);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- user_info.default_wh_objid 를 text 로 변환
|
||||
-- 운영 momo_warehouses.objid 가 text 타입(예: MOMOWH000000001)이라 numeric 으로는 매핑 불가
|
||||
ALTER TABLE user_info
|
||||
ALTER COLUMN default_wh_objid TYPE text USING default_wh_objid::text;
|
||||
@@ -1,99 +0,0 @@
|
||||
-- dept_info: 회사명 업데이트
|
||||
UPDATE dept_info SET company_name = '(주)피토', location_name = '(주)피토';
|
||||
|
||||
-- user_info: 회사명(user_type_name) 업데이트
|
||||
UPDATE user_info SET user_type_name = '(주)피토' WHERE user_type_name IN ('우성에스이', '성하에스이', '현준엔지니어링');
|
||||
|
||||
-- user_info: 이메일 도메인 업데이트
|
||||
UPDATE user_info SET email = REPLACE(email, '@wsse.co.kr', '@fito.co.kr') WHERE email LIKE '%@wsse.co.kr';
|
||||
UPDATE user_info SET email = REPLACE(email, '@sunghase.co.kr', '@fito.co.kr') WHERE email LIKE '%@sunghase.co.kr';
|
||||
|
||||
-- 직원 이름 업데이트
|
||||
UPDATE user_info SET user_name = '강감찬', user_name_eng = 'Kang Gamchan' WHERE user_id = 'kwakdonghun';
|
||||
UPDATE user_info SET user_name = '김유신', user_name_eng = 'Kim Yusin' WHERE user_id = 'kjh871112';
|
||||
UPDATE user_info SET user_name = '홍길동', user_name_eng = 'Hong Gildong' WHERE user_id = 'jonghoon.kim';
|
||||
UPDATE user_info SET user_name = '이순신', user_name_eng = 'Lee Sunsin' WHERE user_id = 'jg.kim';
|
||||
UPDATE user_info SET user_name = '세종대왕', user_name_eng = 'Sejong Daewang' WHERE user_id = 'sh.park';
|
||||
UPDATE user_info SET user_name = '장영실', user_name_eng = 'Jang Youngsil' WHERE user_id = 'yrpark';
|
||||
UPDATE user_info SET user_name = '정약용', user_name_eng = 'Jeong Yagyong' WHERE user_id = 'changhwe.park';
|
||||
UPDATE user_info SET user_name = '을지문덕', user_name_eng = 'Eulji Mundeok' WHERE user_id = 'hosang.park';
|
||||
UPDATE user_info SET user_name = '광개토대왕', user_name_eng = 'Gwanggaeto Daewang' WHERE user_id = 'honggyu.park';
|
||||
UPDATE user_info SET user_name = '사임당', user_name_eng = 'Shin Saimdang' WHERE user_id = 'saito-aki';
|
||||
UPDATE user_info SET user_name = '유관순', user_name_eng = 'Yu Gwansun' WHERE user_id = 'yc.son';
|
||||
UPDATE user_info SET user_name = '안중근', user_name_eng = 'An Junggeun' WHERE user_id = 'hjshin';
|
||||
UPDATE user_info SET user_name = '윤봉길', user_name_eng = 'Yun Bonggil' WHERE user_id = 'gg.yang';
|
||||
UPDATE user_info SET user_name = '김구', user_name_eng = 'Kim Gu' WHERE user_id = 'okjkha55';
|
||||
UPDATE user_info SET user_name = '안창호', user_name_eng = 'An Changho' WHERE user_id = 'dc.lee';
|
||||
UPDATE user_info SET user_name = '이황', user_name_eng = 'Lee Hwang' WHERE user_id = 'mh.lee';
|
||||
UPDATE user_info SET user_name = '이이', user_name_eng = 'Lee I' WHERE user_id = 'sibaek.lee';
|
||||
UPDATE user_info SET user_name = '허준', user_name_eng = 'Heo Jun' WHERE user_id = 'leehyeri';
|
||||
UPDATE user_info SET user_name = '장보고', user_name_eng = 'Jang Bogo' WHERE user_id = 'hj.lim';
|
||||
UPDATE user_info SET user_name = '대조영', user_name_eng = 'Dae Joyoung' WHERE user_id = 'bg.jang';
|
||||
UPDATE user_info SET user_name = '왕건', user_name_eng = 'Wang Geon' WHERE user_id = 'woocheol.cho';
|
||||
UPDATE user_info SET user_name = '최영', user_name_eng = 'Choi Young' WHERE user_id = 'ghh0226';
|
||||
UPDATE user_info SET user_name = '이성계', user_name_eng = 'Lee Seonggye' WHERE user_id = 'yonggyu.choi';
|
||||
UPDATE user_info SET user_name = '정몽주', user_name_eng = 'Jeong Mongju' WHERE user_id = 'jw.choi';
|
||||
UPDATE user_info SET user_name = '김시습', user_name_eng = 'Kim Siseup' WHERE user_id = 'jg.ha';
|
||||
UPDATE user_info SET user_name = '성삼문', user_name_eng = 'Seong Sammun' WHERE user_id = 'jaewon.heo';
|
||||
UPDATE user_info SET user_name = '박혁거세', user_name_eng = 'Bak Hyeokgeose' WHERE user_id = 'ms.hong';
|
||||
UPDATE user_info SET user_name = '김춘추', user_name_eng = 'Kim Chunchu' WHERE user_id = 'sy.kang';
|
||||
UPDATE user_info SET user_name = '김정호', user_name_eng = 'Kim Jeongho' WHERE user_id = 'sungchan.kang';
|
||||
UPDATE user_info SET user_name = '연개소문', user_name_eng = 'Yeon Gaesomun' WHERE user_id = 'jonggu.kwak';
|
||||
UPDATE user_info SET user_name = '계백', user_name_eng = 'Gyebaek' WHERE user_id = 'sb.kim';
|
||||
UPDATE user_info SET user_name = '선덕여왕', user_name_eng = 'Seondeok Yeowang' WHERE user_id = 'ouksung.kim';
|
||||
UPDATE user_info SET user_name = '원효대사', user_name_eng = 'Wonhyo Daesa' WHERE user_id = 'jingon.kim';
|
||||
UPDATE user_info SET user_name = '의자왕', user_name_eng = 'Uija Wang' WHERE user_id = 'hw.kim';
|
||||
UPDATE user_info SET user_name = '근초고왕', user_name_eng = 'Geunchogo Wang' WHERE user_id = 'ys.moon';
|
||||
UPDATE user_info SET user_name = '이사부', user_name_eng = 'Isabu' WHERE user_id = 'jongwoo.bae';
|
||||
UPDATE user_info SET user_name = '석가정', user_name_eng = 'Seok Gajeong' WHERE user_id = 'sg.baek';
|
||||
UPDATE user_info SET user_name = '최무선', user_name_eng = 'Choi Museon' WHERE user_id = 'ts.song';
|
||||
UPDATE user_info SET user_name = '문익점', user_name_eng = 'Mun Ikjeom' WHERE user_id = 'dongkyu.shin';
|
||||
UPDATE user_info SET user_name = '한석봉', user_name_eng = 'Han Seokbong' WHERE user_id = 'mh.shim';
|
||||
UPDATE user_info SET user_name = '조광조', user_name_eng = 'Jo Gwangjo' WHERE user_id = 'sj.yeon';
|
||||
UPDATE user_info SET user_name = '송시열', user_name_eng = 'Song Siyeol' WHERE user_id = 'hs.youn';
|
||||
UPDATE user_info SET user_name = '김홍도', user_name_eng = 'Kim Hongdo' WHERE user_id = 'gy.lee';
|
||||
UPDATE user_info SET user_name = '신윤복', user_name_eng = 'Shin Yunbok' WHERE user_id = 'dongbae.lee';
|
||||
UPDATE user_info SET user_name = '정선', user_name_eng = 'Jeong Seon' WHERE user_id = 'SUNGHA';
|
||||
UPDATE user_info SET user_name = '김만덕', user_name_eng = 'Kim Mandeok' WHERE user_id = 'jongwon.lee';
|
||||
UPDATE user_info SET user_name = '전봉준', user_name_eng = 'Jeon Bongjun' WHERE user_id = 'hogi.lee';
|
||||
UPDATE user_info SET user_name = '김좌진', user_name_eng = 'Kim Jwajin' WHERE user_id = 'hongkyu.jeon';
|
||||
UPDATE user_info SET user_name = '신채호', user_name_eng = 'Shin Chaeho' WHERE user_id = 'sm.jo';
|
||||
UPDATE user_info SET user_name = '박지원', user_name_eng = 'Park Jiwon' WHERE user_id = 'js.kim';
|
||||
UPDATE user_info SET user_name = '김대건', user_name_eng = 'Kim Daegeon' WHERE user_id = 'sw.jo';
|
||||
UPDATE user_info SET user_name = '최치원', user_name_eng = 'Choi Chiwon' WHERE user_id = 'jinyao';
|
||||
UPDATE user_info SET user_name = '이덕무', user_name_eng = 'Lee Deokmu' WHERE user_id = 'hs.choi';
|
||||
UPDATE user_info SET user_name = '권율', user_name_eng = 'Gwon Yul' WHERE user_id = 'dohyung.hong';
|
||||
UPDATE user_info SET user_name = '곽재우', user_name_eng = 'Gwak Jaeu' WHERE user_id = 'kts';
|
||||
UPDATE user_info SET user_name = '이봉창', user_name_eng = 'Lee Bongchang' WHERE user_id = 'ms.park';
|
||||
UPDATE user_info SET user_name = '김원봉', user_name_eng = 'Kim Wonbong' WHERE user_id = 'hyunjun.eng';
|
||||
UPDATE user_info SET user_name = '여운형', user_name_eng = 'Yeo Unhyeong' WHERE user_id = 'br.lee';
|
||||
UPDATE user_info SET user_name = '조만식', user_name_eng = 'Jo Mansik' WHERE user_id = 'jc.lee';
|
||||
UPDATE user_info SET user_name = '이회영', user_name_eng = 'Lee Hoeyoung' WHERE user_id = 'hsi1799';
|
||||
UPDATE user_info SET user_name = '김규식', user_name_eng = 'Kim Gyusik' WHERE user_id = 'sg.yoo';
|
||||
UPDATE user_info SET user_name = '이범석', user_name_eng = 'Lee Beomseok' WHERE user_id = 'my.won';
|
||||
UPDATE user_info SET user_name = '지청천', user_name_eng = 'Ji Cheongcheon' WHERE user_id = 'js.ha';
|
||||
UPDATE user_info SET user_name = '관리자', user_name_eng = 'Admin' WHERE user_id = 'plm_admin';
|
||||
UPDATE user_info SET user_name = '김소월', user_name_eng = 'Kim Sowol' WHERE user_id = 'jaeho.lee';
|
||||
UPDATE user_info SET user_name = '한용운', user_name_eng = 'Han Yongun' WHERE user_id = 'jy.kim';
|
||||
UPDATE user_info SET user_name = '윤동주', user_name_eng = 'Yun Dongju' WHERE user_id = 'wj.lee';
|
||||
UPDATE user_info SET user_name = '이육사', user_name_eng = 'Lee Yuksa' WHERE user_id = 'su.han';
|
||||
UPDATE user_info SET user_name = '정지용', user_name_eng = 'Jeong Jiyong' WHERE user_id = 'hy.jeong';
|
||||
UPDATE user_info SET user_name = '백석', user_name_eng = 'Baek Seok' WHERE user_id = 'ys.choi';
|
||||
UPDATE user_info SET user_name = '나혜석', user_name_eng = 'Na Hyeseok' WHERE user_id = 'sy.kim';
|
||||
UPDATE user_info SET user_name = '이광수', user_name_eng = 'Lee Gwangsu' WHERE user_id = 'ys.lim';
|
||||
UPDATE user_info SET user_name = '방정환', user_name_eng = 'Bang Jeonghwan' WHERE user_id = 'hs.kim';
|
||||
UPDATE user_info SET user_name = '주시경', user_name_eng = 'Ju Sigyeong' WHERE user_id = 'sy.choi';
|
||||
UPDATE user_info SET user_name = '최현배', user_name_eng = 'Choi Hyeonbae' WHERE user_id = 'tw.kim';
|
||||
UPDATE user_info SET user_name = '이상화', user_name_eng = 'Lee Sanghwa' WHERE user_id = 'nm.kim';
|
||||
UPDATE user_info SET user_name = '김병연', user_name_eng = 'Kim Byeongyeon' WHERE user_id = 'hy.kim';
|
||||
UPDATE user_info SET user_name = '이상', user_name_eng = 'Lee Sang' WHERE user_id = 'gh.ok';
|
||||
UPDATE user_info SET user_name = '나운규', user_name_eng = 'Na Ungyu' WHERE user_id = 'ts.jeon';
|
||||
UPDATE user_info SET user_name = '손기정', user_name_eng = 'Son Gijeong' WHERE user_id = 'sh.kim';
|
||||
UPDATE user_info SET user_name = '남궁억', user_name_eng = 'Namgung Eok' WHERE user_id = 'jw.jang';
|
||||
UPDATE user_info SET user_name = '서재필', user_name_eng = 'Seo Jaepil' WHERE user_id = 'jw.lee';
|
||||
UPDATE user_info SET user_name = '안익태', user_name_eng = 'An Iktae' WHERE user_id = 'hc.si';
|
||||
UPDATE user_info SET user_name = '홍범도', user_name_eng = 'Hong Beomdo' WHERE user_id = 'ty.kim';
|
||||
UPDATE user_info SET user_name = '이준', user_name_eng = 'Lee Jun' WHERE user_id = 'ys.park';
|
||||
UPDATE user_info SET user_name = '민영환', user_name_eng = 'Min Yeonghwan' WHERE user_id = 'jh.lee';
|
||||
UPDATE user_info SET user_name = '양기탁', user_name_eng = 'Yang Gitak' WHERE user_id = 'ej.kim';
|
||||
UPDATE user_info SET user_name = '이상재', user_name_eng = 'Lee Sangjae' WHERE user_id = 'hu.na';
|
||||
@@ -1,5 +1,5 @@
|
||||
# 운영 배포 (Traefik + momotogether.com)
|
||||
# 대상 서버: 183.99.177.40 (Traefik v2.11 외부 네트워크 traefik-net 사용)
|
||||
# 대상 서버: 121.156.99.3 (Traefik v2.11 외부 네트워크 traefik-net 사용)
|
||||
# 사용: docker compose -f docker-compose.prod.yml up -d --build
|
||||
services:
|
||||
momo-erp:
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
- ./scripts/deploy.sh:/deploy/deploy.sh:ro
|
||||
# source 디렉토리를 컨테이너 안에서 git pull 하기 위해 호스트의 소스를 마운트
|
||||
- $PWD:/deploy/source
|
||||
# Firebase Admin SDK service account (FCM 발송용) — 호스트의 안전한 위치에서만 마운트
|
||||
- /home/chpark/momo-erp/firebase-sa.json:/deploy/firebase-sa.json:ro
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
|
||||
Binary file not shown.
Generated
+797
-4
@@ -10,6 +10,11 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-image": "^3.23.6",
|
||||
"@tiptap/extension-link": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/react": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -29,6 +34,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"sweetalert2": "^11.26.24",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
@@ -40,6 +46,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4",
|
||||
@@ -532,6 +539,34 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
@@ -2038,6 +2073,460 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
|
||||
"integrity": "sha512-MRB3pHz4Oxqmcawh0cQ5iOGdY5xtNYp/1CoK7hdTLzw5K0C6/gTC2VvanB1R4INaB6EpBkxG/GiWkVirDRnuXw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.6.tgz",
|
||||
"integrity": "sha512-2RmnqNqTltZ2k1F7IfjoDNs935Uq4rRDR7d98mqkg3OlDktcQIyBpv0t9dTay6H5bkQeZUuS8ogK2S1E8Edjug==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.6.tgz",
|
||||
"integrity": "sha512-1LMhjnytdbbhWHSoOwnLxZAOQZWPkKyXVCNmaIk0Mhi4tLPUXptG4qKS5sVYTCveE5H6IBPFrbgBFi5dMI6krA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.6.tgz",
|
||||
"integrity": "sha512-Mwkyp9LkDHFbqmWRIkp63FinRxFu3ajC4qSb9t4mnHsb4kAdbNLLsGtbFg+le0SWk4CxGwAOwM7SzeJ+6UGqCA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.6.tgz",
|
||||
"integrity": "sha512-RMRgfXZykr/13X8UBOwvpgysVOo9KchwqMoEbvqQSj4YFfU56iIn59C8sbxiQ1sKfeltUf0wH4fPc0I4iwKqAA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.6.tgz",
|
||||
"integrity": "sha512-KG8KXFYyLrtYvT7AZ1WGV61ofx8pDe5g9pH658MERxqQGii+Pyfc6xkz04l7XeBts/7+571UQp/0O7i/z560TA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.6.tgz",
|
||||
"integrity": "sha512-4kccgcn5yHThxrzsIhJny3EwfEZYIk+BjUCL4uIuzOyWvExtGhZ6JMHVCZeMhI8D1/bX1LNkkAKN5DXPzH4lXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.6.tgz",
|
||||
"integrity": "sha512-XDAIgG9KcKumFM9KJWUEUhXPbFIhhl47bfy5GknareWTRKke85rcoj/oxKKO9ihLZr8JfpbXjqnS4SCm5yhYPw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.6.tgz",
|
||||
"integrity": "sha512-+XWEoRKf3lXxi7Le1aOM2xU1XHwxICGpXjT3m4QaYqUgIpsq8gQEuso6kVg8DnTD7biKQs6+oIQ0o2b/gTW9WA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.6.tgz",
|
||||
"integrity": "sha512-2kjuDcEq69lEcECl75xqY5MyzUSh2zcC5aLrpwP1WwhJz5bxsIFHiaps5AP6h9R4A+ZBj5b2haay2Y1wDUU3VA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.6.tgz",
|
||||
"integrity": "sha512-wbKmxXsszxWacEkrHucRpSQbiKjz4fmOebD6OVyL9AcrmlbxNk8vcM3iyh/8cVeRy09XY+morM165t/u7/z4IQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.6.tgz",
|
||||
"integrity": "sha512-KeUm+tkUfIVSX9QM9XOIhaay0Fn36sLKUo5NVYjN3uJaxFvaZXZmTlxdO85OTdgF2P5sqh9LomrIgliaFRGk4w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.6.tgz",
|
||||
"integrity": "sha512-A/0jPhxnUh9THSZymlu0OGPZe1wdFdwHAXnRCmqvYUCwJjrG7LCC/ahzmcj1tcNzI9hgHyuYPSfev8RXYrNu/w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.6.tgz",
|
||||
"integrity": "sha512-hEUlz4H+I64r+TH6LCuNCRgO7JTHncXGmx9+WbU69EOfY8O0ZurcgeJc8HeiAKL+r9YuC1e5YHfFxgCaaC0jlg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.6.tgz",
|
||||
"integrity": "sha512-vvNGxArvD2dW+XvV0KdYovRVUzCy8QVNulc2r5pV7umnG1E6cCmMkiHiif8J2ePJu2KtysAvJQe0iF+UqueGMw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.6.tgz",
|
||||
"integrity": "sha512-wol5KdwCPAvpiYhH9PLlvO8ZnJHwZtIboVevrfOGgBcKlXRA3dedR4OAMXHnUtkkzu9KtliLg1+TYzEx4JZG9Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.6.tgz",
|
||||
"integrity": "sha512-KNZz7z7P2/qbQsx5bPAbSPjrKDg1VHsedGlLHJCr8U2VRD5VgmDLkMpkouP1CsDg15qgyUKv/nDib5KgPpLNWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.6.tgz",
|
||||
"integrity": "sha512-z6vj9+Qht2sjdQkyyHcUpsC/yCIZqTrQiyHDhs/HGKrfvoANyAZGpqdNeKf1wSyjIso+27tQuIH5NDfk8ygyNw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.6.tgz",
|
||||
"integrity": "sha512-3zzyhdkUWcHVpXuvy6KiIwjh29rbH6gEDEqPQqHLrl1XGnO9pnShC7pSHctlCDjmcx3O4n9cd4QMtVBlUerbiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.6.tgz",
|
||||
"integrity": "sha512-x8bPcLViGzg/RAmQM/XtmfqIwQ/Pv9Q8mkd+OgfUiTqjeJqKwVQmiqbLFNa7zw81+H61M+HDU+qGAaQ3vRIMjw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.6.tgz",
|
||||
"integrity": "sha512-1m/wWB/ZtXcmG2vNdiUkCqsOgqv5vBjCv/mVaHhF9OvV+zQS8YDjoWE7zEuT/GgELdT77Xq8lHrn4nCDudB3/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.6.tgz",
|
||||
"integrity": "sha512-+7m58LUSncodjrIyXks4RZ3tLNYrvgT77wRR4l3HnM5OABY3GDsDTqi7c1t1yI29NVOSk/DUacqy6UwYAj1DGg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.6.tgz",
|
||||
"integrity": "sha512-8I6b2aevF74aLgymKMxbDxSLxWA2y+2dh0zZDeI8sRZ2m6WHHes+Kyuuwkq1HIPcR+ZLpbec74cmf6lcL/yvqQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.6.tgz",
|
||||
"integrity": "sha512-oF7FEZ37f15aCe5kPgzGDYf/m+hr7VdQ/Ko/Hds/UM9pX7AG1fdtmRrl6wqkRqDM/incZaC/AQR2/Dpo2VCNGQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.6.tgz",
|
||||
"integrity": "sha512-ipoC2TkIAIOTiF5ByiGgvQB1DqDyfP90wrUB3mohBcgvp7lQnwHszCDGv8dNnmcUek8uXV/uoLu2VXeVQlxjPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.6.tgz",
|
||||
"integrity": "sha512-P55wGIZGYTVH92Fq0cgI4/O9AhLCaJC3hhxg15RSERP5/YegM9eJHDK/GQ1EE/DvYA+xpYGOV6agKwAUqfA/Iw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.6.tgz",
|
||||
"integrity": "sha512-X09/Db1teB+ifXzDGVVFmOeQRx7wTAayE9/280spxpsHkHZvJ5bHRvWIzUzviMIjbBz+NPDIKYPK7gMfh9iaig==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.6.tgz",
|
||||
"integrity": "sha512-in5CaMaWlJcH2A1q6GJKFtrodE8WLS3M9tIi/f89jPmIVHJShpodC0KZDNyJkrVBQomYk0DEh86Utm6ASXzQww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.38.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.6.tgz",
|
||||
"integrity": "sha512-Tw9KZkYqFMk3vaJAEQKqEYIO/iq3cSJe7OUEGBul4k4GaMQeLItLf5EYhUd0GIPXci1WVVPNntKJsHfX25M37w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.23.6",
|
||||
"@tiptap/extension-floating-menu": "^3.23.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.6.tgz",
|
||||
"integrity": "sha512-gykwtGWrnWCmtql1hid3opac/KV8zQvOAnu3bTqIqcHrn1FusbUwKmNzavSbfGvcktHM3hFjb35W48JyVLyu/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.23.6",
|
||||
"@tiptap/extension-blockquote": "^3.23.6",
|
||||
"@tiptap/extension-bold": "^3.23.6",
|
||||
"@tiptap/extension-bullet-list": "^3.23.6",
|
||||
"@tiptap/extension-code": "^3.23.6",
|
||||
"@tiptap/extension-code-block": "^3.23.6",
|
||||
"@tiptap/extension-document": "^3.23.6",
|
||||
"@tiptap/extension-dropcursor": "^3.23.6",
|
||||
"@tiptap/extension-gapcursor": "^3.23.6",
|
||||
"@tiptap/extension-hard-break": "^3.23.6",
|
||||
"@tiptap/extension-heading": "^3.23.6",
|
||||
"@tiptap/extension-horizontal-rule": "^3.23.6",
|
||||
"@tiptap/extension-italic": "^3.23.6",
|
||||
"@tiptap/extension-link": "^3.23.6",
|
||||
"@tiptap/extension-list": "^3.23.6",
|
||||
"@tiptap/extension-list-item": "^3.23.6",
|
||||
"@tiptap/extension-list-keymap": "^3.23.6",
|
||||
"@tiptap/extension-ordered-list": "^3.23.6",
|
||||
"@tiptap/extension-paragraph": "^3.23.6",
|
||||
"@tiptap/extension-strike": "^3.23.6",
|
||||
"@tiptap/extension-text": "^3.23.6",
|
||||
"@tiptap/extension-underline": "^3.23.6",
|
||||
"@tiptap/extensions": "^3.23.6",
|
||||
"@tiptap/pm": "^3.23.6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -2190,7 +2679,6 @@
|
||||
"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==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2202,6 +2690,16 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz",
|
||||
@@ -2798,6 +3296,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
@@ -3008,6 +3515,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -3104,6 +3623,12 @@
|
||||
"integrity": "sha512-C4FQ1gCLz1YCxmM8HhNPb4D7WQmdrdllkhNReeLwvIVtJKQFKKfwJwmM3yZEBG4P34cLtrgB+FEPr1u553hF7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
@@ -3162,6 +3687,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -3649,7 +4180,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -3792,6 +4322,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz",
|
||||
@@ -4508,6 +5047,15 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@@ -5052,12 +5600,34 @@
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-status-codes": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
|
||||
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@@ -5121,6 +5691,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -5698,6 +6274,27 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -6003,6 +6600,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -6123,6 +6726,12 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -6140,7 +6749,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -6150,7 +6758,6 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
@@ -6554,6 +7161,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
@@ -6956,6 +7569,135 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.7",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
|
||||
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -7253,6 +7995,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -7297,6 +8045,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@@ -8263,6 +9031,31 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-image": "^3.23.6",
|
||||
"@tiptap/extension-link": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.23.6",
|
||||
"@tiptap/react": "^3.23.6",
|
||||
"@tiptap/starter-kit": "^3.23.6",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -31,6 +36,7 @@
|
||||
"recharts": "^3.8.1",
|
||||
"sweetalert2": "^11.26.24",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
@@ -42,6 +48,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.momotogether.erp",
|
||||
"sha256_cert_fingerprints": ["59:9E:56:6C:AE:A3:B2:AD:DF:60:B1:6E:27:91:CD:60:CC:D3:FE:5F:EB:B7:E3:2F:15:D8:FB:E7:B3:11:AB:6A"]
|
||||
"package_name": "com.momotogether.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"2A:55:B2:9E:03:51:2B:DE:28:E2:A4:34:15:9C:23:1F:21:B6:C0:43:9C:10:3B:6C:E2:D5:46:F7:AF:42:C3:97"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1014 B |
+41
-2
@@ -1,6 +1,6 @@
|
||||
// 모모유통 ERP — Service Worker (PWA install criteria 충족용)
|
||||
const CACHE = 'momo-erp-v1';
|
||||
const PRECACHE = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png'];
|
||||
const CACHE = 'momo-erp-v4';
|
||||
const PRECACHE = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png', '/badge-96.png'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)).catch(() => {}));
|
||||
@@ -25,3 +25,42 @@ self.addEventListener('fetch', (e) => {
|
||||
fetch(e.request).catch(() => caches.match(e.request))
|
||||
);
|
||||
});
|
||||
|
||||
// ===== 웹 푸시 =====
|
||||
// 삼성 인터넷/Samsung Galaxy 에서 상단 배너(heads-up) 가 안 뜨던 문제 해결:
|
||||
// - vibrate: 패턴 명시 (안드로이드가 high-priority 채널로 분류)
|
||||
// - requireInteraction: 사용자가 직접 닫을 때까지 유지
|
||||
// - renotify: 같은 tag 라도 다시 알림
|
||||
// - silent: false 명시 (Samsung 일부 버전에서 기본값이 true 인 케이스 회피)
|
||||
self.addEventListener('push', (e) => {
|
||||
let data = {};
|
||||
try { data = e.data ? e.data.json() : {}; } catch (_) { data = {}; }
|
||||
const title = data.title || '모모유통';
|
||||
const options = {
|
||||
body: data.body || ' ',
|
||||
icon: data.icon || '/icon-192.png', // 큰 아이콘 = 모모 로고(초록 M)
|
||||
badge: data.badge || '/badge-96.png', // 상태바 작은 아이콘 = 흰 M 단색(투명 배경)
|
||||
image: data.image || undefined, // big picture (알림 확장 영역의 큰 이미지)
|
||||
tag: data.tag || undefined,
|
||||
renotify: !!data.tag,
|
||||
requireInteraction: true,
|
||||
silent: false,
|
||||
vibrate: [200, 100, 200],
|
||||
timestamp: Date.now(),
|
||||
data: { url: data.url || '/m/orders/new' },
|
||||
};
|
||||
e.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (e) => {
|
||||
e.notification.close();
|
||||
const target = (e.notification.data && e.notification.data.url) || '/m/orders/new';
|
||||
e.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((cs) => {
|
||||
for (const c of cs) {
|
||||
if ('focus' in c) { c.navigate(target); return c.focus(); }
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(target);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
set -e
|
||||
cd /deploy/source 2>/dev/null || cd "$HOME/momo-erp/source"
|
||||
|
||||
# 컨테이너 안에서 webhook 가 호출 시: 호스트 chpark(1000) 소유 디렉토리를
|
||||
# nextjs(1001) 가 git 명령으로 다룸 → git 의 "dubious ownership" 거부 회피
|
||||
git config --global --add safe.directory "$(pwd)" 2>/dev/null || true
|
||||
git config --global --add safe.directory '*' 2>/dev/null || true
|
||||
|
||||
echo "[$(date)] git fetch + reset --hard origin/main"
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
|
||||
# build-sha.txt — 헬스체크가 이 값으로 운영 반영 SHA 검증
|
||||
git rev-parse HEAD > public/build-sha.txt
|
||||
echo "[$(date)] ▶ 배포 대상 SHA: $(cat public/build-sha.txt)"
|
||||
|
||||
# 업로드 저장은 named volume(momo_data_storage)으로 이전됨 — 호스트 디렉토리 prep 불필요
|
||||
echo "[$(date)] docker compose up --build"
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 푸시알림 발송이력 사이드바 메뉴 등록 (멱등)
|
||||
-- 운영 DB 에서 1회 실행: psql … -f 2026-05-30-notice-history-menu.sql
|
||||
INSERT INTO menu_info (objid, menu_type, parent_obj_id, menu_name_kor, menu_url, seq, status, regdate, writer, system_name)
|
||||
SELECT 9000298, 2, 9000200, '푸시알림 발송이력', '/m/admin/notice-history', 21, 'active', NOW(), 'admin', 'PMS'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM menu_info WHERE objid = 9000298);
|
||||
@@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const BASE = process.env.E2E_BASE || "http://localhost:3000";
|
||||
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution";
|
||||
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution";
|
||||
const ADMIN_EMAIL = "admin@momo.com";
|
||||
const ADMIN_PASS = "admin1234";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import pg from "pg";
|
||||
|
||||
const BASE = process.env.E2E_BASE || "https://momo.junggomoa.com";
|
||||
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@183.99.177.40:5432/distribution";
|
||||
const DB_URL = "postgresql://momo_app:qlalfqjsgh11@121.156.99.3:5432/distribution";
|
||||
|
||||
const log = (...a) => console.log("[e2e]", ...a);
|
||||
const fail = (m) => { console.error("[e2e] ✖", m); process.exit(1); };
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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";
|
||||
import { User, Lock, Eye, EyeOff, ArrowRight, Phone, Mail, MapPin, Home } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const SAVE_KEY = "momo_saved_credentials"; // localStorage 키
|
||||
@@ -56,7 +56,7 @@ export default function LoginPage() {
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
router.push(data.redirectTo || "/dashboard");
|
||||
router.push(data.redirectTo || "/m/orders/new");
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
@@ -100,12 +100,16 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||
title="홈으로"
|
||||
>
|
||||
<img src="/momo-icon.svg" alt="MOMO" className="w-11 h-11" />
|
||||
<span className="text-white/95 text-sm font-semibold tracking-widest">
|
||||
MOMO DISTRIBUTION
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col justify-center py-12 lg:py-0">
|
||||
@@ -136,7 +140,15 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
{/* 우측: 로그인 폼 */}
|
||||
<div className="lg:flex-1 flex items-center justify-center px-6 py-16 lg:py-0 bg-slate-50">
|
||||
<div className="lg:flex-1 flex items-center justify-center px-6 py-16 lg:py-0 bg-slate-50 relative">
|
||||
{/* 홈으로 — 우측 상단 고정 */}
|
||||
<Link
|
||||
href="/"
|
||||
className="absolute top-5 right-5 inline-flex items-center gap-1.5 px-3 h-9 rounded-lg bg-white border border-slate-200 text-slate-600 text-sm font-semibold hover:text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 transition shadow-sm"
|
||||
>
|
||||
<Home size={14} /> 홈으로
|
||||
</Link>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-10">
|
||||
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||
|
||||
@@ -218,6 +218,16 @@ export default function MobileLoginPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 앱 설치 안내 링크 */}
|
||||
<div className="relative z-10 text-center pt-3">
|
||||
<a
|
||||
href="/install"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-emerald-50/90 hover:text-white underline underline-offset-4 decoration-emerald-300/50"
|
||||
>
|
||||
📱 휴대폰 홈 화면에 앱처럼 설치하는 방법
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="relative z-10 text-center pb-4 pt-2">
|
||||
<p className="text-[10px] text-emerald-100/60 tracking-wide">
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ApprovalStatusBadge } from "@/components/approval/ApprovalStatusBadge";
|
||||
import { TARGET_NAME_MAP } from "@/components/approval/TargetLinkMap";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// approvalList.jsp 대응 - 결재함 (미결재/승인/반려/전체 탭)
|
||||
type Tab = "PENDING" | "APPROVED" | "REJECTED" | "";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "PENDING", label: "미결재" },
|
||||
{ key: "APPROVED", label: "승인" },
|
||||
{ key: "REJECTED", label: "반려" },
|
||||
{ key: "", label: "전체" },
|
||||
];
|
||||
|
||||
export default function ApprovalPage() {
|
||||
const [tab, setTab] = useState<Tab>("PENDING");
|
||||
const [title, setTitle] = useState("");
|
||||
const [writerName, setWriterName] = useState("");
|
||||
const [fromDate, setFromDate] = useState("");
|
||||
const [toDate, setToDate] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDetail = (row: Record<string, unknown>) => {
|
||||
const w = 900, h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(`/approval/form?objId=${row.APPROVAL_OBJID || row.OBJID}`,
|
||||
"approvalDetail",
|
||||
`width=${w},height=${h},left=${left},top=${top}`);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "결재번호", field: "APPROVAL_NO", width: 110, hozAlign: "center" },
|
||||
{
|
||||
title: "대상구분", field: "TYPE_NAME", width: 120, hozAlign: "left",
|
||||
formatter: (_, row) => (TARGET_NAME_MAP[String(row.TARGET_TYPE)] || String(row.TYPE_NAME ?? row.TARGET_TYPE ?? "-")),
|
||||
},
|
||||
{
|
||||
title: "제목", field: "TITLE", width: 300, hozAlign: "left",
|
||||
cellClick: openDetail,
|
||||
},
|
||||
{ title: "상신일", field: "REGDATE", width: 110, hozAlign: "center" },
|
||||
{
|
||||
title: "상신자", field: "WRITER_NAME", width: 180, hozAlign: "left",
|
||||
formatter: (_, row) => {
|
||||
const dept = row.DEPT_NAME ? String(row.DEPT_NAME) + " / " : "";
|
||||
return dept + String(row.WRITER_NAME ?? row.WRITER ?? "");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "상태", field: "STATUS_NAME", width: 100, hozAlign: "center",
|
||||
formatter: (_, row) => <ApprovalStatusBadge status={row.STATUS_NAME || row.APPROVAL_STATUS} />,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/approval", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: tab,
|
||||
title,
|
||||
writer_name: writerName,
|
||||
from_date: fromDate,
|
||||
to_date: toDate,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} finally { setLoading(false); }
|
||||
}, [tab, title, writerName, fromDate, toDate]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// 파일 업로드 팝업 등에서 refresh 콜백으로 쓸 수 있게 전역 등록
|
||||
useEffect(() => {
|
||||
(window as unknown as Record<string, unknown>).fn_search = fetchData;
|
||||
return () => { delete (window as unknown as Record<string, unknown>).fn_search; };
|
||||
}, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">결재관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex items-center border-b border-gray-200 mb-4">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm border-b-2 -mb-px transition-colors",
|
||||
tab === t.key
|
||||
? "border-primary text-primary font-semibold"
|
||||
: "border-transparent text-gray-500 hover:text-gray-800"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="제목">
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} className="w-[240px]" />
|
||||
</SearchField>
|
||||
<SearchField label="상신자">
|
||||
<Input value={writerName} onChange={(e) => setWriterName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="상신일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={fromDate} onChange={(e) => setFromDate(e.target.value)} className="w-[140px]" />
|
||||
<span className="text-gray-400">~</span>
|
||||
<Input type="date" value={toDate} onChange={(e) => setToDate(e.target.value)} className="w-[140px]" />
|
||||
</div>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// bom/bomList.jsp 대응 - BOM 관리
|
||||
export default function BomPage() {
|
||||
const [productName, setProductName] = useState("");
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "제품명", field: "PRODUCT_NAME", width: 180, hozAlign: "left",
|
||||
cellClick: (row) => window.open(`/product/part-register/form?objId=${row.OBJID}`, "bomDetail", "width=1200,height=900") },
|
||||
{ title: "제품코드", field: "PRODUCT_CODE", width: 120, hozAlign: "left" },
|
||||
{ title: "Level", field: "BOM_LEVEL", width: 60, hozAlign: "center" },
|
||||
{ title: "파트번호", field: "PART_NO", width: 130, hozAlign: "left" },
|
||||
{ title: "파트명", field: "PART_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "규격", field: "SPEC", width: 120, hozAlign: "left" },
|
||||
{ title: "수량", field: "QTY", width: 70, hozAlign: "right", formatter: "money" },
|
||||
{ title: "단위", field: "UNIT_NAME", width: 60, hozAlign: "center" },
|
||||
{ title: "재질", field: "MATERIAL_NAME", width: 100, hozAlign: "left" },
|
||||
{ title: "비고", field: "REMARK", hozAlign: "left" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/bom", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ product_name: productName, part_no: partNo }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [productName, partNo]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">BOM 관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="제품명">
|
||||
<Input value={productName} onChange={(e) => setProductName(e.target.value)} className="w-[180px]" />
|
||||
</SearchField>
|
||||
<SearchField label="파트번호">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// costMgmt/costMgmtList.jsp 대응 - 원가관리
|
||||
export default function CostMgmtPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120 },
|
||||
{ title: "제품명", field: "PRODUCT_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "목표원가", field: "TARGET_COST", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "실적원가", field: "ACTUAL_COST", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "차이", field: "DIFF_COST", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "달성율", field: "ACHIEVE_RATE", width: 80, hozAlign: "center",
|
||||
formatter: (_cell, row) => `${row.ACHIEVE_RATE || 0}%` },
|
||||
{ title: "등록일", field: "REGDATE", width: 100, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cost-mgmt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, project_no: projectNo }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">원가관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
|
||||
// costMgmt/expenseDashBoard.jsp 대응 - 경비관리
|
||||
export default function CostExpensePage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [customerObjid, setCustomerObjid] = useState("");
|
||||
const [pmUserId, setPmUserId] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "경비현황",
|
||||
columns: [
|
||||
{ title: "목표가", field: "EXPENSE_COST_GOAL", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발생경비", field: "TOTAL_SETTLE_AMOUNT", width: 130, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "INPUT_RATE", width: 90, hozAlign: "right" },
|
||||
{ title: "조립", field: "SETTLE_AMOUNT_ASSEMBLE", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "셋업", field: "SETTLE_AMOUNT_SETUP", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "외주(Turn-key)", field: "SETTLE_AMOUNT_CS", width: 130, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cost/expense", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year, project_no: projectNo, customer_objid: customerObjid, pm_user_id: pmUserId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, customerObjid, pmUserId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
(window as unknown as { fn_search?: () => void }).fn_search = fetchData;
|
||||
return () => { delete (window as unknown as { fn_search?: () => void }).fn_search; };
|
||||
}, [fetchData]);
|
||||
|
||||
const openExpenseApply = () => {
|
||||
const contractObjid = selected.length === 1 ? String(selected[0].OBJID || "") : "";
|
||||
if (selected.length > 1) {
|
||||
Swal.fire({ icon: "warning", title: "한번에 1개의 프로젝트만 선택 가능합니다." });
|
||||
return;
|
||||
}
|
||||
const w = 900, h = 600;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/cost/expense/apply${contractObjid ? `?contractObjid=${encodeURIComponent(contractObjid)}` : ""}`,
|
||||
"expenseApply",
|
||||
`width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">투입원가관리 - 경비관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={openExpenseApply}>경비신청</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="PM">
|
||||
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
|
||||
// costMgmt/laborCostMgmtList.jsp 대응 - 노무비관리
|
||||
export default function CostLaborPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [customerObjid, setCustomerObjid] = useState("");
|
||||
const [product, setProduct] = useState("");
|
||||
const [pmUserId, setPmUserId] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "고객납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "노무비현황",
|
||||
columns: [
|
||||
{ title: "목표가", field: "LABOR_COST_GOAL", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "실투입노무비", field: "LABOR_COST_ACTUAL", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "LABOR_INPUT_RATE", width: 90, hozAlign: "right" },
|
||||
{ title: "투입공수(H)", field: "LABOR_HOURS", width: 100, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cost/labor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year, project_no: projectNo, customer_objid: customerObjid,
|
||||
product, pm_user_id: pmUserId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, customerObjid, product, pmUserId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">투입원가관리 - 노무비관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="제품구분">
|
||||
<CodeSelect codeId="PRODUCT_TYPE" value={product} onChange={setProduct} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="PM">
|
||||
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
|
||||
// costMgmt/materialCostTotaltList.jsp 대응 - 재료비관리
|
||||
export default function CostMaterialPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [customerObjid, setCustomerObjid] = useState("");
|
||||
const [product, setProduct] = useState("");
|
||||
const [pmUserId, setPmUserId] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "고객납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "재료비현황",
|
||||
columns: [
|
||||
{ title: "목표가", field: "MATERIAL_COST_GOAL", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "실투입재료비", field: "ALL_TOTAL_PRICE", width: 130, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "MATERIAL_COST_GOAL_RATE", width: 90, hozAlign: "right" },
|
||||
{ title: "발주금액", field: "NEW_TOTAL_PRICE", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "재발주금액", field: "ALL_TOTAL_PRICE_RE", width: 120, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cost/material", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year, project_no: projectNo, customer_objid: customerObjid,
|
||||
product, pm_user_id: pmUserId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, customerObjid, product, pmUserId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">투입원가관리 - 재료비관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="제품구분">
|
||||
<CodeSelect codeId="PRODUCT_TYPE" value={product} onChange={setProduct} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="PM">
|
||||
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
|
||||
// costMgmt/costTotaltList.jsp 대응 - 투입원가관리 현황
|
||||
export default function CostStatusPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [customerObjid, setCustomerObjid] = useState("");
|
||||
const [product, setProduct] = useState("");
|
||||
const [pmUserId, setPmUserId] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left", frozen: true },
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 100, hozAlign: "left" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 80, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "투입원가현황",
|
||||
columns: [
|
||||
{ title: "수주가", field: "CONTRACT_PRICE", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "목표가", field: "TOTAL_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "실투입원가", field: "TOTAL_COST_ACTUAL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "TOTAL_INPUT_RATE", width: 80, hozAlign: "right" },
|
||||
{ title: "MC율(%)", field: "MC_RATE", width: 80, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "재료비현황",
|
||||
columns: [
|
||||
{ title: "목표가", field: "MATERIAL_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발생재료비", field: "ACCRUAL_MATERIAL_COST", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "MATERIAL_COST_GOAL_RATE", width: 90, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "노무비현황",
|
||||
columns: [
|
||||
{ title: "목표가", field: "LABOR_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발생노무비", field: "LABOR_COST_ACTUAL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "LABOR_INPUT_RATE", width: 90, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "경비현황",
|
||||
columns: [
|
||||
{ title: "목표가", field: "EXPENSE_COST_GOAL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발생경비", field: "ACCRUAL_EXPENSE", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "투입율(%)", field: "EXPENSE_RATE", width: 90, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cost/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year, project_no: projectNo, customer_objid: customerObjid,
|
||||
product, pm_user_id: pmUserId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, customerObjid, product, pmUserId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
// 팝업 저장 후 새로고침
|
||||
useEffect(() => {
|
||||
(window as unknown as { fn_search?: () => void }).fn_search = fetchData;
|
||||
return () => { delete (window as unknown as { fn_search?: () => void }).fn_search; };
|
||||
}, [fetchData]);
|
||||
|
||||
const openGoalPopup = () => {
|
||||
if (selected.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "선택된 내용이 없습니다." });
|
||||
return;
|
||||
}
|
||||
if (selected.length > 1) {
|
||||
Swal.fire({ icon: "warning", title: "한번에 1개의 내용만 등록 가능합니다." });
|
||||
return;
|
||||
}
|
||||
const contractObjid = String(selected[0].OBJID || "");
|
||||
const w = 500;
|
||||
const h = 350;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/cost/goal/form?contractObjid=${encodeURIComponent(contractObjid)}`,
|
||||
"costGoalForm",
|
||||
`width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">투입원가관리 현황</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={openGoalPopup}>목표가 등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="제품구분">
|
||||
<CodeSelect codeId="PRODUCT_TYPE" value={product} onChange={setProduct} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="PM">
|
||||
<CodeSelect codeId="PM_USER" value={pmUserId} onChange={setPmUserId} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
|
||||
// csMgmt/csChartList.jsp 대응 - CS 차트관리
|
||||
export default function CsChartPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [productCode, setProductCode] = useState("");
|
||||
const [chartData, setChartData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cs/chart", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
product_code: productCode,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setChartData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, productCode]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">CS 차트관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="제품구분">
|
||||
<CodeSelect codeId="PRODUCT_TYPE" value={productCode} onChange={setProductCode} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6" style={{ height: "calc(100vh - 350px)" }}>
|
||||
{chartData.length > 0 ? (
|
||||
<div className="text-center text-gray-500">
|
||||
{/* TODO: Chart rendering - integrate with chart library */}
|
||||
<p className="text-sm">차트 데이터 {chartData.length}건 로드됨</p>
|
||||
<p className="text-xs text-gray-400 mt-2">차트 라이브러리 연동 후 표시됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
조회 버튼을 클릭하여 차트 데이터를 로드하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { FolderCell } from "@/components/ui/folder-cell";
|
||||
import { ApprovalButton } from "@/components/approval/ApprovalButton";
|
||||
import { ApprovalStatusBadge } from "@/components/approval/ApprovalStatusBadge";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// asMngList_CS.jsp 대응 - CS등록 및 조치
|
||||
export default function CsManagePage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [productCd, setProductCd] = useState("");
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [warranty, setWarranty] = useState("");
|
||||
const [recStartDate, setRecStartDate] = useState("");
|
||||
const [recEndDate, setRecEndDate] = useState("");
|
||||
const [managerId, setManagerId] = useState("");
|
||||
const [actStartDate, setActStartDate] = useState("");
|
||||
const [actEndDate, setActEndDate] = useState("");
|
||||
const [apprStatus, setApprStatus] = useState("");
|
||||
|
||||
const [projectOpts, setProjectOpts] = useState<{ value: string; label: string }[]>([]);
|
||||
const [userOpts, setUserOpts] = useState<{ value: string; label: string }[]>([]);
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/common/project-list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then((r) => r.json())
|
||||
.then((j) =>
|
||||
setProjectOpts(
|
||||
(j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID),
|
||||
label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
|
||||
}))
|
||||
)
|
||||
)
|
||||
.catch(() => setProjectOpts([]));
|
||||
fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then((r) => r.json())
|
||||
.then((j) =>
|
||||
setUserOpts(
|
||||
(j.RESULTLIST || []).map((u: Record<string, unknown>) => ({
|
||||
value: String(u.USER_ID),
|
||||
label: `${u.USER_NAME || u.USER_ID}${u.DEPT_NAME ? ` (${u.DEPT_NAME})` : ""}`,
|
||||
}))
|
||||
)
|
||||
)
|
||||
.catch(() => setUserOpts([]));
|
||||
}, []);
|
||||
|
||||
const openFormPopup = (objId = "") => {
|
||||
const w = 1400, h = 930;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/cs/manage/form${objId ? `?objId=${objId}` : ""}`,
|
||||
"asMngFormPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`
|
||||
);
|
||||
};
|
||||
|
||||
const openFileRegist = (objId: string) => {
|
||||
const w = 800, h = 500;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/common/files?targetObjId=${encodeURIComponent(objId)}&docType=AS_DOC_01&docTypeName=${encodeURIComponent("CS 조치내역 첨부파일")}`,
|
||||
"fileAS_DOC_01",
|
||||
`width=${w},height=${h},left=${left},top=${top}`
|
||||
);
|
||||
};
|
||||
|
||||
const openApprovalDetail = (approvalObjId: string) => {
|
||||
if (!approvalObjId) return;
|
||||
const w = 900, h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/approval/form?objId=${approvalObjId}`,
|
||||
"approvalDetailPopup",
|
||||
`width=${w},height=${h},left=${left},top=${top}`
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "접수 No.", field: "SERVICE_NO", width: 110, hozAlign: "left",
|
||||
cellClick: (row) => openFormPopup(String(row.OBJID || "")),
|
||||
},
|
||||
{ title: "제품구분(기계형식)", field: "PRODUCT_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 130, hozAlign: "left" },
|
||||
{ title: "출고일자", field: "RELEASE_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 120, hozAlign: "left" },
|
||||
{ title: "유무상", field: "WARRANTY_NAME", width: 80, hozAlign: "left" },
|
||||
{ title: "CS구분", field: "CATEGORY_NAME", width: 100, hozAlign: "left" },
|
||||
{ title: "유형", field: "CATEGORY_H_NAME", width: 100, hozAlign: "left" },
|
||||
{ title: "제목", field: "TITLE", width: 200, hozAlign: "left" },
|
||||
{ title: "접수일", field: "REC_DT", width: 100, hozAlign: "center" },
|
||||
{ title: "예상발생비용", field: "PLAN_COST", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "등록자", field: "MANAGER_NAME", width: 90, hozAlign: "left" },
|
||||
{ title: "조치완료일", field: "ACT_DATE", width: 110, hozAlign: "center" },
|
||||
{
|
||||
title: "첨부파일", field: "CU03_CNT", width: 80, hozAlign: "center",
|
||||
formatter: (cell, row) => (
|
||||
<FolderCell
|
||||
count={Number(cell) || 0}
|
||||
onClick={() => openFileRegist(String(row.OBJID || ""))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "상태", field: "APPR_STATUS_NAME", width: 100, hozAlign: "center",
|
||||
formatter: (_, row) => {
|
||||
const apv = String(row.APPROVAL_OBJID || "");
|
||||
return (
|
||||
<ApprovalStatusBadge
|
||||
status={row.APPR_STATUS_NAME || row.APPR_STATUS}
|
||||
onClick={apv ? () => openApprovalDetail(apv) : undefined}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/cs/manage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
Year: year,
|
||||
product_cd: productCd,
|
||||
project_no: projectNo,
|
||||
warranty,
|
||||
rec_start_date: recStartDate,
|
||||
rec_end_date: recEndDate,
|
||||
manager_id: managerId,
|
||||
act_start_date: actStartDate,
|
||||
act_end_date: actEndDate,
|
||||
appr_status: apprStatus,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, productCd, projectNo, warranty, recStartDate, recEndDate, managerId, actStartDate, actEndDate, apprStatus]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "삭제할 항목을 선택하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
const result = await Swal.fire({
|
||||
title: "삭제 확인",
|
||||
text: `선택한 ${selectedRows.length}건을 삭제하시겠습니까?`,
|
||||
icon: "question", showCancelButton: true,
|
||||
confirmButtonText: "삭제", cancelButtonText: "취소",
|
||||
});
|
||||
if (!result.isConfirmed) return;
|
||||
const res = await fetch("/api/cs/manage/delete", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objIds: selectedRows.map((r) => String(r.OBJID)) }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
Swal.fire({ icon: "success", title: json.message || "삭제되었습니다.", timer: 1200, showConfirmButton: false });
|
||||
fetchData();
|
||||
} else {
|
||||
Swal.fire("오류", json.message || "삭제 실패", "error");
|
||||
}
|
||||
};
|
||||
|
||||
// 단건만 결재 허용 + 이미 진행/완료건 필터
|
||||
const approvalRow = selectedRows.length === 1 ? selectedRows[0] : null;
|
||||
const approvalStatus = String(approvalRow?.APPR_STATUS_NAME || "");
|
||||
const canRequestApproval =
|
||||
!!approvalRow && approvalStatus !== "결재중" && approvalStatus !== "결재완료";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">CS관리_CS등록 및 조회</h2>
|
||||
<div className="flex gap-2">
|
||||
<ApprovalButton
|
||||
objIds={approvalRow ? [String(approvalRow.OBJID)] : []}
|
||||
targetType="CSM"
|
||||
title={approvalRow ? `CS조치내역서 상신 - ${approvalRow.TITLE || approvalRow.SERVICE_NO || ""}` : ""}
|
||||
description={approvalRow ? String(approvalRow.TITLE || "") : ""}
|
||||
onSuccess={fetchData}
|
||||
disabled={!canRequestApproval}
|
||||
/>
|
||||
<Button size="sm" onClick={() => openFormPopup("")}>등록</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>삭제</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[110px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="제품구분">
|
||||
<SearchableCodeSelect codeId="0000001" value={productCd} onChange={setProductCd} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<SearchableSelect options={projectOpts} value={projectNo} onChange={setProjectNo} className="w-[220px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유/무상">
|
||||
<SearchableCodeSelect codeId="0000156" value={warranty} onChange={setWarranty} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="접수일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={recStartDate} onChange={(e) => setRecStartDate(e.target.value)} className="w-[140px]" />
|
||||
<span className="text-gray-400">~</span>
|
||||
<Input type="date" value={recEndDate} onChange={(e) => setRecEndDate(e.target.value)} className="w-[140px]" />
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="조치담당자">
|
||||
<SearchableSelect options={userOpts} value={managerId} onChange={setManagerId} className="w-[180px]" />
|
||||
</SearchField>
|
||||
<SearchField label="조치완료일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={actStartDate} onChange={(e) => setActStartDate(e.target.value)} className="w-[140px]" />
|
||||
<span className="text-gray-400">~</span>
|
||||
<Input type="date" value={actEndDate} onChange={(e) => setActEndDate(e.target.value)} className="w-[140px]" />
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="상태">
|
||||
<select
|
||||
value={apprStatus}
|
||||
onChange={(e) => setApprStatus(e.target.value)}
|
||||
className="h-9 w-[130px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="inProcess">결재중</option>
|
||||
<option value="reject">반려</option>
|
||||
<option value="complete">결재완료</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// customerMng/customerServiceList.jsp 대응 - CS관리
|
||||
export default function CsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "접수번호", field: "CS_NO", width: 120,
|
||||
cellClick: (row) => window.open(`/cs/manage/form?objId=${row.OBJID}`, "csDetail", "width=900,height=700") },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "제품명", field: "PRODUCT_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "접수일", field: "RECEIPT_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "유형", field: "CS_TYPE_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "내용", field: "DESCRIPTION", hozAlign: "left" },
|
||||
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "담당자", field: "CHARGER_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "완료일", field: "COMPLETE_DATE", width: 100, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/cs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, customer_name: customerName }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, customerName]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">CS 관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => window.open("/cs/manage/form", "csForm", "width=900,height=700")}>등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface DynColumn {
|
||||
GROUP_SEQ: number;
|
||||
GROUP_CNT: number;
|
||||
GROUP_NAME: string;
|
||||
PARENT_CODE_ID: string;
|
||||
CODE_ID: string;
|
||||
NAME: string;
|
||||
COL_NAME: string;
|
||||
}
|
||||
|
||||
// asList_CS.jsp 대응 - CS관리_현황 (제품×프로젝트 대시보드)
|
||||
export default function CsStatusPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [productCd, setProductCd] = useState("");
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [warranty, setWarranty] = useState("");
|
||||
const [csCategory, setCsCategory] = useState("");
|
||||
const [categoryH, setCategoryH] = useState("");
|
||||
|
||||
const [projectOpts, setProjectOpts] = useState<{ value: string; label: string }[]>([]);
|
||||
const [categoryHOpts, setCategoryHOpts] = useState<{ value: string; label: string }[]>([]);
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [dynColumns, setDynColumns] = useState<DynColumn[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/common/project-list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then((r) => r.json())
|
||||
.then((j) =>
|
||||
setProjectOpts(
|
||||
(j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID),
|
||||
label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
|
||||
}))
|
||||
)
|
||||
)
|
||||
.catch(() => setProjectOpts([]));
|
||||
}, []);
|
||||
|
||||
// cs_category(0000970) 선택 시 유형(category_h) 옵션 로드
|
||||
useEffect(() => {
|
||||
if (!csCategory) {
|
||||
setCategoryHOpts([]);
|
||||
setCategoryH("");
|
||||
return;
|
||||
}
|
||||
fetch("/api/common/code-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeId: csCategory }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) =>
|
||||
setCategoryHOpts(
|
||||
(j.data || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.code_id || r.CODE_ID),
|
||||
label: String(r.code_name || r.CODE_NAME),
|
||||
}))
|
||||
)
|
||||
)
|
||||
.catch(() => setCategoryHOpts([]));
|
||||
}, [csCategory]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/cs/status", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
Year: year,
|
||||
product_cd: productCd,
|
||||
project_no: projectNo,
|
||||
warranty,
|
||||
cs_category: csCategory,
|
||||
category_h: categoryH,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
setDynColumns(json.COLUMN_LIST || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, productCd, projectNo, warranty, csCategory, categoryH]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const columns: GridColumn[] = useMemo(() => {
|
||||
// 동적 컬럼 → GROUP_NAME(2nd-level 부모코드명) 기준으로 묶고, leaf는 NAME 사용
|
||||
const groupMap = new Map<string, DynColumn[]>();
|
||||
const groupOrder: string[] = [];
|
||||
dynColumns.forEach((c) => {
|
||||
const key = `${c.PARENT_CODE_ID}::${c.GROUP_NAME}`;
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, []);
|
||||
groupOrder.push(key);
|
||||
}
|
||||
groupMap.get(key)!.push(c);
|
||||
});
|
||||
|
||||
const dynGroupColumns: GridColumn[] = groupOrder.map((key) => {
|
||||
const group = groupMap.get(key)!;
|
||||
return {
|
||||
title: group[0].GROUP_NAME || "유형",
|
||||
columns: group.map((c) => ({
|
||||
title: c.NAME,
|
||||
field: c.COL_NAME,
|
||||
width: 90,
|
||||
hozAlign: "right",
|
||||
headerHozAlign: "center",
|
||||
formatter: "money",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// 유형 최상위 묶음 (있을 때만)
|
||||
const categoryWrapper: GridColumn[] = dynGroupColumns.length
|
||||
? [{ title: "유형", columns: dynGroupColumns }]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{ title: "제품구분", field: "PRODUCT_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
|
||||
{
|
||||
title: "유상",
|
||||
columns: [
|
||||
{ title: "건수", field: "WARRANTY1", width: 80, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
|
||||
{ title: "발생비용", field: "COST1", width: 110, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "무상",
|
||||
columns: [
|
||||
{ title: "건수", field: "WARRANTY2", width: 80, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
|
||||
{ title: "발생비용", field: "COST2", width: 110, hozAlign: "right", headerHozAlign: "center", formatter: "money" },
|
||||
],
|
||||
},
|
||||
...categoryWrapper,
|
||||
];
|
||||
}, [dynColumns]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">CS관리_현황</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[110px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="제품구분">
|
||||
<SearchableCodeSelect codeId="0000001" value={productCd} onChange={setProductCd} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<SearchableSelect options={projectOpts} value={projectNo} onChange={setProjectNo} className="w-[220px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유/무상">
|
||||
<SearchableCodeSelect codeId="0000156" value={warranty} onChange={setWarranty} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="CS구분">
|
||||
<SearchableCodeSelect codeId="0000970" value={csCategory} onChange={setCsCategory} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유형">
|
||||
<SearchableSelect options={categoryHOpts} value={categoryH} onChange={setCategoryH} className="w-[150px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,576 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend,
|
||||
ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid,
|
||||
} from "recharts";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { useMenuStore } from "@/store/menu-store";
|
||||
import { numberWithCommas } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FolderKanban, AlertCircle,
|
||||
TrendingUp, BarChart3, Briefcase,
|
||||
} from "lucide-react";
|
||||
|
||||
interface YearGoalRow {
|
||||
YEAR: string;
|
||||
CONTRACT_CNT_YEAR_IN?: number;
|
||||
CONTRACT_CNT_YEAR_OUT?: number;
|
||||
CONTRACT_CNT_YEAR_RATE?: number;
|
||||
CONTRACT_COST_YEAR?: string | number;
|
||||
PRICE?: string | number;
|
||||
GOAL_RATE?: number;
|
||||
YEAR_GOAL_OBJID?: string;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
projectStats: {
|
||||
CNT_TOTAL?: number; CNT_NOPLAN?: number; CNT_ING?: number;
|
||||
CNT_DELAY?: number; CNT_END?: number;
|
||||
ISSUE_TOTAL?: number; ISSUE_MISS?: number;
|
||||
};
|
||||
productDist: { CODE: string; NAME: string; CNT: number }[];
|
||||
supplyDist: { CODE: string; NAME: string; CNT: number }[];
|
||||
monthlyContract: { MONTH: number; AMOUNT: string }[];
|
||||
projectList: Record<string, unknown>[];
|
||||
yearGoalInfo: YearGoalRow[];
|
||||
}
|
||||
|
||||
type Tab = "sales" | "project";
|
||||
|
||||
const PIE_COLORS = ["#3b82f6", "#22c55e", "#a855f7", "#f97316", "#ef4444", "#14b8a6", "#eab308"];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { topMenus, fetchSideMenus } = useMenuStore();
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [tab, setTab] = useState<Tab>("sales");
|
||||
|
||||
useEffect(() => {
|
||||
if (topMenus.length > 0) {
|
||||
const userMenu = topMenus.find((m) => m.MENU_NAME_KOR !== "관리자") || topMenus[0];
|
||||
fetchSideMenus(userMenu.OBJID);
|
||||
}
|
||||
}, [topMenus, fetchSideMenus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dashboard", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year }),
|
||||
})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => setData(d))
|
||||
.catch(() => {});
|
||||
}, [year]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
<h1 className="text-xl font-bold text-gray-800">
|
||||
Dashboard
|
||||
<span className="text-sm font-normal text-gray-400 ml-2">
|
||||
{user?.userName}님, 환영합니다.
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 탭 */}
|
||||
<div className="inline-flex bg-gray-100 rounded-lg p-1">
|
||||
<TabButton active={tab === "sales"} onClick={() => setTab("sales")} icon={TrendingUp} label="영업" />
|
||||
<TabButton active={tab === "project"} onClick={() => setTab("project")} icon={Briefcase} label="프로젝트" />
|
||||
</div>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}년</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 내용 — 남은 공간 가득 채움 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{tab === "sales" ? (
|
||||
<SalesTab data={data} year={year} />
|
||||
) : (
|
||||
<ProjectTab data={data} year={year} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, icon: Icon, label }: {
|
||||
active: boolean; onClick: () => void; icon: React.ElementType; label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
active ? "bg-white text-primary shadow-sm" : "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesTab({ data, year }: { data: DashboardData | null; year: string }) {
|
||||
const openGoalPopup = () => {
|
||||
const w = 700, h = 500;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(`/sales/year-goal?year=${year}`, "yearGoalPopup",
|
||||
`width=${w},height=${h},left=${left},top=${top},menubars=no,scrollbars=yes,resizable=yes`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 h-full min-h-0">
|
||||
{/* 상단: 영업현황 표 (고정 높이) */}
|
||||
<YearGoalTable info={data?.yearGoalInfo || []} onOpenGoal={openGoalPopup} />
|
||||
|
||||
{/* 하단: 3분할 — 제품별 pie / 고객사별 pie / 년도별 combo (남은 공간 가득) */}
|
||||
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
|
||||
<PieCard title="■ 제품별현황" data={data?.productDist || []} />
|
||||
<PieCard title="■ 고객사별현황" data={data?.supplyDist || []} />
|
||||
<YearSalesComboChart info={data?.yearGoalInfo || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGoalTable({ info, onOpenGoal }: { info: YearGoalRow[]; onOpenGoal: () => void }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<TrendingUp size={16} className="text-primary" />
|
||||
영업현황
|
||||
</h3>
|
||||
<Button size="sm" onClick={onOpenGoal}>영업목표 등록</Button>
|
||||
</div>
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-600 text-white">
|
||||
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">년도</th>
|
||||
<th colSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">수주현황(건수)</th>
|
||||
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">수주율(%)</th>
|
||||
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">예상매출(억원)</th>
|
||||
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">영업목표(억원)</th>
|
||||
<th rowSpan={2} className="border border-gray-300 px-2 py-1 font-semibold">달성율(%)</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-600 text-white">
|
||||
<th className="border border-gray-300 px-2 py-1 font-semibold">국내</th>
|
||||
<th className="border border-gray-300 px-2 py-1 font-semibold">해외</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{info.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-4 text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : info.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center font-medium">{row.YEAR}</td>
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.CONTRACT_CNT_YEAR_IN ?? 0}</td>
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.CONTRACT_CNT_YEAR_OUT ?? 0}</td>
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.CONTRACT_CNT_YEAR_RATE ?? 0}</td>
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center">{numberWithCommas(Number(row.CONTRACT_COST_YEAR ?? 0))}</td>
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center">{numberWithCommas(Number(row.PRICE ?? 0))}</td>
|
||||
<td className="border border-gray-200 px-2 py-1.5 text-center">{row.GOAL_RATE ?? 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PieCard({ title, data }: { title: string; data: { CODE: string; NAME: string; CNT: number }[] }) {
|
||||
const chartData = data.map((d, i) => ({
|
||||
name: d.NAME || `코드 ${d.CODE}`,
|
||||
value: d.CNT,
|
||||
color: PIE_COLORS[i % PIE_COLORS.length],
|
||||
}));
|
||||
const total = chartData.reduce((s, d) => s + d.value, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm flex flex-col min-h-0">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 shrink-0">{title}</h3>
|
||||
{total === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-gray-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="75%"
|
||||
label={({ percent }: { percent?: number }) =>
|
||||
percent != null && percent >= 0.05 ? `${Math.round(percent * 100)}%` : ""
|
||||
}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v) => `${v}건`} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
iconSize={10}
|
||||
wrapperStyle={{ fontSize: "11px" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearSalesComboChart({ info }: { info: YearGoalRow[] }) {
|
||||
// 과거→현재 순서
|
||||
const chartData = [...info]
|
||||
.sort((a, b) => Number(a.YEAR) - Number(b.YEAR))
|
||||
.map((r) => ({
|
||||
YEAR: r.YEAR,
|
||||
영업목표: Number(r.PRICE || 0),
|
||||
수주금액: Number(r.CONTRACT_COST_YEAR || 0),
|
||||
달성율: Number(r.GOAL_RATE || 0),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm flex flex-col min-h-0">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 shrink-0">■ 년도별 영업현황</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f1f1" />
|
||||
<XAxis dataKey="YEAR" tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} unit="%" />
|
||||
<Tooltip
|
||||
formatter={(v, name) =>
|
||||
name === "달성율" ? `${v}%` : `${numberWithCommas(Number(v))}억`
|
||||
}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "11px" }} iconSize={10} />
|
||||
<Bar yAxisId="left" dataKey="영업목표" fill="#3b82f6" barSize={20} />
|
||||
<Bar yAxisId="left" dataKey="수주금액" fill="#ef4444" barSize={20} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="달성율" stroke="#f97316" strokeWidth={2}
|
||||
dot={{ r: 5, fill: "#f97316", stroke: "#fff", strokeWidth: 2 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusFilter = "all" | "noplan" | "ing" | "delay" | "end";
|
||||
|
||||
const FILTER_LABELS: Record<StatusFilter, string> = {
|
||||
all: "전체",
|
||||
noplan: "계획미수립",
|
||||
ing: "진행중",
|
||||
delay: "지연",
|
||||
end: "종료",
|
||||
};
|
||||
|
||||
function ProjectTab({ data, year }: { data: DashboardData | null; year: string }) {
|
||||
const stats = data?.projectStats || {};
|
||||
const allProjects = (data?.projectList || []) as Record<string, unknown>[];
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [projectFilter, setProjectFilter] = useState<string>("");
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
|
||||
const projectList = allProjects.filter((p) => {
|
||||
const s = String(p.STATUS_TITLE || "");
|
||||
if (statusFilter !== "all") {
|
||||
if (statusFilter === "noplan" && s !== "계획미수립") return false;
|
||||
if (statusFilter === "ing" && s !== "진행중") return false;
|
||||
if (statusFilter === "delay" && s !== "지연") return false;
|
||||
if (statusFilter === "end" && s !== "종료") return false;
|
||||
}
|
||||
if (projectFilter && String(p.OBJID) !== projectFilter) return false;
|
||||
return true;
|
||||
});
|
||||
const selected = projectList[selectedIdx] || projectList[0];
|
||||
|
||||
const toggleFilter = (f: StatusFilter) => {
|
||||
setSelectedIdx(0);
|
||||
setStatusFilter((cur) => (cur === f ? "all" : f));
|
||||
};
|
||||
|
||||
const openProjectSchedule = () => {
|
||||
// 프로젝트 일정 전체 보기 → 프로젝트 관리 > 종합현황 페이지로 이동
|
||||
window.location.href = `/project/total?year=${year}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 h-full">
|
||||
{/* 상단 프로젝트현황 카드 — 원본 스타일 (5개 숫자 가로 + 컨트롤) */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm shrink-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<FolderKanban size={16} className="text-primary" />
|
||||
프로젝트현황
|
||||
</h3>
|
||||
<Button size="sm" onClick={openProjectSchedule}>프로젝트 일정 전체 보기</Button>
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 좌측: 년도/프로젝트번호 셀렉트 */}
|
||||
<div className="flex flex-col gap-2 min-w-[240px] pr-4 border-r border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 w-20">년도</label>
|
||||
<div className="flex-1 text-sm font-medium">{year}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 w-20">프로젝트번호</label>
|
||||
<select value={projectFilter} onChange={(e) => setProjectFilter(e.target.value)}
|
||||
className="flex-1 h-8 rounded border border-gray-300 bg-white px-2 text-xs">
|
||||
<option value="">선택</option>
|
||||
{allProjects.map((p) => (
|
||||
<option key={String(p.OBJID)} value={String(p.OBJID)}>{String(p.PROJECT_NO || "")}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 우측: 5개 숫자 가로 배치 */}
|
||||
<div className="flex-1 grid grid-cols-5 gap-2">
|
||||
<CountBadge label="전체" value={Number(stats.CNT_TOTAL || 0)} color="blue"
|
||||
active={statusFilter === "all"} onClick={() => setStatusFilter("all")} />
|
||||
<CountBadge label="계획미수립" value={Number(stats.CNT_NOPLAN || 0)} color="blue"
|
||||
active={statusFilter === "noplan"} onClick={() => toggleFilter("noplan")} />
|
||||
<CountBadge label="진행중" value={Number(stats.CNT_ING || 0)} color="blue"
|
||||
active={statusFilter === "ing"} onClick={() => toggleFilter("ing")} />
|
||||
<CountBadge label="지연" value={Number(stats.CNT_DELAY || 0)} color="red"
|
||||
active={statusFilter === "delay"} onClick={() => toggleFilter("delay")} />
|
||||
<CountBadge label="종료" value={Number(stats.CNT_END || 0)} color="blue"
|
||||
active={statusFilter === "end"} onClick={() => toggleFilter("end")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 리스트 — 전체 너비, 원본 10컬럼 구조 */}
|
||||
<div className="flex-1 bg-white rounded-xl border border-gray-200 p-4 flex flex-col min-h-0 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 shrink-0 flex items-center gap-2">
|
||||
<FolderKanban size={16} className="text-primary" />
|
||||
프로젝트 리스트 {statusFilter !== "all" && (
|
||||
<span className="text-xs font-normal text-primary">[{FILTER_LABELS[statusFilter]}]</span>
|
||||
)} · 총 {projectList.length}건
|
||||
{statusFilter !== "all" && (
|
||||
<button onClick={() => setStatusFilter("all")}
|
||||
className="ml-auto text-[10px] text-gray-400 hover:text-gray-700">
|
||||
필터 해제 ×
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex-1 overflow-auto min-h-0 border border-gray-100 rounded">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="sticky top-0 bg-gray-600 text-white z-10">
|
||||
<tr>
|
||||
<th className="px-2 py-2 font-semibold text-xs w-12">선택</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">고객사</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">제품구분</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">프로젝트번호</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">납기일</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">셋업지</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">제작공장</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">진척율(%)</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">셋업완료일</th>
|
||||
<th className="px-2 py-2 font-semibold text-xs">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projectList.length === 0 ? (
|
||||
<tr><td colSpan={10} className="text-center py-10 text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : projectList.map((pjt, idx) => {
|
||||
const statusTitle = String(pjt.STATUS_TITLE || "");
|
||||
const statusColor =
|
||||
statusTitle === "종료" ? "text-green-600" :
|
||||
statusTitle === "지연" ? "text-red-500" :
|
||||
statusTitle === "계획미수립" ? "text-gray-500" :
|
||||
"text-blue-600";
|
||||
const isSelected = idx === selectedIdx;
|
||||
return (
|
||||
<tr key={idx}
|
||||
className={`border-b border-gray-100 cursor-pointer ${isSelected ? "bg-primary/10" : "hover:bg-gray-50"}`}
|
||||
onClick={() => setSelectedIdx(idx)}>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<input type="radio" checked={isSelected} onChange={() => setSelectedIdx(idx)} className="pointer-events-none" />
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-xs">{String(pjt.CUSTOMER_NAME || "")}</td>
|
||||
<td className="px-2 py-2 text-center text-xs">{String(pjt.PRODUCT_NAME || "")}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className="inline-block px-2 py-0.5 bg-gray-700 text-white rounded text-[11px]">{String(pjt.PROJECT_NO || "")}</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-xs">{String(pjt.CONTRACT_DEL_DATE || "-")}</td>
|
||||
<td className="px-2 py-2 text-center text-xs">{String(pjt.SETUP || "")}</td>
|
||||
<td className="px-2 py-2 text-center text-xs">{String(pjt.MANUFACTURE_PLANT_NAME || "")}</td>
|
||||
<td className="px-2 py-2 text-right text-xs">{Number(pjt.SETUP_RATE || 0).toFixed(1)}</td>
|
||||
<td className="px-2 py-2 text-center text-xs">{String(pjt.SETUP_DONE_DATE || "")}</td>
|
||||
<td className={`px-2 py-2 text-center text-xs font-semibold ${statusColor}`}>{statusTitle}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 프로젝트 상세 (이슈 + 투입원가) */}
|
||||
<div className="shrink-0">
|
||||
<ProjectDetailPanel project={selected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectDetailPanel({ project }: { project: Record<string, unknown> | undefined }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex-1 bg-white rounded-xl border border-gray-200 p-10 flex items-center justify-center shadow-sm">
|
||||
<div className="text-sm text-gray-400">프로젝트를 선택하세요</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const issueTotal = Number(project.ISSUE_CNT || 0);
|
||||
const issueDone = Number(project.ISSUE_DONE_CNT || 0);
|
||||
const issueMiss = Number(project.ISSUE_MISS_CNT || 0);
|
||||
const issueRate = issueTotal > 0 ? Math.round((issueDone / issueTotal) * 100) : 0;
|
||||
|
||||
// 투입원가 항목별 (원본 dashboard.jsp fn_getProjectCostStatusList 이식)
|
||||
const contractPrice = Number(project.CONTRACT_PRICE || 0);
|
||||
const materialGoal = Number(project.MATERIAL_COST_GOAL || 0);
|
||||
const materialActual = Number(project.ACCRUAL_MATERIAL_COST || 0);
|
||||
const laborGoal = Number(project.LABOR_COST_GOAL || 0);
|
||||
const laborActual = Number(project.LABOR_COST_ACTUAL || 0);
|
||||
const expenseGoal = Number(project.EXPENSE_COST_GOAL || 0);
|
||||
const expenseActual = Number(project.ACCRUAL_EXPENSE || 0);
|
||||
const totalGoalBase = materialGoal + laborGoal + expenseGoal;
|
||||
const totalActualBase = materialActual + laborActual + expenseActual;
|
||||
// 관리비 = 전체의 10%
|
||||
const mgmtGoal = Math.round(totalGoalBase * 0.1);
|
||||
const mgmtActual = Math.round(totalActualBase * 0.1);
|
||||
const totalGoal = totalGoalBase + mgmtGoal;
|
||||
const totalActual = totalActualBase + mgmtActual;
|
||||
// 각 항목 투입율(%) — 재료비는 수주가 기준, 나머지는 목표 기준 (원본 로직)
|
||||
const materialRate = contractPrice > 0 ? Math.round((materialActual / contractPrice) * 1000) / 10 : 0;
|
||||
const laborRate = laborGoal > 0 ? Math.round((laborActual / laborGoal) * 1000) / 10 : 0;
|
||||
const expenseRate = expenseGoal > 0 ? Math.round((expenseActual / expenseGoal) * 1000) / 10 : 0;
|
||||
const mgmtRate = mgmtGoal > 0 ? Math.round((mgmtActual / mgmtGoal) * 1000) / 10 : 0;
|
||||
const totalRateCost = totalGoal > 0 ? Math.round((totalActual / totalGoal) * 1000) / 10 : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 이슈 + 투입원가 2분할 (가로 배치) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 이슈 (Quality) */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2 shrink-0">
|
||||
<AlertCircle size={16} className="text-red-500" />
|
||||
이슈 (Quality)
|
||||
</h3>
|
||||
<div className="flex-1 grid grid-cols-2 gap-3 content-center">
|
||||
<MiniStat label="발생" value={issueTotal} color="text-gray-800" />
|
||||
<MiniStat label="조치" value={issueDone} color="text-green-600" />
|
||||
<MiniStat label="미결" value={issueMiss} color="text-red-500" />
|
||||
<MiniStat label="조치율" value={`${issueRate}%`} color="text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 투입원가현황 — 원본 dashboard.jsp 5행 테이블 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2 shrink-0">
|
||||
<BarChart3 size={16} className="text-primary" />
|
||||
투입원가현황
|
||||
</h3>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-600 text-white">
|
||||
<th className="border border-gray-300 px-2 py-1.5 font-semibold">수주가(원)</th>
|
||||
<th className="border border-gray-300 px-2 py-1.5 font-semibold">항목</th>
|
||||
<th className="border border-gray-300 px-2 py-1.5 font-semibold">목표원가(원)</th>
|
||||
<th className="border border-gray-300 px-2 py-1.5 font-semibold">투입원가(원)</th>
|
||||
<th className="border border-gray-300 px-2 py-1.5 font-semibold">투입율(%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-700">
|
||||
<tr>
|
||||
<td rowSpan={5} className="border border-gray-300 px-2 py-2 text-right align-middle">{numberWithCommas(contractPrice)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-center">재료비</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(materialGoal)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(materialActual)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{materialRate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-center">노무비</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(laborGoal)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(laborActual)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{laborRate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-center">경비</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(expenseGoal)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(expenseActual)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{expenseRate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-center">관리비</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(mgmtGoal)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{numberWithCommas(mgmtActual)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right">{mgmtRate}</td>
|
||||
</tr>
|
||||
<tr style={{ backgroundColor: "#efb3b3" }}>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-center font-semibold">계</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right font-semibold">{numberWithCommas(totalGoal)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right font-semibold">{numberWithCommas(totalActual)}</td>
|
||||
<td className="border border-gray-300 px-2 py-1.5 text-right font-semibold">{totalRateCost}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value, color }: { label: string; value: number | string; color: string }) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountBadge({ label, value, color, active, onClick }: {
|
||||
label: string; value: number; color: "blue" | "red";
|
||||
active?: boolean; onClick?: () => void;
|
||||
}) {
|
||||
const numColor = value > 0 ? (color === "red" ? "text-red-500" : "text-blue-600") : "text-gray-300";
|
||||
const bg = active ? (color === "red" ? "bg-red-50" : "bg-blue-50") : "hover:bg-gray-50";
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`flex flex-col items-center justify-center rounded-lg py-2 transition-colors ${bg}`}>
|
||||
<div className={`text-3xl font-bold ${numColor}`}>{numberWithCommas(value)}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">({label})</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// 원본: purchaseOrder/deliveryMngAcceptanceList.jsp
|
||||
// 입고관리 > 입고결과등록
|
||||
export default function AcceptancePage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState("");
|
||||
const [customerProjectName, setCustomerProjectName] = useState("");
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [purchaseOrderNo, setPurchaseOrderNo] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [searchPartSpec, setSearchPartSpec] = useState("");
|
||||
const [partnerObjid, setPartnerObjid] = useState("");
|
||||
const [salesMngUserId, setSalesMngUserId] = useState("");
|
||||
const [deliveryStartDate, setDeliveryStartDate] = useState("");
|
||||
const [deliveryEndDate, setDeliveryEndDate] = useState("");
|
||||
const [regStartDate, setRegStartDate] = useState("");
|
||||
const [regEndDate, setRegEndDate] = useState("");
|
||||
const [deliveryStatus, setDeliveryStatus] = useState("");
|
||||
const [searchPartName, setSearchPartName] = useState("");
|
||||
const [searchPartNo, setSearchPartNo] = useState("");
|
||||
const [poClientId, setPoClientId] = useState("");
|
||||
|
||||
const [supplyOptions, setSupplyOptions] = useState<Option[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<Option[]>([]);
|
||||
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/supply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) =>
|
||||
setSupplyOptions(
|
||||
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID),
|
||||
label: String(r.SUPPLY_NAME),
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) =>
|
||||
setUserOptions(
|
||||
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.USER_ID),
|
||||
label: String(r.USER_NAME),
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 발주서 상세 팝업 열기 (발주번호 셀 클릭)
|
||||
const openOrderForm = (objId: string) => {
|
||||
const w = 1460;
|
||||
const h = 1050;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/order/list/form?objId=${objId}&action=view`,
|
||||
`orderForm_${objId}`,
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
// 입고결과 팝업 (view) — 입고결과 셀 클릭
|
||||
const openAcceptanceViewPopup = (objId: string, status: string) => {
|
||||
const w = 1560;
|
||||
const h = 1050;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/delivery/acceptance/form?objId=${objId}&delivery_status=${encodeURIComponent(status)}`,
|
||||
"deliveryAcceptancePopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 110, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "center" },
|
||||
{
|
||||
title: "발주번호",
|
||||
field: "PURCHASE_ORDER_NO",
|
||||
width: 100,
|
||||
hozAlign: "center",
|
||||
cellClick: (row) => openOrderForm(String(row.OBJID || "")),
|
||||
},
|
||||
{ title: "동시", field: "MULTI_YN_MAKED", width: 50, hozAlign: "center" },
|
||||
{ title: "발주서_제목", field: "TITLE", width: 150, hozAlign: "left" },
|
||||
{ title: "입고요청일", field: "DELIVERY_DATE", width: 85, hozAlign: "center" },
|
||||
{ title: "구매/제작업체명", field: "PARTNER_NAME", width: 120, hozAlign: "left" },
|
||||
{ title: "구매담당", field: "SALES_MNG_USER_NAME", width: 78, hozAlign: "center" },
|
||||
{ title: "발주일", field: "REGDATE", width: 78, hozAlign: "center" },
|
||||
{ title: "발주수량", field: "TOTAL_PO_QTY", width: 78, hozAlign: "right", formatter: "money" },
|
||||
{ title: "입고일", field: "CUR_DELIVERY_DATE", width: 78, hozAlign: "center" },
|
||||
{ title: "입고자", field: "CUR_RECEIVER_NAME", width: 70, hozAlign: "center" },
|
||||
{ title: "입고수량", field: "TOTAL_DELIVERY_QTY", width: 75, hozAlign: "right", formatter: "money" },
|
||||
{ title: "미입고수량", field: "NON_DELIVERY_QTY", width: 85, hozAlign: "right", formatter: "money" },
|
||||
{
|
||||
title: "입고결과",
|
||||
field: "DELIVERY_STATUS",
|
||||
width: 75,
|
||||
hozAlign: "center",
|
||||
cellClick: (row) =>
|
||||
openAcceptanceViewPopup(String(row.OBJID || ""), String(row.DELIVERY_STATUS || "")),
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/delivery/acceptance", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
customer_project_name: customerProjectName,
|
||||
project_nos: projectNo ? [projectNo] : [],
|
||||
unit_code: unitCode,
|
||||
purchase_order_no: purchaseOrderNo,
|
||||
type,
|
||||
SEARCH_PART_SPEC: searchPartSpec,
|
||||
partner_objid: partnerObjid,
|
||||
sales_mng_user_ids: salesMngUserId ? [salesMngUserId] : [],
|
||||
delivery_start_date: deliveryStartDate,
|
||||
delivery_end_date: deliveryEndDate,
|
||||
reg_start_date: regStartDate,
|
||||
reg_end_date: regEndDate,
|
||||
delivery_status: deliveryStatus,
|
||||
SEARCH_PART_NAME: searchPartName,
|
||||
SEARCH_PART_NO: searchPartNo,
|
||||
po_client_id: poClientId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
Swal.fire("오류", j.message || "조회 실패", "error");
|
||||
}
|
||||
}, [
|
||||
year, customerProjectName, projectNo, unitCode, purchaseOrderNo, type,
|
||||
searchPartSpec, partnerObjid, salesMngUserId, deliveryStartDate, deliveryEndDate,
|
||||
regStartDate, regEndDate, deliveryStatus, searchPartName, searchPartNo, poClientId,
|
||||
]);
|
||||
|
||||
// 입고결과등록: 원본 가드 로직 동일
|
||||
// - 미선택: "선택된 데이터가 없습니다."
|
||||
// - 2건이상: "한건씩 등록 가능합니다."
|
||||
// - MULTI_YN='Y' AND MULTI_MASTER_YN='N': "동시발주건은 마스터건으로 수입검사해야 합니다."
|
||||
const handleAcceptanceRegister = () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
if (selectedRows.length > 1) {
|
||||
Swal.fire("알림", "한건씩 등록 가능합니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const row = selectedRows[0];
|
||||
const multiYn = String(row.MULTI_YN || "");
|
||||
const multiMasterYn = String(row.MULTI_MASTER_YN || "");
|
||||
if (multiYn === "Y" && multiMasterYn === "N") {
|
||||
Swal.fire("알림", "동시발주건은 마스터건으로 수입검사해야 합니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const w = 1560;
|
||||
const h = 1050;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/delivery/acceptance/form?objId=${row.OBJID}&action=regist`,
|
||||
"deliveryAcceptancePopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
// 부적합등록: 원본 가드 로직 동일
|
||||
// - 미선택/복수선택 체크 + MULTI_MASTER_YN='N': "동시발주건은 마스터건으로 부적합 등록해야 합니다."
|
||||
const handleDefectRegister = () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
if (selectedRows.length > 1) {
|
||||
Swal.fire("알림", "한건씩 등록 가능합니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const row = selectedRows[0];
|
||||
const multiMasterYn = String(row.MULTI_MASTER_YN || "");
|
||||
if (multiMasterYn === "N") {
|
||||
Swal.fire("알림", "동시발주건은 마스터건으로 부적합 등록해야 합니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const w = 1260;
|
||||
const h = 1050;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/delivery/defect/form?objId=${row.OBJID}`,
|
||||
"InvalidFormPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// 최초 1회만
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">입고관리_입고결과등록</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" onClick={handleAcceptanceRegister}>입고결과등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleDefectRegister}>부적합등록</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트명">
|
||||
<Input value={customerProjectName} onChange={(e) => setCustomerProjectName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[140px]" placeholder="프로젝트 OBJID" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛명">
|
||||
<Input value={unitCode} onChange={(e) => setUnitCode(e.target.value)} className="w-[130px]" placeholder="유닛 OBJID" />
|
||||
</SearchField>
|
||||
<SearchField label="발주No.">
|
||||
<Input value={purchaseOrderNo} onChange={(e) => setPurchaseOrderNo(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="부품구분">
|
||||
<SearchableCodeSelect codeId="0001068" value={type} onChange={setType} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="규격">
|
||||
<Input value={searchPartSpec} onChange={(e) => setSearchPartSpec(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="공급업체">
|
||||
<SearchableSelect options={supplyOptions} value={partnerObjid} onChange={setPartnerObjid} className="w-[170px]" />
|
||||
</SearchField>
|
||||
<SearchField label="구매담당자">
|
||||
<SearchableSelect options={userOptions} value={salesMngUserId} onChange={setSalesMngUserId} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="입고요청일">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="date"
|
||||
value={deliveryStartDate}
|
||||
onChange={(e) => setDeliveryStartDate(e.target.value)}
|
||||
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
|
||||
/>
|
||||
<span>~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={deliveryEndDate}
|
||||
onChange={(e) => setDeliveryEndDate(e.target.value)}
|
||||
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="발주일">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="date"
|
||||
value={regStartDate}
|
||||
onChange={(e) => setRegStartDate(e.target.value)}
|
||||
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
|
||||
/>
|
||||
<span>~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={regEndDate}
|
||||
onChange={(e) => setRegEndDate(e.target.value)}
|
||||
className="h-9 rounded border border-gray-300 bg-white px-2 text-sm w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="입고결과">
|
||||
<select
|
||||
value={deliveryStatus}
|
||||
onChange={(e) => setDeliveryStatus(e.target.value)}
|
||||
className="h-9 w-[110px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="입고중">입고중</option>
|
||||
<option value="입고완료">입고완료</option>
|
||||
<option value="지연">지연</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input value={searchPartName} onChange={(e) => setSearchPartName(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품번">
|
||||
<Input value={searchPartNo} onChange={(e) => setSearchPartNo(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="발주처">
|
||||
<SearchableSelect options={supplyOptions} value={poClientId} onChange={setPoClientId} className="w-[170px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 입고관리 > 부적합리스트
|
||||
export default function DefectPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [customerCd, setCustomerCd] = useState("");
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [partnerObjid, setPartnerObjid] = useState("");
|
||||
const [defectType, setDefectType] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "부적합번호", field: "DEFECT_NO", width: 130, hozAlign: "left",
|
||||
cellClick: (row) => {
|
||||
window.open(`/delivery/defect/form?objId=${row.OBJID}`, "defectForm", "width=1000,height=800");
|
||||
},
|
||||
},
|
||||
{ title: "발주No", field: "PURCHASE_ORDER_NO", width: 140, hozAlign: "left" },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
|
||||
{ title: "공급업체", field: "PARTNER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "Part No", field: "PART_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "품명", field: "PART_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "부적합유형", field: "DEFECT_TYPE_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "부적합수량", field: "DEFECT_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "부적합내용", field: "DEFECT_CONTENT", width: 250, hozAlign: "left" },
|
||||
{ title: "처리상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "등록자", field: "REG_USER_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/delivery/defect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
customer_cd: customerCd,
|
||||
project_no: projectNo,
|
||||
partner_objid: partnerObjid,
|
||||
defect_type: defectType,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, customerCd, projectNo, partnerObjid, defectType]);
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">부적합리스트</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<CodeSelect codeId="CUSTOMER" value={customerCd} onChange={setCustomerCd} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} placeholder="프로젝트번호" className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="공급업체">
|
||||
<CodeSelect codeId="PARTNER" value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="부적합유형">
|
||||
<CodeSelect codeId="DEFECT_TYPE" value={defectType} onChange={setDefectType} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 입고관리 > 단가관리
|
||||
export default function PricePage() {
|
||||
const [partnerObjid, setPartnerObjid] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "공급업체", field: "PARTNER_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "Part No", field: "PART_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "품명", field: "PART_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "규격", field: "SPEC", width: 150, hozAlign: "left" },
|
||||
{ title: "재질", field: "MATERIAL", width: 100, hozAlign: "left" },
|
||||
{ title: "단위", field: "UNIT_NAME", width: 60, hozAlign: "center" },
|
||||
{ title: "단가", field: "UNIT_PRICE", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "적용시작일", field: "START_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "적용종료일", field: "END_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/delivery/price", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
partner_objid: partnerObjid,
|
||||
part_name: partName,
|
||||
part_no: partNo,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [partnerObjid, partName, partNo]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "삭제할 항목을 선택하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
const result = await Swal.fire({
|
||||
title: "삭제 확인",
|
||||
text: `선택한 ${selectedRows.length}건을 삭제하시겠습니까?`,
|
||||
icon: "question",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "삭제",
|
||||
cancelButtonText: "취소",
|
||||
});
|
||||
if (result.isConfirmed) {
|
||||
Swal.fire("완료", "삭제되었습니다.", "success");
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">단가관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => {/* TODO: open register form */}}>등록</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>삭제</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="공급업체">
|
||||
<CodeSelect codeId="PARTNER" value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="Part No">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} placeholder="Part No" className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input value={partName} onChange={(e) => setPartName(e.target.value)} placeholder="품명" className="w-[150px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// 원본: purchaseOrder/deliveryMngStatus.jsp
|
||||
// 입고관리 > 현황 — 프로젝트 BOM별 발주/입고/부적합 집계 리포트
|
||||
export default function DeliveryStatusPage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState("");
|
||||
const [customerObjid, setCustomerObjid] = useState("");
|
||||
const [projectNo, setProjectNo] = useState(""); // 단일 project objid
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const [customerOptions, setCustomerOptions] = useState<Option[]>([]);
|
||||
const [projectOptions, setProjectOptions] = useState<Option[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/common/supply-list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) =>
|
||||
setCustomerOptions(
|
||||
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID),
|
||||
label: String(r.SUPPLY_NAME),
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/api/common/project-list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) =>
|
||||
setProjectOptions(
|
||||
(d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID),
|
||||
label: String(r.LABEL || r.PROJECT_NO || r.CUSTOMER_PROJECT_NAME || r.OBJID),
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 구매BOM 팝업
|
||||
const openBomPopup = (bomReportObjId: string) => {
|
||||
if (!bomReportObjId) return;
|
||||
const w = 1600;
|
||||
const h = 900;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/purchase/bom/form?parentObjId=${bomReportObjId}&actType=view`,
|
||||
`bomReport_${bomReportObjId}`,
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
headerHozAlign: "center",
|
||||
frozen: true,
|
||||
columns: [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 110, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "유닛명", field: "UNIT_PART_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "발주내역",
|
||||
headerHozAlign: "center",
|
||||
columns: [
|
||||
{
|
||||
title: "구매BOM",
|
||||
field: "TOTAL_BOM_PART_CNT",
|
||||
width: 80,
|
||||
hozAlign: "center",
|
||||
cellClick: (row) => openBomPopup(String(row.BOM_REPORT_OBJID || "")),
|
||||
formatter: (cell) => (Number(cell) > 0 ? "조회" : "-"),
|
||||
},
|
||||
{ title: "전체품목수", field: "TOTAL_BOM_PART_CNT", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발주품목수", field: "TOTAL_PO_PART_CNT", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발주율(%)", field: "PO_RATE", width: 90, hozAlign: "right" },
|
||||
{ title: "미발주품수", field: "NON_PO_PART_CNT", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "총수량", field: "TOTAL_BOM_PART_QTY_SUM", width: 90, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "입고현황",
|
||||
headerHozAlign: "center",
|
||||
columns: [
|
||||
{ title: "발주수량(신)", field: "TOTAL_PO_NEW_QTY", width: 95, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발주수량(재)", field: "TOTAL_PO_RE_QTY", width: 95, hozAlign: "right", formatter: "money" },
|
||||
{ title: "입고수량", field: "DELIVERY_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "미입고수량", field: "NON_DELIVERY_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "수입검사결과(불량현황)",
|
||||
headerHozAlign: "center",
|
||||
columns: [
|
||||
{ title: "부적합수량", field: "TOTAL_DEFECT_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "불량률(%)", field: "DELIVERY_RATE", width: 90, hozAlign: "right" },
|
||||
{ title: "설계오류", field: "DEFECT_QTY_1", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "제작불량", field: "DEFECT_QTY_2", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "구매오류", field: "DEFECT_QTY_3", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "오품반입", field: "DEFECT_QTY_4", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "손실비용", field: "TOTAL_DEFECT_PRICE", width: 90, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/delivery/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
Year: year,
|
||||
customer_objid: customerObjid,
|
||||
project_nos: projectNo ? [projectNo] : [],
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
Swal.fire("오류", j.message || "조회 실패", "error");
|
||||
}
|
||||
}, [year, customerObjid, projectNo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// 최초 1회만
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">입고관리_현황</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<SearchableSelect
|
||||
options={customerOptions}
|
||||
value={customerObjid}
|
||||
onChange={setCustomerObjid}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<SearchableSelect
|
||||
options={projectOptions}
|
||||
value={projectNo}
|
||||
onChange={setProjectNo}
|
||||
className="w-[240px]"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import { ApprovalButton } from "@/components/approval/ApprovalButton";
|
||||
|
||||
// fundMgmt/fundExpenseFormList.jsp 대응 - 경비신청서관리
|
||||
export default function FundExpenseFormPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [writerName, setWriterName] = useState("");
|
||||
const [statusCode, setStatusCode] = useState("");
|
||||
const [expenseDateFrom, setExpenseDateFrom] = useState("");
|
||||
const [expenseDateTo, setExpenseDateTo] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "신청번호", field: "EXPENSE_FORM_NO", width: 140, hozAlign: "left",
|
||||
cellClick: (row) => window.open(`/fund/expense-form/form?objId=${row.OBJID}`, "expenseFormDetail", "width=1000,height=700") },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "신청자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "신청일", field: "EXPENSE_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "경비구분", field: "EXPENSE_TYPE_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "신청금액", field: "EXPENSE_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "사용처", field: "EXPENSE_PLACE", width: 150, hozAlign: "left" },
|
||||
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/fund/expense-form", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
writer_name: writerName,
|
||||
status_code: statusCode,
|
||||
expense_date_from: expenseDateFrom,
|
||||
expense_date_to: expenseDateTo,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, writerName, statusCode, expenseDateFrom, expenseDateTo]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">경비신청서관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => window.open("/fund/expense-form/form", "expenseFormNew", "width=1000,height=700")}>등록</Button>
|
||||
<ApprovalButton
|
||||
objIds={selectedRows.map((r) => String(r.OBJID))}
|
||||
targetType="EXPENSE_APPLY"
|
||||
title={`경비 결재 요청 (${selectedRows.length}건)`}
|
||||
description={selectedRows.map((r) => `${r.EXPENSE_ID} - ${r.BUS_TITLE}`).join("\n")}
|
||||
onSuccess={fetchData}
|
||||
disabled={selectedRows.length === 0}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="신청자">
|
||||
<Input value={writerName} onChange={(e) => setWriterName(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="상태">
|
||||
<CodeSelect codeId="EXPENSE_STATUS" value={statusCode} onChange={setStatusCode} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="신청일(From)">
|
||||
<Input type="date" value={expenseDateFrom} onChange={(e) => setExpenseDateFrom(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="신청일(To)">
|
||||
<Input type="date" value={expenseDateTo} onChange={(e) => setExpenseDateTo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} showCheckbox
|
||||
onSelectionChange={setSelectedRows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
|
||||
// fundMgmt/fundInvoiceList.jsp 대응 - 거래명세서관리
|
||||
export default function FundInvoicePage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [invoiceDateFrom, setInvoiceDateFrom] = useState("");
|
||||
const [invoiceDateTo, setInvoiceDateTo] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "거래명세서번호", field: "INVOICE_NO", width: 140, hozAlign: "left",
|
||||
cellClick: (row) => window.open(`/fund/invoice/form?objId=${row.OBJID}`, "invoiceDetail", "width=1000,height=700") },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "발행일", field: "INVOICE_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "공급가액", field: "SUPPLY_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "세액", field: "TAX_AMOUNT", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "합계", field: "TOTAL_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/fund/invoice", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
customer_name: customerName,
|
||||
invoice_date_from: invoiceDateFrom,
|
||||
invoice_date_to: invoiceDateTo,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, customerName, invoiceDateFrom, invoiceDateTo]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">거래명세서관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => window.open("/fund/invoice/form", "invoiceForm", "width=1000,height=700")}>등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="발행일(From)">
|
||||
<Input type="date" value={invoiceDateFrom} onChange={(e) => setInvoiceDateFrom(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="발행일(To)">
|
||||
<Input type="date" value={invoiceDateTo} onChange={(e) => setInvoiceDateTo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 자재관리 > 자금관리
|
||||
export default function FundPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [month, setMonth] = useState("");
|
||||
const [partnerObjid, setPartnerObjid] = useState("");
|
||||
const [paymentStatus, setPaymentStatus] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "공급업체", field: "PARTNER_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "발주금액", field: "ORDER_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "입고금액", field: "DELIVERY_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "지급예정금액", field: "PAYMENT_PLAN_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "지급완료금액", field: "PAYMENT_DONE_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "미지급금액", field: "UNPAID_AMOUNT", width: 120, hozAlign: "right", formatter: "money" },
|
||||
{ title: "지급예정일", field: "PAYMENT_PLAN_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "지급상태", field: "PAYMENT_STATUS_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/inventory/fund", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
month,
|
||||
partner_objid: partnerObjid,
|
||||
payment_status: paymentStatus,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, month, partnerObjid, paymentStatus]);
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">자금관리</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="월">
|
||||
<select value={month} onChange={(e) => setMonth(e.target.value)}
|
||||
className="h-9 w-[80px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
<option value="">전체</option>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={String(m).padStart(2, "0")}>{m}월</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="공급업체">
|
||||
<CodeSelect codeId="PARTNER" value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="지급상태">
|
||||
<CodeSelect codeId="PAYMENT_STATUS" value={paymentStatus} onChange={setPaymentStatus} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// 재고 입출고 이력 팝업
|
||||
export default function InventoryHistoryPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const objId = searchParams.get("objId") || "";
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!objId) return;
|
||||
const res = await fetch("/api/inventory/history", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parent_objid: objId }),
|
||||
});
|
||||
if (res.ok) { const json = await res.json(); setData(json.RESULTLIST || []); }
|
||||
}, [objId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "유형", field: "TYPE", width: 80, hozAlign: "center" },
|
||||
{ title: "수량", field: "QTY", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "일자", field: "HIST_DATE", width: 110, hozAlign: "center" },
|
||||
{ title: "위치", field: "LOCATION_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", hozAlign: "left" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">재고 입출고 이력</h2>
|
||||
<DataGrid columns={columns} data={data} />
|
||||
<div className="flex justify-end mt-3">
|
||||
<Button variant="secondary" onClick={() => window.close()}>닫기</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableCodeSelect } from "@/components/ui/searchable-code-select";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 자재관리 > 자재리스트 (원본 /inventoryMng/inventoryMngNewList.do)
|
||||
export default function InventoryListPage() {
|
||||
const [projectNos, setProjectNos] = useState<string[]>([]);
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [partType, setPartType] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
|
||||
const [projectOptions, setProjectOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [unitOptions, setUnitOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 프로젝트 옵션 로드
|
||||
useEffect(() => {
|
||||
fetch("/api/common/project-list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
const list = (j.RESULTLIST || []) as Array<Record<string, unknown>>;
|
||||
setProjectOptions(
|
||||
list.map((r) => ({
|
||||
value: String(r.OBJID || ""),
|
||||
label: String(r.LABEL || r.PROJECT_NO || ""),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setProjectOptions([]));
|
||||
}, []);
|
||||
|
||||
// 프로젝트 선택 변경 시 유닛 로드 (단일/다중 모두 대응 — 첫 번째 프로젝트 기준)
|
||||
useEffect(() => {
|
||||
setUnitCode("");
|
||||
if (projectNos.length === 0) {
|
||||
setUnitOptions([]);
|
||||
return;
|
||||
}
|
||||
const first = projectNos[0];
|
||||
fetch("/api/common/unit-list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract_objid: first }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
const list = (j.RESULTLIST || []) as Array<Record<string, unknown>>;
|
||||
setUnitOptions(
|
||||
list.map((r) => ({
|
||||
value: String(r.OBJID || ""),
|
||||
label: String(r.UNIT_NAME || ""),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setUnitOptions([]));
|
||||
}, [projectNos]);
|
||||
|
||||
const openHistoryPopup = useCallback((objId: string) => {
|
||||
const w = 730;
|
||||
const h = 400;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/inventory/request/history?objId=${encodeURIComponent(objId)}`,
|
||||
"inventoryRequestHistoryPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const columns: GridColumn[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "자재목록",
|
||||
headerHozAlign: "center",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 200, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "품번", field: "PART_NO", width: 180, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "품명", field: "PART_NAME", width: 180, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "재질", field: "MATERIAL", width: 180, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "사양(규격)", field: "SPEC", width: 200, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "PART구분", field: "PART_TYPE_NAME", width: 120, hozAlign: "center", headerHozAlign: "center" },
|
||||
{ title: "보유수량", field: "USE_CNT", width: 100, hozAlign: "center", headerHozAlign: "center", formatter: "money" },
|
||||
{ title: "보유수량(전체)", field: "USE_CNT_ALL", width: 120, hozAlign: "center", headerHozAlign: "center", formatter: "money" },
|
||||
{ title: "Location", field: "LOCATION_NAME", width: 120, hozAlign: "left", headerHozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "불출이력",
|
||||
headerHozAlign: "center",
|
||||
columns: [
|
||||
{
|
||||
title: "불출이력",
|
||||
field: "REQUEST_QTY",
|
||||
width: 80,
|
||||
hozAlign: "center",
|
||||
headerHozAlign: "center",
|
||||
formatter: (cell, row) => {
|
||||
const v = Number(cell || 0);
|
||||
if (v === 0) return "0";
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openHistoryPopup(String(row.OBJID || ""));
|
||||
}}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
{v.toLocaleString()}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left", headerHozAlign: "center" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[openHistoryPopup],
|
||||
);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/inventory/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
project_nos: projectNos.join(","),
|
||||
unit_code: unitCode,
|
||||
part_no: partNo,
|
||||
part_name: partName,
|
||||
part_type: partType,
|
||||
location,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectNos, unitCode, partNo, partName, partType, location]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 등록 팝업 (inventoryFormPopUp)
|
||||
const handleRegister = () => {
|
||||
const w = 850;
|
||||
const h = 500;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
"/inventory/list/form",
|
||||
"inventoryFormPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
// 자재이동 (materialMoveFormPopUp)
|
||||
const handleMove = () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
for (const r of selectedRows) {
|
||||
if (Number(r.USE_CNT || 0) === 0) {
|
||||
alert("보유수량이 0일 경우 이동이 불가능합니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const checkArr = selectedRows.map((r) => String(r.OBJID)).join(",");
|
||||
const w = 1600;
|
||||
const h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/inventory/move/form?checkArr=${encodeURIComponent(checkArr)}`,
|
||||
"materialMoveFormPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
// 불출의뢰 (materialRequestFormPopUp)
|
||||
const handleRequest = () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "선택된 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
for (const r of selectedRows) {
|
||||
if (Number(r.USE_CNT || 0) === 0) {
|
||||
alert("보유수량이 0일 경우 불출의뢰가 불가능합니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const checkArr = selectedRows.map((r) => String(r.OBJID)).join(",");
|
||||
const w = 1800;
|
||||
const h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/inventory/request/form?checkArr=${encodeURIComponent(checkArr)}`,
|
||||
"inventoryRequestPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">자재관리_자재리스트</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleRegister}>
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleMove}>
|
||||
자재이동
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleRequest}>
|
||||
불출의뢰
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="프로젝트번호">
|
||||
<MultiSelect
|
||||
options={projectOptions}
|
||||
value={projectNos}
|
||||
onChange={setProjectNos}
|
||||
placeholder="선택"
|
||||
className="w-[300px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="유닛명">
|
||||
<SearchableSelect
|
||||
options={unitOptions}
|
||||
value={unitCode}
|
||||
onChange={setUnitCode}
|
||||
placeholder="선택"
|
||||
className="w-[230px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="품번">
|
||||
<Input
|
||||
value={partNo}
|
||||
onChange={(e) => setPartNo(e.target.value)}
|
||||
placeholder="품번"
|
||||
className="w-[150px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input
|
||||
value={partName}
|
||||
onChange={(e) => setPartName(e.target.value)}
|
||||
placeholder="품명"
|
||||
className="w-[170px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="PART 구분">
|
||||
<SearchableCodeSelect
|
||||
codeId="0000062"
|
||||
value={partType}
|
||||
onChange={setPartType}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="Location">
|
||||
<SearchableCodeSelect
|
||||
codeId="0000262"
|
||||
value={location}
|
||||
onChange={setLocation}
|
||||
className="w-[230px]"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
loading={loading}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 간단한 다중 선택 — 선택된 라벨을 태그로 표시 + 드롭다운
|
||||
function MultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: {
|
||||
options: { value: string; label: string }[];
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = options.filter((o) => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return o.label.toLowerCase().includes(s) || o.value.toLowerCase().includes(s);
|
||||
});
|
||||
|
||||
const toggle = (v: string) => {
|
||||
if (value.includes(v)) onChange(value.filter((x) => x !== v));
|
||||
else onChange([...value, v]);
|
||||
};
|
||||
|
||||
const selectedLabels = value
|
||||
.map((v) => options.find((o) => o.value === v)?.label || v)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className={`relative ${className || ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
className="h-9 w-full text-left rounded border border-gray-300 bg-white px-2 text-sm truncate pr-6"
|
||||
title={selectedLabels}
|
||||
>
|
||||
{selectedLabels || <span className="text-gray-400">{placeholder || "선택"}</span>}
|
||||
</button>
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 text-sm"
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded shadow max-h-64 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b p-1">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="검색"
|
||||
className="w-full px-2 py-1 text-sm border rounded"
|
||||
/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-2 text-xs text-gray-400 text-center">결과 없음</div>
|
||||
) : (
|
||||
filtered.map((o) => {
|
||||
const selected = value.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => toggle(o.value)}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm hover:bg-blue-50 flex items-center gap-2 ${
|
||||
selected ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
readOnly
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// inventoryMngInputList.jsp 대응 - 재고관리(입고)
|
||||
export default function InventoryPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [spec, setSpec] = useState("");
|
||||
const [clsCd, setClsCd] = useState("");
|
||||
const [cauCd, setCauCd] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// inventoryMngInputList.jsp columns 대응
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left" },
|
||||
{ title: "유닛명", field: "UNIT_NAME", hozAlign: "left" },
|
||||
{ title: "파트번호", field: "PART_NO", width: 130, hozAlign: "left" },
|
||||
{ title: "파트명", field: "PART_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "규격", field: "SPEC", width: 110, hozAlign: "left" },
|
||||
{ title: "업체", field: "MAKER", width: 100, hozAlign: "left" },
|
||||
{ title: "재고구분", field: "CLS_CD_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "발생사유", field: "CAU_CD_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "발생수량", field: "QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "위치", field: "LOCATION_NAME", width: 100, hozAlign: "left" },
|
||||
{ title: "금액(원)", field: "PRICE", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "등록일", field: "REG_DATE", width: 90, hozAlign: "center" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "총입고수량", field: "INPUT_QTY", width: 90, hozAlign: "center",
|
||||
cellClick: (row) => openHistoryPopup(String(row.OBJID)) },
|
||||
{ title: "최종입고일", field: "INPUT_DATE", width: 90, hozAlign: "center" },
|
||||
{ title: "잔여수량", field: "REMAIN_QTY", width: 90, hozAlign: "center",
|
||||
cellClick: (row) => openHistoryPopup(String(row.OBJID)) },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/inventory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year, project_no: projectNo, part_no: partNo, part_name: partName,
|
||||
spec, cls_cd: clsCd, cau_cd: cauCd, location,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
setTotalCount(json.TOTAL_CNT || 0);
|
||||
}
|
||||
}, [year, projectNo, partNo, partName, spec, clsCd, cauCd, location]);
|
||||
|
||||
const openHistoryPopup = (objId: string) => {
|
||||
window.open(`/inventory/history?objId=${objId}`, "inventoryHistory", "width=600,height=500");
|
||||
};
|
||||
|
||||
const openInputPopup = () => {
|
||||
window.open("/inventory/input-form", "inventoryInput", "width=850,height=330");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">재고관리 (입고)</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={openInputPopup}>입고등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="파트번호">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="파트명">
|
||||
<Input value={partName} onChange={(e) => setPartName(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="규격">
|
||||
<Input value={spec} onChange={(e) => setSpec(e.target.value)} className="w-[100px]" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="재고구분">
|
||||
<CodeSelect codeId="0001576" value={clsCd} onChange={setClsCd} placeholder="전체" className="w-[120px]" />
|
||||
</SearchField>
|
||||
|
||||
<SearchField label="위치">
|
||||
<CodeSelect codeId="0000262" value={location} onChange={setLocation} placeholder="전체" className="w-[120px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { numberWithCommas } from "@/lib/utils";
|
||||
|
||||
// 입출고 History 팝업 (원본 /inventoryMng/inventoryRequestHistoryPopUp.do)
|
||||
function HistoryPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const objId = searchParams.get("objId") || searchParams.get("partId") || "";
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!objId) return;
|
||||
const res = await fetch("/api/inventory/request/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objId }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setRows(json.RESULTLIST || []);
|
||||
}
|
||||
}, [objId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openTarget = (id: string, gubun: string) => {
|
||||
if (gubun === "출고") {
|
||||
const w = 1500;
|
||||
const h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
const params = new URLSearchParams({
|
||||
INVENTORY_REQUEST_MASTER_OBJID: id,
|
||||
action: "view",
|
||||
});
|
||||
window.open(
|
||||
`/inventory/request/detail?${params.toString()}`,
|
||||
"inventoryRequestPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
} else if (gubun === "입고") {
|
||||
const w = 1260;
|
||||
const h = 1050;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/delivery/acceptance/form?objId=${encodeURIComponent(id)}&actionType=view`,
|
||||
"deliveryAcceptancePopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-white min-h-screen">
|
||||
<div className="mb-2">
|
||||
<h2 className="text-base font-bold">입출고 이력</h2>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-300 overflow-x-auto max-h-[calc(100vh-120px)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-100 text-center sticky top-0">
|
||||
<tr>
|
||||
<th className="border px-2 py-1.5">프로젝트번호</th>
|
||||
<th className="border px-2 py-1.5">품번</th>
|
||||
<th className="border px-2 py-1.5">품명</th>
|
||||
<th className="border px-2 py-1.5">구분</th>
|
||||
<th className="border px-2 py-1.5">입출고수량</th>
|
||||
<th className="border px-2 py-1.5">Location</th>
|
||||
<th className="border px-2 py-1.5">Sub_Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-4 text-gray-400">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r, i) => {
|
||||
const gubun = String(r.GUBUN || "");
|
||||
const rowObjId = String(r.OBJID || "");
|
||||
return (
|
||||
<tr key={i} className="border-b">
|
||||
<td className="border px-2 py-1">{String(r.PROJECT_NO || "")}</td>
|
||||
<td className="border px-2 py-1">{String(r.PART_NO || "")}</td>
|
||||
<td className="border px-2 py-1">{String(r.PART_NAME || "")}</td>
|
||||
<td className="border px-2 py-1 text-center">
|
||||
{gubun === "입고" || gubun === "출고" ? (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openTarget(rowObjId, gubun);
|
||||
}}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
{gubun}
|
||||
</a>
|
||||
) : (
|
||||
<span>{gubun}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border px-2 py-1 text-right">
|
||||
{numberWithCommas(String(r.RECEIPT_QTY || ""))}
|
||||
</td>
|
||||
<td className="border px-2 py-1">{String(r.LOCATION_NAME || "")}</td>
|
||||
<td className="border px-2 py-1">{String(r.SUB_LOCATION_NAME || "")}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-3 pt-2 border-t">
|
||||
<Button variant="secondary" onClick={() => window.close()}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-center text-gray-400">로딩 중...</div>}>
|
||||
<HistoryPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { ExcelDownloadButton } from "@/components/ui/excel-download-button";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 자재관리 > 불출의뢰서 (원본 /inventoryMng/materialRequestList.do)
|
||||
export default function InventoryRequestPage() {
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [requestStartDate, setRequestStartDate] = useState("");
|
||||
const [requestEndDate, setRequestEndDate] = useState("");
|
||||
const [requestUser, setRequestUser] = useState("");
|
||||
const [receptionStatus, setReceptionStatus] = useState("");
|
||||
const [receptionUser, setReceptionUser] = useState("");
|
||||
const [receptionStartDate, setReceptionStartDate] = useState("");
|
||||
const [receptionEndDate, setReceptionEndDate] = useState("");
|
||||
const [outStatus, setOutStatus] = useState("");
|
||||
|
||||
const [userOptions, setUserOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
const list = (j.RESULTLIST || []) as Array<Record<string, unknown>>;
|
||||
setUserOptions(
|
||||
list.map((r) => ({
|
||||
value: String(r.USER_ID || ""),
|
||||
label: String(r.USER_NAME || r.USER_ID || ""),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setUserOptions([]));
|
||||
}, []);
|
||||
|
||||
const openDetailPopup = useCallback(
|
||||
(objId: string, outStatusTitle: string, receptionStatusTitle: string) => {
|
||||
const w = 1800;
|
||||
const h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
const params = new URLSearchParams({
|
||||
INVENTORY_REQUEST_MASTER_OBJID: objId,
|
||||
action: "view",
|
||||
OUTSTATUS: outStatusTitle,
|
||||
RECEPTION_STATUS: receptionStatusTitle,
|
||||
});
|
||||
window.open(
|
||||
`/inventory/request/detail?${params.toString()}`,
|
||||
"inventoryRequestPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const columns: GridColumn[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "자재불출번호",
|
||||
field: "INVENTORY_OUT_NO",
|
||||
width: 140,
|
||||
hozAlign: "left",
|
||||
headerHozAlign: "center",
|
||||
formatter: (cell, row) => (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openDetailPopup(
|
||||
String(row.OBJID || ""),
|
||||
String(row.OUTSTATUS_TITLE || ""),
|
||||
String(row.RECEPTION_STATUS_TITLE || ""),
|
||||
);
|
||||
}}
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
{String(cell || "")}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{ title: "품번", field: "PART_NO_ARR", width: 480, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "품명", field: "PART_NAME_ARR", width: 480, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "불출의뢰일", field: "REQUEST_DATE", width: 120, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "의뢰자", field: "REQUEST_USER_NAME", width: 100, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "상태", field: "RECEPTION_STATUS_TITLE", width: 100, hozAlign: "left", headerHozAlign: "center" },
|
||||
{ title: "접수자", field: "RECEPTION_USER_NAME", width: 100, hozAlign: "center", headerHozAlign: "center" },
|
||||
{ title: "접수일", field: "RECEPTION_DATE", width: 100, hozAlign: "center", headerHozAlign: "center" },
|
||||
{ title: "불출상태", field: "OUTSTATUS_TITLE", width: 100, hozAlign: "center", headerHozAlign: "center" },
|
||||
],
|
||||
[openDetailPopup],
|
||||
);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/inventory/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
part_no: partNo,
|
||||
part_name: partName,
|
||||
request_start_date: requestStartDate,
|
||||
request_end_date: requestEndDate,
|
||||
request_user: requestUser,
|
||||
reception_status: receptionStatus,
|
||||
reception_user: receptionUser,
|
||||
reception_start_date: receptionStartDate,
|
||||
reception_end_date: receptionEndDate,
|
||||
out_status: outStatus,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
partNo,
|
||||
partName,
|
||||
requestStartDate,
|
||||
requestEndDate,
|
||||
requestUser,
|
||||
receptionStatus,
|
||||
receptionUser,
|
||||
receptionStartDate,
|
||||
receptionEndDate,
|
||||
outStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 접수 (미접수만 가능)
|
||||
const handleReceipt = async () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const checkedArr = selectedRows
|
||||
.filter((r) => String(r.RECEPTION_STATUS_TITLE) === "미접수")
|
||||
.map((r) => String(r.OBJID));
|
||||
if (checkedArr.length === 0) {
|
||||
Swal.fire("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const confirmed = await Swal.fire({
|
||||
title: "선택된 데이터를 접수하시겠습니까?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "확인",
|
||||
cancelButtonText: "취소",
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
});
|
||||
if (!confirmed.isConfirmed) return;
|
||||
|
||||
const res = await fetch("/api/inventory/request/receipt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ checkArr: checkedArr.join(",") }),
|
||||
});
|
||||
const data = await res.json();
|
||||
Swal.fire(data.message || "접수되었습니다.");
|
||||
if (data.success) fetchData();
|
||||
};
|
||||
|
||||
// 자재불출 (단일 선택, 접수 상태만)
|
||||
const handleAccept = () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (selectedRows.length > 1) {
|
||||
Swal.fire("한번에 1개의 내용만 불출가능합니다.");
|
||||
return;
|
||||
}
|
||||
const row = selectedRows[0];
|
||||
const receptionStatusTitle = String(row.RECEPTION_STATUS_TITLE || "");
|
||||
const outStatusTitle = String(row.OUTSTATUS_TITLE || "");
|
||||
|
||||
if (receptionStatusTitle !== "접수") {
|
||||
Swal.fire("접수한 데이터만 불출가능합니다.");
|
||||
return;
|
||||
}
|
||||
if (outStatusTitle === "완료") {
|
||||
Swal.fire("불출완료된 데이터는 불출가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const w = 1800;
|
||||
const h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
const params = new URLSearchParams({
|
||||
INVENTORY_REQUEST_MASTER_OBJID: String(row.OBJID || ""),
|
||||
OUTSTATUS: outStatusTitle,
|
||||
RECEPTION_STATUS: receptionStatusTitle,
|
||||
});
|
||||
window.open(
|
||||
`/inventory/request/detail?${params.toString()}`,
|
||||
"inventoryRequestPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">자재관리_불출의뢰서</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAccept}>
|
||||
자재불출
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleReceipt}>
|
||||
접수
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>
|
||||
조회
|
||||
</Button>
|
||||
<ExcelDownloadButton data={data} columns={columns} filename="자재관리_불출의뢰서" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="품번">
|
||||
<Input
|
||||
value={partNo}
|
||||
onChange={(e) => setPartNo(e.target.value)}
|
||||
placeholder="품번"
|
||||
className="w-[150px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input
|
||||
value={partName}
|
||||
onChange={(e) => setPartName(e.target.value)}
|
||||
placeholder="품명"
|
||||
className="w-[150px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="불출의뢰일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={requestStartDate}
|
||||
onChange={(e) => setRequestStartDate(e.target.value)}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={requestEndDate}
|
||||
onChange={(e) => setRequestEndDate(e.target.value)}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="의뢰자">
|
||||
<SearchableSelect
|
||||
options={userOptions}
|
||||
value={requestUser}
|
||||
onChange={setRequestUser}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="접수상태">
|
||||
<select
|
||||
value={receptionStatus}
|
||||
onChange={(e) => setReceptionStatus(e.target.value)}
|
||||
className="h-9 w-[130px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="reception">접수</option>
|
||||
<option value="AA">미접수</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="접수자">
|
||||
<SearchableSelect
|
||||
options={userOptions}
|
||||
value={receptionUser}
|
||||
onChange={setReceptionUser}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="접수일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={receptionStartDate}
|
||||
onChange={(e) => setReceptionStartDate(e.target.value)}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={receptionEndDate}
|
||||
onChange={(e) => setReceptionEndDate(e.target.value)}
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="불출상태">
|
||||
<select
|
||||
value={outStatus}
|
||||
onChange={(e) => setOutStatus(e.target.value)}
|
||||
className="h-9 w-[130px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
<option value="complete">완료</option>
|
||||
<option value="NG">미완료</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
loading={loading}
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 자재관리 > 현황
|
||||
export default function InventoryStatusPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [locationCd, setLocationCd] = useState("");
|
||||
const [partTypeCd, setPartTypeCd] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 120, hozAlign: "center" },
|
||||
{ title: "부품구분", field: "PART_TYPE_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "위치", field: "LOCATION_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "총보유수량", field: "TOTAL_QTY", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "불출수량", field: "REQUEST_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "잔여수량", field: "REMAIN_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "입고수량", field: "DELIVERY_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "이동수량", field: "MOVE_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/inventory/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
project_no: projectNo,
|
||||
location_cd: locationCd,
|
||||
part_type_cd: partTypeCd,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, locationCd, partTypeCd]);
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">자재현황</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={year} onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[100px] rounded border border-gray-300 bg-white px-3 text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} placeholder="프로젝트번호" className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="위치">
|
||||
<CodeSelect codeId="LOCATION" value={locationCd} onChange={setLocationCd} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="부품구분">
|
||||
<CodeSelect codeId="PART_TYPE" value={partTypeCd} onChange={setPartTypeCd} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useMenuStore } from "@/store/menu-store";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
import { NativePushAutoRegister } from "@/components/native-push-auto-register";
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
@@ -24,6 +25,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* 로그인 직후 자동으로 안드로이드 알림 권한 + FCM 토큰 등록 (Capacitor 앱만, UI 없음) */}
|
||||
<NativePushAutoRegister />
|
||||
{/* 사이드바 — 데스크탑은 정상, 모바일은 오버레이로 등장 */}
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Building2, MapPin, TrendingUp, RefreshCcw } from "lucide-react";
|
||||
|
||||
interface BranchRow {
|
||||
BRANCH: string; BRANCH_NAME: string;
|
||||
REVENUE: number; COST: number; MARGIN: number; ORDER_CNT: number;
|
||||
HQ_FEE_RATE: number; HQ_FEE_AMOUNT: number; NET_TO_BRANCH: number;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function BranchFeePage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [rows, setRows] = useState<BranchRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/branch-fee", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
}, [year, month]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 합계
|
||||
const totalRevenue = rows.reduce((a, r) => a + r.REVENUE, 0);
|
||||
const totalMargin = rows.reduce((a, r) => a + r.MARGIN, 0);
|
||||
const totalHqFee = rows.reduce((a, r) => a + r.HQ_FEE_AMOUNT, 0);
|
||||
const totalNet = rows.reduce((a, r) => a + r.NET_TO_BRANCH, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Building2 size={20} className="text-emerald-700" />
|
||||
지사 관리 — 본사 수수료
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
계산서가 <b>김포 등 지사 명의</b>로 발행된 매출만 표시. 본사(HQ) 발주는 제외. 지사 마진의 20% 가 본사 수수료.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={year} onChange={(e) => setYear(Number(e.target.value))}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}년</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={month} onChange={(e) => setMonth(Number(e.target.value))}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={m}>{m}월</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 카드 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-slate-500 mb-1">총 매출</div>
|
||||
<div className="text-xl font-bold text-slate-800 tabular-nums">₩{fmt(totalRevenue)}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-slate-500 mb-1">총 순수 마진</div>
|
||||
<div className="text-xl font-bold text-emerald-700 tabular-nums">₩{fmt(totalMargin)}</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-amber-700 mb-1 font-semibold">본사 수수료 (지사 마진의 20%)</div>
|
||||
<div className="text-xl font-bold text-amber-700 tabular-nums">₩{fmt(totalHqFee)}</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-slate-500 mb-1">지사 실수령 (마진 − 수수료)</div>
|
||||
<div className="text-xl font-bold text-slate-800 tabular-nums">₩{fmt(totalNet)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지사별 표 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">지사</th>
|
||||
<th className="text-right px-4 py-3">건수</th>
|
||||
<th className="text-right px-4 py-3">매출(공급가)</th>
|
||||
<th className="text-right px-4 py-3">원가</th>
|
||||
<th className="text-right px-4 py-3">순수 마진</th>
|
||||
<th className="text-right px-4 py-3 bg-amber-50 text-amber-700">본사 수수료 (20%)</th>
|
||||
<th className="text-right px-4 py-3">지사 실수령</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "데이터가 없습니다."}</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.BRANCH} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<MapPin size={14} className="text-sky-700" />
|
||||
<span className="font-semibold">{r.BRANCH_NAME}</span>
|
||||
<span className="text-xs text-slate-400">({r.BRANCH})</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">{fmt(r.ORDER_CNT)}</td>
|
||||
<td className="px-4 py-3 text-right">{fmt(r.REVENUE)}</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">{fmt(r.COST)}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold text-emerald-700">{fmt(r.MARGIN)}</td>
|
||||
<td className="px-4 py-3 text-right bg-amber-50/60 font-bold text-amber-700">
|
||||
₩{fmt(r.HQ_FEE_AMOUNT)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-bold">{fmt(r.NET_TO_BRANCH)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-500 flex items-start gap-1.5">
|
||||
<TrendingUp size={12} className="mt-0.5 text-slate-400" />
|
||||
<div>
|
||||
계산서 발행 명의가 <b>본사(HQ) 외</b> 인 발주만 표시. 본사 수수료 = 지사 순수 마진 × 20%.
|
||||
매출/마진은 출고완료(APPROVED) 이상 (계산서발행/입금완료 포함) 만 집계.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCcw, CalendarDays, Download, Search } from "lucide-react";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
||||
interface ItemRow {
|
||||
OBJID: string;
|
||||
ITEM_CODE: string;
|
||||
ITEM_NAME: string;
|
||||
UNIT: string;
|
||||
UNIT_PRICE: string | number;
|
||||
IS_TAX_FREE: string;
|
||||
SALE_START_DATE: string | null;
|
||||
SALE_END_DATE: string | null;
|
||||
VENDOR_NAME: string | null;
|
||||
STOCK: Record<string, number>; // wh_code → 현재고
|
||||
ORDER: Record<string, number>; // wh_code → 발주수량
|
||||
TOTAL_STOCK: number;
|
||||
TOTAL_ORDER: number;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export default function DailyOrderInventoryPage() {
|
||||
const [startDate, setStartDate] = useState<string>(today());
|
||||
const [endDate, setEndDate] = useState<string>(today());
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [warehouses, setWarehouses] = useState<Wh[]>([]);
|
||||
const [items, setItems] = useState<ItemRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/daily-order-inventory", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ startDate, endDate, keyword }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
setWarehouses(j.WAREHOUSES ?? []);
|
||||
setItems(j.ITEMS ?? []);
|
||||
} else {
|
||||
setWarehouses([]);
|
||||
setItems([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startDate, endDate, keyword]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const totalOrderQty = items.reduce((a, r) => a + Number(r.TOTAL_ORDER || 0), 0);
|
||||
const totalStockQty = items.reduce((a, r) => a + Number(r.TOTAL_STOCK || 0), 0);
|
||||
|
||||
const onExport = () => {
|
||||
if (items.length === 0 || warehouses.length === 0) return;
|
||||
type Row = Record<string, string | number>;
|
||||
const data: Row[] = [];
|
||||
for (const w of warehouses) {
|
||||
const orderRow: Row = { WH: `${w.WH_NAME} (${w.WH_CODE})`, KIND: "발주수량" };
|
||||
const stockRow: Row = { WH: `${w.WH_NAME} (${w.WH_CODE})`, KIND: "재고수량" };
|
||||
for (const it of items) {
|
||||
orderRow[it.ITEM_NAME] = Number(it.ORDER[w.WH_CODE] ?? 0);
|
||||
stockRow[it.ITEM_NAME] = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
}
|
||||
data.push(orderRow, stockRow);
|
||||
}
|
||||
const cols = [
|
||||
{ header: "창고", key: "WH" },
|
||||
{ header: "분류", key: "KIND" },
|
||||
...items.map((it) => ({ header: it.ITEM_NAME, key: it.ITEM_NAME })),
|
||||
];
|
||||
const range = startDate === endDate ? startDate : `${startDate}_${endDate}`;
|
||||
downloadXlsx(`일자별발주재고_${range}`, data, cols);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<CalendarDays size={20} className="text-emerald-700" />
|
||||
일자별 발주/재고 현황
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
선택 기간에 <b>판매 가능</b>했던 품목을 헤더로, 각 창고를 좌측에 배치한 매트릭스.
|
||||
발주수량 = 기간 내 발주 합계(REQUESTED는 거래처 default 창고로 가상 배정 / APPROVED 이후는 출고 창고). 재고수량 = 해당 창고 현재고(기간 무관 현재 시점).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
max={endDate || undefined}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm"
|
||||
/>
|
||||
<span className="text-slate-400 text-sm">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate || undefined}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { const t = today(); setStartDate(t); setEndDate(t); }}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 6);
|
||||
setStartDate(start.toISOString().slice(0, 10));
|
||||
setEndDate(end.toISOString().slice(0, 10));
|
||||
}}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold"
|
||||
>
|
||||
최근 7일
|
||||
</button>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") load(); }}
|
||||
placeholder="품목명/코드"
|
||||
className="h-9 pl-8 pr-3 rounded border border-slate-300 text-sm w-48"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport}
|
||||
disabled={items.length === 0}
|
||||
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-40"
|
||||
>
|
||||
<Download size={14} /> 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-3 gap-3">
|
||||
<SummaryCard label="판매가능 품목" value={`${fmt(items.length)} 종`} color="slate" />
|
||||
<SummaryCard label="발주수량 합계" value={`${fmt(totalOrderQty)}`} color="rose" />
|
||||
<SummaryCard label="재고수량 합계" value={`${fmt(totalStockQty)}`} color="emerald" />
|
||||
</div>
|
||||
|
||||
{/* 매트릭스: 헤더=품목, 행=창고 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="text-sm border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[120px]">창고</th>
|
||||
<th className="text-center px-3 py-2 border-b border-slate-200 sticky left-[120px] bg-slate-50 z-10 min-w-[90px]">분류</th>
|
||||
{items.map((it) => (
|
||||
<th key={it.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[110px] whitespace-nowrap">
|
||||
<div>{it.ITEM_NAME}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.ITEM_CODE}</div>
|
||||
{it.SALE_END_DATE && (
|
||||
<div className="text-[10px] text-rose-600 font-semibold tabular-nums">~ {it.SALE_END_DATE}</div>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{items.length === 0 || warehouses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2 + items.length} className="text-center py-12 text-slate-400">
|
||||
{loading ? "조회 중..." : "데이터가 없습니다."}
|
||||
</td>
|
||||
</tr>
|
||||
) : [
|
||||
/* 전체 합계 — 모든 창고의 발주수량/재고수량 합 (상단 강조 행) */
|
||||
<tr key="__total-order" className="bg-emerald-50/70 border-y-2 border-emerald-300 font-bold">
|
||||
<td className="px-3 py-2 align-top sticky left-0 bg-emerald-50/70" rowSpan={2}>
|
||||
<div className="text-emerald-800">전체 합계</div>
|
||||
<div className="text-[10px] text-emerald-600 font-normal">모든 창고</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-rose-700 bg-rose-50/60 sticky left-[120px]">발주수량</td>
|
||||
{items.map((it) => {
|
||||
const v = Number(it.TOTAL_ORDER || 0);
|
||||
return (
|
||||
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-rose-700"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
<tr key="__total-stock" className="bg-emerald-50/70 border-b-2 border-emerald-300 font-bold">
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-100/60 sticky left-[120px]">재고수량</td>
|
||||
{items.map((it) => {
|
||||
const v = Number(it.TOTAL_STOCK || 0);
|
||||
const negative = v < 0;
|
||||
return (
|
||||
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600" : v === 0 ? "text-slate-300" : "text-emerald-700"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
/* 창고별 — 각 창고 발주수량/재고수량 두 줄 */
|
||||
...warehouses.flatMap((w) => [
|
||||
<tr key={`${w.WH_CODE}-order`} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 align-top font-semibold sticky left-0 bg-white" rowSpan={2}>
|
||||
{w.WH_NAME}
|
||||
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-rose-700 bg-rose-50/40 sticky left-[120px]">발주수량</td>
|
||||
{items.map((it) => {
|
||||
const v = Number(it.ORDER[w.WH_CODE] ?? 0);
|
||||
return (
|
||||
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-rose-700 font-semibold"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
<tr key={`${w.WH_CODE}-stock`} className="border-b border-slate-100">
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold sticky left-[120px]">재고수량</td>
|
||||
{items.map((it) => {
|
||||
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
const negative = v < 0;
|
||||
return (
|
||||
<td key={it.OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
]),
|
||||
]}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, color }: { label: string; value: string; color: "slate" | "rose" | "emerald" }) {
|
||||
const cls = {
|
||||
slate: "bg-slate-50 border-slate-200 text-slate-800",
|
||||
rose: "bg-rose-50 border-rose-200 text-rose-800",
|
||||
emerald: "bg-emerald-50 border-emerald-200 text-emerald-800",
|
||||
}[color];
|
||||
return (
|
||||
<div className={`rounded-xl border ${cls} p-4`}>
|
||||
<div className="text-xs font-semibold opacity-80 mb-1">{label}</div>
|
||||
<div className="text-lg sm:text-xl font-bold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { FileText, Send, Download, RefreshCcw, AlertCircle } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Einvoice {
|
||||
OBJID: string;
|
||||
@@ -38,6 +39,11 @@ const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleSt
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: "작성중", QUEUED: "전송대기", SENT: "전송완료", ACK: "승인완료", FAIL: "실패", CANCELED: "취소",
|
||||
};
|
||||
// 발주(출고) 진행 상태 한글 — 발행 가능 발주 목록용
|
||||
const ORDER_STATUS_LABEL: Record<string, string> = {
|
||||
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
|
||||
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소", CANCELED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
DRAFT: "bg-slate-100 text-slate-600",
|
||||
QUEUED: "bg-amber-100 text-amber-700",
|
||||
@@ -53,9 +59,13 @@ function defaultRange() {
|
||||
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
|
||||
}
|
||||
|
||||
interface Customer { USER_ID: string; USER_NAME: string }
|
||||
|
||||
export default function EinvoicesPage() {
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [customerFilter, setCustomerFilter] = useState("");
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [list, setList] = useState<Einvoice[]>([]);
|
||||
const [pending, setPending] = useState<PendingOrder[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -63,10 +73,37 @@ export default function EinvoicesPage() {
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/m/einvoices/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom: from, dateTo: to, status: statusFilter || undefined }),
|
||||
body: JSON.stringify({
|
||||
dateFrom: from,
|
||||
dateTo: to,
|
||||
status: statusFilter || undefined,
|
||||
customerObjid: customerFilter || undefined,
|
||||
}),
|
||||
});
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
}, [from, to, statusFilter]);
|
||||
}, [from, to, statusFilter, customerFilter]);
|
||||
|
||||
const loadCustomers = useCallback(async () => {
|
||||
const res = await fetch("/api/m/customers/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
});
|
||||
setCustomers((await res.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
|
||||
// 면세/과세/합계 합산
|
||||
const summary = useMemo(() => {
|
||||
let taxFreeAmount = 0, taxableSupply = 0, taxableVat = 0, total = 0;
|
||||
for (const e of list) {
|
||||
total += Number(e.TOTAL_AMOUNT) || 0;
|
||||
if (e.INVOICE_KIND === "TAXFREE") {
|
||||
taxFreeAmount += Number(e.TOTAL_SUPPLY) || 0;
|
||||
} else {
|
||||
taxableSupply += Number(e.TOTAL_SUPPLY) || 0;
|
||||
taxableVat += Number(e.TOTAL_VAT) || 0;
|
||||
}
|
||||
}
|
||||
return { taxFreeAmount, taxableSupply, taxableVat, taxableTotal: taxableSupply + taxableVat, total };
|
||||
}, [list]);
|
||||
|
||||
const loadPending = useCallback(async () => {
|
||||
// 발주 + 발행이력 동시 조회 후 이미 발행된 건은 제외
|
||||
@@ -88,6 +125,7 @@ export default function EinvoicesPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); loadPending(); }, [load, loadPending]);
|
||||
useEffect(() => { loadCustomers(); }, [loadCustomers]);
|
||||
|
||||
const issueFromOrder = async (orderObjid: string, kind: "TAX" | "TAXFREE" = "TAX") => {
|
||||
const ok = await Swal.fire({
|
||||
@@ -196,7 +234,7 @@ export default function EinvoicesPage() {
|
||||
<td className="px-3 py-2">{o.ORDER_DATE}</td>
|
||||
<td className="px-3 py-2">{o.COMPANY_NAME}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-3 py-2 text-center text-[11px] text-slate-500">{o.STATUS}</td>
|
||||
<td className="px-3 py-2 text-center text-[11px] text-slate-500">{ORDER_STATUS_LABEL[o.STATUS] ?? o.STATUS}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
onClick={() => issueFromOrder(o.OBJID, "TAX")}
|
||||
@@ -217,12 +255,20 @@ export default function EinvoicesPage() {
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-2.5 bg-slate-50 border-b border-slate-200 text-sm font-bold text-slate-700 flex flex-wrap items-center gap-2 justify-between">
|
||||
<span>발행 이력 ({list.length}건)</span>
|
||||
<div className="flex gap-2 items-center text-xs font-normal">
|
||||
<div className="flex gap-2 items-center text-xs font-normal flex-wrap">
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
|
||||
className="h-8 px-2 rounded border border-slate-200" />
|
||||
<span className="text-slate-400">~</span>
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
|
||||
className="h-8 px-2 rounded border border-slate-200" />
|
||||
<div className="min-w-[180px]">
|
||||
<SearchableSelect
|
||||
options={[{ value: "", label: "전체 거래처" }, ...customers.map((c) => ({ value: c.USER_ID, label: c.USER_NAME }))]}
|
||||
value={customerFilter}
|
||||
onChange={setCustomerFilter}
|
||||
placeholder="거래처"
|
||||
/>
|
||||
</div>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-8 px-2 rounded border border-slate-200 bg-white">
|
||||
<option value="">전체 상태</option>
|
||||
@@ -271,6 +317,27 @@ export default function EinvoicesPage() {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{list.length > 0 && (
|
||||
<tfoot className="bg-slate-50 border-t-2 border-slate-300 font-bold text-[11px]">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-right text-slate-600">면세 합계</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-violet-700" colSpan={3}>₩{fmt(summary.taxFreeAmount)}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-right text-slate-600">과세 합계 (공급가 + 세액)</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-rose-700">₩{fmt(summary.taxableSupply)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-rose-700">₩{fmt(summary.taxableVat)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-rose-700">₩{fmt(summary.taxableTotal)}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
<tr className="border-t border-slate-300">
|
||||
<td colSpan={4} className="px-3 py-2 text-right text-slate-700">총 합계</td>
|
||||
<td colSpan={3} className="px-3 py-2 text-right tabular-nums text-emerald-700 text-sm">₩{fmt(summary.total)}</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertTriangle, AlertCircle, Clock, RefreshCcw } from "lucide-react";
|
||||
|
||||
interface Row {
|
||||
INBOUND_OBJID: string; INBOUND_NO: string;
|
||||
INBOUND_DATE: string; EXPIRY_DATE: string; DAYS_LEFT: number;
|
||||
WH_NAME: string | null; VENDOR_NAME: string | null;
|
||||
COMPLETED_BY: string | null; MEMO: string | null; TOTAL_AMOUNT: number;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
export default function ExpiryAlertsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [counts, setCounts] = useState({ expired: 0, urgent: 0, soon: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/expiry-alerts", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ days }),
|
||||
});
|
||||
const j = await res.json();
|
||||
setRows(j.RESULTLIST ?? []);
|
||||
setCounts({ expired: j.EXPIRED_CNT ?? 0, urgent: j.URGENT_CNT ?? 0, soon: j.SOON_CNT ?? 0 });
|
||||
} finally { setLoading(false); }
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const rowStyle = (daysLeft: number) => {
|
||||
if (daysLeft < 0) return "bg-rose-50 border-l-4 border-l-rose-500";
|
||||
if (daysLeft <= 7) return "bg-amber-50 border-l-4 border-l-amber-500";
|
||||
return "bg-white";
|
||||
};
|
||||
const badge = (daysLeft: number) => {
|
||||
if (daysLeft < 0) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold">만료 {Math.abs(daysLeft)}일 경과</span>;
|
||||
if (daysLeft === 0) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-bold">오늘 만료</span>;
|
||||
if (daysLeft <= 7) return <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">D-{daysLeft}</span>;
|
||||
return <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">D-{daysLeft}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<AlertTriangle size={20} className="text-amber-600" />
|
||||
유통기한 임박 알림
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
입고 시 입력한 소비기한 기준. 만료 / D-7 이내 / D-30 이내 분류. 관리자 그룹 전용.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={days} onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
<option value={7}>7일 이내</option>
|
||||
<option value={14}>14일 이내</option>
|
||||
<option value={30}>30일 이내</option>
|
||||
<option value={60}>60일 이내</option>
|
||||
<option value={90}>90일 이내</option>
|
||||
</select>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 알림 카드 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-rose-50 border border-rose-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-rose-700 font-semibold mb-1 flex items-center gap-1">
|
||||
<AlertCircle size={14} /> 이미 만료
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-rose-700 tabular-nums">{counts.expired}건</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-amber-700 font-semibold mb-1 flex items-center gap-1">
|
||||
<AlertTriangle size={14} /> 7일 이내 임박
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-700 tabular-nums">{counts.urgent}건</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-slate-600 font-semibold mb-1 flex items-center gap-1">
|
||||
<Clock size={14} /> 30일 이내 주의
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-700 tabular-nums">{counts.soon}건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리스트 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">입고번호</th>
|
||||
<th className="text-left px-3 py-2">소비기한</th>
|
||||
<th className="text-center px-3 py-2">남은일</th>
|
||||
<th className="text-left px-3 py-2">창고</th>
|
||||
<th className="text-left px-3 py-2">공급업체</th>
|
||||
<th className="text-left px-3 py-2">입고완료자</th>
|
||||
<th className="text-left px-3 py-2">메모</th>
|
||||
<th className="text-right px-3 py-2">입고금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">
|
||||
{loading ? "조회 중..." : "임박한 유통기한이 없습니다."}
|
||||
</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.INBOUND_OBJID} className={`border-t border-slate-100 ${rowStyle(Number(r.DAYS_LEFT))}`}>
|
||||
<td className="px-3 py-2 font-semibold">{r.INBOUND_NO}
|
||||
<div className="text-[10px] text-slate-400">입고 {r.INBOUND_DATE}</div></td>
|
||||
<td className="px-3 py-2 font-mono">{r.EXPIRY_DATE}</td>
|
||||
<td className="px-3 py-2 text-center">{badge(Number(r.DAYS_LEFT))}</td>
|
||||
<td className="px-3 py-2">{r.WH_NAME ?? "-"}</td>
|
||||
<td className="px-3 py-2">{r.VENDOR_NAME ?? "-"}</td>
|
||||
<td className="px-3 py-2 text-xs">{r.COMPLETED_BY ?? "-"}</td>
|
||||
<td className="px-3 py-2 text-[10px] text-slate-500 whitespace-pre-line max-w-[300px] truncate" title={r.MEMO ?? ""}>
|
||||
{r.MEMO ?? ""}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">₩{fmt(Number(r.TOTAL_AMOUNT))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
interface Wh { OBJID: string; WH_NAME: string }
|
||||
@@ -102,24 +103,37 @@ export default function NewInboundPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">매입발주 (선택 시 라인 자동입력)</label>
|
||||
<select value={procObjid} onChange={(e) => onProcChange(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">단독 입고 (매입발주 없이)</option>
|
||||
{procs.map((p) => <option key={p.OBJID} value={p.OBJID}>{p.PROC_NO} — {p.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
<div className="mt-1">
|
||||
<SearchableSelect
|
||||
options={procs.map((p) => ({ value: p.OBJID, label: `${p.PROC_NO} — ${p.VENDOR_NAME}` }))}
|
||||
value={procObjid}
|
||||
onChange={onProcChange}
|
||||
placeholder="단독 입고 (매입발주 없이)"
|
||||
emptyLabel="단독 입고 (매입발주 없이)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">공급업체</label>
|
||||
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">선택</option>
|
||||
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
<div className="mt-1">
|
||||
<SearchableSelect
|
||||
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
|
||||
value={vendorObjid}
|
||||
onChange={setVendorObjid}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">입고 창고 *</label>
|
||||
<select value={whObjid} onChange={(e) => setWhObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">선택</option>
|
||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
<div className="mt-1">
|
||||
<SearchableSelect
|
||||
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
|
||||
value={whObjid}
|
||||
onChange={setWhObjid}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">입고일</label>
|
||||
@@ -131,10 +145,14 @@ export default function NewInboundPage() {
|
||||
<div className="bg-white border rounded-xl p-5">
|
||||
<h3 className="font-bold mb-3">입고 라인 (정상 + 불량 분리)</h3>
|
||||
<div className="grid grid-cols-12 gap-2 mb-3">
|
||||
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="col-span-4 h-10 px-3 rounded-lg border border-slate-200">
|
||||
<option value="">품목 선택</option>
|
||||
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||
</select>
|
||||
<div className="col-span-4">
|
||||
<SearchableSelect
|
||||
options={items.map((i) => ({ value: i.OBJID, label: i.ITEM_NAME }))}
|
||||
value={pickItem}
|
||||
onChange={setPickItem}
|
||||
placeholder="품목 선택"
|
||||
/>
|
||||
</div>
|
||||
<input type="number" min={0} value={qtyN} onChange={(e) => setQtyN(Number(e.target.value))} placeholder="정상" className="col-span-1 h-10 px-2 rounded-lg border border-slate-200 text-right" />
|
||||
<input type="number" min={0} value={qtyD} onChange={(e) => setQtyD(Number(e.target.value))} placeholder="불량" className="col-span-1 h-10 px-2 rounded-lg border border-slate-200 text-right" />
|
||||
<input type="text" value={defectReason} onChange={(e) => setDefectReason(e.target.value)} placeholder="불량사유" className="col-span-3 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Save, RefreshCcw, CheckCircle2, Clock } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
|
||||
interface ProcRow {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string;
|
||||
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
|
||||
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number;
|
||||
STATUS: string; IS_PAID?: boolean; TOTAL_AMOUNT: number; LINE_CNT: number;
|
||||
TOTAL_QTY: number; RECEIVED_QTY: number;
|
||||
}
|
||||
interface ProcDetail { OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string; VENDOR_NAME: string | null }
|
||||
@@ -19,6 +20,7 @@ interface ProcLine {
|
||||
interface Warehouse { OBJID: string; WH_NAME: string }
|
||||
|
||||
const fmt = (n: number | string | undefined) => Number(n || 0).toLocaleString("ko-KR");
|
||||
// 진행상태 (입금/결재와 무관)
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
REQUESTED: "발주요청", PARTIAL: "입고중", RECEIVED: "입고완료", OPEN: "작성중", CANCELLED: "취소",
|
||||
};
|
||||
@@ -26,13 +28,13 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
PARTIAL: "bg-orange-100 text-orange-700 border-orange-200",
|
||||
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
RECEIVED: "bg-emerald-100 text-emerald-800 border-emerald-300",
|
||||
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
|
||||
};
|
||||
|
||||
export default function InboundsPage() {
|
||||
const [list, setList] = useState<ProcRow[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState("OPEN_OR_PARTIAL");
|
||||
const [statusFilter, setStatusFilter] = useState("INBOUNDABLE");
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
@@ -41,6 +43,23 @@ export default function InboundsPage() {
|
||||
// 라인별 입력 (창고/입고수량/불량수량)
|
||||
const [inputs, setInputs] = useState<Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }>>({});
|
||||
|
||||
// 입고 체크리스트
|
||||
const [checklist, setChecklist] = useState({
|
||||
qtyMatch: false, // 1) 발주수량/입고수량 일치
|
||||
cartonMatch: false, // 2) 1카톤 N개 일치
|
||||
cartonSize: "", // 카톤 단위
|
||||
expiryDate: "", // 3) 소비기한
|
||||
completedBy: "", // 4) 물류팀 입고 최종완료자
|
||||
remark: "", // 5) 특이건 메모
|
||||
});
|
||||
// 물류팀 4명 — 임직원(user_type='A') 중 momo4763/momo7529 외 2명까지
|
||||
const LOGISTICS = [
|
||||
{ id: "momo4763", name: "이효철 (물류총괄)" },
|
||||
{ id: "momo7529", name: "유우형 (물류팀장)" },
|
||||
{ id: "momo9431", name: "강상익 (김포지사 총괄)" },
|
||||
{ id: "momo5315", name: "배연진 (경영팀장)" },
|
||||
];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const body: Record<string, unknown> = {};
|
||||
// 입고 화면은 REQUESTED + PARTIAL 만 보이게
|
||||
@@ -49,7 +68,9 @@ export default function InboundsPage() {
|
||||
});
|
||||
const j = await res.json();
|
||||
let rows: ProcRow[] = j.RESULTLIST ?? [];
|
||||
if (statusFilter === "OPEN_OR_PARTIAL") {
|
||||
// 입고 처리 대상: 진행상태 REQUESTED(발주요청) / PARTIAL(입고중) 만.
|
||||
// 입금(결재)은 입고와 무관 — paid 여부와 상관없이 진행상태로만 판단.
|
||||
if (statusFilter === "INBOUNDABLE") {
|
||||
rows = rows.filter((r) => r.STATUS === "REQUESTED" || r.STATUS === "PARTIAL");
|
||||
} else if (statusFilter && statusFilter !== "ALL") {
|
||||
rows = rows.filter((r) => r.STATUS === statusFilter);
|
||||
@@ -148,6 +169,15 @@ export default function InboundsPage() {
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
|
||||
// 체크리스트 텍스트화 — memo 에 저장 (스키마 변경 없이)
|
||||
const checklistMemo = [
|
||||
`[수량 일치] ${checklist.qtyMatch ? "Y ✓" : "N"}`,
|
||||
`[카톤 일치] ${checklist.cartonMatch ? `Y ✓ (1카톤 ${checklist.cartonSize || "?"}개)` : "N"}`,
|
||||
`[소비기한] ${checklist.expiryDate || "-"}`,
|
||||
`[입고완료자] ${checklist.completedBy || "-"}`,
|
||||
`[특이사항] ${checklist.remark || "-"}`,
|
||||
].join("\n");
|
||||
|
||||
setBusy(true);
|
||||
let successCnt = 0, failCnt = 0;
|
||||
const errors: string[] = [];
|
||||
@@ -159,6 +189,9 @@ export default function InboundsPage() {
|
||||
procObjid: detail.proc.OBJID,
|
||||
whObjid,
|
||||
lines: whLines,
|
||||
memo: checklistMemo,
|
||||
expiryDate: checklist.expiryDate || undefined,
|
||||
completedBy: checklist.completedBy || undefined,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
@@ -182,15 +215,16 @@ export default function InboundsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{busy && <Loading message="입고 처리 중..." />}
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">입고 처리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 발주서를 선택하고, 오른쪽에서 라인별 창고/입고수량을 입력하세요. 부분 입고 가능 — 발주수량까지만.</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">발주요청 즉시 입고 가능 (입금 무관). 왼쪽에서 발주서를 선택하고, 오른쪽에서 라인별 창고/입고수량을 입력하세요. 부분 입고 가능.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
<option value="OPEN_OR_PARTIAL">입고 가능 (발주요청 + 입고중)</option>
|
||||
<option value="INBOUNDABLE">입고 가능 (발주요청 + 입고중)</option>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="REQUESTED">발주요청만</option>
|
||||
<option value="PARTIAL">입고중만</option>
|
||||
@@ -212,15 +246,14 @@ export default function InboundsPage() {
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-2">발주번호</th>
|
||||
<th className="text-left px-2 py-2">공급업체</th>
|
||||
<th className="text-left px-2 py-2">업체 / 발주번호</th>
|
||||
<th className="text-center px-2 py-2">발주/입고/미입고</th>
|
||||
<th className="text-center px-2 py-2">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={4} className="text-center py-12 text-slate-400">입고 가능한 발주서가 없습니다.</td></tr>
|
||||
<tr><td colSpan={3} className="text-center py-12 text-slate-400">입고 가능한 발주서가 없습니다.</td></tr>
|
||||
) : list.map((p) => {
|
||||
const total = Number(p.TOTAL_QTY);
|
||||
const recv = Number(p.RECEIVED_QTY);
|
||||
@@ -230,8 +263,12 @@ export default function InboundsPage() {
|
||||
onClick={() => setActiveId(p.OBJID)}
|
||||
style={{ cursor: "pointer" }}
|
||||
className={`border-t border-slate-100 ${activeId === p.OBJID ? "bg-emerald-50" : "hover:bg-slate-50"}`}>
|
||||
<td className="px-2 py-2 font-semibold">{p.PROC_NO}<div className="text-slate-400 text-[10px]">{p.PROC_DATE}</div></td>
|
||||
<td className="px-2 py-2 truncate max-w-[120px]">{p.VENDOR_NAME ?? <span className="text-slate-300">미선택</span>}</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="font-bold text-[13px] truncate max-w-[170px]" title={p.VENDOR_NAME ?? ""}>
|
||||
{p.VENDOR_NAME ?? <span className="text-slate-300 font-normal">미선택</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500">{p.PROC_DATE} · {p.PROC_NO}</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-[11px] tabular-nums">
|
||||
<span className="text-slate-700">{fmt(total)}</span>
|
||||
<span className="text-slate-300 mx-0.5">/</span>
|
||||
@@ -245,6 +282,11 @@ export default function InboundsPage() {
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
|
||||
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
|
||||
</span>
|
||||
<div className="mt-0.5">
|
||||
<span className={`inline-block px-1 py-0.5 rounded text-[9px] font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
|
||||
{p.IS_PAID ? "입금완료" : "미입금"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -258,17 +300,17 @@ export default function InboundsPage() {
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
||||
<span>입고 처리 입력</span>
|
||||
{detail && (detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL") && (
|
||||
<button onClick={submitInbound} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Save size={12} /> 입고 등록
|
||||
</button>
|
||||
)}
|
||||
{detail && detail.proc.STATUS === "RECEIVED" && (
|
||||
<span className="text-[11px] text-emerald-700 inline-flex items-center gap-1">
|
||||
<CheckCircle2 size={12} /> 입고 완료
|
||||
</span>
|
||||
)}
|
||||
{detail && ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS) && (
|
||||
<button onClick={submitInbound} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Save size={12} /> 입고 등록
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 lg:overflow-auto p-4">
|
||||
{!detail ? (
|
||||
@@ -279,6 +321,9 @@ export default function InboundsPage() {
|
||||
warehouses={warehouses}
|
||||
inputs={inputs}
|
||||
onUpdate={updateInput}
|
||||
checklist={checklist}
|
||||
onChecklistChange={(patch) => setChecklist((p) => ({ ...p, ...patch }))}
|
||||
logistics={LOGISTICS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -288,13 +333,22 @@ export default function InboundsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function InboundForm({ detail, warehouses, inputs, onUpdate }: {
|
||||
interface Checklist {
|
||||
qtyMatch: boolean; cartonMatch: boolean; cartonSize: string;
|
||||
expiryDate: string; completedBy: string; remark: string;
|
||||
}
|
||||
function InboundForm({ detail, warehouses, inputs, onUpdate, checklist, onChecklistChange, logistics }: {
|
||||
detail: { proc: ProcDetail; items: ProcLine[] };
|
||||
warehouses: Warehouse[];
|
||||
inputs: Record<string, { whObjid: string; qtyNormal: number; qtyDefect: number }>;
|
||||
onUpdate: (lineObjid: string, patch: Partial<{ whObjid: string; qtyNormal: number; qtyDefect: number }>) => void;
|
||||
checklist: Checklist;
|
||||
onChecklistChange: (patch: Partial<Checklist>) => void;
|
||||
logistics: { id: string; name: string }[];
|
||||
}) {
|
||||
const editable = detail.proc.STATUS === "REQUESTED" || detail.proc.STATUS === "PARTIAL";
|
||||
// 입고 입력 허용: 진행상태 발주요청 / 입고중 만 (입금 여부 무관).
|
||||
// 입고완료(RECEIVED)는 더 받을 게 없어 읽기전용, OPEN/CANCELLED 도 불가.
|
||||
const editable = ["REQUESTED", "PARTIAL"].includes(detail.proc.STATUS);
|
||||
return (
|
||||
<div className="text-[12px]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -384,10 +438,65 @@ function InboundForm({ detail, warehouses, inputs, onUpdate }: {
|
||||
</table>
|
||||
|
||||
{editable && (
|
||||
<>
|
||||
<div className="mt-3 text-[11px] text-slate-500">
|
||||
※ 정상 입고 + 불량은 <b>남은 수량 이하</b>로만 입력 가능합니다. 0으로 두면 그 라인은 입고하지 않습니다.<br />
|
||||
※ 일부 라인만 입고하면 발주서가 <span className="inline-block px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">입고중</span>으로 표시되고, 나중에 다시 들어와 마저 입고할 수 있어요.
|
||||
</div>
|
||||
|
||||
{/* 입고 체크리스트 — memo 컬럼에 함께 저장 */}
|
||||
<div className="mt-4 border border-emerald-200 bg-emerald-50/40 rounded-lg p-3 space-y-2">
|
||||
<div className="font-bold text-[12px] text-emerald-800 mb-1">📋 입고 체크리스트</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
|
||||
<input type="checkbox" className="w-4 h-4 accent-emerald-600"
|
||||
checked={checklist.qtyMatch}
|
||||
onChange={(e) => onChecklistChange({ qtyMatch: e.target.checked })} />
|
||||
<span>1) 발주수량과 입고수량이 일치하나요?</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" className="w-4 h-4 accent-emerald-600"
|
||||
checked={checklist.cartonMatch}
|
||||
onChange={(e) => onChecklistChange({ cartonMatch: e.target.checked })} />
|
||||
<span>2) 1카톤</span>
|
||||
</label>
|
||||
<input type="number" min={0}
|
||||
value={checklist.cartonSize}
|
||||
onChange={(e) => onChecklistChange({ cartonSize: e.target.value })}
|
||||
placeholder="개수"
|
||||
className="w-20 h-7 px-2 border border-slate-300 rounded text-[11px] text-right tabular-nums" />
|
||||
<span>개 일치하나요?</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<span className="w-44">3) 소비기한</span>
|
||||
<input type="date" value={checklist.expiryDate}
|
||||
onChange={(e) => onChecklistChange({ expiryDate: e.target.value })}
|
||||
className="h-7 px-2 border border-slate-300 rounded text-[11px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<span className="w-44">4) 물류창고 입고 최종완료자</span>
|
||||
<select value={checklist.completedBy}
|
||||
onChange={(e) => onChecklistChange({ completedBy: e.target.value })}
|
||||
className="h-7 px-2 border border-slate-300 rounded text-[11px] bg-white">
|
||||
<option value="">-- 선택 --</option>
|
||||
{logistics.map((p) => <option key={p.id} value={p.name}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="text-[12px]">
|
||||
<div className="mb-1">5) 특이사항</div>
|
||||
<textarea rows={2}
|
||||
value={checklist.remark}
|
||||
onChange={(e) => onChecklistChange({ remark: e.target.value })}
|
||||
placeholder="특이건이 있으면 입력"
|
||||
className="w-full px-2 py-1 border border-slate-300 rounded text-[11px] resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Move {
|
||||
OBJID: string;
|
||||
@@ -13,7 +14,9 @@ interface Move {
|
||||
MOVE_TYPE_NAME: string;
|
||||
QTY: number;
|
||||
REF_TYPE: string;
|
||||
REF_TYPE_LABEL?: string;
|
||||
REF_OBJID: string;
|
||||
COUNTER_WH_NAME?: string | null;
|
||||
MEMO: string;
|
||||
REGID: string;
|
||||
REGDATE: string;
|
||||
@@ -89,11 +92,13 @@ export default function InventoryHistoryPage() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-500 mb-1">창고</label>
|
||||
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)}
|
||||
className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
<option value="">전체 창고</option>
|
||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
|
||||
value={whFilter}
|
||||
onChange={setWhFilter}
|
||||
placeholder="전체 창고"
|
||||
emptyLabel="전체 창고"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-500 mb-1">이동 유형</label>
|
||||
@@ -186,7 +191,14 @@ export default function InventoryHistoryPage() {
|
||||
{m.MOVE_TYPE === "OUT" ? "-" : "+"}{fmt(m.QTY)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{m.REF_TYPE || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-700">
|
||||
{m.REF_TYPE_LABEL || m.REF_TYPE || "-"}
|
||||
{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (
|
||||
<span className="ml-1 text-slate-500">
|
||||
{m.MOVE_TYPE === "OUT" ? `→ ${m.COUNTER_WH_NAME}` : `← ${m.COUNTER_WH_NAME}`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500 max-w-[200px] truncate">{m.MEMO || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{m.REGID || "-"}</td>
|
||||
<td className="px-3 py-2 text-center text-xs text-slate-500">{m.REGDATE}</td>
|
||||
@@ -215,7 +227,7 @@ export default function InventoryHistoryPage() {
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${MOVE_TYPE_BADGE[m.MOVE_TYPE] || "bg-slate-100 text-slate-600"}`}>
|
||||
{m.MOVE_TYPE_NAME}
|
||||
</span>
|
||||
{m.REF_TYPE && <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 font-mono">{m.REF_TYPE}</span>}
|
||||
{m.REF_TYPE && <span className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">{m.REF_TYPE_LABEL || m.REF_TYPE}{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (m.MOVE_TYPE === "OUT" ? ` → ${m.COUNTER_WH_NAME}` : ` ← ${m.COUNTER_WH_NAME}`)}</span>}
|
||||
</div>
|
||||
<div className="font-bold text-sm text-slate-900 truncate">{m.ITEM_NAME}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">{m.ITEM_CODE}</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Search, Trash2, History, ArrowRightLeft, Package } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { Plus, Search, Trash2, History, ArrowRightLeft, Package, LayoutGrid, Columns3 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Stock { OBJID: string; WH_OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string }
|
||||
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
||||
@@ -21,6 +22,7 @@ export default function InventoryPage() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [inboundOpen, setInboundOpen] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState<{ itemObjid: string; whObjid: string; itemName: string; whName: string } | null>(null);
|
||||
|
||||
// 입고 폼
|
||||
const [inboundWh, setInboundWh] = useState("");
|
||||
@@ -34,13 +36,36 @@ export default function InventoryPage() {
|
||||
const [trItem, setTrItem] = useState("");
|
||||
const [trQty, setTrQty] = useState(1);
|
||||
|
||||
const load = async () => {
|
||||
// 매트릭스 보기 모드 — 기본 '품목 가로' (헤더=품목, 행=창고). 토글로 '창고 가로'.
|
||||
const [viewMode, setViewMode] = useState<"by-item" | "by-wh">("by-item");
|
||||
|
||||
// list 평면 → 매트릭스 pivot
|
||||
const matrix = useMemo(() => {
|
||||
const itemSet = new Map<string, { OBJID: string; CODE: string; NAME: string; UNIT: string; IS_TAX_FREE: string }>();
|
||||
const whSet = new Map<string, { OBJID: string; CODE: string; NAME: string }>();
|
||||
const cell: Record<string, Record<string, { qty: number; updateDate: string }>> = {};
|
||||
// cell[itemObjid][whObjid] = { qty, updateDate }
|
||||
for (const s of list) {
|
||||
if (!itemSet.has(s.ITEM_OBJID)) itemSet.set(s.ITEM_OBJID, {
|
||||
OBJID: s.ITEM_OBJID, CODE: s.ITEM_CODE, NAME: s.ITEM_NAME, UNIT: s.UNIT, IS_TAX_FREE: s.IS_TAX_FREE,
|
||||
});
|
||||
if (!whSet.has(s.WH_OBJID)) whSet.set(s.WH_OBJID, { OBJID: s.WH_OBJID, CODE: s.WH_CODE, NAME: s.WH_NAME });
|
||||
if (!cell[s.ITEM_OBJID]) cell[s.ITEM_OBJID] = {};
|
||||
cell[s.ITEM_OBJID][s.WH_OBJID] = { qty: Number(s.QTY), updateDate: s.UPDATE_DATE };
|
||||
}
|
||||
// 창고 7개 — list 에 없는 창고도 헤더에 포함시키려면 whs 사용
|
||||
const allWhs = [...whs].sort((a, b) => a.WH_CODE.localeCompare(b.WH_CODE));
|
||||
const itemList = Array.from(itemSet.values()).sort((a, b) => a.NAME.localeCompare(b.NAME));
|
||||
return { items: itemList, warehouses: allWhs, cell };
|
||||
}, [list, whs]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/m/inventory/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ whObjid: whFilter || undefined, keyword: keyword || undefined }),
|
||||
});
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
}, [whFilter, keyword]);
|
||||
const loadMeta = async () => {
|
||||
const w = await (await fetch("/api/m/warehouses/list", { method: "POST" })).json();
|
||||
setWhs(w.RESULTLIST ?? []);
|
||||
@@ -48,10 +73,15 @@ export default function InventoryPage() {
|
||||
setItems(i.RESULTLIST ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => { loadMeta(); load(); }, []); // eslint-disable-line
|
||||
useEffect(() => { loadMeta(); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const addLine = () => {
|
||||
if (!pickItem) return;
|
||||
if (!pickQty || pickQty === 0) {
|
||||
Swal.fire({ icon: "warning", title: "수량을 입력하세요.", text: "양수=입고, 음수=차감" });
|
||||
return;
|
||||
}
|
||||
const it = items.find((x) => x.OBJID === pickItem);
|
||||
if (!it) return;
|
||||
setLines([...lines, { itemObjid: it.OBJID, itemName: it.ITEM_NAME, qty: pickQty }]);
|
||||
@@ -145,10 +175,13 @@ export default function InventoryPage() {
|
||||
{/* 검색 영역 — 모바일 1열 / sm 2열 / lg 옆으로 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-2.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_2fr_auto] gap-2">
|
||||
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-9 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
<option value="">전체 창고</option>
|
||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
|
||||
value={whFilter}
|
||||
onChange={setWhFilter}
|
||||
placeholder="전체 창고"
|
||||
emptyLabel="전체 창고"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명 / 코드" className="w-full h-9 pl-8 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||
@@ -157,39 +190,141 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 테이블 */}
|
||||
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">창고</th>
|
||||
<th className="text-left px-4 py-3">품목코드</th>
|
||||
<th className="text-left px-4 py-3">품목명</th>
|
||||
<th className="text-center px-4 py-3">구분</th>
|
||||
<th className="text-right px-4 py-3">현재고</th>
|
||||
<th className="text-left px-4 py-3">최종 변경</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">재고 데이터가 없습니다. 매입 입고로 등록하세요.</td></tr>
|
||||
) : list.map((s) => (
|
||||
<tr key={s.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3">{s.WH_NAME}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
|
||||
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{s.IS_TAX_FREE === "Y"
|
||||
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold">과세</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
|
||||
{/* 데스크톱 — 매트릭스 토글 (품목 가로 ↔ 창고 가로) */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="flex items-center justify-end mb-2">
|
||||
<div className="inline-flex rounded border border-slate-300 overflow-hidden">
|
||||
<button onClick={() => setViewMode("by-item")}
|
||||
className={`h-8 px-3 text-xs font-semibold inline-flex items-center gap-1 ${viewMode === "by-item" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
|
||||
<LayoutGrid size={13} /> 품목 가로
|
||||
</button>
|
||||
<button onClick={() => setViewMode("by-wh")}
|
||||
className={`h-8 px-3 text-xs font-semibold inline-flex items-center gap-1 border-l border-slate-300 ${viewMode === "by-wh" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
|
||||
<Columns3 size={13} /> 창고 가로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
{matrix.items.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">재고 데이터가 없습니다. 매입 입고로 등록하세요.</div>
|
||||
) : viewMode === "by-item" ? (
|
||||
/* 품목 가로 (기본): 헤더=품목, 행=창고. 상단에 품목별 전체 합계 강조 행. */
|
||||
<table className="text-sm border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[140px]">창고</th>
|
||||
{matrix.items.map((it) => (
|
||||
<th key={it.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px] whitespace-nowrap">
|
||||
<div>{it.NAME}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.CODE}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* 전체 합계 행 — 헤더 바로 아래, sticky */}
|
||||
<tr className="bg-emerald-50 border-b-2 border-emerald-300">
|
||||
<th className="text-left px-3 py-2 sticky left-0 bg-emerald-50 z-10 text-emerald-800 font-bold">
|
||||
전체 합계
|
||||
</th>
|
||||
{matrix.items.map((it) => {
|
||||
const total = matrix.warehouses.reduce(
|
||||
(sum, w) => sum + (matrix.cell[it.OBJID]?.[w.OBJID]?.qty ?? 0), 0
|
||||
);
|
||||
const cls = total < 0
|
||||
? "text-rose-700 font-extrabold bg-rose-50"
|
||||
: total === 0
|
||||
? "text-emerald-300"
|
||||
: "text-emerald-800 font-bold";
|
||||
return (
|
||||
<th key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
|
||||
{total === 0 ? "-" : `${fmt(total)} ${it.UNIT}`}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{matrix.warehouses.map((w) => (
|
||||
<tr key={w.OBJID} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 font-semibold sticky left-0 bg-white">
|
||||
{w.WH_NAME}
|
||||
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
|
||||
</td>
|
||||
{matrix.items.map((it) => {
|
||||
const c = matrix.cell[it.OBJID]?.[w.OBJID];
|
||||
const qty = c ? c.qty : 0;
|
||||
const cls = qty < 0
|
||||
? "text-rose-700 font-extrabold bg-rose-50"
|
||||
: qty === 0
|
||||
? "text-slate-300"
|
||||
: "text-slate-800 font-semibold";
|
||||
return (
|
||||
<td key={it.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
|
||||
{qty === 0 ? "-" : (
|
||||
<button
|
||||
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
|
||||
className="hover:underline hover:text-emerald-700"
|
||||
title="재고 이력"
|
||||
>
|
||||
{fmt(qty)} {it.UNIT}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
/* 창고 가로: 헤더=창고, 행=품목 */
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-slate-200 min-w-[200px]">품목</th>
|
||||
{matrix.warehouses.map((w) => (
|
||||
<th key={w.OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px]">
|
||||
{w.WH_NAME}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{matrix.items.map((it) => (
|
||||
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-semibold">{it.NAME}</span>
|
||||
<span className="ml-2 text-[10px] text-slate-400 font-mono">{it.CODE}</span>
|
||||
{it.IS_TAX_FREE === "Y"
|
||||
? <span className="ml-2 px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold">면세</span>
|
||||
: <span className="ml-2 px-1 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold">과세</span>}
|
||||
</td>
|
||||
{matrix.warehouses.map((w) => {
|
||||
const c = matrix.cell[it.OBJID]?.[w.OBJID];
|
||||
const qty = c ? c.qty : 0;
|
||||
const cls = qty < 0
|
||||
? "text-rose-700 font-extrabold bg-rose-50"
|
||||
: qty === 0
|
||||
? "text-slate-300"
|
||||
: "text-slate-800 font-semibold";
|
||||
return (
|
||||
<td key={w.OBJID} className={`px-3 py-2 text-right tabular-nums ${cls}`}>
|
||||
{qty === 0 ? "-" : (
|
||||
<button
|
||||
onClick={() => setHistoryOpen({ itemObjid: it.OBJID, whObjid: w.OBJID, itemName: it.NAME, whName: w.WH_NAME })}
|
||||
className="hover:underline hover:text-emerald-700"
|
||||
title="재고 이력"
|
||||
>
|
||||
{fmt(qty)} {it.UNIT}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -215,29 +350,57 @@ export default function InventoryPage() {
|
||||
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold">과세</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-slate-100 text-[10px] text-slate-400">
|
||||
최종 변경 · {s.UPDATE_DATE}
|
||||
<div className="mt-2 pt-2 border-t border-slate-100 text-[10px] text-slate-400 flex items-center justify-between">
|
||||
<span>최종 변경 · {s.UPDATE_DATE}</span>
|
||||
<button onClick={() => setHistoryOpen({ itemObjid: s.ITEM_OBJID, whObjid: s.WH_OBJID, itemName: s.ITEM_NAME, whName: s.WH_NAME })}
|
||||
className="inline-flex items-center gap-1 h-6 px-2 rounded bg-slate-100 text-slate-700 text-[10px] font-bold">
|
||||
<History size={10} /> 이력
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 재고이력 모달 */}
|
||||
{historyOpen && (
|
||||
<StockHistoryModal
|
||||
itemObjid={historyOpen.itemObjid}
|
||||
whObjid={historyOpen.whObjid}
|
||||
itemName={historyOpen.itemName}
|
||||
whName={historyOpen.whName}
|
||||
onClose={() => setHistoryOpen(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 입고 모달 */}
|
||||
{inboundOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setInboundOpen(false)}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-2xl w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="font-bold mb-4">매입 입고 등록</h3>
|
||||
<div className="space-y-3">
|
||||
<select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
|
||||
<option value="">창고 선택</option>
|
||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
|
||||
value={inboundWh}
|
||||
onChange={setInboundWh}
|
||||
placeholder="창고 선택"
|
||||
/>
|
||||
<div className="grid grid-cols-[1fr_80px_auto] gap-2">
|
||||
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 bg-white min-w-0">
|
||||
<option value="">품목 선택</option>
|
||||
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||
</select>
|
||||
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<div className="min-w-0">
|
||||
<SearchableSelect
|
||||
options={items.map((i) => ({ value: i.OBJID, label: i.ITEM_NAME }))}
|
||||
value={pickItem}
|
||||
onChange={setPickItem}
|
||||
placeholder="품목 선택"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step={1}
|
||||
value={pickQty}
|
||||
onChange={(e) => setPickQty(Number(e.target.value))}
|
||||
className="h-10 px-3 rounded-lg border border-slate-200"
|
||||
title="양수=입고, 음수=차감"
|
||||
/>
|
||||
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">추가</button>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto">
|
||||
@@ -252,7 +415,9 @@ export default function InventoryPage() {
|
||||
{lines.map((ln, i) => (
|
||||
<tr key={i} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2">{ln.itemName}</td>
|
||||
<td className="px-3 py-2 text-right">{ln.qty}</td>
|
||||
<td className={`px-3 py-2 text-right font-semibold tabular-nums ${ln.qty < 0 ? "text-rose-600" : "text-emerald-700"}`}>
|
||||
{ln.qty > 0 ? `+${ln.qty}` : ln.qty}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => setLines(lines.filter((_, j) => j !== i))} className="text-slate-400 hover:text-rose-500"><Trash2 size={12} /></button>
|
||||
</td>
|
||||
@@ -280,26 +445,31 @@ export default function InventoryPage() {
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-500 mb-1">출발 창고 (A)</label>
|
||||
<select value={trFrom} onChange={(e) => { setTrFrom(e.target.value); setTrItem(""); }} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
|
||||
<option value="">선택</option>
|
||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={whs.map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
|
||||
value={trFrom}
|
||||
onChange={(v) => { setTrFrom(v); setTrItem(""); }}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-500 mb-1">도착 창고 (B)</label>
|
||||
<select value={trTo} onChange={(e) => setTrTo(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
|
||||
<option value="">선택</option>
|
||||
{whs.filter((w) => w.OBJID !== trFrom).map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={whs.filter((w) => w.OBJID !== trFrom).map((w) => ({ value: w.OBJID, label: w.WH_NAME }))}
|
||||
value={trTo}
|
||||
onChange={setTrTo}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-500 mb-1">품목 {trFrom && <span className="text-slate-400">(출발창고 재고가 있는 품목만)</span>}</label>
|
||||
<select value={trItem} onChange={(e) => setTrItem(e.target.value)} disabled={!trFrom} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white disabled:bg-slate-50">
|
||||
<option value="">{trFrom ? "선택" : "출발창고 먼저 선택"}</option>
|
||||
{trItemsInFrom.map((s) => (
|
||||
<option key={s.ITEM_OBJID} value={s.ITEM_OBJID}>{s.ITEM_NAME} (재고 {fmt(s.QTY)} {s.UNIT})</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={trItemsInFrom.map((s) => ({ value: s.ITEM_OBJID, label: `${s.ITEM_NAME} (재고 ${fmt(s.QTY)} ${s.UNIT})` }))}
|
||||
value={trItem}
|
||||
onChange={setTrItem}
|
||||
disabled={!trFrom}
|
||||
placeholder={trFrom ? "선택" : "출발창고 먼저 선택"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-500 mb-1">
|
||||
@@ -318,3 +488,108 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoveRow {
|
||||
OBJID: string;
|
||||
MOVE_TYPE: string;
|
||||
MOVE_TYPE_NAME?: string;
|
||||
QTY: number;
|
||||
REF_TYPE: string;
|
||||
REF_TYPE_LABEL?: string;
|
||||
REF_OBJID: string;
|
||||
COUNTER_WH_NAME?: string | null;
|
||||
MEMO: string | null;
|
||||
REGID: string | null;
|
||||
REGDATE: string;
|
||||
}
|
||||
|
||||
const MOVE_LABEL: Record<string, string> = { IN: "입고", OUT: "출고", ADJ: "조정", TRANSFER: "이동" };
|
||||
const MOVE_COLOR: Record<string, string> = {
|
||||
IN: "bg-emerald-100 text-emerald-700",
|
||||
OUT: "bg-rose-100 text-rose-700",
|
||||
ADJ: "bg-amber-100 text-amber-700",
|
||||
TRANSFER: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
|
||||
function StockHistoryModal({
|
||||
itemObjid, whObjid, itemName, whName, onClose,
|
||||
}: { itemObjid: string; whObjid: string; itemName: string; whName: string; onClose: () => void }) {
|
||||
const [moves, setMoves] = useState<MoveRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const res = await fetch("/api/m/inventory/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ itemObjid, whObjid }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!cancelled) {
|
||||
setMoves(j.RESULTLIST ?? []);
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [itemObjid, whObjid]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900">{itemName} · 재고이력</h3>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">{whName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 p-1">✕</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-3">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400 text-sm">불러오는 중...</div>
|
||||
) : moves.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400 text-sm">이력이 없습니다.</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-2">일시</th>
|
||||
<th className="text-center px-2 py-2">구분</th>
|
||||
<th className="text-right px-2 py-2">수량</th>
|
||||
<th className="text-left px-2 py-2">참조</th>
|
||||
<th className="text-left px-2 py-2">메모</th>
|
||||
<th className="text-left px-2 py-2">담당</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{moves.map((m) => (
|
||||
<tr key={m.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-2 py-2 whitespace-nowrap">{m.REGDATE}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${MOVE_COLOR[m.MOVE_TYPE] ?? "bg-slate-100 text-slate-600"}`}>
|
||||
{MOVE_LABEL[m.MOVE_TYPE] ?? m.MOVE_TYPE}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-2 py-2 text-right tabular-nums font-bold ${Number(m.QTY) < 0 ? "text-rose-600" : "text-emerald-700"}`}>
|
||||
{Number(m.QTY) > 0 ? "+" : ""}{fmt(m.QTY)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[11px] text-slate-700">
|
||||
{m.REF_TYPE_LABEL || m.REF_TYPE || "-"}
|
||||
{m.REF_TYPE === "TRANSFER" && m.COUNTER_WH_NAME && (
|
||||
<span className="ml-1 text-slate-500">
|
||||
{m.MOVE_TYPE === "OUT" ? `→ ${m.COUNTER_WH_NAME}` : `← ${m.COUNTER_WH_NAME}`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[11px] truncate max-w-[150px]">{m.MEMO || "-"}</td>
|
||||
<td className="px-2 py-2 text-[11px] text-slate-500">{m.REGID || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Order {
|
||||
OBJID: string;
|
||||
ORDER_NO: string;
|
||||
ORDER_DATE: string;
|
||||
COMPANY_NAME: string;
|
||||
CUSTOMER_OBJID?: string;
|
||||
STATUS: string;
|
||||
TOTAL_AMOUNT: number;
|
||||
TOTAL_TAXFREE?: number;
|
||||
TOTAL_TAXABLE?: number;
|
||||
TOTAL_VAT?: number;
|
||||
INVOICE_NO: string | null;
|
||||
INVOICE_DATE: string | null;
|
||||
}
|
||||
interface Customer { USER_ID: string; USER_NAME: string }
|
||||
|
||||
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; INVOICE_NO: string | null; INVOICE_DATE: string | null }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = { APPROVED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행" };
|
||||
// 계산서 발행 대상 — 입금완료(PAID) 이후만. 출고완료(APPROVED)/출고요청 은 노출 안 함.
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PAID: "입금완료",
|
||||
INVOICED: "계산서발행",
|
||||
};
|
||||
|
||||
function defaultRange() {
|
||||
const e = new Date(), s = new Date();
|
||||
s.setDate(s.getDate() - 30);
|
||||
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [list, setList] = useState<Order[]>([]);
|
||||
const [all, setAll] = useState<Order[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/orders/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) });
|
||||
const all = ((await res.json()).RESULTLIST ?? []) as Order[];
|
||||
setList(all.filter((o) => ["APPROVED", "PAID", "INVOICED"].includes(o.STATUS)));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [customerFilter, setCustomerFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const loadCustomers = useCallback(async () => {
|
||||
const res = await fetch("/api/m/customers/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
});
|
||||
setCustomers((await res.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
const res = await fetch("/api/m/orders/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}),
|
||||
});
|
||||
const rows = ((await res.json()).RESULTLIST ?? []) as Order[];
|
||||
// 입금완료(PAID) + 계산서발행(INVOICED) 만 표시. 출고완료(APPROVED) 는 발행 대상 아님.
|
||||
setAll(rows.filter((o) => ["PAID", "INVOICED"].includes(o.STATUS)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAll(); loadCustomers(); }, [loadAll, loadCustomers]);
|
||||
|
||||
// 클라이언트 사이드 필터 — 입력하면 즉시 반영 (조회 버튼 불필요)
|
||||
const list = useMemo(() => {
|
||||
return all.filter((o) => {
|
||||
if (from && o.ORDER_DATE && o.ORDER_DATE < from) return false;
|
||||
if (to && o.ORDER_DATE && o.ORDER_DATE > to) return false;
|
||||
if (customerFilter && o.CUSTOMER_OBJID !== customerFilter) return false;
|
||||
if (statusFilter && o.STATUS !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [all, from, to, customerFilter, statusFilter]);
|
||||
|
||||
// 선택 합산
|
||||
const selectedSum = useMemo(() => {
|
||||
let taxFree = 0, taxable = 0, vat = 0, total = 0;
|
||||
for (const o of list) {
|
||||
if (!selected.has(o.OBJID)) continue;
|
||||
taxFree += Number(o.TOTAL_TAXFREE) || 0;
|
||||
taxable += Number(o.TOTAL_TAXABLE) || 0;
|
||||
vat += Number(o.TOTAL_VAT) || 0;
|
||||
total += Number(o.TOTAL_AMOUNT) || 0;
|
||||
}
|
||||
return { taxFree, taxable, vat, total, count: [...selected].filter((id) => list.some((o) => o.OBJID === id)).length };
|
||||
}, [list, selected]);
|
||||
|
||||
// 전체 합산 (필터 적용된 list)
|
||||
const listSum = useMemo(() => {
|
||||
let taxFree = 0, taxable = 0, vat = 0, total = 0;
|
||||
for (const o of list) {
|
||||
taxFree += Number(o.TOTAL_TAXFREE) || 0;
|
||||
taxable += Number(o.TOTAL_TAXABLE) || 0;
|
||||
vat += Number(o.TOTAL_VAT) || 0;
|
||||
total += Number(o.TOTAL_AMOUNT) || 0;
|
||||
}
|
||||
return { taxFree, taxable, vat, total };
|
||||
}, [list]);
|
||||
|
||||
const issue = async () => {
|
||||
const targets = list.filter((o) => selected.has(o.OBJID) && !o.INVOICE_NO);
|
||||
if (targets.length === 0) return Swal.fire({ icon: "warning", title: "발행 대상 없음", text: "이미 발행된 건은 제외됩니다." });
|
||||
// 발행 가능 = 선택됨 + 입금완료(PAID) + 아직 미발행 (INVOICE_NO 없음)
|
||||
const targets = list.filter((o) => selected.has(o.OBJID) && o.STATUS === "PAID" && !o.INVOICE_NO);
|
||||
if (targets.length === 0) return Swal.fire({ icon: "warning", title: "발행 대상 없음", text: "입금완료된 미발행 건만 발행할 수 있습니다." });
|
||||
const sum = targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0);
|
||||
const r = await Swal.fire({
|
||||
icon: "question", title: `계산서 ${targets.length}건 발행`,
|
||||
text: `합계 ₩${fmt(targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0))}`,
|
||||
showCancelButton: true, confirmButtonText: "발행", confirmButtonColor: "#0f766e",
|
||||
icon: "question",
|
||||
title: `계산서 ${targets.length}건 발행`,
|
||||
text: `합계 ₩${fmt(sum)}`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "발행",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
const res = await fetch("/api/m/orders/invoice", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objids: targets.map((o) => o.OBJID) }),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
Swal.fire({ icon: "success", title: "계산서 발행 완료", timer: 1500, showConfirmButton: false });
|
||||
setSelected(new Set()); load();
|
||||
setSelected(new Set());
|
||||
loadAll();
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const s = new Set(selected);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
if (s.has(id)) s.delete(id);
|
||||
else s.add(id);
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
const issuable = list.filter((o) => !o.INVOICE_NO).map((o) => o.OBJID);
|
||||
if (issuable.every((id) => selected.has(id))) {
|
||||
// 모두 선택돼있으면 해제
|
||||
const s = new Set(selected);
|
||||
issuable.forEach((id) => s.delete(id));
|
||||
setSelected(s);
|
||||
} else {
|
||||
setSelected(new Set([...selected, ...issuable]));
|
||||
}
|
||||
};
|
||||
|
||||
const unissued = list.filter((o) => !o.INVOICE_NO).length;
|
||||
const selectedTotal = list.filter((o) => selected.has(o.OBJID)).reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">계산서 발행</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">미발행 {unissued}건 · 선택 {selected.size}건 (₩{fmt(selectedTotal)})</p>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">
|
||||
전체 {list.length}건 (미발행 {unissued}건) · 선택 {selectedSum.count}건
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={issue}
|
||||
disabled={selected.size === 0}
|
||||
disabled={selectedSum.count === 0}
|
||||
className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold disabled:opacity-50 hover:bg-emerald-800"
|
||||
>
|
||||
{selected.size}건 일괄 발행
|
||||
{selectedSum.count}건 일괄 발행
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 조회 조건 — 입력 즉시 필터 적용 (조회 버튼 없음) */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-3 flex flex-wrap gap-2 items-center text-xs">
|
||||
<span className="text-slate-500 font-semibold mr-1">조회조건</span>
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
|
||||
className="h-9 px-2 rounded border border-slate-200" />
|
||||
<span className="text-slate-400">~</span>
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
|
||||
className="h-9 px-2 rounded border border-slate-200" />
|
||||
<div className="min-w-[200px]">
|
||||
<SearchableSelect
|
||||
options={[{ value: "", label: "전체 거래처" }, ...customers.map((c) => ({ value: c.USER_ID, label: c.USER_NAME }))]}
|
||||
value={customerFilter}
|
||||
onChange={setCustomerFilter}
|
||||
placeholder="거래처"
|
||||
/>
|
||||
</div>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-2 rounded border border-slate-200 bg-white">
|
||||
<option value="">전체 상태</option>
|
||||
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
{(customerFilter || statusFilter) && (
|
||||
<button
|
||||
onClick={() => { setCustomerFilter(""); setStatusFilter(""); setRange(defaultRange()); }}
|
||||
className="h-9 px-3 rounded border border-slate-200 text-slate-600 text-xs"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 합계 요약 — 필터 결과 + 선택 합산 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
|
||||
<div className="text-[11px] text-slate-500 font-semibold mb-1.5">조회 결과 합계 ({list.length}건)</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div><div className="text-violet-600">면세</div><div className="font-bold tabular-nums">₩{fmt(listSum.taxFree)}</div></div>
|
||||
<div><div className="text-rose-600">과세</div><div className="font-bold tabular-nums">₩{fmt(listSum.taxable + listSum.vat)}</div></div>
|
||||
<div><div className="text-slate-700">합계</div><div className="font-bold tabular-nums text-emerald-700">₩{fmt(listSum.total)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-300 rounded-lg p-3">
|
||||
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5">✓ 선택 합산 ({selectedSum.count}건)</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div><div className="text-violet-700">면세</div><div className="font-bold tabular-nums">₩{fmt(selectedSum.taxFree)}</div></div>
|
||||
<div><div className="text-rose-700">과세</div><div className="font-bold tabular-nums">₩{fmt(selectedSum.taxable + selectedSum.vat)}</div></div>
|
||||
<div><div className="text-slate-800">합계</div><div className="font-bold tabular-nums text-emerald-800">₩{fmt(selectedSum.total)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일: 카드 리스트 */}
|
||||
<div className="space-y-2 sm:hidden">
|
||||
{list.length === 0 ? (
|
||||
@@ -87,25 +237,24 @@ export default function InvoicesPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-sm truncate">{o.ORDER_NO}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate">{o.ORDER_DATE} · {o.COMPANY_NAME}</div>
|
||||
<div className="font-bold text-base truncate">{o.COMPANY_NAME}</div>
|
||||
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
|
||||
<span>{o.ORDER_DATE}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="font-semibold text-slate-600">{o.ORDER_NO}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 px-2 py-0.5 rounded text-[10px] font-semibold ${o.STATUS === "INVOICED" ? "bg-violet-100 text-violet-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div>
|
||||
<div className="text-slate-400">합계</div>
|
||||
<div className="font-bold tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-400">계산서</div>
|
||||
<div className="font-mono text-[11px] truncate">{o.INVOICE_NO || <span className="text-slate-300">미발행</span>}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div><div className="text-slate-400">면세</div><div className="tabular-nums">₩{fmt(o.TOTAL_TAXFREE ?? 0)}</div></div>
|
||||
<div><div className="text-slate-400">과세</div><div className="tabular-nums">₩{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}</div></div>
|
||||
<div><div className="text-slate-400">합계</div><div className="font-bold tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</div></div>
|
||||
</div>
|
||||
{o.INVOICE_DATE && (
|
||||
<div className="text-[10px] text-slate-500 mt-1">발행일 {o.INVOICE_DATE}</div>
|
||||
{o.INVOICE_NO && (
|
||||
<div className="text-[10px] text-slate-500 mt-1">계산서 {o.INVOICE_NO} {o.INVOICE_DATE && `· ${o.INVOICE_DATE}`}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,38 +265,49 @@ export default function InvoicesPage() {
|
||||
|
||||
{/* 데스크탑: 표 */}
|
||||
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[800px]">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="px-3 py-3 w-10"></th>
|
||||
<th className="text-left px-4 py-3">발주번호</th>
|
||||
<th className="text-left px-4 py-3">발주일</th>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
<th className="text-left px-4 py-3">계산서번호</th>
|
||||
<th className="text-left px-4 py-3">발행일</th>
|
||||
<th className="px-3 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 accent-emerald-600"
|
||||
checked={unissued > 0 && list.filter((o) => !o.INVOICE_NO).every((o) => selected.has(o.OBJID))}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-1.5">발주번호</th>
|
||||
<th className="text-left px-4 py-1.5">발주일</th>
|
||||
<th className="text-left px-4 py-1.5">업체명</th>
|
||||
<th className="text-right px-4 py-1.5">면세</th>
|
||||
<th className="text-right px-4 py-1.5">과세</th>
|
||||
<th className="text-right px-4 py-1.5">합계</th>
|
||||
<th className="text-center px-4 py-1.5">상태</th>
|
||||
<th className="text-left px-4 py-1.5">계산서번호</th>
|
||||
<th className="text-left px-4 py-1.5">발행일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">대상 발주가 없습니다.</td></tr>
|
||||
<tr><td colSpan={10} className="text-center py-12 text-slate-400">대상 발주가 없습니다.</td></tr>
|
||||
) : list.map((o) => (
|
||||
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||
<tr key={o.OBJID} className={`border-t border-slate-100 ${selected.has(o.OBJID) ? "bg-emerald-50/40" : ""}`}>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{!o.INVOICE_NO && <input type="checkbox" checked={selected.has(o.OBJID)} onChange={() => toggle(o.OBJID)} className="w-4 h-4 accent-emerald-600" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-3">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<td className="px-3 py-1.5 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-1.5">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-1.5">{o.COMPANY_NAME}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-violet-700">₩{fmt(o.TOTAL_TAXFREE ?? 0)}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-rose-700">₩{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums font-bold text-emerald-700">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "INVOICED" ? "bg-violet-100 text-violet-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{o.INVOICE_NO || "-"}</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500">{o.INVOICE_DATE || "-"}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs">{o.INVOICE_NO || "-"}</td>
|
||||
<td className="px-3 py-1.5 text-xs text-slate-500">{o.INVOICE_DATE || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -19,10 +19,14 @@ interface Item {
|
||||
STOCK_QTY: number;
|
||||
ATTRIBUTES: Record<string, unknown> | null;
|
||||
MAX_ORDER_QTY: number | null;
|
||||
LIMIT_QTY: number | null;
|
||||
IS_HIDDEN: string;
|
||||
REQUIRES_DELIVERY: string;
|
||||
VENDOR_OBJID?: string;
|
||||
VENDOR_NAME?: string;
|
||||
SALE_START_DATE?: string | null;
|
||||
SALE_END_DATE?: string | null;
|
||||
IS_ALWAYS_SALE?: string;
|
||||
}
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
|
||||
@@ -38,21 +42,40 @@ interface ItemAttributes {
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
// API 응답 "YYYY-MM-DD HH:MM" → datetime-local 입력값 "YYYY-MM-DDTHH:MM"
|
||||
const toLocal = (s?: string | null) => (s ? String(s).replace(" ", "T").slice(0, 16) : "");
|
||||
|
||||
export default function AdminItemsPage() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
const [filterVendor, setFilterVendor] = useState("");
|
||||
const [editing, setEditing] = useState<Partial<Item> | null>(null);
|
||||
const [attrs, setAttrs] = useState<ItemAttributes>({});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const toggleSel = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleSelAll = () => {
|
||||
setSelectedIds((prev) => {
|
||||
if (prev.size === items.length) return new Set();
|
||||
return new Set(items.map((it) => it.OBJID));
|
||||
});
|
||||
};
|
||||
|
||||
const loadItems = async () => {
|
||||
const res = await fetch("/api/m/items/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keyword, status: filterStatus || undefined }),
|
||||
body: JSON.stringify({ keyword, vendorObjid: filterVendor || undefined }),
|
||||
});
|
||||
setItems((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
@@ -80,7 +103,7 @@ export default function AdminItemsPage() {
|
||||
};
|
||||
|
||||
const openNew = () => {
|
||||
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
|
||||
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, LIMIT_QTY: null, REQUIRES_DELIVERY: "N" });
|
||||
setAttrs({});
|
||||
};
|
||||
|
||||
@@ -101,9 +124,13 @@ export default function AdminItemsPage() {
|
||||
status: editing.STATUS || "ACTIVE",
|
||||
attributes: Object.keys(attrs).length > 0 ? attrs : null,
|
||||
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
|
||||
limitQty: editing.LIMIT_QTY ?? null,
|
||||
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
|
||||
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
|
||||
vendorObjid: editing.VENDOR_OBJID || null,
|
||||
saleStartDate: editing.SALE_START_DATE || null,
|
||||
saleEndDate: editing.SALE_END_DATE || null,
|
||||
isAlwaysSale: editing.IS_ALWAYS_SALE === "Y" ? "Y" : "N",
|
||||
};
|
||||
const res = await fetch("/api/m/items/save", {
|
||||
method: "POST",
|
||||
@@ -159,6 +186,10 @@ export default function AdminItemsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<BulkSaleRangeBar
|
||||
selectedIds={selectedIds}
|
||||
onApplied={() => { setSelectedIds(new Set()); loadItems(); }}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-800">품목 관리</h1>
|
||||
<button
|
||||
@@ -180,15 +211,14 @@ export default function AdminItemsPage() {
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none bg-white"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="ACTIVE">사용</option>
|
||||
<option value="INACTIVE">중지</option>
|
||||
</select>
|
||||
<div className="min-w-[200px]">
|
||||
<SearchableSelect
|
||||
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
|
||||
value={filterVendor}
|
||||
onChange={setFilterVendor}
|
||||
placeholder="공급업체"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadItems}
|
||||
className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"
|
||||
@@ -244,27 +274,44 @@ export default function AdminItemsPage() {
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={items.length > 0 && selectedIds.size === items.length}
|
||||
onChange={toggleSelAll}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 w-14"></th>
|
||||
<th className="text-left px-3 py-3">품목코드</th>
|
||||
<th className="text-left px-3 py-3">품목명</th>
|
||||
<th className="text-center px-3 py-3">구분</th>
|
||||
<th className="text-right px-3 py-3">단가</th>
|
||||
<th className="text-right px-3 py-3">원가</th>
|
||||
<th className="text-right px-3 py-3">재고</th>
|
||||
<th className="text-center px-3 py-3">상태</th>
|
||||
<th className="text-right px-3 py-3 w-[70px]">작업</th>
|
||||
<th className="text-left px-3 py-1.5">품목코드</th>
|
||||
<th className="text-left px-3 py-1.5">품목명</th>
|
||||
<th className="text-center px-3 py-1.5">구분</th>
|
||||
<th className="text-right px-3 py-1.5">단가</th>
|
||||
<th className="text-right px-3 py-1.5">원가</th>
|
||||
<th className="text-right px-3 py-1.5">재고</th>
|
||||
<th className="text-center px-3 py-1.5 whitespace-nowrap">판매기간</th>
|
||||
<th className="text-center px-3 py-1.5">상태</th>
|
||||
<th className="text-right px-3 py-1.5 w-[80px] whitespace-nowrap">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-center py-12 text-slate-400">
|
||||
<td colSpan={11} className="text-center py-12 text-slate-400">
|
||||
품목이 없습니다. 신규 등록 버튼을 눌러주세요.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-2 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(it.OBJID)}
|
||||
onChange={() => toggleSel(it.OBJID)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-10 h-10 bg-slate-50 rounded overflow-hidden flex items-center justify-center">
|
||||
{it.IMAGE_URL ? (
|
||||
@@ -275,7 +322,7 @@ export default function AdminItemsPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-slate-500">{it.ITEM_CODE}</td>
|
||||
<td className="px-3 py-2 font-semibold text-slate-800">{it.ITEM_NAME}</td>
|
||||
<td className="px-3 py-2 font-semibold text-slate-800 max-w-[260px] truncate" title={it.ITEM_NAME}>{it.ITEM_NAME}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{it.IS_TAX_FREE === "Y" ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||
@@ -290,12 +337,18 @@ export default function AdminItemsPage() {
|
||||
{fmt(it.STOCK_QTY)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-[11px] tabular-nums whitespace-nowrap">
|
||||
{it.IS_ALWAYS_SALE === "Y" ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold">상시</span>
|
||||
) : (it.SALE_START_DATE || it.SALE_END_DATE) ? (
|
||||
<span className="text-slate-600">{it.SALE_START_DATE ?? "—"} <span className="text-slate-300">~</span> {it.SALE_END_DATE ?? "—"}</span>
|
||||
) : (
|
||||
<span className="inline-block px-2 py-0.5 rounded bg-rose-100 text-rose-600 font-bold">미노출</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${it.STATUS === "ACTIVE" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{it.STATUS === "ACTIVE" ? "사용" : "중지"}
|
||||
</span>
|
||||
{it.IS_HIDDEN === "Y" && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">숨김</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">숨김</span>
|
||||
)}
|
||||
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">≤{it.MAX_ORDER_QTY}</span>
|
||||
@@ -304,11 +357,11 @@ export default function AdminItemsPage() {
|
||||
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold">택배</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1" title="수정">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button onClick={() => onDelete(it.OBJID)} className="text-slate-400 hover:text-rose-600 p-1 ml-1">
|
||||
<button onClick={() => onDelete(it.OBJID)} className="text-slate-400 hover:text-rose-600 p-1 ml-1" title="삭제">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
@@ -408,16 +461,6 @@ export default function AdminItemsPage() {
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm tabular-nums text-right focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="상태">
|
||||
<select
|
||||
value={editing.STATUS ?? "ACTIVE"}
|
||||
onChange={(e) => setEditing({ ...editing, STATUS: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white focus:border-emerald-500 outline-none"
|
||||
>
|
||||
<option value="ACTIVE">사용</option>
|
||||
<option value="INACTIVE">중지</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="발주 제한수량 (1회 최대)">
|
||||
<input
|
||||
type="number" min={0}
|
||||
@@ -430,6 +473,22 @@ export default function AdminItemsPage() {
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="한정 수량 (이번 마감 사이클 누적 상한)">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={editing.LIMIT_QTY ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEditing({ ...editing, LIMIT_QTY: v === "" ? null : Number(v) });
|
||||
}}
|
||||
placeholder="공란 = 제한 없음"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400 mt-1 leading-tight">
|
||||
모든 거래처 출고요청+출고완료 합산이 이 수량을 넘으면 추가 요청 차단.<br/>
|
||||
월요일 마감: 저번주 금~월 마감 시각, 화요일 마감: 저번주 금~화 마감 시각.
|
||||
</p>
|
||||
</Field>
|
||||
<Field label="숨김 처리">
|
||||
<div className="flex gap-2 h-10">
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
@@ -470,6 +529,44 @@ export default function AdminItemsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="상시 판매">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.IS_ALWAYS_SALE === "Y"}
|
||||
onChange={(e) => setEditing({
|
||||
...editing,
|
||||
IS_ALWAYS_SALE: e.target.checked ? "Y" : "N",
|
||||
// 상시 체크 시 날짜 비우기 (요청 정책)
|
||||
SALE_START_DATE: e.target.checked ? null : editing.SALE_START_DATE,
|
||||
SALE_END_DATE: e.target.checked ? null : editing.SALE_END_DATE,
|
||||
})}
|
||||
className="w-4 h-4 accent-emerald-600"
|
||||
/>
|
||||
<span className="text-sm">상시 판매로 노출 (체크 시 아래 날짜 입력은 비활성)</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-500 mt-1">미체크 + 날짜 미입력 시 출고요청 화면에서 노출되지 않습니다.</p>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="판매 시작일시 (분 단위)">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocal(editing.SALE_START_DATE)}
|
||||
disabled={editing.IS_ALWAYS_SALE === "Y"}
|
||||
onChange={(e) => setEditing({ ...editing, SALE_START_DATE: e.target.value || null })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none disabled:bg-slate-100 disabled:text-slate-400"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="판매 종료일시 (분 단위)">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocal(editing.SALE_END_DATE)}
|
||||
disabled={editing.IS_ALWAYS_SALE === "Y"}
|
||||
onChange={(e) => setEditing({ ...editing, SALE_END_DATE: e.target.value || null })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none disabled:bg-slate-100 disabled:text-slate-400"
|
||||
/>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="상세 설명">
|
||||
<textarea
|
||||
@@ -604,3 +701,113 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkSaleRangeBar({
|
||||
selectedIds,
|
||||
onApplied,
|
||||
}: {
|
||||
selectedIds: Set<string>;
|
||||
onApplied: () => void;
|
||||
}) {
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const apply = async (mode: "apply" | "clear" | "always") => {
|
||||
if (selectedIds.size === 0) {
|
||||
Swal.fire({ icon: "warning", title: "품목을 선택하세요" });
|
||||
return;
|
||||
}
|
||||
if (mode === "apply" && !from && !to) {
|
||||
Swal.fire({ icon: "warning", title: "시작일 또는 종료일을 입력하세요" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/items/bulk-sale-range", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
objids: Array.from(selectedIds),
|
||||
saleStartDate: from || null,
|
||||
saleEndDate: to || null,
|
||||
clear: mode === "clear",
|
||||
alwaysSale: mode === "always",
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
const title = mode === "clear" ? "미노출 처리됨"
|
||||
: mode === "always" ? "상시 판매로 설정됨"
|
||||
: "판매기간 일괄 적용 완료";
|
||||
Swal.fire({
|
||||
icon: "success",
|
||||
title,
|
||||
text: `${j.count}개 품목 적용`,
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
setFrom("");
|
||||
setTo("");
|
||||
onApplied();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "실패", text: j.message });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg px-4 py-3 flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-emerald-800 mb-1">판매 시작일시</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-emerald-800 mb-1">판매 종료일시</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => apply("apply")}
|
||||
className="h-9 px-3 rounded-md bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50"
|
||||
>
|
||||
선택 {selectedIds.size}건 일괄 적용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => apply("always")}
|
||||
className="h-9 px-3 rounded-md bg-emerald-600 text-white text-sm font-bold hover:bg-emerald-700 disabled:opacity-50"
|
||||
title="선택한 품목을 상시 판매로 설정 (날짜는 모두 초기화)"
|
||||
>
|
||||
상시 판매로 설정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => apply("clear")}
|
||||
className="h-9 px-3 rounded-md bg-white border border-emerald-300 text-emerald-700 text-sm font-semibold hover:bg-emerald-100 disabled:opacity-50"
|
||||
>
|
||||
판매기간 해제
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] text-emerald-700/80 ml-auto">
|
||||
체크된 품목에 일괄 적용 · <b>상시</b>=항상 노출(날짜 초기화) · <b>해제</b>=미노출(날짜 초기화)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { Plus, Search, Pencil, Trash2, Factory } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Maker {
|
||||
OBJID: string;
|
||||
MAKER_NAME: string;
|
||||
CONTACT: string;
|
||||
PHONE: string;
|
||||
MEMO: string;
|
||||
REGDATE: string;
|
||||
}
|
||||
|
||||
export default function AdminMakersPage() {
|
||||
const [makers, setMakers] = useState<Maker[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [editing, setEditing] = useState<Partial<Maker> | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/makers/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keyword }),
|
||||
});
|
||||
setMakers((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const isNew = !editing.OBJID;
|
||||
const res = await fetch("/api/m/makers/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
objid: editing.OBJID,
|
||||
actionType: isNew ? "regist" : "update",
|
||||
makerName: editing.MAKER_NAME,
|
||||
contact: editing.CONTACT,
|
||||
phone: editing.PHONE,
|
||||
memo: editing.MEMO,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
|
||||
setEditing(null);
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (objid: string, name: string) => {
|
||||
const ok = await Swal.fire({
|
||||
icon: "warning",
|
||||
title: `"${name}" 삭제`,
|
||||
text: "삭제하시겠습니까?",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "삭제",
|
||||
cancelButtonText: "취소",
|
||||
confirmButtonColor: "#dc2626",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
const res = await fetch("/api/m/makers/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objids: [objid] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: keyof Maker) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setEditing((prev) => prev ? { ...prev, [k]: e.target.value } : prev);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-slate-800">제조사 관리</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">총 {makers.length}개</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditing({ MAKER_NAME: "", CONTACT: "", PHONE: "", MEMO: "" })}
|
||||
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800"
|
||||
>
|
||||
<Plus size={16} /> 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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" && load()}
|
||||
placeholder="제조사명 검색"
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold shrink-0">조회</button>
|
||||
</div>
|
||||
|
||||
{/* 모바일: 카드 */}
|
||||
<div className="grid grid-cols-1 sm:hidden gap-2">
|
||||
{makers.length === 0 ? (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center text-slate-400">제조사가 없습니다.</div>
|
||||
) : makers.map((m) => (
|
||||
<div key={m.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Factory size={16} className="text-emerald-700 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-sm truncate">{m.MAKER_NAME}</span>
|
||||
<div className="shrink-0 flex gap-1">
|
||||
<button onClick={() => setEditing(m)} className="text-slate-400 hover:text-emerald-700 p-1.5"><Pencil size={14} /></button>
|
||||
<button onClick={() => onDelete(m.OBJID, m.MAKER_NAME)} className="text-slate-400 hover:text-rose-600 p-1.5"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-600 space-y-0.5 mt-0.5">
|
||||
<div>📞 {m.PHONE || "-"} {m.CONTACT && `· ${m.CONTACT}`}</div>
|
||||
{m.MEMO && <div className="text-slate-500">📝 {m.MEMO}</div>}
|
||||
<div className="text-slate-400 text-[10px]">등록 {m.REGDATE}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데스크탑: 표 */}
|
||||
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">제조사명</th>
|
||||
<th className="text-left px-4 py-3">담당자</th>
|
||||
<th className="text-left px-4 py-3">연락처</th>
|
||||
<th className="text-left px-4 py-3">메모</th>
|
||||
<th className="text-center px-4 py-3 w-[100px]">등록일</th>
|
||||
<th className="text-right px-4 py-3 w-[80px]">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{makers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-12 text-slate-400">
|
||||
제조사가 없습니다. 신규 등록 버튼을 눌러주세요.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
makers.map((m) => (
|
||||
<tr key={m.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-3 font-semibold text-slate-800">{m.MAKER_NAME}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{m.CONTACT || "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{m.PHONE || "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">{m.MEMO || "-"}</td>
|
||||
<td className="px-4 py-3 text-center text-slate-500 text-xs">{m.REGDATE}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => setEditing(m)} className="text-slate-400 hover:text-emerald-700 p-1"><Pencil size={14} /></button>
|
||||
<button onClick={() => onDelete(m.OBJID, m.MAKER_NAME)} className="text-slate-400 hover:text-rose-600 p-1 ml-1"><Trash2 size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
{editing && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
<form
|
||||
onSubmit={onSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-5 text-slate-800">
|
||||
{editing.OBJID ? "제조사 수정" : "제조사 등록"}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
|
||||
제조사명 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
value={editing.MAKER_NAME ?? ""}
|
||||
onChange={set("MAKER_NAME")}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">담당자</label>
|
||||
<input
|
||||
value={editing.CONTACT ?? ""}
|
||||
onChange={set("CONTACT")}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">연락처</label>
|
||||
<input
|
||||
value={editing.PHONE ?? ""}
|
||||
onChange={set("PHONE")}
|
||||
placeholder="010-0000-0000"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">메모</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editing.MEMO ?? ""}
|
||||
onChange={set("MEMO")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { History, Users, ExternalLink, ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
interface HistoryRow {
|
||||
OBJID: string; TITLE: string; BODY: string | null;
|
||||
IMAGE_URL: string | null; URL: string | null;
|
||||
REGDATE: string; REGID: string;
|
||||
SENT_COUNT: number; FAILED_COUNT: number;
|
||||
RECIPIENT_COUNT: number;
|
||||
RECIPIENT_USER_IDS: string[];
|
||||
GROUP_NAMES: string[];
|
||||
RECIPIENT_NAMES: string[];
|
||||
}
|
||||
|
||||
export default function NoticeHistoryPage() {
|
||||
const [list, setList] = useState<HistoryRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch("/api/m/admin/notice-history/list", { method: "POST" });
|
||||
setList((await r.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(id)) n.delete(id); else n.add(id);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
|
||||
<History size={18} className="text-emerald-700" /> 푸시알림 발송이력
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">보낸 공지의 발송 시각·대상·성공/실패 카운트를 확인. 행을 펼치면 수신자 명단과 그룹·본문을 볼 수 있어요.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
||||
<span>총 {list.length}건</span>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="text-[11px] px-2 py-0.5 rounded border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-50">
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 max-h-[calc(100vh-220px)] overflow-y-auto">
|
||||
{list.length === 0 ? (
|
||||
<div className="text-center py-10 text-slate-400 text-sm">{loading ? "불러오는 중…" : "발송 이력이 없습니다."}</div>
|
||||
) : list.map((n) => {
|
||||
const open = expanded.has(n.OBJID);
|
||||
return (
|
||||
<div key={n.OBJID} className="px-3 py-2">
|
||||
<button onClick={() => toggle(n.OBJID)} className="w-full flex items-center gap-3 text-left">
|
||||
<span className="text-slate-400 shrink-0">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
|
||||
{n.IMAGE_URL && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={n.IMAGE_URL} alt="" className="w-10 h-10 rounded object-cover shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold truncate">{n.TITLE}</div>
|
||||
<div className="text-[11px] text-slate-500 truncate flex items-center gap-1.5">
|
||||
<span>{n.REGDATE}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span>대상 <b className="text-slate-700">{n.RECIPIENT_COUNT < 0 ? "전체" : n.RECIPIENT_COUNT}</b>명</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-emerald-700">성공 {n.SENT_COUNT}</span>
|
||||
{n.FAILED_COUNT > 0 && <><span className="text-slate-300">/</span><span className="text-rose-600">실패 {n.FAILED_COUNT}</span></>}
|
||||
{n.GROUP_NAMES && n.GROUP_NAMES.length > 0 && (
|
||||
<>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-violet-700 inline-flex items-center gap-0.5"><Users size={11} />{n.GROUP_NAMES.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a href={`/m/notices/${n.OBJID}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 text-slate-400 hover:text-emerald-700 inline-flex items-center gap-0.5 text-[11px]" title="공지 페이지 열기">
|
||||
공지 <ExternalLink size={11} />
|
||||
</a>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 pl-7 space-y-2">
|
||||
{n.BODY && (
|
||||
// 본문은 Tiptap 에디터의 HTML (이미지/서식 포함) — 관리자만 작성 가능해 XSS 위험 낮음
|
||||
<div
|
||||
className="bg-slate-50 rounded p-3 text-xs leading-relaxed text-slate-700 border border-slate-100 notice-body-html [&_img]:max-w-full [&_img]:rounded [&_img]:my-1 [&_p]:my-1 [&_h2]:text-sm [&_h2]:font-bold [&_h2]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-emerald-700 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-2 [&_blockquote]:text-slate-500"
|
||||
dangerouslySetInnerHTML={{ __html: n.BODY }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-slate-600 mb-1">수신자 {n.RECIPIENT_NAMES?.length ?? 0}명</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{n.RECIPIENT_COUNT < 0 ? (
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">전체 구독자</span>
|
||||
) : n.RECIPIENT_NAMES?.length === 0 ? (
|
||||
<span className="text-[11px] text-slate-400">기록 없음 (구버전)</span>
|
||||
) : (
|
||||
n.RECIPIENT_NAMES.map((nm, i) => (
|
||||
<span key={i} className="text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-700">
|
||||
{nm || n.RECIPIENT_USER_IDS?.[i]}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
// 푸시알림 게시판 — 권한 관리 화면(admin-panel/AuthManagement)과 동일한 3-패널 패턴.
|
||||
// 좌측 : 수신자 그룹 목록 [+ 생성]
|
||||
// 우측 상단 : 그룹 멤버 / [추가/제거] / 전체 사용자 풀 ← 권한있는/권한없는 직원
|
||||
// 우측 하단 : 컨텐츠(제목/리치 본문/외부링크) + 발송
|
||||
// - 본문은 Tiptap 리치 에디터 (이미지 복붙/드래그 자동 업로드, 볼드/리스트/링크 등)
|
||||
// 발송이력은 별도 메뉴(/m/admin/notice-history)로 분리됨 — 본 화면에서 제거.
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { Send, X, Bell, Users, Shield, Upload } from "lucide-react";
|
||||
import { RichEditor } from "@/components/rich-editor";
|
||||
|
||||
interface Group {
|
||||
OBJID: string; NAME: string; DESCRIPTION: string | null;
|
||||
REGDATE: string; MEMBER_CNT: number; MEMBER_IDS: string[];
|
||||
}
|
||||
interface AllUser {
|
||||
USER_ID: string;
|
||||
USER_NAME: string;
|
||||
USER_TYPE: string;
|
||||
IS_ADMIN: boolean;
|
||||
DEPT_NAME: string;
|
||||
SUBSCRIBED: boolean;
|
||||
}
|
||||
|
||||
export default function AdminNoticesPage() {
|
||||
// ===== 좌측: 그룹 목록 =====
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [groupQuery, setGroupQuery] = useState("");
|
||||
const [activeGroup, setActiveGroup] = useState<Group | null>(null);
|
||||
|
||||
// ===== 우측 상단: 멤버 양쪽 패널 =====
|
||||
const [allUsers, setAllUsers] = useState<AllUser[]>([]);
|
||||
const [memberQ, setMemberQ] = useState("");
|
||||
const [availQ, setAvailQ] = useState("");
|
||||
const [chkMember, setChkMember] = useState<Set<string>>(new Set());
|
||||
const [chkAvail, setChkAvail] = useState<Set<string>>(new Set());
|
||||
|
||||
// ===== 우측 하단: 작성 폼 =====
|
||||
const [title, setTitle] = useState("");
|
||||
const [bodyText, setBodyText] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
// 그룹 외 추가 옵션 — 활성 그룹 없이 발송하고 싶을 때
|
||||
const [sendAll, setSendAll] = useState(false);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/groups/list", { method: "POST" });
|
||||
const j = await r.json();
|
||||
const list = (j.RESULTLIST ?? []) as Group[];
|
||||
setGroups(list);
|
||||
// 활성 그룹이 갱신되도록 sync
|
||||
setActiveGroup((cur) => (cur ? (list.find((g) => g.OBJID === cur.OBJID) ?? null) : null));
|
||||
}, []);
|
||||
const loadAllUsers = useCallback(async () => {
|
||||
const r = await fetch("/api/m/admin/notices/all-users", { method: "POST" });
|
||||
setAllUsers((await r.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
useEffect(() => { loadGroups(); loadAllUsers(); }, [loadGroups, loadAllUsers]);
|
||||
|
||||
// 활성 그룹이 바뀌면 체크박스 초기화
|
||||
useEffect(() => { setChkMember(new Set()); setChkAvail(new Set()); }, [activeGroup?.OBJID]);
|
||||
|
||||
const filteredGroups = useMemo(() => groups.filter((g) =>
|
||||
!groupQuery || g.NAME.toLowerCase().includes(groupQuery.toLowerCase()) || (g.DESCRIPTION ?? "").toLowerCase().includes(groupQuery.toLowerCase())
|
||||
), [groups, groupQuery]);
|
||||
|
||||
// 활성 그룹 멤버 / 풀
|
||||
const memberSet = useMemo(() => new Set(activeGroup?.MEMBER_IDS ?? []), [activeGroup]);
|
||||
const members = useMemo(() => allUsers.filter((u) => memberSet.has(u.USER_ID)), [allUsers, memberSet]);
|
||||
const available = useMemo(() => allUsers.filter((u) => !memberSet.has(u.USER_ID)), [allUsers, memberSet]);
|
||||
const filteredMembers = useMemo(() => members.filter((u) => !memberQ || u.USER_NAME.includes(memberQ) || u.USER_ID.includes(memberQ) || (u.DEPT_NAME ?? "").includes(memberQ)), [members, memberQ]);
|
||||
const filteredAvail = useMemo(() => available.filter((u) => !availQ || u.USER_NAME.includes(availQ) || u.USER_ID.includes(availQ) || (u.DEPT_NAME ?? "").includes(availQ)), [available, availQ]);
|
||||
|
||||
// ===== 그룹 생성/수정/삭제 (SweetAlert) =====
|
||||
const onCreate = async () => {
|
||||
const r = await Swal.fire({
|
||||
title: "수신자 그룹 생성",
|
||||
html: `
|
||||
<input id="sw_name" class="swal2-input" placeholder="그룹명 (예: 본사 거래처)">
|
||||
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)">
|
||||
`,
|
||||
showCancelButton: true, confirmButtonText: "생성", confirmButtonColor: "#0f766e",
|
||||
preConfirm: () => ({
|
||||
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
|
||||
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
|
||||
}),
|
||||
});
|
||||
if (!r.isConfirmed || !r.value?.name) return;
|
||||
const sv = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: r.value.name, description: r.value.description }),
|
||||
});
|
||||
const sj = await sv.json();
|
||||
if (!sj.success) { Swal.fire({ icon: "error", title: "생성 실패", text: sj.message }); return; }
|
||||
await loadGroups();
|
||||
// 새로 만든 그룹을 활성화
|
||||
setActiveGroup({ OBJID: sj.objId, NAME: r.value.name, DESCRIPTION: r.value.description || null, REGDATE: "", MEMBER_CNT: 0, MEMBER_IDS: [] });
|
||||
};
|
||||
|
||||
const onRename = async (g: Group) => {
|
||||
const r = await Swal.fire({
|
||||
title: "그룹 수정", icon: "info",
|
||||
html: `
|
||||
<input id="sw_name" class="swal2-input" value="${g.NAME.replace(/"/g, """)}">
|
||||
<input id="sw_desc" class="swal2-input" placeholder="설명 (선택)" value="${(g.DESCRIPTION ?? "").replace(/"/g, """)}">
|
||||
`,
|
||||
showCancelButton: true, showDenyButton: true, denyButtonText: "삭제", confirmButtonText: "저장", confirmButtonColor: "#0f766e",
|
||||
preConfirm: () => ({
|
||||
name: (document.getElementById("sw_name") as HTMLInputElement).value.trim(),
|
||||
description: (document.getElementById("sw_desc") as HTMLInputElement).value.trim(),
|
||||
}),
|
||||
});
|
||||
if (r.isDenied) {
|
||||
const ok = await Swal.fire({ icon: "warning", title: `"${g.NAME}" 삭제?`, showCancelButton: true, confirmButtonColor: "#dc2626", confirmButtonText: "삭제" });
|
||||
if (!ok.isConfirmed) return;
|
||||
const res = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: g.OBJID, action: "delete" }),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
if (activeGroup?.OBJID === g.OBJID) setActiveGroup(null);
|
||||
loadGroups();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!r.isConfirmed || !r.value?.name) return;
|
||||
const res = await fetch("/api/m/admin/notices/groups/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: g.OBJID, name: r.value.name, description: r.value.description }),
|
||||
});
|
||||
if ((await res.json()).success) loadGroups();
|
||||
};
|
||||
|
||||
// ===== 멤버 추가/제거 =====
|
||||
const saveMembers = async (newIds: string[]) => {
|
||||
if (!activeGroup) return false;
|
||||
const r = await fetch("/api/m/admin/notices/groups/members/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ groupObjid: activeGroup.OBJID, userIds: newIds }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.success) { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); return false; }
|
||||
return true;
|
||||
};
|
||||
const addSelected = async () => {
|
||||
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
|
||||
if (chkAvail.size === 0) return Swal.fire({ icon: "warning", title: "추가할 사용자를 선택하세요" });
|
||||
const newIds = Array.from(new Set([...(activeGroup.MEMBER_IDS ?? []), ...Array.from(chkAvail)]));
|
||||
if (await saveMembers(newIds)) { setChkAvail(new Set()); loadGroups(); }
|
||||
};
|
||||
const removeSelected = async () => {
|
||||
if (!activeGroup) return Swal.fire({ icon: "warning", title: "그룹을 선택하세요" });
|
||||
if (chkMember.size === 0) return Swal.fire({ icon: "warning", title: "제거할 사용자를 선택하세요" });
|
||||
const newIds = (activeGroup.MEMBER_IDS ?? []).filter((id) => !chkMember.has(id));
|
||||
if (await saveMembers(newIds)) { setChkMember(new Set()); loadGroups(); }
|
||||
};
|
||||
|
||||
// ===== 이미지 업로드 =====
|
||||
const onUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await fetch("/api/m/items/upload-image", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (j.success) setImageUrl(j.url);
|
||||
else Swal.fire({ icon: "error", title: "업로드 실패", text: j.message });
|
||||
} finally { setUploading(false); }
|
||||
};
|
||||
|
||||
// ===== 발송 =====
|
||||
const targetUserIds = useMemo(() => sendAll ? [] : (activeGroup?.MEMBER_IDS ?? []), [sendAll, activeGroup]);
|
||||
const targetCount = sendAll ? -1 : targetUserIds.length;
|
||||
|
||||
const send = async () => {
|
||||
if (!title.trim()) return Swal.fire({ icon: "warning", title: "제목을 입력하세요" });
|
||||
if (!sendAll && targetUserIds.length === 0) return Swal.fire({ icon: "warning", title: "그룹(또는 멤버)을 선택하거나 [전체 구독자] 옵션을 켜세요" });
|
||||
|
||||
const targetLabel = sendAll ? "전체 구독자" : `${activeGroup?.NAME} (${targetUserIds.length}명)`;
|
||||
const ok = await Swal.fire({
|
||||
icon: "question", title: `${targetLabel} 에게 발송`, text: `제목: ${title}`,
|
||||
showCancelButton: true, confirmButtonText: "발송", cancelButtonText: "취소", confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const sv = await fetch("/api/m/admin/notices/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, body: bodyText, imageUrl, url: linkUrl }),
|
||||
});
|
||||
const svj = await sv.json();
|
||||
if (!svj.success) { Swal.fire({ icon: "error", title: "저장 실패", text: svj.message }); return; }
|
||||
const noticeObjid = svj.objId;
|
||||
const groupNames = activeGroup && !sendAll ? [activeGroup.NAME] : [];
|
||||
// 푸시 본문은 plain text — HTML 태그 제거 + 공백 압축
|
||||
const plainBody = bodyText.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
||||
// 첨부 이미지가 없으면 본문 HTML 의 첫 <img src=""> 추출해서 big picture 로 사용
|
||||
const firstImgInBody = (() => {
|
||||
const m = bodyText.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
return m ? m[1] : undefined;
|
||||
})();
|
||||
const sendRes = await fetch("/api/m/admin/notices/send-push", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
noticeObjid, title, message: plainBody,
|
||||
url: linkUrl || `/m/notices/${noticeObjid}`,
|
||||
userIds: sendAll ? undefined : targetUserIds,
|
||||
sendAll,
|
||||
groupNames,
|
||||
imageUrl: imageUrl || firstImgInBody,
|
||||
}),
|
||||
});
|
||||
const sj = await sendRes.json();
|
||||
if (sj.success) {
|
||||
await Swal.fire({ icon: "success", title: "발송 완료", text: `성공 ${sj.sent} · 실패 ${sj.failed}`, timer: 1800, showConfirmButton: false });
|
||||
setTitle(""); setBodyText(""); setImageUrl(""); setLinkUrl(""); setSendAll(false);
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "발송 실패", text: sj.message });
|
||||
}
|
||||
} finally { setSending(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold inline-flex items-center gap-2">
|
||||
<Bell size={18} className="text-emerald-700" /> 푸시알림 게시판
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 <b>수신자 그룹</b>을 선택하고, 가운데 추가/제거로 멤버를 관리한 뒤, 아래쪽에서 내용을 작성해 [발송]을 누르세요. 발송 이력은 좌측 메뉴 <b>푸시알림 발송이력</b>에서 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* ===== 상단: 좌측 그룹 목록 + 우측 멤버 양쪽 패널 ===== */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-3">
|
||||
{/* 좌측: 수신자 그룹 목록 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
|
||||
<div className="text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
|
||||
<Shield size={14} className="text-emerald-700" /> 수신자 그룹 ({groups.length})
|
||||
</div>
|
||||
<button onClick={onCreate} className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-600 text-white text-[11px] font-bold hover:bg-emerald-700">
|
||||
<span>+ 생성</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<input value={groupQuery} onChange={(e) => setGroupQuery(e.target.value)}
|
||||
placeholder="검색..." className="w-full h-8 px-2 text-xs border border-slate-200 rounded" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto max-h-[40vh] lg:max-h-[calc(100vh-540px)] divide-y divide-slate-100">
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="p-6 text-center text-xs text-slate-400">그룹이 없습니다.</div>
|
||||
) : filteredGroups.map((g) => (
|
||||
<button
|
||||
key={g.OBJID}
|
||||
onClick={() => setActiveGroup(g)}
|
||||
onDoubleClick={() => onRename(g)}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-slate-50 transition-colors ${
|
||||
activeGroup?.OBJID === g.OBJID ? "bg-emerald-50/70 border-l-4 border-l-emerald-600" : ""
|
||||
}`}
|
||||
title="더블클릭: 수정/삭제"
|
||||
>
|
||||
<div className="text-sm font-bold text-slate-800 truncate">{g.NAME}</div>
|
||||
<div className="text-[10px] text-slate-500 truncate">
|
||||
{g.MEMBER_CNT}명{g.DESCRIPTION ? ` · ${g.DESCRIPTION}` : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 멤버 양쪽 패널 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-3">
|
||||
{/* 그룹 멤버 (= 권한있는 직원) */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
|
||||
<Users size={14} className="text-emerald-700" /> 그룹 멤버 ({members.length})
|
||||
</div>
|
||||
<div className="p-2 flex items-center gap-2 border-b border-slate-100">
|
||||
<label className="text-xs inline-flex items-center gap-1.5">
|
||||
<input type="checkbox"
|
||||
checked={filteredMembers.length > 0 && filteredMembers.every((u) => chkMember.has(u.USER_ID))}
|
||||
onChange={(e) => setChkMember(e.target.checked ? new Set(filteredMembers.map((u) => u.USER_ID)) : new Set())}
|
||||
className="w-4 h-4 accent-emerald-600" /> 전체선택
|
||||
</label>
|
||||
<input value={memberQ} onChange={(e) => setMemberQ(e.target.value)}
|
||||
placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto max-h-[40vh]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 sticky top-0">
|
||||
<tr><th className="w-8 p-1.5"></th><th className="text-left p-1.5">부서</th><th className="text-left p-1.5">이름</th><th className="text-left p-1.5">ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMembers.map((u) => (
|
||||
<tr key={u.USER_ID} className="border-t hover:bg-slate-50">
|
||||
<td className="text-center p-1.5">
|
||||
<input type="checkbox" checked={chkMember.has(u.USER_ID)}
|
||||
onChange={(e) => { const s = new Set(chkMember); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkMember(s); }}
|
||||
className="w-4 h-4 accent-emerald-600" />
|
||||
</td>
|
||||
<td className="p-1.5">{u.DEPT_NAME || "-"}</td>
|
||||
<td className="p-1.5 font-semibold">
|
||||
{u.USER_NAME}
|
||||
{u.IS_ADMIN && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold">관리자</span>}
|
||||
{u.SUBSCRIBED && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold">구독중</span>}
|
||||
</td>
|
||||
<td className="p-1.5 text-slate-400 font-mono">{u.USER_ID}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredMembers.length === 0 && (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-slate-400">
|
||||
{activeGroup ? "멤버가 없습니다. 오른쪽 풀에서 추가하세요." : "왼쪽에서 수신자 그룹을 선택하세요"}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가/제거 버튼 */}
|
||||
<div className="flex flex-row lg:flex-col items-center justify-center gap-3 px-2 lg:px-4 min-w-[120px]">
|
||||
<button type="button" onClick={addSelected} disabled={!activeGroup}
|
||||
className="h-12 w-full lg:w-32 rounded-lg bg-emerald-600 text-white text-sm font-bold hover:bg-emerald-700 active:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed shadow-sm transition-colors">
|
||||
‹ 추가
|
||||
</button>
|
||||
<button type="button" onClick={removeSelected} disabled={!activeGroup}
|
||||
className="h-12 w-full lg:w-32 rounded-lg border border-slate-300 bg-white text-slate-700 text-sm font-bold hover:bg-rose-50 hover:border-rose-300 hover:text-rose-700 active:bg-rose-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
|
||||
제거 ›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 전체 사용자 풀 (= 권한없는 직원) */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-slate-100 text-sm font-bold text-slate-700 inline-flex items-center gap-1.5">
|
||||
<Users size={14} className="text-slate-400" /> 멤버 아닌 사용자 ({available.length})
|
||||
</div>
|
||||
<div className="p-2 flex items-center gap-2 border-b border-slate-100">
|
||||
<label className="text-xs inline-flex items-center gap-1.5">
|
||||
<input type="checkbox"
|
||||
checked={filteredAvail.length > 0 && filteredAvail.every((u) => chkAvail.has(u.USER_ID))}
|
||||
onChange={(e) => setChkAvail(e.target.checked ? new Set(filteredAvail.map((u) => u.USER_ID)) : new Set())}
|
||||
className="w-4 h-4 accent-emerald-600" /> 전체선택
|
||||
</label>
|
||||
<input value={availQ} onChange={(e) => setAvailQ(e.target.value)}
|
||||
placeholder="검색" className="ml-auto h-7 w-40 px-2 text-xs border border-slate-200 rounded" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto max-h-[40vh]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 sticky top-0">
|
||||
<tr><th className="w-8 p-1.5"></th><th className="text-left p-1.5">부서</th><th className="text-left p-1.5">이름</th><th className="text-left p-1.5">ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAvail.map((u) => (
|
||||
<tr key={u.USER_ID} className="border-t hover:bg-slate-50">
|
||||
<td className="text-center p-1.5">
|
||||
<input type="checkbox" checked={chkAvail.has(u.USER_ID)}
|
||||
onChange={(e) => { const s = new Set(chkAvail); if (e.target.checked) s.add(u.USER_ID); else s.delete(u.USER_ID); setChkAvail(s); }}
|
||||
className="w-4 h-4 accent-emerald-600" />
|
||||
</td>
|
||||
<td className="p-1.5">{u.DEPT_NAME || "-"}</td>
|
||||
<td className="p-1.5 font-semibold">
|
||||
{u.USER_NAME}
|
||||
{u.IS_ADMIN && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-bold">관리자</span>}
|
||||
{u.SUBSCRIBED && <span className="ml-1 text-[9px] px-1 py-0.5 rounded bg-emerald-100 text-emerald-700 font-bold">구독중</span>}
|
||||
</td>
|
||||
<td className="p-1.5 text-slate-400 font-mono">{u.USER_ID}</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredAvail.length === 0 && (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-slate-400">
|
||||
{activeGroup ? "추가 가능한 사용자가 없습니다." : "왼쪽에서 수신자 그룹을 선택하세요"}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 하단: 컨텐츠 작성 + 발송 ===== */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-sm font-bold text-slate-700 inline-flex items-center gap-1.5 mb-3">
|
||||
<Send size={14} className="text-emerald-700" /> 발송 내용
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">제목 *</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 오늘의 특가" maxLength={60}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">푸시 알림의 제목으로 표시 (60자 이내)</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">본문</label>
|
||||
<div className="mt-1">
|
||||
<RichEditor
|
||||
value={bodyText}
|
||||
onChange={setBodyText}
|
||||
placeholder="알림 내용 + 공지 페이지 본문이 됩니다. 이미지는 그냥 복사해서 붙여넣으세요 (Ctrl+V)."
|
||||
minHeight="200px"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">푸시 본문에는 앞 240자(텍스트만)가 노출. 전체 서식·이미지는 공지 페이지에서 확인.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">이미지 첨부 (선택)</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<label className="inline-flex items-center gap-1 h-9 px-3 rounded-lg border border-slate-300 bg-white text-xs font-semibold cursor-pointer hover:bg-slate-50">
|
||||
<Upload size={14} /> 파일 선택
|
||||
<input type="file" accept="image/*" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(f); }} />
|
||||
</label>
|
||||
{imageUrl && (
|
||||
<button type="button" onClick={() => setImageUrl("")} className="text-rose-500 hover:text-rose-700" title="이미지 제거"><X size={14} /></button>
|
||||
)}
|
||||
{uploading && <span className="text-xs text-slate-400">업로드 중…</span>}
|
||||
</div>
|
||||
{imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imageUrl} alt="첨부" className="mt-2 max-h-56 rounded border border-slate-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">외부 링크 (선택)</label>
|
||||
<input value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://… (입력 시 푸시 탭하면 해당 URL 로 이동)"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm mt-1" />
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">비워두면 자체 공지 페이지(이미지+본문)로 이동합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 pt-3 mt-3 border-t border-slate-100">
|
||||
<div className="flex items-center gap-3 text-xs text-slate-600">
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={sendAll} onChange={(e) => setSendAll(e.target.checked)} className="w-4 h-4 accent-emerald-600" />
|
||||
<span>전체 구독자에게 발송 (그룹 무시)</span>
|
||||
</label>
|
||||
{!sendAll && (
|
||||
<span>
|
||||
대상 그룹: <b className="text-emerald-700">{activeGroup?.NAME ?? "(선택 없음)"}</b>
|
||||
{activeGroup && <span className="ml-1 text-slate-500">· {targetUserIds.length}명</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={send} disabled={sending}
|
||||
className="inline-flex items-center gap-2 px-5 h-11 rounded-xl bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Send size={14} /> {sending ? "발송 중…" : sendAll ? "전체 구독자에게 발송" : `${targetCount < 0 ? "전체" : targetCount}명에게 발송`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { makeSwalDraggable } from "@/lib/swal-draggable";
|
||||
|
||||
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; PAID_AMOUNT: number }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
@@ -49,8 +50,8 @@ export default function PaymentsPage() {
|
||||
}
|
||||
}, [dateFrom, dateTo, keyword, payFilter]);
|
||||
|
||||
// 최초 1회만 자동 로드. 검색 조건 변경은 [조회] 버튼으로
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
// 검색 조건 변경 시 즉시 갱신
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onPay = async (o: Order) => {
|
||||
const remain = Number(o.TOTAL_AMOUNT) - Number(o.PAID_AMOUNT || 0);
|
||||
@@ -58,6 +59,7 @@ export default function PaymentsPage() {
|
||||
title: `${o.COMPANY_NAME} 입금 등록`,
|
||||
html: `미입금 ₩${fmt(remain)}`, input: "number", inputValue: remain,
|
||||
showCancelButton: true, confirmButtonText: "입금 처리", confirmButtonColor: "#0f766e",
|
||||
didOpen: () => makeSwalDraggable(),
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
const amt = Number(r.value);
|
||||
@@ -153,12 +155,16 @@ export default function PaymentsPage() {
|
||||
const paid = o.STATUS === "PAID" || o.STATUS === "INVOICED";
|
||||
return (
|
||||
<div key={o.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{o.ORDER_NO}</div>
|
||||
<div className="text-[11px] text-slate-500">{o.ORDER_DATE} · {o.COMPANY_NAME}</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold text-base truncate">{o.COMPANY_NAME}</div>
|
||||
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
|
||||
<span>{o.ORDER_DATE}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="font-semibold text-slate-600">{o.ORDER_NO}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${paid ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
<span className={`shrink-0 px-2 py-0.5 rounded text-[10px] font-semibold ${paid ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</div>
|
||||
@@ -192,14 +198,14 @@ export default function PaymentsPage() {
|
||||
<table className="w-full text-sm min-w-[800px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">발주번호</th>
|
||||
<th className="text-left px-4 py-3">발주일</th>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-right px-4 py-3">입금액</th>
|
||||
<th className="text-right px-4 py-3">미수금</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
<th className="text-right px-4 py-3"></th>
|
||||
<th className="text-left px-4 py-1.5">발주번호</th>
|
||||
<th className="text-left px-4 py-1.5">발주일</th>
|
||||
<th className="text-left px-4 py-1.5">업체명</th>
|
||||
<th className="text-right px-4 py-1.5">합계</th>
|
||||
<th className="text-right px-4 py-1.5">입금액</th>
|
||||
<th className="text-right px-4 py-1.5">미수금</th>
|
||||
<th className="text-center px-4 py-1.5">상태</th>
|
||||
<th className="text-right px-4 py-1.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -209,18 +215,18 @@ export default function PaymentsPage() {
|
||||
const remain = Number(o.TOTAL_AMOUNT) - Number(o.PAID_AMOUNT || 0);
|
||||
return (
|
||||
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-3">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-emerald-700">₩{fmt(o.PAID_AMOUNT || 0)}</td>
|
||||
<td className={`px-4 py-3 text-right tabular-nums font-bold ${remain > 0 ? "text-rose-700" : "text-slate-400"}`}>₩{fmt(remain)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<td className="px-3 py-1.5 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-1.5">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-1.5">{o.COMPANY_NAME}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-emerald-700">₩{fmt(o.PAID_AMOUNT || 0)}</td>
|
||||
<td className={`px-3 py-1.5 text-right tabular-nums font-bold ${remain > 0 ? "text-rose-700" : "text-slate-400"}`}>₩{fmt(remain)}</td>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "PAID" || o.STATUS === "INVOICED" ? "bg-emerald-100 text-emerald-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<td className="px-3 py-1.5 text-right">
|
||||
{o.STATUS !== "INVOICED" && (
|
||||
<button onClick={() => onPay(o)} className="text-xs px-3 h-8 rounded-md bg-emerald-700 text-white hover:bg-emerald-800 font-semibold">
|
||||
입금 등록
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
import { makeSwalDraggable } from "@/lib/swal-draggable";
|
||||
|
||||
interface Proc {
|
||||
OBJID: string;
|
||||
PROC_NO: string;
|
||||
PROC_DATE: string;
|
||||
VENDOR_OBJID: string;
|
||||
VENDOR_NAME: string;
|
||||
VENDOR_CONTACT: string;
|
||||
TOTAL_AMOUNT: number;
|
||||
TOTAL_QTY: number;
|
||||
RECEIVED_QTY: number;
|
||||
RECEIVED_AMOUNT: number;
|
||||
PENDING_AMOUNT: number;
|
||||
STATUS: string;
|
||||
IS_PAID: boolean;
|
||||
PAYMENT_TERMS: string | null;
|
||||
PAID_DATE: string | null;
|
||||
PAID_AMOUNT: number | null;
|
||||
PAID_METHOD: string | null;
|
||||
PAID_MEMO: string | null;
|
||||
}
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
|
||||
const fmt = (n: number | string | null | undefined) => Number(n || 0).toLocaleString("ko-KR");
|
||||
// 진행상태 (입금과 무관)
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
OPEN: "bg-slate-100 text-slate-600",
|
||||
REQUESTED: "bg-amber-100 text-amber-700",
|
||||
PARTIAL: "bg-sky-100 text-sky-700",
|
||||
RECEIVED: "bg-blue-100 text-blue-700",
|
||||
CANCELLED: "bg-rose-100 text-rose-600",
|
||||
};
|
||||
|
||||
function defaultRange() {
|
||||
const e = new Date(), s = new Date();
|
||||
s.setDate(s.getDate() - 60);
|
||||
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
|
||||
}
|
||||
|
||||
export default function ProcPaymentsPage() {
|
||||
const [list, setList] = useState<Proc[]>([]);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [vendorFilter, setVendorFilter] = useState("");
|
||||
const [payFilter, setPayFilter] = useState(""); // "" | PAID | UNPAID
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/m/admin/proc-payments/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dateFrom: from || undefined,
|
||||
dateTo: to || undefined,
|
||||
vendorObjid: vendorFilter || undefined,
|
||||
payStatus: payFilter || undefined,
|
||||
}),
|
||||
});
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
}, [from, to, vendorFilter, payFilter]);
|
||||
|
||||
const loadVendors = useCallback(async () => {
|
||||
const res = await fetch("/api/m/vendors/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
setVendors((await res.json()).RESULTLIST ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadVendors(); }, [loadVendors]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let requested = 0, requestedAmt = 0, paid = 0, paidAmt = 0;
|
||||
for (const p of list) {
|
||||
if (p.IS_PAID) { paid++; paidAmt += Number(p.PAID_AMOUNT || p.TOTAL_AMOUNT) || 0; }
|
||||
else { requested++; requestedAmt += Number(p.TOTAL_AMOUNT) || 0; }
|
||||
}
|
||||
return { requested, requestedAmt, paid, paidAmt };
|
||||
}, [list]);
|
||||
|
||||
const onPay = async (p: Proc) => {
|
||||
// 입금 금액 기본값 = 실제 입고된 만큼(입고금액). 부분입고면 미입고분 만큼 줄어든 금액이 제안됨.
|
||||
// 입고 전(RECEIVED_AMOUNT=0) 이면 발주금액으로 폴백.
|
||||
const suggested = Number(p.RECEIVED_AMOUNT) > 0 ? Number(p.RECEIVED_AMOUNT) : Number(p.TOTAL_AMOUNT);
|
||||
const result = await Swal.fire({
|
||||
title: "입금 처리",
|
||||
html: `
|
||||
<div class="text-left text-sm space-y-1">
|
||||
<div><b>발주번호</b> ${p.PROC_NO}</div>
|
||||
<div><b>공급업체</b> ${p.VENDOR_NAME ?? "-"}</div>
|
||||
<div><b>발주</b> ₩${fmt(p.TOTAL_AMOUNT)} <span class="text-slate-400">(${fmt(p.TOTAL_QTY)}개)</span></div>
|
||||
<div><b>입고</b> <span class="text-emerald-700">₩${fmt(p.RECEIVED_AMOUNT)}</span> <span class="text-slate-400">(${fmt(p.RECEIVED_QTY)}개)</span></div>
|
||||
<div><b>미입고</b> <span class="text-rose-600">₩${fmt(p.PENDING_AMOUNT)}</span></div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2 text-left">
|
||||
<input id="sw-amount" class="swal2-input" placeholder="입금 금액 (기본=입고금액)" value="${suggested}" type="number" />
|
||||
<input id="sw-method" class="swal2-input" placeholder="입금 방법 (예: 계좌이체)" />
|
||||
<input id="sw-memo" class="swal2-input" placeholder="메모 (선택)" />
|
||||
</div>
|
||||
`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "입금 완료",
|
||||
confirmButtonColor: "#0f766e",
|
||||
cancelButtonText: "취소",
|
||||
focusConfirm: false,
|
||||
didOpen: () => makeSwalDraggable(),
|
||||
preConfirm: () => {
|
||||
const a = (document.getElementById("sw-amount") as HTMLInputElement)?.value;
|
||||
const m = (document.getElementById("sw-method") as HTMLInputElement)?.value;
|
||||
const memo = (document.getElementById("sw-memo") as HTMLInputElement)?.value;
|
||||
return { amount: Number(a) || suggested, method: m, memo };
|
||||
},
|
||||
});
|
||||
if (!result.isConfirmed || !result.value) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/proc-payments/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: p.OBJID, ...result.value }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "입금 처리 완료", timer: 1200, showConfirmButton: false });
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "처리 실패", text: j.message });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 입금완료(PAID) 건 수정 — 입금액/입금일/방법/메모 수정 또는 입금 취소
|
||||
const onEdit = async (p: Proc) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const result = await Swal.fire({
|
||||
title: "입금 정보 수정",
|
||||
html: `
|
||||
<div class="text-left text-sm space-y-1">
|
||||
<div><b>발주번호</b> ${p.PROC_NO}</div>
|
||||
<div><b>공급업체</b> ${p.VENDOR_NAME ?? "-"}</div>
|
||||
<div><b>발주</b> ₩${fmt(p.TOTAL_AMOUNT)} <span class="text-slate-400">(${fmt(p.TOTAL_QTY)}개)</span></div>
|
||||
<div><b>입고</b> <span class="text-emerald-700">₩${fmt(p.RECEIVED_AMOUNT)}</span> <span class="text-slate-400">(${fmt(p.RECEIVED_QTY)}개)</span></div>
|
||||
<div><b>미입고</b> <span class="text-rose-600">₩${fmt(p.PENDING_AMOUNT)}</span></div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2 text-left">
|
||||
<label class="text-xs text-slate-500">입금일</label>
|
||||
<input id="sw-date" class="swal2-input" type="date" value="${p.PAID_DATE || today}" />
|
||||
<label class="text-xs text-slate-500">입금 금액 (권장=입고금액)</label>
|
||||
<input id="sw-amount" class="swal2-input" type="number" value="${p.PAID_AMOUNT ?? (Number(p.RECEIVED_AMOUNT) > 0 ? Number(p.RECEIVED_AMOUNT) : Number(p.TOTAL_AMOUNT))}" />
|
||||
<input id="sw-method" class="swal2-input" placeholder="입금 방법 (예: 계좌이체)" value="${p.PAID_METHOD ?? ""}" />
|
||||
<input id="sw-memo" class="swal2-input" placeholder="메모 (선택)" value="${p.PAID_MEMO ?? ""}" />
|
||||
</div>
|
||||
`,
|
||||
showCancelButton: true,
|
||||
showDenyButton: true,
|
||||
confirmButtonText: "저장",
|
||||
denyButtonText: "입금 취소",
|
||||
cancelButtonText: "닫기",
|
||||
confirmButtonColor: "#0f766e",
|
||||
denyButtonColor: "#dc2626",
|
||||
focusConfirm: false,
|
||||
didOpen: () => makeSwalDraggable(),
|
||||
preConfirm: () => ({
|
||||
paidDate: (document.getElementById("sw-date") as HTMLInputElement)?.value || undefined,
|
||||
amount: Number((document.getElementById("sw-amount") as HTMLInputElement)?.value) || Number(p.TOTAL_AMOUNT),
|
||||
method: (document.getElementById("sw-method") as HTMLInputElement)?.value,
|
||||
memo: (document.getElementById("sw-memo") as HTMLInputElement)?.value,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.isConfirmed && result.value) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/proc-payments/update", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: p.OBJID, action: "edit", ...result.value }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) { await Swal.fire({ icon: "success", title: "수정 완료", timer: 1200, showConfirmButton: false }); load(); }
|
||||
else Swal.fire({ icon: "error", title: "수정 실패", text: j.message });
|
||||
} finally { setBusy(false); }
|
||||
} else if (result.isDenied) {
|
||||
const ok = await Swal.fire({
|
||||
icon: "warning", title: "입금을 취소하시겠습니까?",
|
||||
text: "입금완료를 해제하고 입금 정보를 지웁니다. (입고 진행 상태로 되돌아갑니다)",
|
||||
showCancelButton: true, confirmButtonText: "입금 취소", cancelButtonText: "닫기",
|
||||
confirmButtonColor: "#dc2626",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/proc-payments/update", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: p.OBJID, action: "cancel" }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) { await Swal.fire({ icon: "success", title: "입금 취소됨", timer: 1200, showConfirmButton: false }); load(); }
|
||||
else Swal.fire({ icon: "error", title: "취소 실패", text: j.message });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{busy && <Loading message="저장 중..." />}
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">매입 입금관리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">발주 요청된 매입에 대해 입금 처리합니다. 입금 완료된 건은 입고 처리 메뉴에서 입고 등록할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 조회조건 — 입력 즉시 반영, 모바일에서도 한 줄 (가로 스크롤) */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-2 flex gap-1.5 items-center flex-nowrap overflow-x-auto text-xs max-w-full">
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
|
||||
className="h-8 px-2 rounded border border-slate-200 shrink-0 w-[130px]" />
|
||||
<span className="text-slate-400 shrink-0">~</span>
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
|
||||
className="h-8 px-2 rounded border border-slate-200 shrink-0 w-[130px]" />
|
||||
<div className="shrink-0 w-[150px]">
|
||||
<SearchableSelect
|
||||
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
|
||||
value={vendorFilter}
|
||||
onChange={setVendorFilter}
|
||||
placeholder="공급업체"
|
||||
/>
|
||||
</div>
|
||||
<select value={payFilter} onChange={(e) => setPayFilter(e.target.value)}
|
||||
className="h-8 px-2 rounded border border-slate-200 bg-white shrink-0">
|
||||
<option value="">결재 전체</option>
|
||||
<option value="UNPAID">미입금</option>
|
||||
<option value="PAID">입금완료</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 합계 카드 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="text-[11px] text-amber-700 font-semibold mb-1.5">미입금 ({summary.requested}건)</div>
|
||||
<div className="text-lg font-bold tabular-nums text-amber-700">₩{fmt(summary.requestedAmt)}</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
|
||||
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5">입금완료 ({summary.paid}건)</div>
|
||||
<div className="text-lg font-bold tabular-nums text-emerald-700">₩{fmt(summary.paidAmt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일: 카드 */}
|
||||
<div className="space-y-2 sm:hidden">
|
||||
{list.length === 0 ? (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 text-center text-slate-400 text-sm">발주가 없습니다.</div>
|
||||
) : list.map((p) => (
|
||||
<div key={p.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-bold text-base truncate">{p.VENDOR_NAME ?? "-"}</div>
|
||||
<div className="text-[11px] text-slate-500 flex items-center gap-1.5">
|
||||
<span>{p.PROC_DATE}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="font-semibold text-slate-600">{p.PROC_NO}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
|
||||
{STATUS_LABEL[p.STATUS] || p.STATUS}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
|
||||
{p.IS_PAID ? "입금완료" : "미입금"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] py-1.5 mb-2 border-t border-slate-100">
|
||||
<div>
|
||||
<div className="text-slate-400">발주</div>
|
||||
<div className="font-bold tabular-nums">₩{fmt(p.TOTAL_AMOUNT)}</div>
|
||||
<div className="text-[10px] text-slate-400 tabular-nums">{fmt(p.TOTAL_QTY)}개</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-emerald-700/80">입고</div>
|
||||
<div className="font-bold text-emerald-700 tabular-nums">₩{fmt(p.RECEIVED_AMOUNT)}</div>
|
||||
<div className="text-[10px] text-emerald-700/70 tabular-nums">{fmt(p.RECEIVED_QTY)}개</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-rose-600/80">미입고</div>
|
||||
<div className={`font-bold tabular-nums ${Number(p.PENDING_AMOUNT) > 0 ? "text-rose-600" : "text-slate-300"}`}>₩{fmt(p.PENDING_AMOUNT)}</div>
|
||||
<div className="text-[10px] text-rose-600/70 tabular-nums">{fmt(Number(p.TOTAL_QTY) - Number(p.RECEIVED_QTY))}개</div>
|
||||
</div>
|
||||
</div>
|
||||
{!p.IS_PAID ? (
|
||||
<button onClick={() => onPay(p)} disabled={busy}
|
||||
className="w-full h-8 rounded bg-emerald-700 text-white text-xs font-bold disabled:opacity-50">입금 처리</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-emerald-700 min-w-0 truncate">
|
||||
입금일 {p.PAID_DATE} · ₩{fmt(p.PAID_AMOUNT)} {p.PAID_METHOD && `· ${p.PAID_METHOD}`}
|
||||
</div>
|
||||
<button onClick={() => onEdit(p)} disabled={busy}
|
||||
className="shrink-0 h-7 px-2 rounded border border-slate-300 bg-white text-slate-600 text-[11px] font-bold disabled:opacity-50">수정</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데스크탑: 표 */}
|
||||
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-1.5">발주번호</th>
|
||||
<th className="text-left px-3 py-1.5">발주일</th>
|
||||
<th className="text-left px-3 py-1.5">공급업체</th>
|
||||
<th className="text-right px-3 py-1.5">발주</th>
|
||||
<th className="text-right px-3 py-1.5 text-emerald-700">입고</th>
|
||||
<th className="text-right px-3 py-1.5 text-rose-600">미입고</th>
|
||||
<th className="text-center px-3 py-1.5">진행상태</th>
|
||||
<th className="text-center px-3 py-1.5">결재상태</th>
|
||||
<th className="text-left px-3 py-1.5">입금일</th>
|
||||
<th className="text-right px-3 py-1.5">입금액</th>
|
||||
<th className="text-center px-3 py-2.5 w-[120px]">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={11} className="text-center py-10 text-slate-400">발주가 없습니다.</td></tr>
|
||||
) : list.map((p) => (
|
||||
<tr key={p.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2.5 font-semibold">{p.PROC_NO}</td>
|
||||
<td className="px-3 py-1.5">{p.PROC_DATE}</td>
|
||||
<td className="px-3 py-1.5">{p.VENDOR_NAME ?? "-"}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
<div className="font-bold text-slate-800 text-sm">₩{fmt(p.TOTAL_AMOUNT)}</div>
|
||||
<div className="text-[11px] text-slate-400">{fmt(p.TOTAL_QTY)}개</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
<div className="font-bold text-emerald-700 text-sm">₩{fmt(p.RECEIVED_AMOUNT)}</div>
|
||||
<div className="text-[11px] text-emerald-700/70">{fmt(p.RECEIVED_QTY)}개</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
<div className={`font-bold text-sm ${Number(p.PENDING_AMOUNT) > 0 ? "text-rose-600" : "text-slate-300"}`}>₩{fmt(p.PENDING_AMOUNT)}</div>
|
||||
<div className={`text-[11px] ${Number(p.PENDING_AMOUNT) > 0 ? "text-rose-600/70" : "text-slate-300"}`}>{fmt(Number(p.TOTAL_QTY) - Number(p.RECEIVED_QTY))}개</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold ${STATUS_COLOR[p.STATUS] ?? "bg-slate-100 text-slate-500"}`}>
|
||||
{STATUS_LABEL[p.STATUS] || p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
|
||||
{p.IS_PAID ? "입금완료" : "미입금"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[11px] text-slate-600">{p.PAID_DATE || "-"}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-emerald-700">{p.PAID_AMOUNT ? `₩${fmt(p.PAID_AMOUNT)}` : "-"}</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{!p.IS_PAID ? (
|
||||
<button onClick={() => onPay(p)} disabled={busy}
|
||||
className="h-7 px-2 rounded bg-emerald-700 text-white text-[11px] font-bold disabled:opacity-50">
|
||||
입금 처리
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => onEdit(p)} disabled={busy}
|
||||
className="h-7 px-2 rounded border border-slate-300 bg-white text-slate-600 text-[11px] font-bold hover:bg-slate-50 disabled:opacity-50">
|
||||
수정
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2, Plus } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Vendor { OBJID: string; VENDOR_NAME: string }
|
||||
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; COST_PRICE: number }
|
||||
@@ -58,10 +59,14 @@ export default function NewProcPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">공급업체 *</label>
|
||||
<select value={vendorObjid} onChange={(e) => setVendorObjid(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 mt-1">
|
||||
<option value="">선택</option>
|
||||
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
<div className="mt-1">
|
||||
<SearchableSelect
|
||||
value={vendorObjid}
|
||||
onChange={setVendorObjid}
|
||||
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
|
||||
placeholder="공급업체 검색/선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-600">발주일</label>
|
||||
@@ -73,10 +78,14 @@ export default function NewProcPage() {
|
||||
<div className="bg-white border rounded-xl p-5">
|
||||
<h3 className="font-bold mb-3">발주 라인</h3>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="flex-1 h-10 px-3 rounded-lg border border-slate-200">
|
||||
<option value="">품목 선택</option>
|
||||
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||
</select>
|
||||
<div className="flex-1">
|
||||
<SearchableSelect
|
||||
options={items.map((i) => ({ value: i.OBJID, label: i.ITEM_NAME }))}
|
||||
value={pickItem}
|
||||
onChange={setPickItem}
|
||||
placeholder="품목 선택"
|
||||
/>
|
||||
</div>
|
||||
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} placeholder="수량" className="w-24 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<input type="number" min={0} value={pickPrice} onChange={(e) => setPickPrice(Number(e.target.value))} placeholder="단가" className="w-32 h-10 px-3 rounded-lg border border-slate-200" />
|
||||
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-bold flex items-center gap-1"><Plus size={14} />추가</button>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Plus, Send, Search, RefreshCcw, X, Download, Image as ImageIcon } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
import { captureAndShare } from "@/lib/capture-share";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface ProcRow {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string;
|
||||
VENDOR_OBJID: string | null; VENDOR_NAME: string | null;
|
||||
STATUS: string; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
|
||||
STATUS: string; IS_PAID?: boolean; TOTAL_AMOUNT: number; LINE_CNT: number; MEMO?: string;
|
||||
}
|
||||
interface ProcDetail {
|
||||
OBJID: string; PROC_NO: string; PROC_DATE: string; STATUS: string;
|
||||
@@ -18,7 +19,9 @@ interface ProcDetail {
|
||||
DELIVERY_PERIOD?: string;
|
||||
PAYMENT_TERMS?: string;
|
||||
FREIGHT_TERMS?: string;
|
||||
BRANCH?: string;
|
||||
}
|
||||
interface StatementBranch { CODE: string; NAME: string; IS_DEFAULT: string; SORT_ORDER: number }
|
||||
interface ProcLine {
|
||||
OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
UNIT: string; QTY: number; COST_PRICE: number; TOTAL_AMOUNT: number;
|
||||
@@ -32,41 +35,64 @@ interface Item {
|
||||
}
|
||||
|
||||
const fmt = (n: number | string | undefined | null) => Number(n || 0).toLocaleString("ko-KR");
|
||||
// 진행상태 (결재/입금과 무관) — 입금완료는 별도 결재 배지로 표시
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
OPEN: "작성중", REQUESTED: "발주요청", RECEIVED: "입고완료", CANCELLED: "취소",
|
||||
OPEN: "작성중", REQUESTED: "발주요청", PARTIAL: "부분입고", RECEIVED: "입고완료", CANCELLED: "취소",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
OPEN: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
PARTIAL: "bg-sky-100 text-sky-700 border-sky-200",
|
||||
RECEIVED: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
CANCELLED: "bg-rose-100 text-rose-600 border-rose-200",
|
||||
CANCELED: "bg-rose-100 text-rose-600 border-rose-200",
|
||||
};
|
||||
|
||||
// 기본 한 달 전 ~ 오늘
|
||||
function defaultRange() {
|
||||
const e = new Date(), s = new Date();
|
||||
s.setDate(s.getDate() - 30);
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return [fmt(s), fmt(e)];
|
||||
}
|
||||
|
||||
export default function ProcurementsPage() {
|
||||
const [list, setList] = useState<ProcRow[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [vendorFilter, setVendorFilter] = useState("");
|
||||
const [[dateFrom, dateTo], setRange] = useState(defaultRange());
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [branches, setBranches] = useState<StatementBranch[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch("/api/m/procurements/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: statusFilter || undefined }),
|
||||
body: JSON.stringify({
|
||||
status: statusFilter || undefined,
|
||||
vendorObjid: vendorFilter || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
const rows: ProcRow[] = j.RESULTLIST ?? [];
|
||||
setList(rows);
|
||||
if (rows.length && !rows.some((r) => r.OBJID === activeId)) setActiveId(rows[0].OBJID);
|
||||
if (!rows.length) { setActiveId(""); setDetail(null); }
|
||||
}, [statusFilter, activeId]);
|
||||
}, [statusFilter, vendorFilter, dateFrom, dateTo, activeId]);
|
||||
|
||||
const loadVendors = async () => {
|
||||
const r = await fetch("/api/m/vendors/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||
setVendors((await r.json()).RESULTLIST ?? []);
|
||||
};
|
||||
const loadBranches = async () => {
|
||||
const r = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
|
||||
setBranches((await r.json()).RESULTLIST ?? []);
|
||||
};
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!activeId) { setDetail(null); return; }
|
||||
@@ -78,7 +104,7 @@ export default function ProcurementsPage() {
|
||||
if (j.success) setDetail({ proc: j.proc, items: j.items });
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => { loadVendors(); }, []);
|
||||
useEffect(() => { loadVendors(); loadBranches(); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadDetail(); }, [loadDetail]);
|
||||
|
||||
@@ -93,7 +119,7 @@ export default function ProcurementsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateHeader = async (patch: { vendorObjid?: string | null; memo?: string }) => {
|
||||
const updateHeader = async (patch: Record<string, unknown>) => {
|
||||
if (!detail) return;
|
||||
const res = await fetch("/api/m/procurements/update-header", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
@@ -156,6 +182,39 @@ export default function ProcurementsPage() {
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
// 작성중(OPEN) 발주서 삭제 — 라인 포함 hard delete
|
||||
const deleteProc = async () => {
|
||||
if (!detail) return;
|
||||
if (detail.proc.STATUS !== "OPEN") {
|
||||
Swal.fire({ icon: "warning", title: "작성중 상태만 삭제 가능합니다." });
|
||||
return;
|
||||
}
|
||||
const ok = await Swal.fire({
|
||||
icon: "warning",
|
||||
title: `발주서 ${detail.proc.PROC_NO} 를 삭제하시겠습니까?`,
|
||||
text: "작성중인 라인까지 모두 삭제됩니다.",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#dc2626",
|
||||
confirmButtonText: "삭제",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/procurements/delete", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: detail.proc.OBJID }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: "삭제되었습니다", timer: 1200, showConfirmButton: false });
|
||||
setActiveId("");
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
@@ -163,22 +222,32 @@ export default function ProcurementsPage() {
|
||||
<h1 className="text-xl font-bold">매입 발주서 관리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">왼쪽에서 발주서를 선택하면 오른쪽에 양식이 보여요. [+ 새 발주]로 작성, [발주 요청]으로 공급업체에 메일 발송.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1.5 items-center flex-nowrap overflow-x-auto pb-1 max-w-full">
|
||||
<input type="date" value={dateFrom} onChange={(e) => setRange([e.target.value, dateTo])}
|
||||
className="h-8 px-2 rounded border border-slate-300 text-xs shrink-0 w-[130px]" />
|
||||
<span className="text-slate-400 text-xs shrink-0">~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setRange([dateFrom, e.target.value])}
|
||||
className="h-8 px-2 rounded border border-slate-300 text-xs shrink-0 w-[130px]" />
|
||||
<div className="shrink-0 w-[150px]">
|
||||
<SearchableSelect
|
||||
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
|
||||
value={vendorFilter}
|
||||
onChange={setVendorFilter}
|
||||
placeholder="공급업체"
|
||||
/>
|
||||
</div>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-sm">
|
||||
className="h-8 px-2 rounded border border-slate-300 bg-white text-xs shrink-0">
|
||||
<option value="">전체 상태</option>
|
||||
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<button onClick={load} className="h-9 px-3 rounded bg-white border border-slate-300 text-sm font-semibold inline-flex items-center gap-1">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
<button onClick={createNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
|
||||
<Plus size={14} /> 새 발주
|
||||
<button onClick={createNew} className="h-8 px-2 rounded bg-emerald-700 text-white text-xs font-bold inline-flex items-center gap-1 hover:bg-emerald-800 shrink-0">
|
||||
<Plus size={12} /> 새 발주
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-3">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
|
||||
발주서 목록 ({list.length}건)
|
||||
@@ -191,12 +260,13 @@ export default function ProcurementsPage() {
|
||||
<th className="text-left px-2 py-2">일자</th>
|
||||
<th className="text-left px-2 py-2">공급업체</th>
|
||||
<th className="text-right px-2 py-2">금액</th>
|
||||
<th className="text-center px-2 py-2">상태</th>
|
||||
<th className="text-center px-2 py-2">진행</th>
|
||||
<th className="text-center px-2 py-2">결재</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-slate-400">발주서가 없습니다.</td></tr>
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">발주서가 없습니다.</td></tr>
|
||||
) : list.map((p) => (
|
||||
<tr key={p.OBJID}
|
||||
onClick={() => setActiveId(p.OBJID)}
|
||||
@@ -210,6 +280,11 @@ export default function ProcurementsPage() {
|
||||
{STATUS_LABEL[p.STATUS] ?? p.STATUS}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${p.IS_PAID ? "bg-emerald-100 text-emerald-700" : "bg-rose-100 text-rose-600"}`}>
|
||||
{p.IS_PAID ? "입금완료" : "미입금"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -221,10 +296,16 @@ export default function ProcurementsPage() {
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
||||
<span>발주서</span>
|
||||
{detail && detail.proc.STATUS === "OPEN" && (
|
||||
<button onClick={sendOrder} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Send size={12} /> 발주 요청
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={deleteProc} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded border border-rose-300 bg-white text-rose-700 text-xs font-bold hover:bg-rose-50 disabled:opacity-50">
|
||||
삭제
|
||||
</button>
|
||||
<button onClick={sendOrder} disabled={busy}
|
||||
className="inline-flex items-center gap-1 h-8 px-3 rounded bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Send size={12} /> 발주 요청
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{detail && detail.proc.STATUS === "REQUESTED" && (
|
||||
<span className="text-[11px] text-amber-700">발주 요청 완료 — 공급업체 응답 대기</span>
|
||||
@@ -237,7 +318,9 @@ export default function ProcurementsPage() {
|
||||
<ProcurementForm
|
||||
detail={detail}
|
||||
vendors={vendors}
|
||||
branches={branches}
|
||||
onSetVendor={(v) => updateHeader({ vendorObjid: v || null })}
|
||||
onSetBranch={(b) => updateHeader({ branch: b })}
|
||||
onSetMemo={(m) => updateHeader({ memo: m })}
|
||||
onSetTerm={(field, val) => updateHeader({ [field]: val })}
|
||||
onAddPicker={() => setPickerOpen(true)}
|
||||
@@ -277,17 +360,21 @@ export default function ProcurementsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: {
|
||||
function ProcurementForm({ detail, vendors, branches, onSetVendor, onSetBranch, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: {
|
||||
detail: { proc: ProcDetail; items: ProcLine[] };
|
||||
vendors: Vendor[];
|
||||
branches: StatementBranch[];
|
||||
onSetVendor: (id: string) => void;
|
||||
onSetBranch: (code: string) => void;
|
||||
onSetMemo: (m: string) => void;
|
||||
onSetTerm: (field: "deliveryPlace" | "deliveryPeriod" | "paymentTerms" | "freightTerms", val: string) => void;
|
||||
onAddPicker: () => void;
|
||||
onUpdateLine: (line: { objid?: string; itemObjid?: string; qty: number; costPrice: number }) => void;
|
||||
onDeleteLine: (objid: string) => void;
|
||||
}) {
|
||||
const editable = detail.proc.STATUS === "OPEN";
|
||||
// OPEN(작성중) / REQUESTED(발주요청) 만 수정 가능.
|
||||
// RECEIVED(입고완료) / PARTIAL(부분입고) / PAID(입금완료) / CANCELLED 는 수정 불가.
|
||||
const editable = ["OPEN", "REQUESTED"].includes(detail.proc.STATUS);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCapture = async () => {
|
||||
@@ -330,16 +417,16 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div ref={formRef} className="bg-white p-3">
|
||||
<div ref={formRef} className="bg-white p-3 font-bold [&_*]:font-bold">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold tracking-[0.4em] text-slate-900">발 주 서</h2>
|
||||
</div>
|
||||
|
||||
<table className="text-[11px] mt-3 border border-slate-400" style={{borderCollapse:'collapse',width:'auto'}}>
|
||||
<table className="text-[13px] mt-3 border border-slate-400" style={{borderCollapse:'collapse',width:'auto'}}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 w-[100px] text-center">분류번호</th>
|
||||
<td className="border border-slate-400 px-3 py-1 font-semibold w-[200px]">매입발주</td>
|
||||
<td className="border border-slate-400 px-3 py-1 font-semibold w-[260px]">매입발주</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">발주서번호</th>
|
||||
@@ -349,16 +436,40 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">발주일</th>
|
||||
<td className="border border-slate-400 px-3 py-1">{detail.proc.PROC_DATE}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">발주지사</th>
|
||||
<td className="border border-slate-400 px-3 py-1">
|
||||
{editable ? (
|
||||
<select
|
||||
value={detail.proc.BRANCH ?? branches.find((b) => b.IS_DEFAULT === "Y")?.CODE ?? "HQ"}
|
||||
onChange={(e) => onSetBranch(e.target.value)}
|
||||
className="h-7 px-2 rounded border border-slate-300 text-[13px] bg-white"
|
||||
>
|
||||
{branches.length === 0 ? (
|
||||
<option value="HQ">본사 (HQ)</option>
|
||||
) : branches.map((b) => (
|
||||
<option key={b.CODE} value={b.CODE}>{b.NAME} ({b.CODE}){b.IS_DEFAULT === "Y" ? " ★" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-semibold">
|
||||
{branches.find((b) => b.CODE === (detail.proc.BRANCH ?? "HQ"))?.NAME ?? (detail.proc.BRANCH ?? "본사")} 발주
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center">공급업체</th>
|
||||
<td className="border border-slate-400 px-3 py-1">
|
||||
{editable ? (
|
||||
<select value={detail.proc.VENDOR_OBJID ?? ""}
|
||||
onChange={(e) => onSetVendor(e.target.value)}
|
||||
className="h-7 px-2 rounded border border-slate-300 text-[11px] bg-white">
|
||||
<option value="">-- 공급업체 선택 --</option>
|
||||
{vendors.map((v) => <option key={v.OBJID} value={v.OBJID}>{v.VENDOR_NAME}</option>)}
|
||||
</select>
|
||||
<div className="max-w-[280px]">
|
||||
<SearchableSelect
|
||||
value={detail.proc.VENDOR_OBJID ?? ""}
|
||||
onChange={onSetVendor}
|
||||
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
|
||||
placeholder="공급업체 검색/선택"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-semibold">{detail.proc.VENDOR_NAME ?? "-"}</span>
|
||||
)}
|
||||
@@ -367,9 +478,9 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-3 font-semibold text-[12px]">1. 물품의 표시</p>
|
||||
<p className="mt-3 font-semibold text-[14px]">1. 물품의 표시</p>
|
||||
{editable && (
|
||||
<div className="flex gap-2 mb-2 text-[11px] flex-wrap">
|
||||
<div className="flex gap-2 mb-2 text-[13px] flex-wrap">
|
||||
<button onClick={onAddPicker}
|
||||
className="inline-flex items-center gap-1 h-7 px-3 rounded bg-emerald-100 text-emerald-800 font-bold hover:bg-emerald-200">
|
||||
<Plus size={12} /> 품목 추가
|
||||
@@ -377,11 +488,11 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
<span className="self-center text-slate-400">— 모달에서 공급업체 필터, 결과내 검색, 다중 선택 가능</span>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full text-[11px] border border-slate-400" style={{borderCollapse:'collapse'}}>
|
||||
<table className="w-full text-[13px] border border-slate-400" style={{borderCollapse:'collapse'}}>
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="border border-slate-400 px-1 py-1 w-8">#</th>
|
||||
<th className="border border-slate-400 px-2 py-1 text-left">품명</th>
|
||||
<th className="border border-slate-400 px-2 py-1 text-left w-[220px]">품명</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-12">단위</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-16">수량</th>
|
||||
<th className="border border-slate-400 px-1 py-1 w-20">단가</th>
|
||||
@@ -399,9 +510,9 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-right text-[11px] mt-1 text-slate-500">(V.A.T 별도, 단위: 원)</div>
|
||||
<div className="text-right text-[13px] mt-1 text-slate-500">(V.A.T 별도, 단위: 원)</div>
|
||||
|
||||
<table className="ml-auto text-[12px] tabular-nums mt-3">
|
||||
<table className="ml-auto text-[14px] tabular-nums mt-3">
|
||||
<tbody>
|
||||
<tr className="border-t-2 border-slate-900 font-bold">
|
||||
<td className="px-3 py-1.5">총액</td>
|
||||
@@ -410,14 +521,13 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-4 font-semibold text-[12px]">2. 납품조건</p>
|
||||
<ol className="text-[11px] mt-1 space-y-1 leading-relaxed list-decimal pl-5">
|
||||
<li>상기 품목의 납기 지연 시, 지연일수 매 1일에 대하여 미납 금액의 3/1000을 납품대금 지불 시 우선 공제한다.</li>
|
||||
<p className="mt-4 font-semibold text-[14px]">2. 납품조건</p>
|
||||
<ol className="text-[13px] mt-1 space-y-1 leading-relaxed list-decimal pl-5">
|
||||
<li>납품된 물품은 당사의 지정인에게 검수를 받아야 하며, 부적합품은 즉시 납품자의 비용으로 반출하여야 한다.</li>
|
||||
<li>상기 수량 및 규격은 당사의 사정에 의하여 변경될 수 있으며, 납품자는 이에 대하여 이의를 제기할 수 없다.</li>
|
||||
</ol>
|
||||
|
||||
<table className="text-[11px] mt-2 border border-slate-400 w-full" style={{borderCollapse:'collapse'}}>
|
||||
<table className="text-[13px] mt-2 border border-slate-400 w-full" style={{borderCollapse:'collapse'}}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center font-semibold w-[80px]">4) 납품장소</th>
|
||||
@@ -425,7 +535,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.DELIVERY_PLACE ?? ""}
|
||||
onBlur={(e) => onSetTerm("deliveryPlace", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.DELIVERY_PLACE || "-"}</span>
|
||||
)}
|
||||
@@ -435,7 +545,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.PAYMENT_TERMS ?? ""}
|
||||
onBlur={(e) => onSetTerm("paymentTerms", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.PAYMENT_TERMS || "-"}</span>
|
||||
)}
|
||||
@@ -447,7 +557,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.DELIVERY_PERIOD ?? ""}
|
||||
onBlur={(e) => onSetTerm("deliveryPeriod", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.DELIVERY_PERIOD || "-"}</span>
|
||||
)}
|
||||
@@ -457,7 +567,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
{editable ? (
|
||||
<input defaultValue={detail.proc.FREIGHT_TERMS ?? ""}
|
||||
onBlur={(e) => onSetTerm("freightTerms", e.target.value)}
|
||||
className="w-full h-7 px-2 text-[11px] outline-none border-0 bg-transparent" />
|
||||
className="w-full h-7 px-2 text-[13px] outline-none border-0 bg-transparent" />
|
||||
) : (
|
||||
<span>{detail.proc.FREIGHT_TERMS || "-"}</span>
|
||||
)}
|
||||
@@ -466,27 +576,27 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ol className="text-[11px] mt-2 space-y-1 leading-relaxed list-decimal pl-5" start={8}>
|
||||
<ol className="text-[13px] mt-2 space-y-1 leading-relaxed list-decimal pl-5" start={8}>
|
||||
<li>본 발주서는 납품자가 FAX 또는 기타 방법으로 접수 후 3일 이내에 이의를 제기치 않으면 제반 법적 효력을 발휘한다.</li>
|
||||
<li>상기 조항에 명시되지 아니한 사항은 상호 협의하고, 협의가 불가능할 경우에는 당사의 해석에 따른다.</li>
|
||||
</ol>
|
||||
|
||||
<p className="mt-4 font-semibold text-[12px]">3. 비고</p>
|
||||
<p className="mt-4 font-semibold text-[14px]">3. 비고</p>
|
||||
{editable ? (
|
||||
<textarea
|
||||
defaultValue={detail.proc.MEMO ?? ""}
|
||||
onBlur={(e) => onSetMemo(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="추가로 전달할 사항이 있으면 입력"
|
||||
className="w-full mt-1 px-3 py-2 rounded border border-slate-300 text-[11px] resize-y"
|
||||
className="w-full mt-1 px-3 py-2 rounded border border-slate-300 text-[13px] resize-y"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[11px] whitespace-pre-wrap min-h-[40px]">
|
||||
<div className="mt-1 px-3 py-2 rounded border border-slate-200 bg-slate-50 text-[13px] whitespace-pre-wrap min-h-[40px]">
|
||||
{detail.proc.MEMO || <span className="text-slate-400">없음</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-[11px] text-slate-600">
|
||||
<div className="mt-6 text-center text-[13px] text-slate-600">
|
||||
<p>상기와 같이 발주함.</p>
|
||||
<p className="mt-2">{detail.proc.PROC_DATE.replace(/-/g, ".") + "."}</p>
|
||||
<p className="mt-3 font-semibold">발주자 : {process.env.NEXT_PUBLIC_MOMO_NAME ?? "(주)모모유통"}</p>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Branch {
|
||||
CODE: string;
|
||||
NAME: string;
|
||||
BANK_ACCOUNT: string;
|
||||
PHONE: string | null;
|
||||
EMAIL: string | null;
|
||||
CEO_NAME: string | null;
|
||||
BIZ_NO: string | null;
|
||||
ADDRESS: string | null;
|
||||
IS_DEFAULT: string;
|
||||
SORT_ORDER: number;
|
||||
}
|
||||
|
||||
export default function StatementBranchesPage() {
|
||||
const [list, setList] = useState<Branch[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Branch> | null>(null);
|
||||
const [isNew, setIsNew] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
|
||||
setList((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const openNew = () => {
|
||||
setIsNew(true);
|
||||
setEditing({ CODE: "", NAME: "", BANK_ACCOUNT: "", IS_DEFAULT: "N", SORT_ORDER: list.length + 1 });
|
||||
};
|
||||
const openEdit = (b: Branch) => { setIsNew(false); setEditing({ ...b }); };
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
if (!editing.CODE || !editing.NAME || !editing.BANK_ACCOUNT) {
|
||||
Swal.fire({ icon: "warning", title: "코드/이름/계좌번호는 필수" });
|
||||
return;
|
||||
}
|
||||
const res = await fetch("/api/m/admin/statement-branches/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
actionType: isNew ? "regist" : "update",
|
||||
code: editing.CODE, name: editing.NAME, bankAccount: editing.BANK_ACCOUNT,
|
||||
phone: editing.PHONE ?? "", email: editing.EMAIL ?? "",
|
||||
ceoName: editing.CEO_NAME ?? "", bizNo: editing.BIZ_NO ?? "", address: editing.ADDRESS ?? "",
|
||||
isDefault: editing.IS_DEFAULT ?? "N", sortOrder: String(editing.SORT_ORDER ?? 0),
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: "저장 완료", timer: 1200, showConfirmButton: false });
|
||||
setEditing(null); load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||
}
|
||||
};
|
||||
|
||||
const del = async (b: Branch) => {
|
||||
if (b.IS_DEFAULT === "Y") {
|
||||
Swal.fire({ icon: "warning", title: "기본 명세표는 삭제 불가" });
|
||||
return;
|
||||
}
|
||||
const ok = await Swal.fire({ icon: "question", title: `${b.NAME} (${b.CODE}) 삭제?`, showCancelButton: true });
|
||||
if (!ok.isConfirmed) return;
|
||||
const res = await fetch("/api/m/admin/statement-branches/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actionType: "delete", code: b.CODE, name: b.NAME, bankAccount: b.BANK_ACCOUNT }),
|
||||
});
|
||||
if ((await res.json()).success) load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">기준 명세표 관리</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">거래명세표/계산서에 표시될 공급자(모모유통) 정보. 사용자별 기준 명세서 설정에서 어느 코드를 사용할지 선택.</p>
|
||||
</div>
|
||||
<button onClick={openNew} className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800">
|
||||
<Plus size={14} /> 신규
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">코드</th>
|
||||
<th className="px-3 py-2 text-left">이름</th>
|
||||
<th className="px-3 py-2 text-left">결제 계좌</th>
|
||||
<th className="px-3 py-2 text-left">전화</th>
|
||||
<th className="px-3 py-2 text-left">이메일</th>
|
||||
<th className="px-3 py-2 text-center">기본</th>
|
||||
<th className="px-3 py-2 text-center w-[100px]">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={7} className="text-center py-12 text-slate-400">등록된 기준 명세표가 없습니다.</td></tr>
|
||||
) : list.map((b) => (
|
||||
<tr key={b.CODE} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono font-semibold text-emerald-700">{b.CODE}</td>
|
||||
<td className="px-3 py-2 font-semibold">{b.NAME}</td>
|
||||
<td className="px-3 py-2 text-xs">{b.BANK_ACCOUNT}</td>
|
||||
<td className="px-3 py-2 text-xs">{b.PHONE}</td>
|
||||
<td className="px-3 py-2 text-xs text-blue-700">{b.EMAIL}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{b.IS_DEFAULT === "Y" && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold">기본</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button onClick={() => openEdit(b)} className="text-emerald-700 hover:text-emerald-800 mr-2"><Pencil size={14} /></button>
|
||||
<button onClick={() => del(b)} className="text-rose-500 hover:text-rose-600"><Trash2 size={14} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={() => setEditing(null)}>
|
||||
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-lg p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="font-bold text-lg mb-4">{isNew ? "기준 명세표 등록" : `기준 명세표 수정 (${editing.CODE})`}</h3>
|
||||
<div className="space-y-3">
|
||||
<Field label="코드 *">
|
||||
<input required disabled={!isNew} value={editing.CODE ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, CODE: e.target.value.toUpperCase() })}
|
||||
placeholder="HQ / KIMPO 등" className="w-full h-9 px-3 rounded border border-slate-200 text-sm font-mono disabled:bg-slate-100" />
|
||||
</Field>
|
||||
<Field label="이름 *">
|
||||
<input required value={editing.NAME ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, NAME: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="결제 계좌번호 *">
|
||||
<input required value={editing.BANK_ACCOUNT ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, BANK_ACCOUNT: e.target.value })}
|
||||
placeholder="예: 기업은행 434-115361-01-016 (이상용)"
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="전화">
|
||||
<input value={editing.PHONE ?? ""} onChange={(e) => setEditing({ ...editing, PHONE: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="이메일">
|
||||
<input type="email" value={editing.EMAIL ?? ""} onChange={(e) => setEditing({ ...editing, EMAIL: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="대표자">
|
||||
<input value={editing.CEO_NAME ?? ""} onChange={(e) => setEditing({ ...editing, CEO_NAME: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<Field label="사업자등록번호">
|
||||
<input value={editing.BIZ_NO ?? ""} onChange={(e) => setEditing({ ...editing, BIZ_NO: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="주소">
|
||||
<input value={editing.ADDRESS ?? ""} onChange={(e) => setEditing({ ...editing, ADDRESS: e.target.value })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" checked={editing.IS_DEFAULT === "Y"}
|
||||
onChange={(e) => setEditing({ ...editing, IS_DEFAULT: e.target.checked ? "Y" : "N" })}
|
||||
className="w-4 h-4 accent-emerald-600" />
|
||||
기본 명세표
|
||||
</label>
|
||||
<Field label="정렬순">
|
||||
<input type="number" value={editing.SORT_ORDER ?? 0}
|
||||
onChange={(e) => setEditing({ ...editing, SORT_ORDER: Number(e.target.value) })}
|
||||
className="w-full h-9 px-3 rounded border border-slate-200 text-sm" />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end mt-5">
|
||||
<button type="button" onClick={() => setEditing(null)} className="h-10 px-4 rounded-lg border border-slate-200 text-sm font-semibold">취소</button>
|
||||
<button type="submit" className="h-10 px-5 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-[11px] font-semibold text-slate-600 mb-1">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function defaultRange() {
|
||||
|
||||
export default function DailyStatsPage() {
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -26,12 +27,12 @@ export default function DailyStatsPage() {
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/daily", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom: from, dateTo: to }),
|
||||
body: JSON.stringify({ dateFrom: from, dateTo: to, branch }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
useEffect(() => { load(); }, [from, to, branch]); // eslint-disable-line
|
||||
|
||||
const total = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
|
||||
const totalFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||
@@ -78,6 +79,11 @@ export default function DailyStatsPage() {
|
||||
<div className="flex gap-2 flex-wrap items-end">
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])} className="h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
<option value="ALL">전체 (계산서 기준)</option>
|
||||
<option value="HQ">본사 명세서</option>
|
||||
<option value="KIMPO">김포 명세서</option>
|
||||
</select>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
@@ -88,58 +94,66 @@ export default function DailyStatsPage() {
|
||||
<Card label="총 매출 (VAT)" value={`₩${fmt(total)}`} color="emerald" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">일별 매출 추이</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 20, bottom: 0, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => name === "건수" ? `${Number(v)}건` : `₩${fmt(Number(v))}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar yAxisId="left" dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar yAxisId="left" dataKey="과세" stackId="a" fill="#f43f5e" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="건수" stroke="#0ea5e9" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
{/* 좌: 일자별 리스트 / 우: 추이 차트 — 50/50 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="bg-white border rounded-xl overflow-hidden flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
|
||||
일자별 매출 ({rows.length}일)
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[480px]">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">일자</th>
|
||||
<th className="text-right px-3 py-2">건수</th>
|
||||
<th className="text-right px-3 py-2">면세</th>
|
||||
<th className="text-right px-3 py-2">과세</th>
|
||||
<th className="text-right px-3 py-2">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "선택한 기간의 매출 데이터가 없습니다."}</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.DAY} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 font-semibold">{r.DAY}</td>
|
||||
<td className="px-3 py-2 text-right">{r.ORDER_CNT}건</td>
|
||||
<td className="px-3 py-2 text-right text-violet-700">₩{fmt(r.TAX_FREE)}</td>
|
||||
<td className="px-3 py-2 text-right text-rose-700">₩{fmt(r.TAXABLE)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold">₩{fmt(r.TOTAL)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">일자</th>
|
||||
<th className="text-right px-4 py-3">건수</th>
|
||||
<th className="text-right px-4 py-3">면세</th>
|
||||
<th className="text-right px-4 py-3">과세</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={5} className="text-center py-12 text-slate-400">선택한 기간의 매출 데이터가 없습니다.</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.DAY} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2.5 font-semibold">{r.DAY}</td>
|
||||
<td className="px-4 py-2.5 text-right">{r.ORDER_CNT}건</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-violet-700">₩{fmt(r.TAX_FREE)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-rose-700">₩{fmt(r.TAXABLE)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-bold">₩{fmt(r.TOTAL)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-white border rounded-xl p-4 flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm shrink-0">일별 매출 추이</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 20, bottom: 0, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v, name) => name === "건수" ? `${Number(v)}건` : `₩${fmt(Number(v))}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar yAxisId="left" dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar yAxisId="left" dataKey="과세" stackId="a" fill="#f43f5e" />
|
||||
<Line yAxisId="right" type="monotone" dataKey="건수" stroke="#0ea5e9" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
export default function MarginPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -21,12 +22,12 @@ export default function MarginPage() {
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/margin", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
body: JSON.stringify({ year, month, branch }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
useEffect(() => { load(); }, [year, month, branch]); // eslint-disable-line
|
||||
|
||||
const totalRev = rows.reduce((a, r) => a + Number(r.REVENUE), 0);
|
||||
const totalCost = rows.reduce((a, r) => a + Number(r.COST), 0);
|
||||
@@ -82,6 +83,11 @@ export default function MarginPage() {
|
||||
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => <option key={m} value={m}>{m}월</option>)}
|
||||
</select>
|
||||
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
<option value="ALL">전체 (계산서 기준)</option>
|
||||
<option value="HQ">본사 명세서</option>
|
||||
<option value="KIMPO">김포 명세서</option>
|
||||
</select>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
@@ -92,62 +98,70 @@ export default function MarginPage() {
|
||||
<Card label="마진율" value={`${marginPct}%`} color="violet" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">마진 TOP 10 품목</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 20, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="원가" fill="#f59e0b" />
|
||||
<Bar dataKey="마진" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">품목</th>
|
||||
<th className="text-right px-4 py-3">판매수량</th>
|
||||
<th className="text-right px-4 py-3">매출</th>
|
||||
<th className="text-right px-4 py-3">원가</th>
|
||||
<th className="text-right px-4 py-3">마진</th>
|
||||
<th className="text-right px-4 py-3">마진율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">데이터가 없습니다.</td></tr>
|
||||
) : rows.map((r) => {
|
||||
const pct = Number(r.REVENUE) ? ((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<tr key={r.ITEM_CODE} className="border-t border-slate-100">
|
||||
<td className="px-4 py-2.5 font-semibold">{r.ITEM_NAME}</td>
|
||||
<td className="px-4 py-2.5 text-right">{fmt(r.QTY)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">₩{fmt(r.REVENUE)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-amber-700">₩{fmt(r.COST)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums font-bold text-emerald-700">₩{fmt(r.MARGIN)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">{pct}%</td>
|
||||
{/* 좌: 품목 리스트 / 우: 차트 — 50/50, 화면 높이 내 스크롤 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="bg-white border rounded-xl overflow-hidden flex flex-col" style={{ maxHeight: "calc(100vh - 280px)" }}>
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
|
||||
품목별 상세 ({rows.length}건)
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[560px]">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">품목</th>
|
||||
<th className="text-right px-3 py-2">수량</th>
|
||||
<th className="text-right px-3 py-2">매출</th>
|
||||
<th className="text-right px-3 py-2">원가</th>
|
||||
<th className="text-right px-3 py-2">마진</th>
|
||||
<th className="text-right px-3 py-2">%</th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "데이터가 없습니다."}</td></tr>
|
||||
) : rows.map((r) => {
|
||||
const pct = Number(r.REVENUE) ? ((Number(r.MARGIN) / Number(r.REVENUE)) * 100).toFixed(1) : "0.0";
|
||||
return (
|
||||
<tr key={r.ITEM_CODE} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 font-semibold">{r.ITEM_NAME}</td>
|
||||
<td className="px-3 py-2 text-right">{fmt(r.QTY)}</td>
|
||||
<td className="px-3 py-2 text-right">₩{fmt(r.REVENUE)}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-700">₩{fmt(r.COST)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-emerald-700">₩{fmt(r.MARGIN)}</td>
|
||||
<td className="px-3 py-2 text-right">{pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-xl p-4 flex flex-col" style={{ maxHeight: "calc(100vh - 280px)" }}>
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm shrink-0">마진 TOP 10 품목</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 20, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="원가" fill="#f59e0b" />
|
||||
<Bar dataKey="마진" fill="#10b981" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ const COLORS = ["#10b981", "#0ea5e9", "#8b5cf6", "#f59e0b", "#ef4444", "#14b8a6"
|
||||
export default function StatisticsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
|
||||
const [rows, setRows] = useState<MonthlyRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -28,12 +29,12 @@ export default function StatisticsPage() {
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/monthly", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
body: JSON.stringify({ year, month, branch }),
|
||||
});
|
||||
setRows((await res.json()).RESULTLIST ?? []);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
useEffect(() => { load(); }, [year, month, branch]); // eslint-disable-line
|
||||
|
||||
const grandTotal = rows.reduce((a, r) => a + Number(r.TOTAL), 0);
|
||||
const grandFree = rows.reduce((a, r) => a + Number(r.TAX_FREE), 0);
|
||||
@@ -85,6 +86,11 @@ export default function StatisticsPage() {
|
||||
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => <option key={m} value={m}>{m}월</option>)}
|
||||
</select>
|
||||
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
<option value="ALL">전체 (계산서 기준)</option>
|
||||
<option value="HQ">본사 명세서</option>
|
||||
<option value="KIMPO">김포 명세서</option>
|
||||
</select>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
@@ -94,59 +100,66 @@ export default function StatisticsPage() {
|
||||
<Card label="총 매출 (VAT포함)" value={fmt(grandTotal)} color="emerald" />
|
||||
</div>
|
||||
|
||||
{/* 차트 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm">업체별 매출 (TOP 15)</h3>
|
||||
<div className="w-full h-72 sm:h-80">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
cursor={{ fill: "rgba(16, 185, 129, 0.05)" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar dataKey="과세" stackId="a" fill="#f43f5e">
|
||||
{chartData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.7} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
{/* 좌: 업체별 리스트 / 우: 차트 — 50/50 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
|
||||
업체별 매출 ({rows.length}건)
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[480px]">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">업체명</th>
|
||||
<th className="text-right px-3 py-2">면세</th>
|
||||
<th className="text-right px-3 py-2">과세</th>
|
||||
<th className="text-right px-3 py-2">총 매출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={4} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "선택한 월의 매출 데이터가 없습니다."}</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.COMPANY_NAME} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 font-semibold">{r.COMPANY_NAME}</td>
|
||||
<td className="px-3 py-2 text-right text-violet-700">{fmt(r.TAX_FREE)}</td>
|
||||
<td className="px-3 py-2 text-right text-rose-700">{fmt(r.TAXABLE)}</td>
|
||||
<td className="px-3 py-2 text-right font-bold">₩{fmt(r.TOTAL)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">업체명</th>
|
||||
<th className="text-right px-4 py-3">면세 합계</th>
|
||||
<th className="text-right px-4 py-3">과세 공급가</th>
|
||||
<th className="text-right px-4 py-3">총 매출</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={4} className="text-center py-12 text-slate-400">선택한 월의 매출 데이터가 없습니다.</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.COMPANY_NAME} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-semibold">{r.COMPANY_NAME}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-violet-700">{fmt(r.TAX_FREE)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-rose-700">{fmt(r.TAXABLE)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(r.TOTAL)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 flex flex-col" style={{ maxHeight: "calc(100vh - 320px)" }}>
|
||||
<h3 className="font-bold text-slate-700 mb-3 text-sm shrink-0">업체별 매출 (TOP 15)</h3>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">불러오는 중...</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">데이터가 없습니다.</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, bottom: 30, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} interval={0} angle={-25} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${(v / 10000).toFixed(0)}만`} />
|
||||
<Tooltip
|
||||
formatter={(v) => `₩${fmt(Number(v))}`}
|
||||
labelFormatter={(_, payload) => (payload?.[0]?.payload as { fullName: string })?.fullName ?? ""}
|
||||
cursor={{ fill: "rgba(16, 185, 129, 0.05)" }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="면세" stackId="a" fill="#8b5cf6" />
|
||||
<Bar dataKey="과세" stackId="a" fill="#f43f5e">
|
||||
{chartData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.7} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ const fmt = (n: number | undefined | null) => Number(n || 0).toLocaleString("ko-
|
||||
export default function PivotStatsPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||
const [branch, setBranch] = useState<"ALL" | "HQ" | "KIMPO">("ALL");
|
||||
const [data, setData] = useState<PivotResp | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -26,14 +27,14 @@ export default function PivotStatsPage() {
|
||||
try {
|
||||
const res = await fetch("/api/m/statistics/monthly-pivot", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, month }),
|
||||
body: JSON.stringify({ year, month, branch }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) setData(j);
|
||||
} finally { setLoading(false); }
|
||||
}, [year, month]);
|
||||
}, [year, month, branch]);
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const dayLabel = (d: string) => {
|
||||
const [, mm, dd] = d.split("-");
|
||||
@@ -96,6 +97,12 @@ export default function PivotStatsPage() {
|
||||
className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((mm) => <option key={mm} value={mm}>{mm}월</option>)}
|
||||
</select>
|
||||
<select value={branch} onChange={(e) => setBranch(e.target.value as "ALL" | "HQ" | "KIMPO")}
|
||||
className="h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
<option value="ALL">전체 (계산서 기준)</option>
|
||||
<option value="HQ">본사 명세서</option>
|
||||
<option value="KIMPO">김포 명세서</option>
|
||||
</select>
|
||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowRightLeft, RefreshCcw, Download } from "lucide-react";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Row {
|
||||
OBJID: string; MOVED_AT: string;
|
||||
ITEM_CODE: string; ITEM_NAME: string; UNIT: string;
|
||||
COST_PRICE: number; QTY: number; AMOUNT: number;
|
||||
FROM_WH: string; FROM_CODE: string;
|
||||
TO_WH: string; TO_CODE: string;
|
||||
MEMO: string | null; REGID: string; REG_USER_NAME: string | null;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||
const monthAgo = () => { const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); };
|
||||
|
||||
export default function TransfersPage() {
|
||||
const [dateFrom, setDateFrom] = useState(monthAgo());
|
||||
const [dateTo, setDateTo] = useState(todayStr());
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [totals, setTotals] = useState({ qty: 0, amount: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/transfers", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom, dateTo }),
|
||||
});
|
||||
const j = await res.json();
|
||||
setRows(j.RESULTLIST ?? []);
|
||||
setTotals({ qty: Number(j.TOTAL_QTY ?? 0), amount: Number(j.TOTAL_AMOUNT ?? 0) });
|
||||
} finally { setLoading(false); }
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onExport = () => {
|
||||
if (rows.length === 0) return;
|
||||
downloadXlsx(`창고이동_${dateFrom}_${dateTo}`, rows, [
|
||||
{ header: "이동일시", key: "MOVED_AT" },
|
||||
{ header: "품목코드", key: "ITEM_CODE" },
|
||||
{ header: "품목명", key: "ITEM_NAME" },
|
||||
{ header: "단위", key: "UNIT" },
|
||||
{ header: "수량", key: (r) => Number(r.QTY) },
|
||||
{ header: "단가", key: (r) => Number(r.COST_PRICE) },
|
||||
{ header: "금액", key: (r) => Number(r.AMOUNT) },
|
||||
{ header: "출발창고", key: (r) => `${r.FROM_WH ?? ""} (${r.FROM_CODE ?? ""})` },
|
||||
{ header: "도착창고", key: (r) => `${r.TO_WH ?? ""} (${r.TO_CODE ?? ""})` },
|
||||
{ header: "메모", key: (r) => r.MEMO ?? "" },
|
||||
{ header: "이동자ID", key: "REGID" },
|
||||
{ header: "이동자", key: (r) => r.REG_USER_NAME ?? "" },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<ArrowRightLeft size={20} className="text-sky-700" />
|
||||
창고 이동 통계 (본사 → 김포)
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
<b>본사 창고 계열</b>에서 <b>김포 창고 계열</b>(김포지사/창고/용차/시장) 로 이동한 건만 표시. 수량 × 단가(cost_price) = 이동 금액.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<span className="text-slate-400">~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<button onClick={load} disabled={loading}
|
||||
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
<button onClick={onExport} disabled={rows.length === 0}
|
||||
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-50">
|
||||
<Download size={14} /> 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 합계 카드 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-slate-500 mb-1">이동 건수</div>
|
||||
<div className="text-xl font-bold tabular-nums">{fmt(rows.length)}건</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-slate-500 mb-1">총 이동 수량</div>
|
||||
<div className="text-xl font-bold tabular-nums">{fmt(totals.qty)}</div>
|
||||
</div>
|
||||
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4">
|
||||
<div className="text-[11px] text-sky-700 mb-1 font-semibold">총 이동 금액 (단가 기준)</div>
|
||||
<div className="text-xl font-bold text-sky-700 tabular-nums">₩{fmt(totals.amount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리스트 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">이동일시</th>
|
||||
<th className="text-left px-3 py-2">품목</th>
|
||||
<th className="text-right px-3 py-2">수량</th>
|
||||
<th className="text-right px-3 py-2">단가</th>
|
||||
<th className="text-right px-3 py-2">금액</th>
|
||||
<th className="text-left px-3 py-2">출발 → 도착</th>
|
||||
<th className="text-left px-3 py-2">이동자</th>
|
||||
<th className="text-left px-3 py-2">메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">
|
||||
{loading ? "조회 중..." : "이동 내역이 없습니다."}
|
||||
</td></tr>
|
||||
) : rows.map((r) => (
|
||||
<tr key={r.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 text-xs font-mono">{r.MOVED_AT}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-semibold">{r.ITEM_NAME}</div>
|
||||
<div className="text-[10px] text-slate-400">{r.ITEM_CODE}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold">{fmt(Number(r.QTY))} {r.UNIT}</td>
|
||||
<td className="px-3 py-2 text-right text-slate-500">{fmt(Number(r.COST_PRICE))}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-sky-700">₩{fmt(Number(r.AMOUNT))}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<span className="text-slate-700">{r.FROM_WH}</span>
|
||||
<span className="text-slate-300 mx-1">→</span>
|
||||
<span className="text-emerald-700 font-semibold">{r.TO_WH}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="font-semibold">{r.REG_USER_NAME ?? "-"}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono">{r.REGID}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[10px] text-slate-500 max-w-[200px] truncate" title={r.MEMO ?? ""}>
|
||||
{r.MEMO ?? ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,15 +7,38 @@ import Swal from "sweetalert2";
|
||||
interface Warehouse {
|
||||
OBJID: string; WH_CODE: string; WH_NAME: string; LOCATION: string; WH_TYPE: string;
|
||||
}
|
||||
// 본사/김포 각각 창고/용차/시장 + 김포지사 자체 = 총 7가지
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
STOCK: "본사 창고", PICKUP_TEAM: "창고픽업팀", MARKET: "시장픽업", DELIVERY: "용차배송",
|
||||
HQ_STOCK: "본사 창고",
|
||||
HQ_CHARTER: "본사 용차",
|
||||
HQ_MARKET: "본사 시장",
|
||||
KIMPO_BRANCH:"김포지사",
|
||||
KIMPO_STOCK: "김포 창고",
|
||||
KIMPO_CHARTER:"김포 용차",
|
||||
KIMPO_MARKET:"김포 시장",
|
||||
// ↓ 옛 enum (기존 데이터 표시 유지)
|
||||
STOCK: "본사 창고",
|
||||
KIMPO: "김포 창고",
|
||||
PICKUP_TEAM: "창고픽업팀",
|
||||
MARKET: "시장픽업",
|
||||
DELIVERY: "용차배송",
|
||||
};
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
STOCK: "bg-emerald-100 text-emerald-700",
|
||||
PICKUP_TEAM: "bg-sky-100 text-sky-700",
|
||||
MARKET: "bg-amber-100 text-amber-700",
|
||||
DELIVERY: "bg-orange-100 text-orange-700",
|
||||
HQ_STOCK: "bg-emerald-100 text-emerald-700",
|
||||
HQ_CHARTER: "bg-orange-100 text-orange-700",
|
||||
HQ_MARKET: "bg-amber-100 text-amber-700",
|
||||
KIMPO_BRANCH: "bg-teal-100 text-teal-700",
|
||||
KIMPO_STOCK: "bg-teal-100 text-teal-700",
|
||||
KIMPO_CHARTER:"bg-orange-100 text-orange-700",
|
||||
KIMPO_MARKET: "bg-amber-100 text-amber-700",
|
||||
STOCK: "bg-emerald-100 text-emerald-700",
|
||||
KIMPO: "bg-teal-100 text-teal-700",
|
||||
PICKUP_TEAM: "bg-sky-100 text-sky-700",
|
||||
MARKET: "bg-amber-100 text-amber-700",
|
||||
DELIVERY: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
// 신규 카테고리 (select 옵션). 옛 enum 은 데이터 표시만 유지, 신규 추가는 새 enum 만.
|
||||
const NEW_TYPE_ORDER = ["HQ_STOCK", "HQ_CHARTER", "HQ_MARKET", "KIMPO_BRANCH", "KIMPO_STOCK", "KIMPO_CHARTER", "KIMPO_MARKET"];
|
||||
|
||||
export default function WarehousesPage() {
|
||||
const [list, setList] = useState<Warehouse[]>([]);
|
||||
@@ -35,7 +58,7 @@ export default function WarehousesPage() {
|
||||
body: JSON.stringify({
|
||||
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
|
||||
whName: editing.WH_NAME,
|
||||
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
|
||||
location: editing.LOCATION, whType: editing.WH_TYPE || "HQ_STOCK",
|
||||
}),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
@@ -51,7 +74,7 @@ export default function WarehousesPage() {
|
||||
<h1 className="text-xl sm:text-2xl font-bold">창고 관리</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">총 {list.length}개</p>
|
||||
</div>
|
||||
<button onClick={() => setEditing({ WH_TYPE: "STOCK" })}
|
||||
<button onClick={() => setEditing({ WH_TYPE: "HQ_STOCK" })}
|
||||
className="h-10 px-3 sm:px-4 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800">
|
||||
<Plus size={16} /> 추가
|
||||
</button>
|
||||
@@ -128,8 +151,8 @@ export default function WarehousesPage() {
|
||||
/>
|
||||
</div>
|
||||
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
<select value={editing.WH_TYPE ?? "HQ_STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||
{NEW_TYPE_ORDER.map((k) => <option key={k} value={k}>{TYPE_LABEL[k]}</option>)}
|
||||
</select>
|
||||
<input placeholder="위치 (선택)" value={editing.LOCATION ?? ""} onChange={(e) => setEditing({ ...editing, LOCATION: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCcw, Warehouse, Download, LayoutGrid, Columns3 } from "lucide-react";
|
||||
import { downloadXlsx } from "@/lib/xlsx-export";
|
||||
|
||||
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
||||
interface ItemRow {
|
||||
ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
|
||||
STOCK: Record<string, number>; // wh_code → 현재고
|
||||
AVAILABLE: Record<string, number>; // wh_code → 여유분(현재고 - 진행중)
|
||||
TOTAL_STOCK: number;
|
||||
TOTAL_PENDING: number;
|
||||
}
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
// 이번 주 월요일 → 오늘
|
||||
function weekRange() {
|
||||
const today = new Date();
|
||||
const day = today.getDay(); // 0=일
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + mondayOffset);
|
||||
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return [iso(monday), iso(today)];
|
||||
}
|
||||
|
||||
export default function WhStockStatusPage() {
|
||||
const [[dateFrom, dateTo], setRange] = useState(weekRange());
|
||||
const [warehouses, setWarehouses] = useState<Wh[]>([]);
|
||||
const [items, setItems] = useState<ItemRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 보기 모드: by-wh = 헤더가 창고(가로로 김) / by-item = 헤더가 품목(오른쪽으로 길게)
|
||||
const [viewMode, setViewMode] = useState<"by-wh" | "by-item">("by-item");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/m/admin/wh-stock-status", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dateFrom, dateTo }),
|
||||
});
|
||||
const j = await res.json();
|
||||
setWarehouses(j.WAREHOUSES ?? []);
|
||||
setItems(j.ITEMS ?? []);
|
||||
} finally { setLoading(false); }
|
||||
}, [dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const onExport = () => {
|
||||
if (items.length === 0) return;
|
||||
const cols = [
|
||||
{ header: "품목", key: "ITEM_NAME" },
|
||||
{ header: "분류", key: "KIND" },
|
||||
...warehouses.map((w) => ({ header: w.WH_NAME, key: w.WH_CODE })),
|
||||
];
|
||||
type Row = Record<string, string | number>;
|
||||
const data: Row[] = [];
|
||||
for (const it of items) {
|
||||
const stockRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "발주수량(현재고)" };
|
||||
const availRow: Row = { ITEM_NAME: it.ITEM_NAME, KIND: "여유분" };
|
||||
for (const w of warehouses) {
|
||||
stockRow[w.WH_CODE] = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
availRow[w.WH_CODE] = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
|
||||
}
|
||||
data.push(stockRow, availRow);
|
||||
}
|
||||
downloadXlsx(`창고별재고_${dateFrom}_${dateTo}`, data, cols);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-end justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Warehouse size={20} className="text-emerald-700" />
|
||||
창고별 재고 현황
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
기간 내 발주(출고요청/완료/계산서/입금) 수량과 현재고를 창고별로 나란히. <b>발주수량</b> = 현재 보유 재고, <b>여유분</b> = 현재고 − 기간 내 발주된 출고 예정 수량 (해당 창고 기준).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 보기 모드 토글 */}
|
||||
<div className="inline-flex rounded border border-slate-300 overflow-hidden">
|
||||
<button onClick={() => setViewMode("by-wh")}
|
||||
className={`h-9 px-3 text-xs font-semibold inline-flex items-center gap-1 ${viewMode === "by-wh" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
|
||||
<Columns3 size={14} /> 창고 가로
|
||||
</button>
|
||||
<button onClick={() => setViewMode("by-item")}
|
||||
className={`h-9 px-3 text-xs font-semibold inline-flex items-center gap-1 border-l border-slate-300 ${viewMode === "by-item" ? "bg-emerald-700 text-white" : "bg-white text-slate-600 hover:bg-slate-50"}`}>
|
||||
<LayoutGrid size={14} /> 품목 가로
|
||||
</button>
|
||||
</div>
|
||||
<input type="date" value={dateFrom} onChange={(e) => setRange([e.target.value, dateTo])}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<span className="text-slate-400">~</span>
|
||||
<input type="date" value={dateTo} onChange={(e) => setRange([dateFrom, e.target.value])}
|
||||
className="h-9 px-3 rounded border border-slate-300 text-sm" />
|
||||
<button onClick={() => setRange(weekRange())}
|
||||
className="h-9 px-3 rounded border border-slate-300 bg-white text-slate-700 text-xs font-semibold">금주</button>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="h-9 px-3 rounded bg-slate-800 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-slate-900 disabled:opacity-50">
|
||||
<RefreshCcw size={14} /> 조회
|
||||
</button>
|
||||
<button onClick={onExport} disabled={items.length === 0}
|
||||
className="h-9 px-3 rounded bg-emerald-700 text-white text-sm font-bold inline-flex items-center gap-1 hover:bg-emerald-800 disabled:opacity-40">
|
||||
<Download size={14} /> 엑셀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
{viewMode === "by-wh" ? (
|
||||
/* 보기 1: 헤더=창고(가로), 행=품목 */
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-slate-200 min-w-[160px]">품목</th>
|
||||
<th className="text-center px-3 py-2 border-b border-slate-200 min-w-[100px]">분류</th>
|
||||
{warehouses.map((w) => (
|
||||
<th key={w.WH_CODE} className="text-right px-3 py-2 border-b border-slate-200 min-w-[88px]">
|
||||
{w.WH_NAME}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={2 + warehouses.length} className="text-center py-12 text-slate-400">
|
||||
{loading ? "조회 중..." : "데이터가 없습니다."}
|
||||
</td></tr>
|
||||
) : items.flatMap((it) => [
|
||||
<tr key={`${it.ITEM_OBJID}-stock`} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 align-top font-semibold" rowSpan={2}>
|
||||
{it.ITEM_NAME}
|
||||
<div className="text-[10px] text-slate-400 font-mono">{it.ITEM_CODE}</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-50/70">발주수량</td>
|
||||
{warehouses.map((w) => {
|
||||
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
return (
|
||||
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-800"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
<tr key={`${it.ITEM_OBJID}-avail`} className="border-b border-slate-100">
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold">여유분</td>
|
||||
{warehouses.map((w) => {
|
||||
const v = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
|
||||
const negative = v < 0;
|
||||
return (
|
||||
<td key={w.WH_CODE} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
])}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
/* 보기 2: 헤더=품목(가로), 행=창고 7줄 — 전치 */
|
||||
<table className="text-sm border-collapse">
|
||||
<thead className="bg-slate-50 text-slate-600 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 border-b border-slate-200 sticky left-0 bg-slate-50 z-10 min-w-[120px]">창고</th>
|
||||
<th className="text-center px-3 py-2 border-b border-slate-200 sticky left-[120px] bg-slate-50 z-10 min-w-[90px]">분류</th>
|
||||
{items.map((it) => (
|
||||
<th key={it.ITEM_OBJID} className="text-right px-3 py-2 border-b border-slate-200 min-w-[100px] whitespace-nowrap">
|
||||
<div>{it.ITEM_NAME}</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono font-normal">{it.ITEM_CODE}</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
{items.length === 0 || warehouses.length === 0 ? (
|
||||
<tr><td colSpan={2 + items.length} className="text-center py-12 text-slate-400">
|
||||
{loading ? "조회 중..." : "데이터가 없습니다."}
|
||||
</td></tr>
|
||||
) : [
|
||||
/* 전체 합계 — 모든 창고의 발주수량/여유분 합 (상단 강조 행) */
|
||||
<tr key="__total-stock" className="bg-emerald-50/70 border-y-2 border-emerald-300 font-bold">
|
||||
<td className="px-3 py-2 align-top sticky left-0 bg-emerald-50/70" rowSpan={2}>
|
||||
<div className="text-emerald-800">전체 합계</div>
|
||||
<div className="text-[10px] text-emerald-600 font-normal">모든 창고</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-100 sticky left-[120px]">발주수량</td>
|
||||
{items.map((it) => {
|
||||
const v = warehouses.reduce((acc, w) => acc + Number(it.STOCK[w.WH_CODE] ?? 0), 0);
|
||||
return (
|
||||
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-900"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
<tr key="__total-avail" className="bg-emerald-50/70 border-b-2 border-emerald-300 font-bold">
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-100/60 sticky left-[120px]">여유분</td>
|
||||
{items.map((it) => {
|
||||
const v = warehouses.reduce((acc, w) => acc + Number(it.AVAILABLE[w.WH_CODE] ?? 0), 0);
|
||||
const negative = v < 0;
|
||||
return (
|
||||
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600" : v === 0 ? "text-slate-300" : "text-emerald-700"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
/* 창고별 7줄 */
|
||||
...warehouses.flatMap((w) => [
|
||||
<tr key={`${w.WH_CODE}-stock`} className="border-t border-slate-100 hover:bg-slate-50/60">
|
||||
<td className="px-3 py-2 align-top font-semibold sticky left-0 bg-white" rowSpan={2}>
|
||||
{w.WH_NAME}
|
||||
<div className="text-[10px] text-slate-400 font-mono">{w.WH_CODE}</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-slate-700 bg-slate-50/70 sticky left-[120px]">발주수량</td>
|
||||
{items.map((it) => {
|
||||
const v = Number(it.STOCK[w.WH_CODE] ?? 0);
|
||||
return (
|
||||
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${v === 0 ? "text-slate-300" : "text-slate-800"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
<tr key={`${w.WH_CODE}-avail`} className="border-b border-slate-100">
|
||||
<td className="px-3 py-1.5 text-center text-[11px] text-emerald-700 bg-emerald-50/40 font-semibold sticky left-[120px]">여유분</td>
|
||||
{items.map((it) => {
|
||||
const v = Number(it.AVAILABLE[w.WH_CODE] ?? 0);
|
||||
const negative = v < 0;
|
||||
return (
|
||||
<td key={it.ITEM_OBJID} className={`px-3 py-1.5 text-right ${negative ? "text-rose-600 font-bold" : v === 0 ? "text-slate-300" : "text-emerald-700 font-semibold"}`}>
|
||||
{v === 0 ? "-" : fmt(v)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>,
|
||||
]),
|
||||
]}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user