merge: nextjs-main → main (모모유통 유통관리 ERP 본 브랜치 통합)
Deploy momo-erp to production / deploy (push) Failing after 4s
- 가입/로그인/랜딩/품목/재고/발주/승인/메일/엑셀 전체 기능 - DB 마이그레이션 + 시드 + CI 자동 마이그레이션 - SMTP 환경변수 (chpark@coa-soft.com) - 배포 트리거 main 단일 브랜치 정책
@@ -0,0 +1,76 @@
|
||||
---
|
||||
globs: src/app/api/**/*.ts
|
||||
---
|
||||
|
||||
# API Route 코딩 규칙
|
||||
|
||||
## 인증 체크
|
||||
모든 핸들러 첫 줄에 세션 검증 필수:
|
||||
```typescript
|
||||
const user = await getSession();
|
||||
if (!user) return NextResponse.json({ success: false }, { status: 401 });
|
||||
```
|
||||
|
||||
## import 순서
|
||||
1. `next/server` (NextRequest, NextResponse)
|
||||
2. `@/lib/session` (getSession)
|
||||
3. `@/lib/db` (queryRows, queryOne, execute)
|
||||
4. `@/lib/utils` (createObjectId, checkNull 등)
|
||||
5. 기타 (`@/lib/auth`, `@/lib/encrypt` — login 전용)
|
||||
|
||||
## 동적 SQL 파라미터
|
||||
```typescript
|
||||
const conditions: string[] = ["1=1"];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (body.field) {
|
||||
conditions.push(`TABLE.COLUMN = $${idx++}`);
|
||||
params.push(body.field);
|
||||
}
|
||||
// LIKE: conditions.push(`COLUMN LIKE '%' || $${idx++} || '%'`);
|
||||
|
||||
const sql = `SELECT ... WHERE ${conditions.join(" AND ")} ORDER BY regdate DESC`;
|
||||
const rows = await queryRows(sql, params);
|
||||
```
|
||||
|
||||
## SQL alias 대문자
|
||||
PostgreSQL은 따옴표 없는 alias를 소문자로 반환한다. 대문자 유지하려면 큰따옴표 필수:
|
||||
```sql
|
||||
SELECT id AS "OBJID", name AS "USER_NAME" -- O (대문자 유지)
|
||||
SELECT id AS OBJID -- X (소문자 objid로 반환)
|
||||
```
|
||||
|
||||
## 응답 형식
|
||||
- 목록 조회: `{ RESULTLIST: rows, TOTAL_CNT: rows.length }`
|
||||
- 단순 조회: `{ success: true, data: rows }`
|
||||
- 저장/수정: `{ success: true }` 또는 `{ success: true, objId: newId }`
|
||||
- 오류: `{ success: false, message: "..." }` + HTTP 400/401/500
|
||||
|
||||
## 에러 처리
|
||||
```typescript
|
||||
try {
|
||||
// 비즈니스 로직
|
||||
} catch (error) {
|
||||
console.error("설명:", error);
|
||||
return NextResponse.json({ success: false, message: "처리 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
```
|
||||
|
||||
## INSERT/UPDATE 구분
|
||||
`actionType` 필드로 분기:
|
||||
```typescript
|
||||
const { actionType } = body;
|
||||
if (actionType === "regist") {
|
||||
const newId = createObjectId();
|
||||
await execute(`INSERT INTO ...`, [...]);
|
||||
} else {
|
||||
await execute(`UPDATE ... WHERE OBJID = $${idx}`, [...]);
|
||||
}
|
||||
```
|
||||
|
||||
## SQL 공통 패턴
|
||||
- 삭제 플래그: `COALESCE(IS_DEL, 'N') != 'Y'`
|
||||
- 날짜 포맷: `TO_CHAR(regdate, 'YYYY-MM-DD')`
|
||||
- 기본 정렬: `ORDER BY regdate DESC`
|
||||
- objId 타입 변환: `objid::text AS "OBJID"`
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
globs: src/components/**/*.tsx
|
||||
---
|
||||
|
||||
# 컴포넌트 코딩 규칙
|
||||
|
||||
## 폼 컴포넌트 (ui/)
|
||||
- `forwardRef` 패턴 사용 (Button, Input, Select)
|
||||
- disabled 스타일: `disabled:opacity-50 disabled:cursor-not-allowed`
|
||||
- focus 스타일: `focus:outline-none focus:ring-1 focus:ring-primary/50`
|
||||
- `cn()` 유틸리티로 Tailwind 클래스 병합
|
||||
|
||||
## CodeSelect props
|
||||
```tsx
|
||||
<CodeSelect
|
||||
codeId="CODE_GROUP_ID" // 필수: 코드 그룹 ID
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="선택" // 기본값
|
||||
/>
|
||||
```
|
||||
- API: `POST /api/common/code-list` (body: `{ codeId }`)
|
||||
- 응답 필드: `CODE_ID`, `CODE_NAME`
|
||||
|
||||
## FileUpload props
|
||||
```tsx
|
||||
<FileUpload
|
||||
targetObjId="대상ID" // 필수
|
||||
docType="문서타입코드" // 필수
|
||||
docTypeName="문서타입명" // 필수
|
||||
onUploadComplete={() => fetchFiles()}
|
||||
/>
|
||||
```
|
||||
- formData 키: `"file"`, `"targetObjId"`, `"docType"`, `"docTypeName"`
|
||||
|
||||
## DataGrid GridColumn 인터페이스
|
||||
```typescript
|
||||
{
|
||||
title: string;
|
||||
field: string; // RESULTLIST의 대문자 필드명
|
||||
width?: number | string;
|
||||
hozAlign?: "left" | "center" | "right";
|
||||
formatter?: "money" | ((cell: unknown, row: Record<string, unknown>) => string);
|
||||
cellClick?: (row: Record<string, unknown>) => void;
|
||||
visible?: boolean;
|
||||
}
|
||||
```
|
||||
- formatter `"money"` → `numberWithCommas()` 자동 적용
|
||||
- 데이터 소스: `data` prop (배열) 또는 `dataUrl` (POST)
|
||||
- 빈 데이터 메시지: "데이터가 없습니다."
|
||||
|
||||
## SearchForm 구조
|
||||
```tsx
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="검색어">
|
||||
<Input name="keyword" />
|
||||
</SearchField>
|
||||
<SearchField label="분류">
|
||||
<CodeSelect codeId="CATEGORY" name="category" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
globs: src/app/(main)/**/*.tsx, src/app/(auth)/**/*.tsx, src/app/admin-panel/**/*.tsx
|
||||
---
|
||||
|
||||
# 페이지 코딩 규칙
|
||||
|
||||
## 기본 구조
|
||||
모든 페이지는 `"use client"` 클라이언트 컴포넌트:
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { DataGrid, GridColumn } from "@/components/grid/data-grid";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CodeSelect } from "@/components/ui/code-select";
|
||||
import Swal from "sweetalert2";
|
||||
```
|
||||
|
||||
## 페이지 레이아웃 순서
|
||||
1. 제목: `<h2 className="text-lg font-bold text-gray-800 mb-4">{제목}</h2>`
|
||||
2. 검색 폼: `<SearchForm>` + `<SearchField>`
|
||||
3. 버튼 영역: 조회/등록/삭제
|
||||
4. 데이터 그리드: `<DataGrid>` + `<Pagination>`
|
||||
|
||||
## 데이터 조회
|
||||
```tsx
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/{domain}/{resource}", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, category_cd }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, category_cd]);
|
||||
```
|
||||
|
||||
## 팝업
|
||||
```tsx
|
||||
const left = (window.screen.width - 1200) / 2;
|
||||
const top = (window.screen.height - 550) / 2;
|
||||
window.open(url, "formPopup", `width=1200,height=550,left=${left},top=${top}`);
|
||||
```
|
||||
|
||||
## 삭제 확인
|
||||
```tsx
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "삭제할 항목을 선택하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## DataGrid 높이
|
||||
페이지별 `calc(100vh - 350px)` ~ `calc(100vh - 400px)` 사용.
|
||||
|
||||
## 년도 필터 (자주 사용)
|
||||
```tsx
|
||||
Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
globs: **/*.ts, **/*.tsx
|
||||
---
|
||||
|
||||
# TypeScript 공통 규칙
|
||||
|
||||
## 경로 alias
|
||||
`@/*` → `./src/*` (tsconfig paths). 항상 `@/` 접두사 사용:
|
||||
```typescript
|
||||
import { queryRows } from "@/lib/db"; // O
|
||||
import { queryRows } from "../../lib/db"; // X
|
||||
```
|
||||
|
||||
## import 그룹 순서
|
||||
1. React/Next.js (`react`, `next/*`)
|
||||
2. 외부 라이브러리 (`sweetalert2`, `jose`, `lucide-react`)
|
||||
3. 내부 모듈 (`@/lib/*`, `@/store/*`, `@/types/*`, `@/components/*`)
|
||||
|
||||
## 데이터 타입
|
||||
- API 응답 데이터: `Record<string, unknown>[]`
|
||||
- 그리드 행 데이터: `Record<string, unknown>`
|
||||
- 필드명은 대문자: `row.OBJID`, `row.USER_NAME`
|
||||
- 중앙 타입은 `@/types/index.ts`에서 import
|
||||
|
||||
## null 처리
|
||||
`checkNull(value)` 유틸리티 사용. 문자열 `"null"`, `"undefined"`도 빈 문자열로 변환.
|
||||
|
||||
## strict mode
|
||||
tsconfig에 `strict: true` 설정됨. 암시적 any 금지.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(where *)",
|
||||
"Bash(docker --version)",
|
||||
"Bash(docker-compose *)",
|
||||
"Bash(docker compose *)",
|
||||
"Bash(git push*)",
|
||||
"Bash(git push -u origin*)",
|
||||
"Bash(docker run *)",
|
||||
"Bash(docker exec *)",
|
||||
"Bash(docker cp *)",
|
||||
"Bash(ssh *)",
|
||||
"Bash(scp *)",
|
||||
"Bash(sshpass *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env*
|
||||
!.env.production.example
|
||||
!.env.development.example
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
*.log
|
||||
coverage
|
||||
.claude
|
||||
.dockerignore
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -0,0 +1,19 @@
|
||||
# 모모유통(MOMO) 추가 환경변수 — .env / .env.production 에 함께 설정
|
||||
# 기존 FITO 변수에 아래 항목을 추가합니다.
|
||||
|
||||
# ============ DB ============
|
||||
# 모모유통 테이블도 동일 DATABASE_URL 의 momo_* 테이블에 저장됩니다.
|
||||
# DATABASE_URL 은 기존과 동일하게 사용
|
||||
|
||||
# ============ SMTP (메일 발송) ============
|
||||
# 발주 승인 시 거래명세표를 메일로 자동 발송합니다.
|
||||
# 미설정 시: 메일은 jsonTransport 로 콘솔에만 출력 (개발 편의), DB mail_logs 에는 SENT 로 기록
|
||||
SMTP_HOST=smtp.daum.net
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=momo8443@daum.net
|
||||
SMTP_PASS=__다음 메일 앱 비밀번호__
|
||||
SMTP_FROM=모모유통 <momo8443@daum.net>
|
||||
|
||||
# ============ 거래명세표에 표시될 공급자 정보 ============
|
||||
MOMO_BANK_ACCOUNT=기업은행 434-115361-01-016
|
||||
MOMO_PHONE=010-6624-5315
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Deploy momo-erp to production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy via SSH
|
||||
env:
|
||||
SSH_USER: ${{ secrets.DEPLOY_USER }}
|
||||
SSH_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
MASTER_PWD: ${{ secrets.MASTER_PWD }}
|
||||
AES_KEY: ${{ secrets.AES_KEY }}
|
||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||
SMTP_PASS: ${{ secrets.SMTP_PASS }}
|
||||
SMTP_FROM: ${{ secrets.SMTP_FROM }}
|
||||
MOMO_BANK_ACCOUNT: ${{ secrets.MOMO_BANK_ACCOUNT }}
|
||||
MOMO_PHONE: ${{ secrets.MOMO_PHONE }}
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" bash -s << 'REMOTE'
|
||||
set -e
|
||||
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
|
||||
cat > .env.production <<EOF
|
||||
DATABASE_URL="$DATABASE_URL"
|
||||
NEXTAUTH_URL="$NEXTAUTH_URL"
|
||||
NEXTAUTH_SECRET="$NEXTAUTH_SECRET"
|
||||
NEXT_PUBLIC_APP_NAME="유통관리 ERP"
|
||||
NEXT_PUBLIC_COMPANY_NAME="모모유통"
|
||||
MASTER_PWD="$MASTER_PWD"
|
||||
AES_KEY="$AES_KEY"
|
||||
FILE_STORAGE_PATH="/data_storage"
|
||||
LOG_LEVEL=info
|
||||
SMTP_HOST="$SMTP_HOST"
|
||||
SMTP_PORT="$SMTP_PORT"
|
||||
SMTP_USER="$SMTP_USER"
|
||||
SMTP_PASS="$SMTP_PASS"
|
||||
SMTP_FROM="$SMTP_FROM"
|
||||
MOMO_BANK_ACCOUNT="$MOMO_BANK_ACCOUNT"
|
||||
MOMO_PHONE="$MOMO_PHONE"
|
||||
EOF
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
# DB 마이그레이션 (idempotent — 이미 있으면 IF NOT EXISTS 로 스킵)
|
||||
docker compose -f docker-compose.prod.yml exec -T momo-erp npm run migrate:momo || true
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
REMOTE
|
||||
@@ -1,27 +1,44 @@
|
||||
# Compiled class files
|
||||
WebContent/WEB-INF/classes/
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Eclipse project files
|
||||
.settings/
|
||||
.classpath
|
||||
.project
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# OS files
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.pem
|
||||
|
||||
# Docker environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
env.development
|
||||
env.production
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
/logs/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,48 @@
|
||||
# CI/CD 배포 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
`.gitea/workflows/deploy.yml` 워크플로가 `main` 브랜치 푸시 시 자동으로
|
||||
배포 서버(183.99.177.40)에 SSH 접속 → `docker compose up -d --build` 실행합니다.
|
||||
|
||||
## Gitea 시크릿 등록
|
||||
|
||||
Gitea 저장소 → **Settings → Actions → Secrets** 에 다음 시크릿을 추가하세요:
|
||||
|
||||
| 시크릿 이름 | 값 (예시) |
|
||||
|-------------|----------|
|
||||
| `DEPLOY_HOST` | `183.99.177.40` |
|
||||
| `DEPLOY_USER` | `chpark` |
|
||||
| `DEPLOY_SSH_KEY` | SSH 개인키 전체 (BEGIN/END 포함) |
|
||||
| `DATABASE_URL` | `postgresql://postgres:qlalfqjsgh11@183.99.177.40:5432/distribution` |
|
||||
| `NEXTAUTH_URL` | `https://momo.junggomoa.com` |
|
||||
| `NEXTAUTH_SECRET` | 임의의 32바이트 hex (현재 .env.production 값 재사용 가능) |
|
||||
| `MASTER_PWD` | `qlalfqjsgh11` |
|
||||
| `AES_KEY` | `ILJIAESSECRETKEY` |
|
||||
|
||||
### SSH 키 생성 (최초 1회)
|
||||
|
||||
로컬에서:
|
||||
```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
|
||||
# 개인키를 Gitea Secret `DEPLOY_SSH_KEY` 에 붙여넣기
|
||||
cat ~/.ssh/momo_deploy
|
||||
```
|
||||
|
||||
## Gitea Actions Runner
|
||||
|
||||
워크플로가 실행되려면 Gitea Actions Runner가 등록돼 있어야 합니다.
|
||||
`git.junggomoa.com` 인스턴스에 Runner가 이미 있으면 이 단계는 생략.
|
||||
없으면 배포 서버나 별도 머신에 [act_runner](https://gitea.com/gitea/act_runner) 설치 필요.
|
||||
|
||||
## 수동 배포 (CI/CD 우회)
|
||||
|
||||
긴급 시:
|
||||
```bash
|
||||
ssh chpark@183.99.177.40
|
||||
cd ~/momo-erp/source
|
||||
git pull
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
@@ -1,136 +1,77 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
ILSHIN PLM (Product Lifecycle Management) 솔루션 - 제품 수명 주기 전체를 관리하는 Java 기반 엔터프라이즈 웹 애플리케이션
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- **Java 7** (Eclipse 프로젝트, JRE 1.7)
|
||||
- **Spring Framework 3.2.4** - MVC, IoC 컨테이너
|
||||
- **MyBatis 3.2.3** - SQL 매핑 프레임워크
|
||||
- **PostgreSQL** - 데이터베이스
|
||||
- **Apache Tomcat 7.0** - 웹/애플리케이션 서버
|
||||
- **JSP/jQuery/jqGrid** - 프론트엔드
|
||||
- **Apache Tiles 3.0.5** - 템플릿 엔진
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
plm-ilshin/
|
||||
├── src/ # Java 소스 코드
|
||||
│ └── com/pms/
|
||||
│ ├── controller/ # Spring MVC 컨트롤러
|
||||
│ ├── service/ # 비즈니스 로직
|
||||
│ ├── dao/ # 데이터 액세스 계층
|
||||
│ └── mapper/ # MyBatis XML 매퍼 파일
|
||||
├── WebContent/
|
||||
│ ├── WEB-INF/
|
||||
│ │ ├── classes/ # 컴파일된 클래스 (빌드 산출물)
|
||||
│ │ ├── lib/ # JAR 라이브러리
|
||||
│ │ ├── view/ # JSP 뷰 파일
|
||||
│ │ ├── web.xml # 웹 애플리케이션 설정
|
||||
│ │ └── dispatcher-servlet.xml # Spring MVC 설정
|
||||
│ ├── resources/ # 정적 리소스 (CSS, JS, 이미지)
|
||||
│ └── *.jsp # JSP 페이지
|
||||
└── docker-compose.*.yml # Docker 구성 파일
|
||||
```
|
||||
|
||||
## 빌드 및 실행
|
||||
|
||||
### Eclipse 빌드
|
||||
- Eclipse IDE에서 직접 빌드 (Maven/Gradle 미사용)
|
||||
- 소스: `src/` → 컴파일 결과: `WebContent/WEB-INF/classes/`
|
||||
|
||||
### Docker 환경 실행
|
||||
|
||||
```bash
|
||||
# 개발 환경 실행
|
||||
docker-compose -f docker-compose.dev.yml up --build -d
|
||||
|
||||
# 프로덕션 환경 실행
|
||||
docker-compose -f docker-compose.prod.yml up --build -d
|
||||
|
||||
# 로그 확인
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# 컨테이너 중지
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
### 접속 정보
|
||||
- 애플리케이션: `http://localhost:9090` (개발)
|
||||
- DB 연결: PostgreSQL (JNDI: `java:comp/env/plm`)
|
||||
|
||||
## 주요 모듈
|
||||
|
||||
### 핵심 기능 패키지
|
||||
- `com.pms.controller` - MVC 컨트롤러
|
||||
- `com.pms.ions.itemmgmt` - 품목 관리 (BOM, 자재 등)
|
||||
- `com.pms.ions.productioninventory` - 생산/재고 관리
|
||||
- `com.pms.salesmgmt` - 영업 관리
|
||||
|
||||
### 주요 컨트롤러
|
||||
- `BomController` - BOM 관리
|
||||
- `ImItemController` - 품목 마스터
|
||||
- `ProductionInventoryController` - 생산 재고
|
||||
- `OrderMgmtController` - 주문 관리
|
||||
- `ECRController`, `EOController` - 설계 변경 관리
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### MyBatis 매퍼 위치
|
||||
- 소스: `src/com/pms/mapper/*.xml`
|
||||
- 빌드: `WebContent/WEB-INF/classes/com/pms/mapper/*.xml`
|
||||
|
||||
### 주요 매퍼 파일
|
||||
- `bom.xml` - BOM 쿼리
|
||||
- `imItem.xml` - 품목 관리
|
||||
- `productionplanning.xml` - 생산 계획
|
||||
- `orderMgmt.xml` - 주문 관리
|
||||
|
||||
## 개발 주의사항
|
||||
|
||||
1. **Java 버전**: Java 7 문법만 사용 (람다, 스트림 API 사용 불가)
|
||||
2. **Spring 버전**: Spring 3.2.4 기능만 사용
|
||||
3. **문자 인코딩**: UTF-8 설정 필수
|
||||
4. **세션 관리**: Spring Security 미사용, 직접 구현된 세션 관리 사용
|
||||
5. **파일 구조**: Eclipse Dynamic Web Project 구조 유지
|
||||
6. **빌드**: IDE 기반 빌드, Maven/Gradle 미사용
|
||||
|
||||
## 테스트
|
||||
|
||||
### 단위 테스트
|
||||
```bash
|
||||
# 테스트 프레임워크 미구성 - 수동 테스트 필요
|
||||
# Tomcat 서버에 배포 후 브라우저에서 기능 테스트
|
||||
```
|
||||
|
||||
### 로컬 테스트
|
||||
1. Eclipse에서 Tomcat 서버 추가
|
||||
2. 프로젝트를 서버에 배포
|
||||
3. `http://localhost:8080/ilshin` 접속
|
||||
|
||||
## 디버깅
|
||||
|
||||
### 로그 확인
|
||||
- Log4j 설정: `WebContent/WEB-INF/log4j.xml`
|
||||
- 로그 레벨 조정 가능
|
||||
|
||||
### Docker 디버깅
|
||||
```bash
|
||||
# 컨테이너 내부 접속
|
||||
docker exec -it plm-ilshin-dev bash
|
||||
|
||||
# Tomcat 로그 확인
|
||||
docker logs plm-ilshin-dev
|
||||
```
|
||||
|
||||
## 주요 설정 파일
|
||||
|
||||
- `WebContent/WEB-INF/web.xml` - 웹 애플리케이션 설정
|
||||
- `WebContent/WEB-INF/dispatcher-servlet.xml` - Spring MVC 설정
|
||||
- `SETTING_GUIDE.txt` - Tomcat Context 설정 (JNDI DataSource)
|
||||
- `.classpath`, `.project` - Eclipse 프로젝트 설정
|
||||
# FITO — (주)피토 PLM (Next.js 풀스택)
|
||||
|
||||
기존 Java/Spring MVC + JSP + MyBatis 기반 FITO 시스템을 Next.js 15 + Node.js로 1:1 컨버전한 제조/PLM 시스템.
|
||||
|
||||
> **프로젝트 히스토리**: `woosung-nextjs`에서 피벗. 이전 상태는 태그 `woosung-v1-snapshot`에 보존. 우성 도메인 코드(api/*, app/(main)/*)는 점진적으로 FITO로 교체 중.
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리별 CLAUDE.md
|
||||
|
||||
| 디렉토리 | 내용 |
|
||||
|---------|------|
|
||||
| [src/lib/](src/lib/CLAUDE.md) | 핵심 인프라: DB, 인증, 세션, 암호화, 상수 |
|
||||
| [src/store/](src/store/CLAUDE.md) | Zustand 전역 상태 (auth, menu, theme) |
|
||||
| [src/types/](src/types/CLAUDE.md) | TypeScript 중앙 타입 정의 |
|
||||
| [src/components/](src/components/CLAUDE.md) | UI/레이아웃/그리드 공통 컴포넌트 |
|
||||
| [src/app/api/](src/app/api/CLAUDE.md) | API 라우트 |
|
||||
| [src/app/(main)/](src/app/(main)/CLAUDE.md) | 업무 페이지 (인증 필수) |
|
||||
| [src/app/(auth)/](src/app/(auth)/CLAUDE.md) | 로그인 페이지 |
|
||||
| [src/app/admin-panel/](src/app/admin-panel/CLAUDE.md) | 관리자 패널 (팝업) |
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
- **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`)
|
||||
- **인증**: JWT (jose) + Cookie 기반 세션
|
||||
- **상태관리**: Zustand
|
||||
- **UI**: SweetAlert2, Lucide Icons, Custom DataGrid (TanStack React Table)
|
||||
|
||||
## 원본 프로젝트 매핑
|
||||
원본 FITO 위치: `/Users/jhj/FITO/` (Java 7 + Spring 3.2.4 + MyBatis 3.2.3 + JSP + Tomcat 7)
|
||||
|
||||
- `src/app/api/` → Java Controller (`*.do` 엔드포인트, `com.pms.controller.*`)
|
||||
- `src/app/(main)/` → JSP 페이지 (`/WebContent/WEB-INF/view/`)
|
||||
- `src/lib/db.ts` → MyBatis SqlSession (queryRows = selectList, queryOne = selectOne)
|
||||
- `src/lib/auth.ts` → LoginService
|
||||
- `src/lib/session.ts` → SessionManager + PersonBean
|
||||
- `src/lib/encrypt.ts` → EncryptUtil (AES)
|
||||
- `src/lib/constants.ts` → Constants.java
|
||||
- `src/lib/utils.ts` → CommonUtils.java
|
||||
|
||||
## DB 쿼리 패턴
|
||||
기존 MyBatis XML mapper의 SQL을 `queryRows`/`queryOne`/`execute`로 직접 실행.
|
||||
파라미터는 `$1`, `$2` 형태의 PostgreSQL prepared statement 사용.
|
||||
|
||||
**FITO mapper 위치**: `/Users/jhj/FITO/src/com/pms/mapper/` (77개 XML)
|
||||
|
||||
## 새 모듈 추가 시
|
||||
1. `src/app/api/[module]/route.ts` — API 라우트 (기존 Controller 대응)
|
||||
2. `src/app/(main)/[module]/page.tsx` — 페이지 (기존 JSP 대응)
|
||||
3. FITO MyBatis XML에서 SQL 복사하여 raw query로 변환
|
||||
4. `SearchForm` + `DataGrid` 컴포넌트 조합으로 목록 페이지 구성
|
||||
|
||||
## 미들웨어 (인증)
|
||||
`src/middleware.ts`가 모든 요청을 가로채서 `plm-session` 쿠키 존재 여부 확인.
|
||||
- 공개 경로: `/login`, `/api/auth/login`, `/_next`, `/favicon.ico`
|
||||
- 세션 없는 일반 요청 → `/login`으로 리다이렉트
|
||||
- 세션 없는 API 요청 → 401 JSON 응답
|
||||
|
||||
## 실행
|
||||
```bash
|
||||
npm run dev # 개발 서버 (localhost:3000)
|
||||
```
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `.env.development` — 로컬 개발용 (외부 FITO DB 접속)
|
||||
- `.env.development.example` — 팀 공유 템플릿 (비밀번호 마스킹)
|
||||
- `.env` — Prisma CLI용 기본값
|
||||
|
||||
## 배포 표준 (예정)
|
||||
|
||||
- Docker Compose dev/prod 분리
|
||||
- Traefik 리버스 프록시 + `fito.wace.me` 서브도메인
|
||||
- DB는 외부 `211.115.91.141:11140/fito` 공유 (컨테이너 내부 DB 없음)
|
||||
|
||||
@@ -1,71 +1,44 @@
|
||||
# Multi-stage build for production
|
||||
# Stage 1: Build stage - compile Java sources
|
||||
FROM dockerhub.wace.me/tomcat:7.0.94-jre7-alpine.linux AS builder
|
||||
# Multi-stage build for FITO Next.js 풀스택 — production
|
||||
# Stage 1: 의존성 설치
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Install JDK for compilation (JRE image doesn't have javac)
|
||||
RUN apk add --no-cache openjdk7
|
||||
# Stage 2: 빌드
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
# 환경변수 (빌드 타임에 주입되는 NEXT_PUBLIC_* 변수)
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy source code and libraries
|
||||
COPY src ./src
|
||||
COPY WebContent ./WebContent
|
||||
RUN npm run build
|
||||
|
||||
# Create classes directory
|
||||
RUN mkdir -p WebContent/WEB-INF/classes
|
||||
# Stage 3: 런타임 (최소 이미지)
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Compile Java sources (include Tomcat servlet API in classpath)
|
||||
RUN find src -name "*.java" -print0 | xargs -0 javac \
|
||||
-encoding UTF-8 \
|
||||
-source 1.7 \
|
||||
-target 1.7 \
|
||||
-d WebContent/WEB-INF/classes \
|
||||
-cp "WebContent/WEB-INF/lib/*:/usr/local/tomcat/lib/*" \
|
||||
-Xlint:-options \
|
||||
-Xlint:-deprecation \
|
||||
-Xlint:-unchecked
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy resources (XML, properties files)
|
||||
RUN find src -type f \( -name "*.xml" -o -name "*.properties" \) | while read -r filepath; do \
|
||||
relative_path="${filepath#src/}"; \
|
||||
target_file="WebContent/WEB-INF/classes/$relative_path"; \
|
||||
mkdir -p "$(dirname "$target_file")"; \
|
||||
cp "$filepath" "$target_file"; \
|
||||
done
|
||||
# 비루트 사용자 (보안)
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Verify compilation
|
||||
RUN CLASS_COUNT=$(find WebContent/WEB-INF/classes -name "*.class" | wc -l); \
|
||||
if [ $CLASS_COUNT -eq 0 ]; then \
|
||||
echo "ERROR: No Java classes were compiled!"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "Successfully compiled $CLASS_COUNT Java classes"; \
|
||||
fi
|
||||
# standalone 번들 복사
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Stage 2: Runtime stage
|
||||
FROM dockerhub.wace.me/tomcat:7.0.94-jre7-alpine.linux AS production
|
||||
# 파일 업로드 디렉토리
|
||||
RUN mkdir -p /data_storage && chown nextjs:nodejs /data_storage
|
||||
|
||||
# Install fonts for POI Excel generation
|
||||
RUN apk add --no-cache fontconfig ttf-dejavu
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
# Remove default webapps
|
||||
RUN rm -rf /usr/local/tomcat/webapps/*
|
||||
|
||||
# Copy compiled application from builder stage
|
||||
COPY --from=builder /build/WebContent /usr/local/tomcat/webapps/ROOT
|
||||
|
||||
# Copy source for reference (optional)
|
||||
COPY src /usr/local/tomcat/webapps/ROOT/WEB-INF/src
|
||||
|
||||
# Copy custom Tomcat context configuration for JNDI
|
||||
COPY ./tomcat-conf/context.xml /usr/local/tomcat/conf/context.xml
|
||||
|
||||
# Configure Tomcat Connector for UTF-8 URI encoding
|
||||
RUN sed -i 's/<Connector port="8080"/<Connector port="8080" URIEncoding="UTF-8"/g' /usr/local/tomcat/conf/server.xml
|
||||
|
||||
# Expose Tomcat port
|
||||
EXPOSE 8080
|
||||
|
||||
# Start Tomcat
|
||||
CMD ["catalina.sh", "run"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,68 +1,19 @@
|
||||
# Multi-stage build for development
|
||||
# Stage 1: Build stage - compile Java sources
|
||||
FROM dockerhub.wace.me/tomcat:7.0.94-jre7-alpine.arm64 AS builder
|
||||
# 개발환경 — hot reload 지원
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Install JDK for compilation (JRE image doesn't have javac)
|
||||
RUN apk add --no-cache openjdk7
|
||||
# 의존성 캐시용
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
# 소스는 bind mount (docker-compose.dev.yml에서 지정)
|
||||
COPY . .
|
||||
|
||||
# Copy source code and libraries
|
||||
COPY src ./src
|
||||
COPY WebContent ./WebContent
|
||||
ENV NODE_ENV=development
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Create classes directory
|
||||
RUN mkdir -p WebContent/WEB-INF/classes
|
||||
EXPOSE 3000
|
||||
|
||||
# Compile Java sources (include Tomcat servlet API in classpath)
|
||||
RUN find src -name "*.java" -print0 | xargs -0 javac \
|
||||
-encoding UTF-8 \
|
||||
-source 1.7 \
|
||||
-target 1.7 \
|
||||
-d WebContent/WEB-INF/classes \
|
||||
-cp "WebContent/WEB-INF/lib/*:/usr/local/tomcat/lib/*" \
|
||||
-Xlint:-options \
|
||||
-Xlint:-deprecation \
|
||||
-Xlint:-unchecked
|
||||
|
||||
# Copy resources (XML, properties files)
|
||||
RUN find src -type f \( -name "*.xml" -o -name "*.properties" \) | while read -r filepath; do \
|
||||
relative_path="${filepath#src/}"; \
|
||||
target_file="WebContent/WEB-INF/classes/$relative_path"; \
|
||||
mkdir -p "$(dirname "$target_file")"; \
|
||||
cp "$filepath" "$target_file"; \
|
||||
done
|
||||
|
||||
# Verify compilation
|
||||
RUN CLASS_COUNT=$(find WebContent/WEB-INF/classes -name "*.class" | wc -l); \
|
||||
if [ $CLASS_COUNT -eq 0 ]; then \
|
||||
echo "ERROR: No Java classes were compiled!"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "Successfully compiled $CLASS_COUNT Java classes"; \
|
||||
fi
|
||||
|
||||
# Stage 2: Runtime stage
|
||||
FROM dockerhub.wace.me/tomcat:7.0.94-jre7-alpine.arm64 AS development
|
||||
|
||||
# Remove default webapps
|
||||
RUN rm -rf /usr/local/tomcat/webapps/*
|
||||
|
||||
# Copy compiled application from builder stage
|
||||
COPY --from=builder /build/WebContent /usr/local/tomcat/webapps/ROOT
|
||||
|
||||
# Copy source for reference
|
||||
COPY src /usr/local/tomcat/webapps/ROOT/WEB-INF/src
|
||||
|
||||
# Copy custom Tomcat context configuration for JNDI
|
||||
COPY ./tomcat-conf/context.xml /usr/local/tomcat/conf/context.xml
|
||||
|
||||
# Configure Tomcat Connector for UTF-8 URI encoding
|
||||
RUN sed -i 's/<Connector port="8080"/<Connector port="8080" URIEncoding="UTF-8"/g' /usr/local/tomcat/conf/server.xml
|
||||
|
||||
# Expose Tomcat port
|
||||
EXPOSE 8080
|
||||
|
||||
# Start Tomcat
|
||||
CMD ["catalina.sh", "run"]
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,80 +1,70 @@
|
||||
# PLM 솔루션 (ILSHIN)
|
||||
# FITO — (주)피토 PLM (Next.js)
|
||||
|
||||
## 프로젝트 개요
|
||||
기존 Java/Spring MVC + JSP + MyBatis 기반 FITO PLM을 Next.js 15 + Node.js로 컨버전한 시스템.
|
||||
|
||||
본 프로젝트는 제품 수명 주기 관리(PLM - Product Lifecycle Management) 솔루션입니다. 제품 개발 초기 단계부터 설계, 생산, 유통, 유지보수 및 폐기에 이르기까지 제품과 관련된 모든 데이터와 프로세스를 통합적으로 관리하는 것을 목표로 합니다.
|
||||
- 원본: [/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`.
|
||||
|
||||
## 주요 기능 (예상)
|
||||
## 개발 시작
|
||||
|
||||
소스 코드 분석 및 일반적인 PLM 솔루션의 기능을 바탕으로 다음과 같은 메뉴/기능을 포함할 것으로 예상됩니다. 실제 메뉴는 애플리케이션 실행 후 확인해야 합니다.
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
- **제품 정보 관리:** 제품 분류, 속성, 사양 등 관리
|
||||
- **BOM (Bill of Materials) 관리:** 부품 목록, 계층 구조 관리 (eBOM, mBOM 등)
|
||||
- **설계 변경 관리 (ECO/ECR):** 설계 변경 요청, 검토, 승인 프로세스 관리
|
||||
- **문서 관리:** CAD 데이터, 도면, 기술 문서 등의 버전 관리 및 접근 제어
|
||||
- **프로젝트/일정 관리:** 개발 프로젝트 일정, 리소스, 산출물 관리
|
||||
- **사용자 및 권한 관리:** 역할 기반 접근 제어
|
||||
- **워크플로우 관리:** 표준 프로세스 자동화 및 추적
|
||||
- **데이터 시각화/리포트:** 다양한 형식의 보고서 생성 (데이터 표시는 `jqGrid` 등을 활용할 것으로 보입니다.)
|
||||
## 환경변수
|
||||
|
||||
## 기술 스택
|
||||
`.env.development`의 DB 접속 정보를 확인. 필수 키:
|
||||
|
||||
- **Backend:**
|
||||
- Java 7 (Eclipse project settings and JRE 7 libraries confirm version 1.7)
|
||||
- Spring Framework (v3.2.4.RELEASE)
|
||||
- MyBatis (v3.2.3) - 데이터베이스 연동
|
||||
- **Frontend:**
|
||||
- JSP (JavaServer Pages)
|
||||
- JavaScript (jQuery v1.11.3 / v2.1.4, jqGrid v4.7.1 확인)
|
||||
- CSS
|
||||
- Apache Tiles (v3.0.5) - 페이지 레이아웃/템플릿
|
||||
- **Database:**
|
||||
- PostgreSQL (연결 정보 및 드라이버 확인)
|
||||
- **Web Server / WAS:**
|
||||
- Apache Tomcat (v7.0 - `.classpath` 및 `SETTING_GUIDE.txt` 참조)
|
||||
- **Build:**
|
||||
- IDE (Eclipse) 기반 빌드 (소스는 `src`, 컴파일된 클래스는 `WebContent/WEB-INF/classes`, 라이브러리는 `WebContent/WEB-INF/lib`)
|
||||
- Maven/Gradle 등 표준 빌드 관리 도구는 사용되지 않음
|
||||
- **주요 라이브러리:**
|
||||
- `log4j`, `slf4j` (로깅)
|
||||
- Tomcat JNDI DataSource 사용 추정 (Datasource 설정 및 관련 라이브러리 부재 기반)
|
||||
- `jackson`, `json-lib`, `gson` (JSON 처리)
|
||||
- Apache Commons 라이브러리 (Lang, IO, Collections, FileUpload 등)
|
||||
- `jxl`, `poi` (Excel 파일 처리)
|
||||
- `pdfbox` (PDF 처리)
|
||||
- 기타 다수 (`WebContent/WEB-INF/lib` 디렉토리 참조)
|
||||
- `DATABASE_URL` — 외부 PostgreSQL 접속
|
||||
- `NEXTAUTH_SECRET` — JWT 서명 키
|
||||
- `MASTER_PWD` — 마스터 비밀번호 (개발 편의용)
|
||||
- `AES_KEY` — 비밀번호 AES 암호화 키 (기존 Java 호환)
|
||||
|
||||
## 프로젝트 구조 (폴더별 역할)
|
||||
## 배포 표준
|
||||
|
||||
- **`.git/`**: Git 버전 관리 시스템 메타데이터 저장소.
|
||||
- **`tomcat-conf/`**: Docker 환경에서 사용될 Tomcat 서버 설정 파일 (예: `server.xml`, `context.xml`).
|
||||
- **`db/`**: 데이터베이스 관련 파일 저장 (예: Docker 초기화용 DB 덤프 `.pgsql` 파일).
|
||||
- **`.svn/`**: Subversion 버전 관리 메타데이터 (과거 SVN 사용 이력 추정).
|
||||
- **`WebContent/`**: 웹 애플리케이션 루트 디렉토리 (Eclipse Dynamic Web Project 표준).
|
||||
- 정적 리소스 (HTML, CSS, JS, 이미지 등).
|
||||
- JSP 파일.
|
||||
- `WEB-INF/`: 웹 애플리케이션 설정 및 라이브러리.
|
||||
- `classes/`: 컴파일된 Java 클래스 파일 (`src` 폴더의 결과물).
|
||||
- `lib/`: 애플리케이션 실행에 필요한 라이브러리 (`.jar` 파일).
|
||||
- `web.xml`: 웹 애플리케이션 배포 서술자.
|
||||
- 기타 설정 파일 (예: Tiles 설정).
|
||||
- **`src/`**: Java 소스 코드 (`.java` 파일) - 백엔드 로직 (Controller, Service, DAO 등).
|
||||
- **`.settings/`**: Eclipse IDE 프로젝트 관련 설정 파일.
|
||||
- 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 없음
|
||||
|
||||
## 실행 환경 설정 (Docker)
|
||||
### `start.sh` 배포 스크립트 (권장)
|
||||
|
||||
이 프로젝트는 제공된 `Dockerfile` 및 `docker-compose.yml` 파일을 사용하여 Docker 컨테이너 환경에서 실행할 수 있습니다.
|
||||
```bash
|
||||
# 첫 배포 (서버에서)
|
||||
cp .env.production.example .env.production
|
||||
vi .env.production # DATABASE_URL, NEXTAUTH_SECRET, AES_KEY 등 입력
|
||||
|
||||
1. **Docker 및 Docker Compose 설치:** 시스템에 Docker와 Docker Compose가 설치되어 있어야 합니다.
|
||||
2. **데이터베이스 파일 준비:** `ilshin.pgsql` 파일이 프로젝트 루트 디렉토리에 있는지 확인합니다. (필요시 `docker-compose.yml`에서 파일 경로 수정)
|
||||
3. **Docker Compose 실행:** 프로젝트 루트 디렉토리에서 다음 명령어를 실행합니다.
|
||||
```bash
|
||||
docker-compose up --build -d
|
||||
```
|
||||
- `-d` 옵션은 백그라운드 실행을 의미합니다.
|
||||
4. **애플리케이션 접속:** 웹 브라우저에서 `http://localhost:8080` (또는 `docker-compose.yml`에 설정된 포트)으로 접속합니다.
|
||||
./start.sh prod # git pull → build → 기동 → Traefik 라우팅 확인
|
||||
|
||||
**참고:**
|
||||
# 이후 배포 (git commit 후)
|
||||
./start.sh prod # 자동 git pull + 재빌드
|
||||
|
||||
- 애플리케이션이 데이터베이스 연결 정보를 읽는 방식 (예: JNDI, 환경 변수, 설정 파일)에 따라 `docker-compose.yml`의 `app` 서비스 환경 변수 또는 Tomcat 설정 파일 (`context.xml` 등) 수정이 필요할 수 있습니다.
|
||||
- `SETTING_GUIDE.txt`에 명시된 Tomcat 컨텍스트 경로 (`/ilshin`)를 사용하려면 `Dockerfile`에서 `WebContent`를 `/usr/local/tomcat/webapps/ilshin`로 복사하거나 Tomcat 설정을 변경해야 할 수 있습니다. 현재 설정은 `/` (ROOT) 경로로 배포됩니다.
|
||||
# 기타 운영
|
||||
./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 # 전체 삭제 (확인 필요)
|
||||
```
|
||||
|
||||
스크립트는 start.sh 자체가 업데이트되면 새 버전으로 **자동 재실행**하므로 안전합니다.
|
||||
|
||||
### 로컬 개발
|
||||
|
||||
```bash
|
||||
./start.sh # docker 기반 (localhost:3643, hot reload)
|
||||
npm run dev # docker 없이 Node 직접 (localhost:3000)
|
||||
```
|
||||
|
||||
### 인프라 정보
|
||||
|
||||
- 컨테이너명: `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) 참고.
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
-- ============================================================================
|
||||
-- 모모유통 (MOMO) 유통관리 시스템 — 초기 스키마
|
||||
-- 기존 FITO 테이블과 분리하기 위해 momo_ 접두사 사용
|
||||
-- 실행: psql $DATABASE_URL -f db/migrations/001_momo_init.sql
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 회원 (대리점 + 관리자) ----------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_users (
|
||||
objid TEXT PRIMARY KEY,
|
||||
email VARCHAR(200) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(200) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
ceo_name VARCHAR(100),
|
||||
biz_no VARCHAR(20),
|
||||
phone VARCHAR(50),
|
||||
address VARCHAR(300),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER | ADMIN
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | LOCKED | LEFT
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_users_email ON momo_users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_users_role ON momo_users(role, status);
|
||||
|
||||
-- 2. 제조사 ------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_makers (
|
||||
objid TEXT PRIMARY KEY,
|
||||
maker_name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
|
||||
-- 3. 품목 --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
item_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
item_name VARCHAR(200) NOT NULL,
|
||||
item_detail TEXT,
|
||||
maker_objid TEXT,
|
||||
unit VARCHAR(20) DEFAULT 'EA',
|
||||
unit_price NUMERIC(15,2) DEFAULT 0,
|
||||
cost_price NUMERIC(15,2) DEFAULT 0,
|
||||
is_tax_free CHAR(1) DEFAULT 'N', -- 'Y' = 면세 (M 접두 품목)
|
||||
image_url TEXT,
|
||||
attributes JSONB,
|
||||
status VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_status ON momo_items(status, is_del);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_taxfree ON momo_items(is_tax_free);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_items_name ON momo_items(item_name);
|
||||
|
||||
-- 4. 창고 --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_warehouses (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
wh_name VARCHAR(200) NOT NULL,
|
||||
location VARCHAR(200),
|
||||
wh_type VARCHAR(20) DEFAULT 'STOCK', -- STOCK | PICKUP_TEAM | MARKET | DELIVERY
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 5. 재고 (창고×품목) --------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_stocks (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
qty NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
update_date TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(wh_objid, item_objid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_stocks_item ON momo_stocks(item_objid);
|
||||
|
||||
-- 6. 입출고 이력 -------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_stock_moves (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
move_type VARCHAR(20) NOT NULL, -- IN | OUT | ADJ | TRANSFER
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
ref_type VARCHAR(20), -- ORDER | PROCUREMENT | MANUAL
|
||||
ref_objid TEXT,
|
||||
memo TEXT,
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_moves_item ON momo_stock_moves(item_objid, regdate);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_moves_ref ON momo_stock_moves(ref_type, ref_objid);
|
||||
|
||||
-- 7. 발주서 (대리점 → 모모유통) ---------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_orders (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_no VARCHAR(50) NOT NULL UNIQUE,
|
||||
customer_objid TEXT NOT NULL,
|
||||
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
|
||||
approve_user TEXT,
|
||||
approve_date TIMESTAMP,
|
||||
ship_date TIMESTAMP,
|
||||
invoice_no VARCHAR(50),
|
||||
invoice_date DATE,
|
||||
total_supply NUMERIC(15,2) DEFAULT 0,
|
||||
total_vat NUMERIC(15,2) DEFAULT 0,
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
total_taxfree NUMERIC(15,2) DEFAULT 0,
|
||||
total_taxable NUMERIC(15,2) DEFAULT 0,
|
||||
paid_amount NUMERIC(15,2) DEFAULT 0,
|
||||
paid_date DATE,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_orders_cust ON momo_orders(customer_objid, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_orders_status ON momo_orders(status, order_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_order_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
item_name_snap VARCHAR(200),
|
||||
unit_price NUMERIC(15,2) NOT NULL,
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
is_tax_free CHAR(1) NOT NULL DEFAULT 'N',
|
||||
supply_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
total_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
seq INT,
|
||||
remark VARCHAR(200)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_order_items ON momo_order_items(order_objid);
|
||||
|
||||
-- 8. 매입처 / 매입발주 -------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_vendors (
|
||||
objid TEXT PRIMARY KEY,
|
||||
vendor_name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
biz_no VARCHAR(20),
|
||||
is_del CHAR(1) DEFAULT 'N'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_procurements (
|
||||
objid TEXT PRIMARY KEY,
|
||||
proc_no VARCHAR(50) NOT NULL UNIQUE,
|
||||
vendor_objid TEXT,
|
||||
proc_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'OPEN',
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_procurement_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
proc_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
cost_price NUMERIC(15,2) NOT NULL,
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
total_amount NUMERIC(15,2) NOT NULL,
|
||||
received_qty NUMERIC(15,2) DEFAULT 0
|
||||
);
|
||||
|
||||
-- 9. 첨부 / 메일 로그 --------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS momo_attachments (
|
||||
objid TEXT PRIMARY KEY,
|
||||
ref_type VARCHAR(20) NOT NULL,
|
||||
ref_objid TEXT NOT NULL,
|
||||
file_name VARCHAR(300) NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
mime_type VARCHAR(100),
|
||||
file_size BIGINT,
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_attach_ref ON momo_attachments(ref_type, ref_objid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS momo_mail_logs (
|
||||
objid TEXT PRIMARY KEY,
|
||||
to_email VARCHAR(200) NOT NULL,
|
||||
subject VARCHAR(300),
|
||||
body TEXT,
|
||||
ref_type VARCHAR(20),
|
||||
ref_objid TEXT,
|
||||
status VARCHAR(20) DEFAULT 'PENDING',
|
||||
error_msg TEXT,
|
||||
sent_at TIMESTAMP,
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_momo_maillogs_ref ON momo_mail_logs(ref_type, ref_objid);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- ============================================================================
|
||||
-- 모모유통 시드 데이터 — 초기 관리자, 창고 4개, 샘플 제조사
|
||||
-- 실행 전 db/migrations/001_momo_init.sql 적용 필요
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 초기 관리자 (이메일: admin@momo.com / 비밀번호: admin1234 — bcrypt)
|
||||
-- bcrypt hash for "admin1234" cost=10 — 운영 시 반드시 비밀번호 변경
|
||||
INSERT INTO momo_users (objid, email, password_hash, company_name, role, status, regdate)
|
||||
VALUES (
|
||||
'MOMOADM00000001',
|
||||
'admin@momo.com',
|
||||
'$2b$10$gqkZxYVzQwH8gCWPvfBtFOg/9QDx2iO3p0d8RA7d7j.VhSZqHfqTa',
|
||||
'모모유통(관리자)',
|
||||
'ADMIN',
|
||||
'ACTIVE',
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- 창고
|
||||
INSERT INTO momo_warehouses (objid, wh_code, wh_name, wh_type, regdate) VALUES
|
||||
('MOMOWH000000001', 'WH001', '본사창고', 'STOCK', NOW()),
|
||||
('MOMOWH000000002', 'WH002', '시장픽업', 'MARKET', NOW()),
|
||||
('MOMOWH000000003', 'WH003', '용차배송', 'DELIVERY', NOW()),
|
||||
('MOMOWH000000004', 'WH004', '창고픽업팀','PICKUP_TEAM', NOW())
|
||||
ON CONFLICT (wh_code) DO NOTHING;
|
||||
|
||||
-- 샘플 제조사
|
||||
INSERT INTO momo_makers (objid, maker_name, regdate) VALUES
|
||||
('MOMOMK000000001', '성부유통', NOW()),
|
||||
('MOMOMK000000002', '과트', NOW()),
|
||||
('MOMOMK000000003', '날로유진', NOW())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,25 +1,24 @@
|
||||
version: '3.8'
|
||||
|
||||
# 로컬 개발 (hot reload)
|
||||
# 사용: docker compose -f docker-compose.dev.yml up --build
|
||||
services:
|
||||
momo-erp:
|
||||
plm-fito-next-dev:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: momo-erp-dev
|
||||
container_name: plm-fito-next-dev
|
||||
ports:
|
||||
- "9090:8080"
|
||||
- "3643:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
CATALINA_OPTS: >-
|
||||
-DDB_URL=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||
-DDB_USERNAME=${DB_USERNAME}
|
||||
-DDB_PASSWORD=${DB_PASSWORD}
|
||||
- .env.development
|
||||
volumes:
|
||||
- momo-erp-project_data:/data_storage
|
||||
- momo-erp-app_data:/path/inside/container
|
||||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
- ./prisma:/app/prisma
|
||||
- ./next.config.ts:/app/next.config.ts
|
||||
- ./tsconfig.json:/app/tsconfig.json
|
||||
- ./package.json:/app/package.json
|
||||
- plm-fito-next-data:/data_storage
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
momo-erp-project_data:
|
||||
momo-erp-app_data:
|
||||
plm-fito-next-data:
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
# 운영 배포 (Traefik + momo.junggomoa.com)
|
||||
# 대상 서버: 183.99.177.40 (Traefik v2.11 외부 네트워크 traefik-net 사용)
|
||||
# 사용: docker compose -f docker-compose.prod.yml up -d --build
|
||||
services:
|
||||
momo-erp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: momo-erp:latest
|
||||
container_name: momo-erp
|
||||
restart: always
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
CATALINA_OPTS: >-
|
||||
-DDB_URL=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||
-DDB_USERNAME=${DB_USERNAME}
|
||||
-DDB_PASSWORD=${DB_PASSWORD}
|
||||
-Xms512m
|
||||
-Xmx1536m
|
||||
-XX:MaxPermSize=256m
|
||||
-Djava.net.preferIPv4Stack=true
|
||||
-Dfile.encoding=UTF-8
|
||||
volumes:
|
||||
- /home/momo-erp/project_data:/data_storage
|
||||
- /home/momo-erp/app_data:/path/inside/container
|
||||
- ./data_storage:/data_storage
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik-net
|
||||
- traefik.http.routers.momo-erp.rule=Host(`momo.junggomoa.com`)
|
||||
- traefik.http.routers.momo-erp.entrypoints=websecure,web
|
||||
- traefik.http.routers.momo-erp.tls=true
|
||||
- traefik.http.routers.momo-erp.tls.certresolver=le
|
||||
- traefik.http.services.momo-erp.loadbalancer.server.port=8080
|
||||
- traefik.http.services.momo-erp.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: toktork_server_default
|
||||
traefik-net:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
# 모모유통 — 도매 유통 관리 시스템 개발 스펙
|
||||
|
||||
> **버전**: 0.1 (초안)
|
||||
> **작성일**: 2026-04-25
|
||||
> **대상 도메인**: `momo.junggomoa.com`
|
||||
> **기술 스택**: Next.js 15 (App Router) · React 19 · TypeScript · Tailwind · PostgreSQL (raw SQL via `pg`) · JWT 세션 · Zustand · TanStack Table · SweetAlert2
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 (TL;DR)
|
||||
|
||||
모모유통은 **대형 도매 유통 업체**다. 본사는 도매처에서 물품을 사들여 **자체 창고에 적재**한 뒤, 가입된 **소매 대리점(소상공인)** 들이 시스템에서 출고를 요청하면 담당자가 검수·승인하여 출고한다.
|
||||
|
||||
- **사용자 그룹 2종**
|
||||
- **일반 사용자(대리점)** — 가입·로그인 후 재고 보유 품목을 보고 **발주(출고요청)** 작성
|
||||
- **관리자(모모유통 담당자)** — 품목·재고·창고 마스터 관리, 발주서 승인, 거래명세서/계산서 발행, 통계 조회
|
||||
- **핵심 워크플로우**: `발주요청` → (담당자 승인) → `발주완료` + **메일 자동 발송** → (월말 일괄) → `계산서 발행 완료`
|
||||
- **면세/과세 구분**: 품목 코드 접두어 `M` = 면세 (예: `M유정란`, `M꽃계탕`). 거래명세서·매출통계에서 `면세매출합` / `과세매출합` 분리 집계.
|
||||
- **이메일 발송**: 발주 승인 시 가입 시 등록한 이메일로 **거래명세표(첨부 또는 본문)** 발송.
|
||||
|
||||
---
|
||||
|
||||
## 1. 사용자 역할 / 권한
|
||||
|
||||
| 역할 | 코드 | 설명 | 접근 가능 메뉴 |
|
||||
|---|---|---|---|
|
||||
| 일반 사용자 | `USER` | 대리점/소매상 | 대시보드(본인용), 재고 조회(보유 수량 노출), **출고요청서 작성**, 본인 발주 이력 조회, 본인 미수금/계산서 조회 |
|
||||
| 관리자 | `ADMIN` | 모모유통 담당자 | 전체 메뉴 (품목·재고·창고·발주서 승인·거래명세서·계산서·통계·회원관리) |
|
||||
|
||||
> `users` 테이블의 `role` 컬럼으로 구분. 가입 직후 기본값은 `USER`. 관리자 승격은 어드민 패널에서 수동.
|
||||
|
||||
---
|
||||
|
||||
## 2. 회원가입 / 로그인
|
||||
|
||||
### 2.1 가입 화면 (`/signup`)
|
||||
- 필드
|
||||
- **이메일** (필수, 유니크, 로그인 ID 겸용)
|
||||
- **사용자명 (업체명)** (필수, 표시명)
|
||||
- **비밀번호** / **비밀번호 확인** (필수)
|
||||
- **연락처** (선택)
|
||||
- **사업자등록번호** (선택, 거래명세서 출력용)
|
||||
- **대표자명** (선택, 거래명세서 출력용)
|
||||
- 검증
|
||||
- 이메일 형식, 비밀번호 8자 이상, 업체명 중복은 허용 (이메일만 유니크)
|
||||
- 저장 시
|
||||
- `password`는 bcrypt 해시 (`bcryptjs` 권장, `pg` 환경에서 트라이비얼)
|
||||
- `role = 'USER'`, `status = 'ACTIVE'`, `is_del = 'N'`
|
||||
|
||||
### 2.2 로그인 화면 (`/login`) — 기존 화면 재사용
|
||||
- 입력: 이메일 + 비밀번호
|
||||
- 성공 시: JWT 발급 → `plm-session` 쿠키 설정 → `/` 리다이렉트
|
||||
- 가입 링크 추가: 로그인 폼 하단에 "회원가입" 버튼
|
||||
|
||||
### 2.3 미들웨어 정책 변경
|
||||
`src/middleware.ts`의 공개 경로에 `/signup`, `/api/auth/signup` 추가.
|
||||
|
||||
---
|
||||
|
||||
## 3. 메뉴 정리 (기존 FITO/PLM → 모모유통)
|
||||
|
||||
기존 PLM 메뉴 중 **불필요한 항목 삭제**, **유사 메뉴 재활용**, 신규 메뉴 추가.
|
||||
|
||||
### 3.1 제거 (사용 안 함)
|
||||
다음 디렉토리/메뉴는 폐기 — `src/app/(main)/` 및 `src/app/api/` 양쪽에서 정리 필요:
|
||||
|
||||
- `bom`, `product/bom-list`, `product/bom-register` — BOM 관리 (제조 PLM 전용)
|
||||
- `product/design-change`, `product/part-change` — 설계변경/부품변경
|
||||
- `product/spec`, `product/part-list`, `product/part-register` — 부품 마스터
|
||||
- `part`, `part-mgmt` — 부품 관리
|
||||
- `procurement-std` — 조달 표준
|
||||
- `production` — 생산 관리
|
||||
- `quality` — 품질 관리
|
||||
- `project` — 프로젝트 관리
|
||||
- `scm` — SCM
|
||||
- `work` — 업무 (워크플로우)
|
||||
- `cost`, `cost-mgmt` — 원가 관리 (※ 어드민용 매출/원가/마진 통계는 §10에서 별도 신규)
|
||||
- `cs` — 고객지원 (필요 시 후속 단계로 보류)
|
||||
- `fund` — 자금 (필요 시 후속 단계로 보류)
|
||||
- `delivery` — 납품 (출고관리로 대체)
|
||||
- `approval` — 결재 (단순 상태 전이로 대체)
|
||||
- `purchase` — 매입 (`procurement` 신규로 대체)
|
||||
- `sales` — 매출 (`statistics` 신규로 대체)
|
||||
|
||||
### 3.2 재활용 (이름·로직 일부 변경)
|
||||
| 기존 | 신규 | 비고 |
|
||||
|---|---|---|
|
||||
| `product/*` | `item/*` (품목 마스터) | 품목명/제조사/사진/면세여부/속성 |
|
||||
| `inventory/list` | `inventory/list` | 창고별 현재고 — 그대로 |
|
||||
| `inventory/status` | `inventory/status` | 입출고 이력 — 그대로 |
|
||||
| `inventory/request` | (삭제) | 신규 `order` 메뉴로 대체 |
|
||||
| `purchase-order` | `procurement` | 도매처 → 모모유통 매입 발주 (관리자 전용) |
|
||||
| `order` | `order` | 대리점 → 모모유통 출고요청서 (핵심) |
|
||||
| `dashboard` | `dashboard` | 그대로 — 콘텐츠 교체 |
|
||||
| `admin` / `admin-panel` | `admin` | 회원/코드/메뉴 관리 |
|
||||
|
||||
### 3.3 최종 메뉴 트리
|
||||
|
||||
```
|
||||
홈 (/)
|
||||
├─ 대시보드 (/dashboard)
|
||||
│ ├─ 일반: 내 발주 진행 현황, 추천/신규 품목, 미수금
|
||||
│ └─ 관리자: 발주 승인 대기, 재고 알림, 매출 그래프, 미수금 합계
|
||||
├─ 품목 관리 (/item) [관리자]
|
||||
│ ├─ 품목 목록 (/item/list)
|
||||
│ ├─ 품목 등록·수정 (/item/form)
|
||||
│ └─ 제조사 관리 (/item/maker)
|
||||
├─ 창고 관리 (/warehouse) [관리자]
|
||||
│ ├─ 창고 목록 (/warehouse/list)
|
||||
│ └─ 창고별 재고 (/warehouse/stock)
|
||||
├─ 재고 관리 (/inventory) [공용 — 일반은 조회만]
|
||||
│ ├─ 현재고 (/inventory/list)
|
||||
│ ├─ 입출고 이력 (/inventory/history)
|
||||
│ └─ 입고 등록 (/inventory/inbound) [관리자]
|
||||
├─ 발주 관리 (/order) [공용]
|
||||
│ ├─ 발주서 목록 (/order/list) (일반: 본인 / 관리자: 전체)
|
||||
│ ├─ 발주서 작성 (/order/form)
|
||||
│ └─ 거래명세표 (/order/statement/[id])
|
||||
├─ 매입(조달) 관리 (/procurement) [관리자]
|
||||
│ ├─ 매입 발주서 (/procurement/list)
|
||||
│ └─ 매입처 관리 (/procurement/vendor)
|
||||
├─ 정산 관리 (/settlement) [관리자]
|
||||
│ ├─ 거래명세서 일괄 발행 (/settlement/statement)
|
||||
│ ├─ 계산서 발행 (/settlement/invoice)
|
||||
│ └─ 입금 관리 (/settlement/payment)
|
||||
├─ 통계 (/statistics) [관리자]
|
||||
│ ├─ 일자별 발주 현황 (/statistics/daily)
|
||||
│ ├─ 월간 누적 (/statistics/monthly)
|
||||
│ ├─ 업체별 매출 (/statistics/by-company)
|
||||
│ └─ 품목별 발주 (/statistics/by-item)
|
||||
└─ 시스템 (/admin) [관리자]
|
||||
├─ 회원 관리 (/admin/users)
|
||||
├─ 공통 코드 (/admin/code)
|
||||
└─ 메뉴 관리 (/admin/menu)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 모델 (PostgreSQL)
|
||||
|
||||
> **명명 규칙**: 테이블·컬럼은 모두 `snake_case`. PK는 `objid TEXT` (기존 FITO 규약 — `createObjectId()` 사용). 삭제는 soft delete (`is_del CHAR(1) DEFAULT 'N'`). 생성/수정 시각은 `regdate`, `regid`, `update_date`, `update_id`.
|
||||
|
||||
### 4.1 사용자 / 인증
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
objid TEXT PRIMARY KEY,
|
||||
email VARCHAR(200) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(200) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL, -- 업체명 (표시명)
|
||||
ceo_name VARCHAR(100), -- 대표자명
|
||||
biz_no VARCHAR(20), -- 사업자등록번호
|
||||
phone VARCHAR(50),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'USER', -- USER | ADMIN
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | LOCKED | LEFT
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
```
|
||||
|
||||
### 4.2 제조사 / 품목
|
||||
|
||||
```sql
|
||||
CREATE TABLE makers (
|
||||
objid TEXT PRIMARY KEY,
|
||||
maker_name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
item_code VARCHAR(50) NOT NULL UNIQUE, -- 자동 생성: ITM-YYYYMMDD-####
|
||||
item_name VARCHAR(200) NOT NULL, -- 표시명 (예: "M유정란", "빨강 탈취제")
|
||||
item_detail TEXT, -- 상세명 / 설명
|
||||
maker_objid TEXT REFERENCES makers(objid),
|
||||
unit VARCHAR(20) DEFAULT 'EA', -- EA, BOX, KG 등
|
||||
unit_price NUMERIC(15,2) DEFAULT 0, -- 기본 단가 (대리점 출고가)
|
||||
cost_price NUMERIC(15,2) DEFAULT 0, -- 매입 원가
|
||||
is_tax_free CHAR(1) DEFAULT 'N', -- 'Y' = 면세 (M 접두 품목)
|
||||
image_url TEXT, -- /uploads/items/xxx.jpg
|
||||
attributes JSONB, -- 자유 속성 (소비기한, 보관조건 등)
|
||||
status VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE | INACTIVE
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX idx_items_status ON items(status, is_del);
|
||||
CREATE INDEX idx_items_taxfree ON items(is_tax_free);
|
||||
```
|
||||
|
||||
> **`is_tax_free` 자동 판정 보조**: 등록 화면에서 품목명이 `M`으로 시작하면 기본값을 `'Y'`로 토글 (사용자가 수정 가능).
|
||||
|
||||
### 4.3 창고 / 재고
|
||||
|
||||
```sql
|
||||
CREATE TABLE warehouses (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
wh_name VARCHAR(200) NOT NULL, -- 예: "본사창고", "시장픽업", "용차배송"
|
||||
location VARCHAR(200), -- 위치 메모
|
||||
wh_type VARCHAR(20) DEFAULT 'STOCK', -- STOCK | PICKUP_TEAM | MARKET | DELIVERY
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 창고×품목별 현재고 (스냅샷)
|
||||
CREATE TABLE stocks (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_objid TEXT NOT NULL REFERENCES warehouses(objid),
|
||||
item_objid TEXT NOT NULL REFERENCES items(objid),
|
||||
qty NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||
update_date TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(wh_objid, item_objid)
|
||||
);
|
||||
CREATE INDEX idx_stocks_item ON stocks(item_objid);
|
||||
|
||||
-- 입출고 이력 (감사 로그) — 모든 재고 변동은 여기 기록
|
||||
CREATE TABLE stock_moves (
|
||||
objid TEXT PRIMARY KEY,
|
||||
wh_objid TEXT NOT NULL,
|
||||
item_objid TEXT NOT NULL,
|
||||
move_type VARCHAR(20) NOT NULL, -- IN(매입입고) | OUT(출고) | ADJ(조정) | TRANSFER
|
||||
qty NUMERIC(15,2) NOT NULL, -- 양수: 입고, 음수: 출고
|
||||
ref_type VARCHAR(20), -- ORDER | PROCUREMENT | MANUAL
|
||||
ref_objid TEXT, -- orders.objid 또는 procurements.objid
|
||||
memo TEXT,
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX idx_stock_moves_item ON stock_moves(item_objid, regdate);
|
||||
CREATE INDEX idx_stock_moves_ref ON stock_moves(ref_type, ref_objid);
|
||||
```
|
||||
|
||||
### 4.4 발주서 (대리점 → 모모유통)
|
||||
|
||||
```sql
|
||||
CREATE TABLE orders (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_no VARCHAR(50) NOT NULL UNIQUE, -- ORD-YYYYMMDD-####
|
||||
customer_objid TEXT NOT NULL REFERENCES users(objid), -- 발주한 대리점
|
||||
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
|
||||
-- REQUESTED(발주요청) | APPROVED(발주완료) | SHIPPED(출고완료)
|
||||
-- | INVOICED(계산서발행완료) | PAID(완납) | CANCELLED
|
||||
approve_user TEXT REFERENCES users(objid), -- 승인 담당자
|
||||
approve_date TIMESTAMP,
|
||||
ship_date TIMESTAMP,
|
||||
invoice_no VARCHAR(50), -- 계산서 번호
|
||||
invoice_date DATE,
|
||||
total_supply NUMERIC(15,2) DEFAULT 0, -- 공급가액 합계
|
||||
total_vat NUMERIC(15,2) DEFAULT 0, -- 세액 합계
|
||||
total_amount NUMERIC(15,2) DEFAULT 0, -- 총 합계 (VAT 포함)
|
||||
total_taxfree NUMERIC(15,2) DEFAULT 0, -- 면세 합계
|
||||
total_taxable NUMERIC(15,2) DEFAULT 0, -- 과세 합계 (공급가)
|
||||
paid_amount NUMERIC(15,2) DEFAULT 0, -- 입금액
|
||||
paid_date DATE,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT,
|
||||
update_date TIMESTAMP,
|
||||
update_id TEXT
|
||||
);
|
||||
CREATE INDEX idx_orders_customer ON orders(customer_objid, status);
|
||||
CREATE INDEX idx_orders_status_date ON orders(status, order_date);
|
||||
|
||||
CREATE TABLE order_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
order_objid TEXT NOT NULL REFERENCES orders(objid) ON DELETE CASCADE,
|
||||
item_objid TEXT NOT NULL REFERENCES items(objid),
|
||||
item_name_snap VARCHAR(200), -- 발주 시점 품목명 스냅샷
|
||||
unit_price NUMERIC(15,2) NOT NULL, -- 발주 시점 단가
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
is_tax_free CHAR(1) NOT NULL DEFAULT 'N', -- 발주 시점 면세 플래그
|
||||
supply_amount NUMERIC(15,2) NOT NULL, -- 공급가 = unit_price × qty (면세)
|
||||
-- = round(unit_price × qty / 1.1) (과세)
|
||||
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,-- 세액 (면세 = 0)
|
||||
total_amount NUMERIC(15,2) NOT NULL, -- 합계 = supply + vat
|
||||
seq INT,
|
||||
remark VARCHAR(200)
|
||||
);
|
||||
CREATE INDEX idx_order_items_order ON order_items(order_objid);
|
||||
```
|
||||
|
||||
### 4.5 매입 발주 (모모유통 → 도매처)
|
||||
|
||||
```sql
|
||||
CREATE TABLE vendors (
|
||||
objid TEXT PRIMARY KEY,
|
||||
vendor_name VARCHAR(200) NOT NULL,
|
||||
contact VARCHAR(100),
|
||||
phone VARCHAR(50),
|
||||
biz_no VARCHAR(20),
|
||||
is_del CHAR(1) DEFAULT 'N'
|
||||
);
|
||||
|
||||
CREATE TABLE procurements (
|
||||
objid TEXT PRIMARY KEY,
|
||||
proc_no VARCHAR(50) NOT NULL UNIQUE, -- PRC-YYYYMMDD-####
|
||||
vendor_objid TEXT REFERENCES vendors(objid),
|
||||
proc_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
status VARCHAR(20) DEFAULT 'OPEN', -- OPEN | RECEIVED | CLOSED
|
||||
total_amount NUMERIC(15,2) DEFAULT 0,
|
||||
memo TEXT,
|
||||
is_del CHAR(1) DEFAULT 'N',
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE procurement_items (
|
||||
objid TEXT PRIMARY KEY,
|
||||
proc_objid TEXT NOT NULL REFERENCES procurements(objid) ON DELETE CASCADE,
|
||||
item_objid TEXT NOT NULL REFERENCES items(objid),
|
||||
cost_price NUMERIC(15,2) NOT NULL, -- 매입 단가
|
||||
qty NUMERIC(15,2) NOT NULL,
|
||||
total_amount NUMERIC(15,2) NOT NULL,
|
||||
received_qty NUMERIC(15,2) DEFAULT 0 -- 입고 처리된 수량
|
||||
);
|
||||
```
|
||||
|
||||
### 4.6 첨부 / 알림 / 메일 로그
|
||||
|
||||
```sql
|
||||
CREATE TABLE attachments (
|
||||
objid TEXT PRIMARY KEY,
|
||||
ref_type VARCHAR(20) NOT NULL, -- ITEM | ORDER | PROCUREMENT
|
||||
ref_objid TEXT NOT NULL,
|
||||
file_name VARCHAR(300) NOT NULL,
|
||||
file_path TEXT NOT NULL, -- /public/uploads/...
|
||||
mime_type VARCHAR(100),
|
||||
file_size BIGINT,
|
||||
regdate TIMESTAMP DEFAULT NOW(),
|
||||
regid TEXT
|
||||
);
|
||||
CREATE INDEX idx_attach_ref ON attachments(ref_type, ref_objid);
|
||||
|
||||
CREATE TABLE mail_logs (
|
||||
objid TEXT PRIMARY KEY,
|
||||
to_email VARCHAR(200) NOT NULL,
|
||||
subject VARCHAR(300),
|
||||
body TEXT,
|
||||
ref_type VARCHAR(20),
|
||||
ref_objid TEXT,
|
||||
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING | SENT | FAILED
|
||||
error_msg TEXT,
|
||||
sent_at TIMESTAMP,
|
||||
regdate TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 라우트 (Next.js Route Handler)
|
||||
|
||||
> 모든 핸들러 첫 줄에 `getSession()` 검증, ADMIN 전용은 `user.role !== 'ADMIN'` 시 403. 응답 규약은 `.claude/rules/api-routes.md` 준수.
|
||||
|
||||
### 5.1 인증
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/api/auth/signup` | 가입 (`email`, `password`, `companyName`, `ceoName?`, `bizNo?`, `phone?`) |
|
||||
| POST | `/api/auth/login` | 로그인 (기존) |
|
||||
| POST | `/api/auth/logout` | 로그아웃 (기존) |
|
||||
| GET | `/api/auth/me` | 세션 사용자 (기존) |
|
||||
|
||||
### 5.2 품목 / 제조사 (ADMIN)
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/api/item/list` | 품목 검색 (검색어, 면세여부, 제조사, 상태) |
|
||||
| POST | `/api/item/save` | 등록/수정 (`actionType`: `regist` \| `update`) |
|
||||
| POST | `/api/item/delete` | 일괄 soft delete |
|
||||
| POST | `/api/item/upload-image` | 이미지 업로드 → `image_url` |
|
||||
| POST | `/api/maker/list` | 제조사 목록 |
|
||||
| POST | `/api/maker/save` | 제조사 등록/수정 |
|
||||
|
||||
### 5.3 창고 / 재고
|
||||
| Method | Path | 설명 | 권한 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/warehouse/list` | 창고 목록 | ADMIN |
|
||||
| POST | `/api/warehouse/save` | 창고 등록/수정 | ADMIN |
|
||||
| POST | `/api/inventory/list` | 현재고 (창고×품목) | 공용 (일반은 본인 가용 재고만) |
|
||||
| POST | `/api/inventory/history` | 입출고 이력 | ADMIN |
|
||||
| POST | `/api/inventory/inbound` | 매입 입고 등록 (재고 +, stock_moves IN 기록) | ADMIN |
|
||||
| POST | `/api/inventory/adjust` | 재고 수동 조정 | ADMIN |
|
||||
|
||||
### 5.4 발주서 (출고요청)
|
||||
| Method | Path | 설명 | 권한 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/order/list` | 발주서 목록 (USER는 본인만, ADMIN은 전체) | 공용 |
|
||||
| POST | `/api/order/detail` | 발주서 상세 + items | 공용 (본인/ADMIN) |
|
||||
| POST | `/api/order/save` | **신규 작성/수정** (status=REQUESTED) | 공용 |
|
||||
| POST | `/api/order/cancel` | 본인 발주 취소 (REQUESTED 상태에서만) | 공용 |
|
||||
| POST | `/api/order/approve` | **승인 → APPROVED + 재고 차감 + 메일 발송** | ADMIN |
|
||||
| POST | `/api/order/reject` | 반려 → CANCELLED | ADMIN |
|
||||
| POST | `/api/order/ship` | 출고 처리 → SHIPPED | ADMIN |
|
||||
| GET | `/api/order/statement/[id]` | 거래명세표 데이터 (HTML/PDF용 JSON) | 공용 (본인/ADMIN) |
|
||||
| POST | `/api/order/invoice` | 계산서 발행 일괄 처리 → INVOICED | ADMIN |
|
||||
| POST | `/api/order/payment` | 입금 등록 → PAID | ADMIN |
|
||||
|
||||
### 5.5 매입 (조달, ADMIN)
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/api/procurement/list` | |
|
||||
| POST | `/api/procurement/save` | |
|
||||
| POST | `/api/procurement/receive` | 입고 처리 → 재고 + |
|
||||
| POST | `/api/vendor/list` | |
|
||||
| POST | `/api/vendor/save` | |
|
||||
|
||||
### 5.6 통계 (ADMIN)
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/api/statistics/daily` | 일자별 발주 합계 (날짜 범위) |
|
||||
| POST | `/api/statistics/monthly` | 월별 누적 합계 (연도) |
|
||||
| POST | `/api/statistics/by-company` | 업체별 매출 (월/연도) |
|
||||
| POST | `/api/statistics/by-item` | 품목별 발주 수량 (날짜 범위) |
|
||||
| POST | `/api/statistics/dashboard` | 대시보드 카드용 요약 (오늘 발주, 승인 대기, 미수금, 재고 부족) |
|
||||
|
||||
### 5.7 회원 관리 (ADMIN)
|
||||
| Method | Path | 설명 |
|
||||
|---|---|---|
|
||||
| POST | `/api/admin/users/list` | |
|
||||
| POST | `/api/admin/users/save` | 권한 변경, 상태 변경 |
|
||||
| POST | `/api/admin/users/reset-password` | 비밀번호 초기화 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 핵심 워크플로우 — 발주서 라이프사이클
|
||||
|
||||
```
|
||||
[대리점] 출고요청서 작성 (status=REQUESTED)
|
||||
│
|
||||
▼
|
||||
[관리자] 발주 관리 → 승인 (POST /api/order/approve)
|
||||
├─ 1) 재고 검증: 모든 라인 qty <= 가용 재고? 실패 시 400
|
||||
├─ 2) UPDATE orders SET status='APPROVED', approve_user, approve_date
|
||||
├─ 3) FOR EACH order_item:
|
||||
│ UPDATE stocks SET qty = qty - {qty} WHERE item_objid=...
|
||||
│ INSERT stock_moves(move_type='OUT', ref='ORDER', ref_objid=order)
|
||||
├─ 4) 거래명세표 HTML 생성
|
||||
├─ 5) 메일 발송: customer.email → 거래명세표 첨부/본문
|
||||
└─ 6) mail_logs INSERT (status=SENT/FAILED)
|
||||
│
|
||||
▼
|
||||
[관리자] 출고 처리 (선택) → status=SHIPPED (포장/배송 완료)
|
||||
│
|
||||
▼
|
||||
[관리자] 월말 일괄 → 계산서 발행 (POST /api/order/invoice)
|
||||
└─ status=INVOICED, invoice_no/invoice_date 채움
|
||||
│
|
||||
▼
|
||||
[관리자] 입금 등록 → status=PAID, paid_amount, paid_date
|
||||
```
|
||||
|
||||
### 6.1 트랜잭션 경계
|
||||
승인(approve)은 **단일 트랜잭션** 안에서 처리한다 — 재고 차감 실패 시 발주 상태도 롤백.
|
||||
|
||||
```typescript
|
||||
// 의사 코드
|
||||
await db.tx(async (tx) => {
|
||||
for (const item of items) {
|
||||
const stock = await tx.queryOne(
|
||||
`SELECT qty FROM stocks WHERE item_objid=$1 FOR UPDATE`, [item.itemObjid]
|
||||
);
|
||||
if (Number(stock.qty) < Number(item.qty)) throw new Error('재고 부족');
|
||||
await tx.execute(`UPDATE stocks SET qty = qty - $1 ...`, [...]);
|
||||
await tx.execute(`INSERT INTO stock_moves (...)`, [...]);
|
||||
}
|
||||
await tx.execute(`UPDATE orders SET status='APPROVED', ...`);
|
||||
});
|
||||
// 트랜잭션 성공 후 메일 발송 (실패해도 발주 상태는 유지, mail_logs로 추적)
|
||||
```
|
||||
|
||||
### 6.2 금액 계산 규칙
|
||||
- **면세** (`is_tax_free='Y'`): `supply_amount = unit_price × qty`, `vat_amount = 0`, `total_amount = supply_amount`
|
||||
- **과세** (`is_tax_free='N'`): `unit_price`가 **VAT 포함가**라고 가정
|
||||
- `total_amount = unit_price × qty`
|
||||
- `supply_amount = round(total_amount / 1.1)`
|
||||
- `vat_amount = total_amount - supply_amount`
|
||||
- 발주 헤더 합계는 라인 합산:
|
||||
- `total_supply = SUM(supply_amount)`
|
||||
- `total_vat = SUM(vat_amount)`
|
||||
- `total_amount = SUM(total_amount)`
|
||||
- `total_taxfree = SUM(supply_amount WHERE is_tax_free='Y')`
|
||||
- `total_taxable = SUM(supply_amount WHERE is_tax_free='N')`
|
||||
|
||||
> 단가가 VAT-별도 모델인 경우 `items.price_mode` 컬럼을 `INCL`/`EXCL`로 추가하여 분기 (현재 스펙은 INCL 기본).
|
||||
|
||||
---
|
||||
|
||||
## 7. 페이지 명세
|
||||
|
||||
### 7.1 대리점(USER) 페이지
|
||||
|
||||
#### `/dashboard` (USER 화면)
|
||||
- 카드 4개: `진행중 발주 N건` / `이번달 누적 ₩` / `미수금 ₩` / `재고 알림 N건`
|
||||
- 최근 발주 5건 그리드 (발주번호, 일자, 합계, 상태)
|
||||
- "출고요청 작성" CTA 버튼 → `/order/form`
|
||||
|
||||
#### `/order/form` — 출고요청서 작성
|
||||
- 좌측: **품목 선택 패널**
|
||||
- 검색: 품목명, 제조사, 면세여부 필터
|
||||
- 그리드 컬럼: 이미지, 품목명(M표시), 제조사, 단가, **현재고**, 단위, [+ 담기]
|
||||
- `현재고 = SUM(stocks.qty WHERE item_objid AND wh_type='STOCK')` — 0인 품목은 비활성화
|
||||
- 우측: **장바구니**
|
||||
- 라인: 품목명, 단가, 수량(±), 합계, [삭제]
|
||||
- 합계 박스: 공급가, 세액, 총합 + 면세합/과세합
|
||||
- 메모 입력 / [발주 요청] 버튼 → POST `/api/order/save`
|
||||
- 저장 후 `/order/list` 리다이렉트 + 토스트
|
||||
|
||||
#### `/order/list` — 발주서 목록
|
||||
- 검색: 기간, 상태, (관리자만 업체명)
|
||||
- 컬럼: 발주번호, 발주일, **업체명**, 합계, 상태(뱃지), [상세] [거래명세표]
|
||||
- 행 클릭 → `/order/[id]` 상세 모달 또는 페이지
|
||||
|
||||
#### `/order/statement/[id]` — 거래명세표
|
||||
- 이미지#3 양식 재현
|
||||
- 헤더: "거래 명세 표", 발행일, 공급받는자(대리점), 공급자(모모유통)
|
||||
- 본문 테이블: 순번, 품명, EA, 단가, **공급가액**, **세액**, 합계, 비고
|
||||
- 푸터: "합계 ₩{total} (VAT 포함)", 공급자 정보(계좌·전화·이메일), `momo8443@daum.net`
|
||||
- 출력: 브라우저 인쇄용 CSS (A4) + PDF 다운로드 버튼
|
||||
|
||||
### 7.2 관리자(ADMIN) 페이지
|
||||
|
||||
#### `/dashboard` (ADMIN)
|
||||
- KPI 카드: `오늘 발주 N건` / `승인 대기 N건` / `이번달 매출 ₩` / `미수금 ₩` / `재고 부족 품목 N개`
|
||||
- 그래프 영역:
|
||||
- 막대: 최근 14일 일별 발주 합계 (Recharts)
|
||||
- 도넛: 이번달 면세/과세 비율
|
||||
- 막대: 업체별 이번달 매출 TOP 10
|
||||
- 위젯:
|
||||
- 승인 대기 발주서 5건 (빠른 승인 버튼)
|
||||
- 재고 부족 품목 (현재고 < 임계치, 임계치는 일단 10 고정)
|
||||
|
||||
#### `/item/list` & `/item/form`
|
||||
- 목록 컬럼: 이미지(40px), 품목코드, 품목명, 제조사, 단위, 단가, **면세여부 뱃지**, 상태
|
||||
- 등록 폼:
|
||||
- 품목명, 상세설명(textarea), 제조사 선택, 단위, 단가, 원가
|
||||
- **면세여부 토글** (품목명 첫글자 `M` 입력 시 자동 ON, 사용자 변경 가능)
|
||||
- 이미지 업로드 (드래그앤드롭, 단일 또는 다중)
|
||||
- 속성정보 (key-value 동적 행 — JSONB로 저장: `{ "소비기한일수": 30, "보관": "냉장" }`)
|
||||
|
||||
#### `/inventory/list`
|
||||
- 컬럼: 창고, 품목코드, 품목명, 면세, **현재고**, 마지막 변경일
|
||||
- 검색: 창고, 품목, 면세여부
|
||||
|
||||
#### `/inventory/inbound` — 매입 입고 등록
|
||||
- 매입처(vendor) 선택 → 품목 라인 추가 → 입고 처리
|
||||
- 저장 시 `procurements` + `procurement_items` 생성, `stocks` qty 증가, `stock_moves` IN 기록
|
||||
|
||||
#### `/order/list` (관리자 뷰)
|
||||
- 일반 뷰와 동일하나 **상태 변경 액션** 컬럼 추가
|
||||
- 일괄 승인 버튼 (체크박스로 선택)
|
||||
- 행 우측: [승인] [반려] [출고] [계산서 발행] [입금]
|
||||
|
||||
#### `/settlement/statement` — 거래명세서 일괄
|
||||
- 기간 + 업체별 필터링 → 선택된 발주 묶어서 PDF 일괄 다운로드
|
||||
- 옵션: 메일 재발송
|
||||
|
||||
#### `/settlement/invoice` — 계산서 발행
|
||||
- 미발행 발주 목록 (status=APPROVED|SHIPPED 이면서 invoice_no IS NULL)
|
||||
- 업체별 그룹핑 → 한 업체의 여러 발주를 하나의 계산서로 묶음 (선택)
|
||||
- 발행 시 `invoice_no` 생성 (`INV-YYYYMM-####`), 상태 INVOICED
|
||||
|
||||
#### `/settlement/payment` — 입금 관리
|
||||
- 이미지#4 재현: 업체명, 총합, M포함, M미포함, **입금액**, 차액, 자동적용(완납/미납), 계산서발행여부, 입금일자
|
||||
- 행에서 입금 드롭다운으로 입금 등록
|
||||
|
||||
#### `/statistics/daily`
|
||||
- 이미지#1 재현: 일자별 / 업체별 발주 수량 피벗 그리드
|
||||
- X축: 업체명, Y축: 품목명, 셀: 수량
|
||||
- 단위: 단가/발주수량/(여유분=발주-입수량) 색상 구분
|
||||
|
||||
#### `/statistics/monthly`
|
||||
- 이미지#5 재현: 월별 업체별 면세매출/과세매출 피벗
|
||||
- 라인 그래프 보조: 월별 총매출 추이 (12개월)
|
||||
|
||||
#### `/statistics/by-company`
|
||||
- 업체별 (대리점별) 누적 매출 — 막대 + 표
|
||||
|
||||
#### `/statistics/by-item`
|
||||
- 품목별 누적 발주 수량 — 막대 + 표
|
||||
|
||||
---
|
||||
|
||||
## 8. 이메일 발송
|
||||
|
||||
### 8.1 인프라
|
||||
- **라이브러리**: `nodemailer`
|
||||
- **설정**: `.env`에 SMTP 정보
|
||||
```
|
||||
SMTP_HOST=smtp.daum.net
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=momo8443@daum.net
|
||||
SMTP_PASS=...
|
||||
SMTP_FROM=모모유통 <momo8443@daum.net>
|
||||
```
|
||||
- 송신 모듈: `src/lib/mailer.ts` — `sendOrderApprovalMail(order)` 함수 export
|
||||
|
||||
### 8.2 발송 시점
|
||||
| 트리거 | 수신자 | 내용 |
|
||||
|---|---|---|
|
||||
| 발주 승인 (`/api/order/approve`) | `users.email` | 거래명세표 (HTML 인라인 + PDF 첨부) |
|
||||
| 계산서 발행 (`/api/order/invoice`) | `users.email` | 계산서 안내 + PDF |
|
||||
| 가입 환영 (선택) | 신규 가입자 | 환영 메일 |
|
||||
|
||||
### 8.3 본문 템플릿 (거래명세표 예시)
|
||||
```
|
||||
[모모유통] {업체명}님, 발주가 승인되었습니다.
|
||||
|
||||
발주번호: {orderNo}
|
||||
발주일자: {orderDate}
|
||||
합계: ₩{totalAmount} (VAT 포함)
|
||||
- 면세 합계: ₩{totalTaxFree}
|
||||
- 과세 공급가: ₩{totalTaxable}
|
||||
- 세액: ₩{totalVat}
|
||||
|
||||
[품목 목록]
|
||||
{각 라인}
|
||||
|
||||
상세 거래명세표는 첨부 PDF를 확인하세요.
|
||||
모모유통 / momo8443@daum.net / 010-6369-8443
|
||||
```
|
||||
|
||||
### 8.4 PDF 생성
|
||||
- **방안 A (권장 단순)**: 서버에서 거래명세표 HTML 렌더 → `puppeteer` 헤드리스로 PDF 캡처
|
||||
- **방안 B (가벼움)**: 클라이언트만 인쇄 + PDF는 첨부 없이 메일 본문에 링크
|
||||
- 1차 구현은 **B**, 후속으로 **A** 추가
|
||||
|
||||
### 8.5 실패 처리
|
||||
- 메일 발송 실패해도 트랜잭션은 커밋 (이미 승인된 상태)
|
||||
- `mail_logs.status='FAILED'` 기록 → 관리자가 `/admin/mail-logs`(추후)에서 재시도 가능
|
||||
|
||||
---
|
||||
|
||||
## 9. 파일 업로드
|
||||
|
||||
- 저장 위치: `public/uploads/items/{yyyymm}/{uuid}.{ext}`
|
||||
- 업로드 엔드포인트: `POST /api/item/upload-image` (multipart/form-data)
|
||||
- 검증: 이미지 MIME만 허용 (`image/jpeg|png|webp`), 5MB 제한
|
||||
- 응답: `{ success: true, url: "/uploads/items/202604/xxx.jpg" }`
|
||||
- DB: `items.image_url` 또는 `attachments` 테이블 (다중 첨부 시)
|
||||
|
||||
---
|
||||
|
||||
## 10. 어드민 — 매출/원가/마진 (이미지#6 §9)
|
||||
|
||||
### `/statistics/margin` (관리자 전용)
|
||||
- 월별 업체별 매출 / 매입 원가 / **마진** 산출
|
||||
- 매출 = `SUM(orders.total_supply)` (면세+과세 공급가)
|
||||
- 원가 = `SUM(order_items.qty × items.cost_price)` (발주 시점 원가 스냅샷이 더 정확하나 1차는 현재 원가)
|
||||
- 마진 = 매출 - 원가, 마진율 = 마진 / 매출 × 100
|
||||
- 그리드 + 막대 그래프
|
||||
|
||||
> 정확한 원가 추적이 필요하면 `order_items`에 `cost_price_snap` 컬럼 추가.
|
||||
|
||||
---
|
||||
|
||||
## 11. 공통 코드 / 정적 데이터
|
||||
|
||||
`code_master` (기존 FITO `code` 테이블 재사용 또는 신규)에 다음 코드그룹 등록:
|
||||
|
||||
| 코드그룹 ID | 의미 | 코드 예시 |
|
||||
|---|---|---|
|
||||
| `ORDER_STATUS` | 발주 상태 | REQUESTED, APPROVED, SHIPPED, INVOICED, PAID, CANCELLED |
|
||||
| `WH_TYPE` | 창고 유형 | STOCK, PICKUP_TEAM, MARKET, DELIVERY |
|
||||
| `MOVE_TYPE` | 재고 변동 | IN, OUT, ADJ, TRANSFER |
|
||||
| `UNIT` | 단위 | EA, BOX, KG, L, PACK |
|
||||
| `USER_ROLE` | 권한 | USER, ADMIN |
|
||||
|
||||
---
|
||||
|
||||
## 12. 권한 가드 (서버 + 클라이언트)
|
||||
|
||||
### 12.1 서버
|
||||
```typescript
|
||||
const user = await getSession();
|
||||
if (!user) return NextResponse.json({ success: false }, { status: 401 });
|
||||
if (user.role !== 'ADMIN') {
|
||||
return NextResponse.json({ success: false, message: '권한 없음' }, { status: 403 });
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 클라이언트
|
||||
- `auth-store`에 `role` 추가 → 메뉴 store에서 `role !== 'ADMIN'` 인 항목 필터링
|
||||
- ADMIN 전용 페이지: 페이지 컴포넌트 최상단에서 `if (user?.role !== 'ADMIN') redirect('/dashboard')`
|
||||
|
||||
### 12.3 데이터 격리
|
||||
- `/api/order/list`에서 `USER`인 경우 `WHERE customer_objid = $session_user`
|
||||
- `/api/inventory/list`에서 `USER`는 `qty > 0` 인 품목만 노출, 창고는 `STOCK` 타입만
|
||||
|
||||
---
|
||||
|
||||
## 13. 마이그레이션 / 시드
|
||||
|
||||
### 13.1 SQL 마이그레이션 파일
|
||||
`db/migrations/` 디렉토리 신규:
|
||||
- `001_init_users.sql`
|
||||
- `002_init_items.sql`
|
||||
- `003_init_warehouse_stock.sql`
|
||||
- `004_init_orders.sql`
|
||||
- `005_init_procurement.sql`
|
||||
- `006_init_attachments_mail.sql`
|
||||
- `007_seed_codes.sql` — 공통 코드
|
||||
- `008_seed_admin.sql` — 초기 관리자 (`admin@momo.com` / 임시 비밀번호)
|
||||
|
||||
> 기존 FITO 테이블은 건드리지 않고 신규 테이블만 추가. 기존 사용자가 동일 DB인 경우 충돌 회피를 위해 `momo_` 접두사 검토.
|
||||
|
||||
### 13.2 초기 데이터
|
||||
- 관리자 계정 1개
|
||||
- 창고 4개 (본사창고, 시장픽업, 용차배송, 기타)
|
||||
- 공통 코드 위 §11 항목 전부
|
||||
|
||||
---
|
||||
|
||||
## 14. 비기능 / 운영
|
||||
|
||||
- **성능**: 통계 조회는 `regdate` / `order_date` 인덱스 + LIMIT/OFFSET 페이징
|
||||
- **로그**: `console.error` 통일, 운영 단계에서 `pino` 도입 검토
|
||||
- **백업**: 외부 DB(`211.115.91.141:11140/fito`) 정기 백업은 인프라 책임
|
||||
- **보안**:
|
||||
- 비밀번호 bcrypt 해시 (cost 10)
|
||||
- JWT 만료 24h (`SESSION_TTL`)
|
||||
- SQL Injection: prepared statement (`$1`...) 강제, 동적 컬럼명 금지
|
||||
- 파일 업로드 MIME/크기 검증 + 파일명 sanitize
|
||||
- **i18n**: 한국어 단일 (현 단계)
|
||||
|
||||
---
|
||||
|
||||
## 15. 개발 우선순위 (스프린트 가이드)
|
||||
|
||||
### Sprint 1 — 기초 (1주)
|
||||
1. 메뉴 정리: 불필요 디렉토리 일괄 삭제 (`bom`, `production`, `quality`, `scm`, `work`, `procurement-std`, `cs`, `fund`, `delivery`, `approval`, `cost*`, `purchase`, `sales`, `part*`, `product/*` 일부)
|
||||
2. DB 스키마 마이그레이션 적용 (§4 전체)
|
||||
3. 회원가입 (`/signup` + `/api/auth/signup`)
|
||||
4. `users.role`, `auth-store`에 role 추가, 메뉴/페이지 권한 가드
|
||||
|
||||
### Sprint 2 — 마스터 (1주)
|
||||
5. 품목 등록/목록/이미지 업로드 (`/item/*`)
|
||||
6. 제조사 관리
|
||||
7. 창고 관리 + 재고 등록·조정 + 이력
|
||||
|
||||
### Sprint 3 — 발주 핵심 (1.5주)
|
||||
8. 출고요청서 작성 (`/order/form`)
|
||||
9. 발주서 목록 + 상세
|
||||
10. 승인 워크플로우 + 트랜잭션 재고 차감
|
||||
11. 거래명세표 페이지 (`/order/statement/[id]`)
|
||||
12. 메일 발송 (nodemailer 연동)
|
||||
|
||||
### Sprint 4 — 정산·통계 (1주)
|
||||
13. 계산서 발행 / 입금 관리
|
||||
14. 통계 4종 (일자별·월별·업체별·품목별)
|
||||
15. 대시보드 (USER + ADMIN)
|
||||
|
||||
### Sprint 5 — 매입·마무리 (3일)
|
||||
16. 매입(조달) 관리 + 매입 입고 → 재고 +
|
||||
17. 마진 통계
|
||||
18. 메일 로그/재시도 UI
|
||||
19. 시드 / 운영 가이드 정리
|
||||
|
||||
---
|
||||
|
||||
## 16. 명세 외 합의 필요 항목 (TODO 확인)
|
||||
|
||||
- [ ] 가입 시 **관리자 승인** 필요 여부 (현재 자동 ACTIVE)
|
||||
- [ ] 단가 모델: VAT 포함가(INCL) vs 별도(EXCL) — 현재 INCL 가정
|
||||
- [ ] 거래명세서 PDF 생성: 서버(puppeteer) vs 클라이언트(브라우저 인쇄)
|
||||
- [ ] 재고 부족 임계치: 품목별 vs 전역 고정값 (현재 10 고정)
|
||||
- [ ] 미수금 정의: 누적 미입금 vs 30일 초과 미입금
|
||||
- [ ] 계산서 발행 단위: 발주 1건 vs 업체별 월합산 (둘 다 가능, UI에서 선택)
|
||||
- [ ] 이메일 송신 계정: `momo8443@daum.net` 비밀번호/SMTP 설정 확보 필요
|
||||
|
||||
---
|
||||
|
||||
## 17. 참고 — 원본 엑셀 워크플로우 매핑
|
||||
|
||||
| 엑셀 시트 (스크린샷) | 시스템 화면 |
|
||||
|---|---|
|
||||
| 시트1 — 날짜별 업체×품목 발주표 | `/statistics/daily` (피벗 그리드) |
|
||||
| 시트2 — 창고/픽업팀별 분류 | `/inventory/list` (창고 필터) + `/statistics/daily` (창고 컬럼) |
|
||||
| 시트3 — 거래명세표 자동생성 | `/order/statement/[id]` |
|
||||
| 시트4 — 입금/계산서 체크 | `/settlement/payment` |
|
||||
| 시트5 — 월간 면세/과세 매출 합산 | `/statistics/monthly` |
|
||||
| 시트6 — 누적 그래프 | `/dashboard` (관리자) |
|
||||
| 시트7 — 제조사 발주 본사/지사/여유분 | `/procurement/list` + `/statistics/by-item` |
|
||||
| 시트8 — 제조관리(소비기한/입고가) | `items.attributes` JSONB + `/inventory/inbound` |
|
||||
| 시트9 — 어드민(매출/원가/마진) | `/statistics/margin` |
|
||||
|
||||
---
|
||||
|
||||
**문서 끝.**
|
||||
변경/추가 요구사항은 본 문서 §16 TODO 또는 PR 코멘트로 전달.
|
||||
@@ -0,0 +1,255 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>모모유통 — 유통관리 시스템 제안서</title>
|
||||
<style>
|
||||
:root{
|
||||
--brand:#0f766e;
|
||||
--brand-2:#14b8a6;
|
||||
--ink:#0f172a;
|
||||
--ink-2:#334155;
|
||||
--line:#e2e8f0;
|
||||
--bg:#f8fafc;
|
||||
--warn:#f59e0b;
|
||||
--ok:#10b981;
|
||||
--tax-free:#7c3aed;
|
||||
--tax:#e11d48;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Pretendard","Apple SD Gothic Neo","Malgun Gothic",sans-serif;line-height:1.65;-webkit-font-smoothing:antialiased}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:48px 24px 80px}
|
||||
header.hero{background:linear-gradient(135deg,var(--brand) 0%,var(--brand-2) 100%);color:#fff;padding:64px 32px;border-radius:24px;box-shadow:0 20px 50px -20px rgba(15,118,110,.4);margin-bottom:48px}
|
||||
header.hero h1{margin:0 0 8px;font-size:40px;letter-spacing:-.5px}
|
||||
header.hero .sub{font-size:16px;opacity:.9;margin-bottom:24px}
|
||||
header.hero .meta{display:flex;gap:24px;flex-wrap:wrap;font-size:14px;opacity:.9}
|
||||
header.hero .meta b{font-weight:600}
|
||||
section{background:#fff;border:1px solid var(--line);border-radius:18px;padding:32px;margin-bottom:24px;box-shadow:0 1px 2px rgba(15,23,42,.04)}
|
||||
h2{margin:0 0 16px;font-size:24px;color:var(--brand);display:flex;align-items:center;gap:10px}
|
||||
h2 .num{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;background:var(--brand);color:#fff;border-radius:8px;font-size:16px;font-weight:700}
|
||||
h3{margin:24px 0 12px;font-size:18px;color:var(--ink)}
|
||||
p,li{color:var(--ink-2);font-size:15px}
|
||||
ul,ol{padding-left:20px}
|
||||
ul li{margin:6px 0}
|
||||
.grid{display:grid;gap:16px}
|
||||
.grid.cols-2{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}
|
||||
.grid.cols-3{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
||||
.card{background:#f8fafc;border:1px solid var(--line);border-radius:12px;padding:18px}
|
||||
.card .ico{width:36px;height:36px;background:var(--brand);color:#fff;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;margin-bottom:10px}
|
||||
.card h4{margin:0 0 6px;font-size:15px;color:var(--ink)}
|
||||
.card p{margin:0;font-size:13.5px}
|
||||
.badge{display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-right:4px}
|
||||
.badge.brand{background:#ccfbf1;color:#0f766e}
|
||||
.badge.warn{background:#fef3c7;color:#92400e}
|
||||
.badge.ok{background:#d1fae5;color:#065f46}
|
||||
.badge.free{background:#ede9fe;color:#6d28d9}
|
||||
.badge.tax{background:#ffe4e6;color:#9f1239}
|
||||
.flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:16px 0}
|
||||
.flow .step{flex:1;min-width:140px;background:#fff;border:2px solid var(--line);border-radius:12px;padding:14px;text-align:center}
|
||||
.flow .step .t{font-size:13px;color:var(--ink-2);margin-bottom:4px}
|
||||
.flow .step .l{font-weight:700;color:var(--ink)}
|
||||
.flow .step.s1{border-color:#fde68a}
|
||||
.flow .step.s2{border-color:#a7f3d0}
|
||||
.flow .step.s3{border-color:#bfdbfe}
|
||||
.flow .step.s4{border-color:#ddd6fe}
|
||||
.flow .arrow{font-size:24px;color:#94a3b8}
|
||||
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:14px}
|
||||
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line)}
|
||||
th{background:#f1f5f9;color:var(--ink);font-weight:600;font-size:13px}
|
||||
td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.callout{border-left:4px solid var(--brand);background:#f0fdfa;padding:14px 18px;border-radius:8px;margin:16px 0;font-size:14.5px}
|
||||
.callout.warn{border-left-color:var(--warn);background:#fffbeb}
|
||||
.timeline{display:grid;grid-template-columns:120px 1fr;gap:0;margin-top:16px}
|
||||
.timeline .t{padding:14px 12px;border-right:2px solid var(--brand);font-weight:700;color:var(--brand);font-size:14px}
|
||||
.timeline .c{padding:14px 18px;border-bottom:1px solid var(--line)}
|
||||
.timeline .c:last-child{border-bottom:0}
|
||||
.timeline .t:last-of-type{border-right:2px solid var(--brand)}
|
||||
.device{background:#0f172a;border-radius:24px;padding:18px;color:#e2e8f0;margin:16px 0;font-size:13px;font-family:"SF Mono",Consolas,monospace;line-height:1.6}
|
||||
.device .bar{height:6px;background:#334155;border-radius:3px;margin-bottom:12px}
|
||||
.price{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px dashed #334155}
|
||||
.price:last-child{border-bottom:0;font-weight:700;color:#5eead4}
|
||||
footer{margin-top:48px;padding:24px;text-align:center;color:var(--ink-2);font-size:13px}
|
||||
.signature{margin-top:32px;padding-top:24px;border-top:2px solid var(--line);display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px}
|
||||
.signature .col{flex:1;min-width:240px}
|
||||
.signature .col h4{margin:0 0 4px;color:var(--brand)}
|
||||
@media print{
|
||||
body{background:#fff}
|
||||
section{break-inside:avoid;box-shadow:none;border-color:#cbd5e1}
|
||||
header.hero{box-shadow:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="hero">
|
||||
<div style="font-size:13px;letter-spacing:2px;opacity:.85;margin-bottom:8px">PROPOSAL · 2026-04-25</div>
|
||||
<h1>모모유통 유통관리 시스템</h1>
|
||||
<div class="sub">엑셀 기반 발주 업무를 웹 + 모바일 앱으로 전환합니다</div>
|
||||
<div class="meta">
|
||||
<span><b>고객사</b> · 모모유통</span>
|
||||
<span><b>도메인</b> · momo.junggomoa.com</span>
|
||||
<span><b>기간(예상)</b> · 5주</span>
|
||||
<span><b>플랫폼</b> · 웹(PC) + 안드로이드 앱</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">1</span> 왜 이 시스템이 필요한가</h2>
|
||||
<p>현재는 엑셀 한 파일에 <b>여러 명이 동시에 입력</b>하다 보니 발주 총수량과 명세서 자동계산이 어긋나는 일이 잦습니다. 업체가 늘어날수록 단가·재고·입금 상태를 한 시트에서 관리하기가 점점 더 어렵습니다.</p>
|
||||
<div class="grid cols-3" style="margin-top:18px">
|
||||
<div class="card"><div class="ico">📋</div><h4>발주 누락·중복</h4><p>여러 명이 동시 편집 → 셀이 겹치거나 사라짐</p></div>
|
||||
<div class="card"><div class="ico">🧮</div><h4>금액 오류</h4><p>VAT·면세 분리 합산이 수동, 자릿수 실수</p></div>
|
||||
<div class="card"><div class="ico">📨</div><h4>명세서 수작업</h4><p>업체별로 매번 별도 시트 복사·메일 발송</p></div>
|
||||
<div class="card"><div class="ico">📦</div><h4>재고 불투명</h4><p>창고별 현재고가 엑셀에 반영되지 않음</p></div>
|
||||
<div class="card"><div class="ico">📊</div><h4>매출 가시성 부족</h4><p>월간 누적·업체별 매출을 한눈에 보기 어려움</p></div>
|
||||
<div class="card"><div class="ico">📱</div><h4>현장 입력 불가</h4><p>거래처가 PC 앞에 가야만 발주 가능</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">2</span> 누가 어떻게 사용하나</h2>
|
||||
|
||||
<h3>👤 일반 사용자 (대리점·소매상)</h3>
|
||||
<ul>
|
||||
<li>이메일과 업체명으로 <b>회원가입</b></li>
|
||||
<li>웹 또는 <b>안드로이드 앱</b>에서 로그인</li>
|
||||
<li>현재 재고가 있는 품목을 검색·선택해 <b>발주 요청</b></li>
|
||||
<li>본인 발주 이력·미수금·계산서 조회</li>
|
||||
</ul>
|
||||
|
||||
<h3>🛠 관리자 (모모유통 담당자)</h3>
|
||||
<ul>
|
||||
<li>품목 마스터 관리 (사진·제조사·면세여부·속성)</li>
|
||||
<li>창고별 재고 등록·조정·이력 추적</li>
|
||||
<li>발주 요청 검토 → <b>승인 한 번으로</b> 재고 차감 + 거래명세표 메일 자동 발송</li>
|
||||
<li>월말 계산서 발행 / 입금 관리 / 누적 매출 통계</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">3</span> 핵심 업무 흐름</h2>
|
||||
<div class="flow">
|
||||
<div class="step s1"><div class="t">1단계 · 대리점</div><div class="l">발주 요청</div></div>
|
||||
<div class="arrow">▶</div>
|
||||
<div class="step s2"><div class="t">2단계 · 모모유통</div><div class="l">승인 + 메일 발송</div></div>
|
||||
<div class="arrow">▶</div>
|
||||
<div class="step s3"><div class="t">3단계 · 모모유통</div><div class="l">출고 처리</div></div>
|
||||
<div class="arrow">▶</div>
|
||||
<div class="step s4"><div class="t">4단계 · 월말</div><div class="l">계산서 + 입금</div></div>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<b>승인 버튼 한 번</b>으로 다음이 자동 처리됩니다:<br>
|
||||
① 재고에서 발주 수량만큼 차감 → ② 거래명세표 PDF/엑셀 자동 생성 → ③ 가입한 이메일로 명세서 본문 + 엑셀 첨부 메일 발송 → ④ 발주 상태 "발주완료"로 변경
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">4</span> 면세 / 과세 자동 분리</h2>
|
||||
<p>품목명이 <b>"M"</b>으로 시작하는 면세 품목(예: M유정란, M꽃계탕)은 시스템이 자동으로 면세 플래그를 켜고, 거래명세표·매출통계에서 면세 합계와 과세 합계를 분리 집계합니다.</p>
|
||||
<table>
|
||||
<thead><tr><th>품명</th><th>구분</th><th class="num">단가</th><th class="num">수량</th><th class="num">공급가</th><th class="num">세액</th><th class="num">합계</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>M 유정란</td><td><span class="badge free">면세</span></td><td class="num">10,000</td><td class="num">30</td><td class="num">300,000</td><td class="num">-</td><td class="num">300,000</td></tr>
|
||||
<tr><td>빨강 탈취제</td><td><span class="badge tax">과세</span></td><td class="num">9,200</td><td class="num">11</td><td class="num">92,000</td><td class="num">9,200</td><td class="num">101,200</td></tr>
|
||||
<tr><td>초록 탈취제</td><td><span class="badge tax">과세</span></td><td class="num">9,200</td><td class="num">3</td><td class="num">25,091</td><td class="num">2,509</td><td class="num">27,600</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">5</span> 메일 자동 발송 (거래명세표)</h2>
|
||||
<p>관리자가 발주를 승인하면, 가입 시 등록한 이메일로 다음과 같은 메일이 즉시 발송됩니다.</p>
|
||||
<div class="device">
|
||||
<div class="bar"></div>
|
||||
<div style="font-size:11px;color:#94a3b8">받는사람: 수원 거래처 <suwon@example.com></div>
|
||||
<div style="font-size:11px;color:#94a3b8;margin-bottom:10px">제목: [모모유통] 발주 ORD-20260425-0007 승인되었습니다</div>
|
||||
<div style="color:#5eead4;font-size:14px;margin-bottom:8px">📎 첨부 · 거래명세표.xlsx (12 KB)</div>
|
||||
<div class="price"><span>면세 합계</span><span>₩300,000</span></div>
|
||||
<div class="price"><span>과세 공급가</span><span>₩928,650</span></div>
|
||||
<div class="price"><span>세액</span><span>₩81,900</span></div>
|
||||
<div class="price"><span>총 합계 (VAT 포함)</span><span>₩1,310,550</span></div>
|
||||
</div>
|
||||
<p style="margin-top:14px">메일에는 <b>본문에 명세서 표</b>가 포함되며, 동시에 <b>엑셀 파일(.xlsx)</b>이 첨부됩니다. 거래처가 그대로 회계 시스템에 올릴 수 있습니다.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">6</span> 화면 구성</h2>
|
||||
<h3>웹 (PC) — 관리자 + 대리점 공용</h3>
|
||||
<ul>
|
||||
<li><b>대시보드</b> — 오늘의 발주, 승인 대기, 이번달 매출, 미수금, 재고 부족 알림</li>
|
||||
<li><b>품목 관리</b> — 사진 업로드, 제조사, 면세 여부, 속성(소비기한 등)</li>
|
||||
<li><b>창고/재고</b> — 창고별 현재고, 입출고 이력</li>
|
||||
<li><b>발주 관리</b> — 발주서 작성/목록/승인, 거래명세표 출력</li>
|
||||
<li><b>정산</b> — 계산서 발행, 입금 등록, 미수금 관리</li>
|
||||
<li><b>통계</b> — 일자별·월별·업체별·품목별 그래프</li>
|
||||
</ul>
|
||||
|
||||
<h3>📱 안드로이드 앱 — 대리점 전용</h3>
|
||||
<ul>
|
||||
<li>로그인</li>
|
||||
<li>품목 검색 (사진·재고·단가 표시)</li>
|
||||
<li>장바구니 → 발주 요청</li>
|
||||
<li>내 발주 이력 + 알림</li>
|
||||
</ul>
|
||||
<div class="callout">앱은 <b>APK 파일</b>로 전달드립니다. 구글 플레이 등록 없이 사내 배포 가능합니다.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">7</span> 일정 (5주)</h2>
|
||||
<div class="timeline">
|
||||
<div class="t">1주차</div>
|
||||
<div class="c"><b>기초 — 메뉴 정리, DB 구축, 회원가입, 권한</b><br>불필요한 PLM 잔재 메뉴 제거 후 모모유통 메뉴 트리로 재편</div>
|
||||
<div class="t">2주차</div>
|
||||
<div class="c"><b>마스터 — 품목·제조사·창고·재고</b><br>품목 사진 업로드, 면세 자동 인식, 창고별 재고 관리</div>
|
||||
<div class="t">3~4주차</div>
|
||||
<div class="c"><b>발주 핵심 — 작성·승인·메일·엑셀</b><br>장바구니 UI, 트랜잭션 재고 차감, 거래명세표 메일 + 엑셀 첨부</div>
|
||||
<div class="t">5주차</div>
|
||||
<div class="c"><b>정산·통계·앱 + 마무리</b><br>계산서·입금·통계 화면, 안드로이드 APK 빌드, 운영 가이드</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">8</span> 결정해 주실 사항</h2>
|
||||
<ol>
|
||||
<li><b>가입 승인</b> — 거래처가 가입하면 자동 활성화? 아니면 관리자 승인 후 활성화?</li>
|
||||
<li><b>단가 모델</b> — 등록 단가에 VAT가 <b>포함</b>되어 있나요, 별도인가요? (엑셀 보면 포함으로 보입니다)</li>
|
||||
<li><b>이메일 송신 계정</b> — momo8443@daum.net 사용 시 SMTP 비밀번호/앱 비밀번호 필요</li>
|
||||
<li><b>계산서 발행 단위</b> — 발주 1건씩 / 업체별 월 합산 (둘 다 가능, 디폴트 결정 필요)</li>
|
||||
<li><b>재고 부족 알림 기준</b> — 임계 수량 (예: 10개 미만 알림)</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span class="num">9</span> 기대 효과</h2>
|
||||
<div class="grid cols-3">
|
||||
<div class="card"><div class="ico">⚡</div><h4>발주 처리 시간</h4><p>엑셀 대비 <b>70% 단축</b><br>(승인 1클릭 = 재고+메일+명세서)</p></div>
|
||||
<div class="card"><div class="ico">✅</div><h4>금액 오류</h4><p>VAT·면세 자동 계산으로<br><b>0건</b> 목표</p></div>
|
||||
<div class="card"><div class="ico">📈</div><h4>매출 가시성</h4><p>월간 누적·업체별 그래프를<br><b>실시간</b>으로</p></div>
|
||||
<div class="card"><div class="ico">📱</div><h4>거래처 편의</h4><p>휴대폰만으로 발주 가능<br>현장 즉시 주문</p></div>
|
||||
<div class="card"><div class="ico">🔒</div><h4>데이터 안전성</h4><p>동시 편집 충돌 없음<br>모든 변경 이력 추적</p></div>
|
||||
<div class="card"><div class="ico">🧾</div><h4>회계 연계</h4><p>엑셀 명세서 자동 첨부<br>거래처가 그대로 활용</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="signature">
|
||||
<div class="col">
|
||||
<h4>고객사</h4>
|
||||
<div>모모유통</div>
|
||||
<div style="font-size:13px;color:#64748b">대표: ____________</div>
|
||||
<div style="font-size:13px;color:#64748b">날짜: 2026 . __ . __</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>개발사</h4>
|
||||
<div>chpark@wace.me</div>
|
||||
<div style="font-size:13px;color:#64748b">담당: ____________</div>
|
||||
<div style="font-size:13px;color:#64748b">날짜: 2026 . __ . __</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>© 2026 모모유통 유통관리 시스템 · Next.js 15 + React Native (APK) · momo.junggomoa.com</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Docker 배포용 — .next/standalone 번들 생성 (이미지 경량화)
|
||||
output: "standalone",
|
||||
// 프로젝트 루트 강제 고정 (상위 디렉토리 오탐 방지)
|
||||
outputFileTracingRoot: path.join(__dirname),
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "fito-nextjs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"migrate:momo": "node scripts/migrate-momo.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jose": "^6.2.2",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pg": "^8.20.0",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"sweetalert2": "^11.26.24",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="fg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1b5e3a"/>
|
||||
<stop offset="100%" stop-color="#3CB371"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#fg)"/>
|
||||
<!-- Stylized M with sparkle -->
|
||||
<text x="32" y="43" font-family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="32" font-weight="900" fill="#ffffff" text-anchor="middle" letter-spacing="-2">M</text>
|
||||
<circle cx="51" cy="14" r="2" fill="#ffffff" opacity="0.95"/>
|
||||
<circle cx="56" cy="20" r="1.2" fill="#ffffff" opacity="0.7"/>
|
||||
<circle cx="47" cy="9" r="1.1" fill="#ffffff" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 799 B |
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160" width="480" height="160">
|
||||
<defs>
|
||||
<linearGradient id="momoG" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1b5e3a"/>
|
||||
<stop offset="55%" stop-color="#2E8B57"/>
|
||||
<stop offset="100%" stop-color="#3CB371"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sparkleG" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#7ED9A7" stop-opacity="0"/>
|
||||
<stop offset="50%" stop-color="#7ED9A7" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#7ED9A7" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Magical trail sweeping across the letters -->
|
||||
<path d="M 10 118 Q 120 98 240 118 T 470 108"
|
||||
fill="none" stroke="url(#sparkleG)" stroke-width="3" stroke-linecap="round"/>
|
||||
|
||||
<!-- MOMO wordmark -->
|
||||
<g fill="url(#momoG)" font-family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-weight="900">
|
||||
<text x="0" y="120" font-size="128" letter-spacing="-6">MOMO</text>
|
||||
</g>
|
||||
|
||||
<!-- Fairy silhouette above the second O -->
|
||||
<g fill="url(#momoG)" transform="translate(395 6)">
|
||||
<!-- hair -->
|
||||
<path d="M 12 2 Q 6 -2 3 5 Q 8 3 14 5 Z"/>
|
||||
<!-- head -->
|
||||
<circle cx="13" cy="9" r="4.2"/>
|
||||
<!-- body / dress flowing -->
|
||||
<path d="M 13 13 Q 7 22 6 34 L 20 34 Q 19 22 13 13 Z"/>
|
||||
<!-- arm with wand -->
|
||||
<path d="M 14 15 L 29 7 L 31 10 L 16 18 Z"/>
|
||||
<!-- wand star -->
|
||||
<g transform="translate(30 3) scale(0.7)">
|
||||
<path d="M 3 0 L 4 2.5 L 7 2.8 L 4.7 4.7 L 5.5 7.5 L 3 6 L 0.5 7.5 L 1.3 4.7 L -1 2.8 L 2 2.5 Z"/>
|
||||
</g>
|
||||
<!-- wings -->
|
||||
<path d="M 7 18 Q -2 14 -1 28 Q 4 25 9 23 Z" opacity="0.5"/>
|
||||
<path d="M 19 18 Q 28 14 27 28 Q 22 25 17 23 Z" opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Sparkles scattered -->
|
||||
<g fill="#3CB371">
|
||||
<circle cx="450" cy="18" r="2.4"/>
|
||||
<circle cx="438" cy="32" r="1.4"/>
|
||||
<circle cx="460" cy="42" r="1.8"/>
|
||||
<circle cx="430" cy="10" r="1.2" opacity="0.7"/>
|
||||
<path d="M 455 62 L 456.5 65.5 L 460 66 L 457.5 68.5 L 458.5 72 L 455 70 L 451.5 72 L 452.5 68.5 L 450 66 L 453.5 65.5 Z" opacity="0.85"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,44 @@
|
||||
// 모모유통 마이그레이션 실행 스크립트
|
||||
// 사용법: node scripts/migrate-momo.mjs
|
||||
// .env.development 또는 .env.production 의 DATABASE_URL 사용
|
||||
|
||||
import pg from "pg";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const envFile = process.env.NODE_ENV === "production" ? ".env.production" : ".env.development";
|
||||
const envPath = path.join(__dirname, "..", envFile);
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
const dir = path.join(__dirname, "..", "db", "migrations");
|
||||
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
||||
const conn = process.env.DATABASE_URL;
|
||||
if (!conn) {
|
||||
console.error("DATABASE_URL 환경변수가 설정되지 않았습니다.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new pg.Client({ connectionString: conn });
|
||||
await client.connect();
|
||||
console.log(`[migrate] DB connected. Running ${files.length} files...`);
|
||||
for (const f of files) {
|
||||
const sql = fs.readFileSync(path.join(dir, f), "utf-8");
|
||||
console.log(` → ${f}`);
|
||||
try {
|
||||
await client.query(sql);
|
||||
} catch (err) {
|
||||
console.error(` ✖ ${f} 실패:`, err.message);
|
||||
await client.end();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("[migrate] ✔ 완료");
|
||||
await client.end();
|
||||
@@ -0,0 +1,173 @@
|
||||
// E2E 테스트: 가입 → 시드 데이터 → 발주 → 승인 + 메일 발송
|
||||
// 전제: dev 서버 실행 중 (localhost:3000), DB 마이그레이션 완료
|
||||
// 사용법: node scripts/test-e2e.mjs
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pg from "pg";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.join(__dirname, "..", ".env.development");
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = "http://localhost:3000";
|
||||
const TEST_EMAIL = "chpark@wace.me";
|
||||
const TEST_PASSWORD = "test1234abcd";
|
||||
|
||||
let cookieJar = "";
|
||||
function setCookies(res) {
|
||||
const sc = res.headers.getSetCookie?.() || res.headers.raw?.()["set-cookie"] || [];
|
||||
for (const c of sc) {
|
||||
const kv = c.split(";")[0];
|
||||
cookieJar = cookieJar
|
||||
? cookieJar.split("; ").filter((p) => !p.startsWith(kv.split("=")[0] + "=")).concat(kv).join("; ")
|
||||
: kv;
|
||||
}
|
||||
}
|
||||
async function api(path, init = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookieJar ? { Cookie: cookieJar } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
setCookies(res);
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||
return { status: res.status, body: json };
|
||||
}
|
||||
|
||||
const log = (...a) => console.log("[e2e]", ...a);
|
||||
const fail = (msg) => { console.error("[e2e] ✖", msg); process.exit(1); };
|
||||
|
||||
// ===== 1. DB 정리 + 시드 (테스트 데이터) =====
|
||||
log("1. DB 정리 + 시드");
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await client.connect();
|
||||
|
||||
// 기존 테스트 사용자 삭제 (재실행 가능하게)
|
||||
await client.query(`DELETE FROM momo_order_items WHERE order_objid IN (SELECT objid FROM momo_orders WHERE customer_objid IN (SELECT objid FROM momo_users WHERE email = $1))`, [TEST_EMAIL]);
|
||||
await client.query(`DELETE FROM momo_orders WHERE customer_objid IN (SELECT objid FROM momo_users WHERE email = $1)`, [TEST_EMAIL]);
|
||||
await client.query(`DELETE FROM momo_users WHERE email = $1`, [TEST_EMAIL]);
|
||||
|
||||
// 품목 / 재고 시드 (테스트 품목 3개 + 본사창고 재고 100개씩)
|
||||
const items = [
|
||||
{ code: "TEST-EGG-01", name: "M 유정란", price: 10000, taxFree: "Y" },
|
||||
{ code: "TEST-CHKN-01", name: "M 꽃계탕", price: 4500, taxFree: "Y" },
|
||||
{ code: "TEST-CLEAN-01", name: "빨강 탈취제", price: 9200, taxFree: "N" },
|
||||
];
|
||||
for (const it of items) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_items (objid, item_code, item_name, unit, unit_price, is_tax_free, status, regdate)
|
||||
VALUES ($1, $2, $3, 'EA', $4, $5, 'ACTIVE', NOW())
|
||||
ON CONFLICT (item_code) DO UPDATE SET unit_price = EXCLUDED.unit_price, is_tax_free = EXCLUDED.is_tax_free, status='ACTIVE', is_del='N'`,
|
||||
[`TEST-${it.code}`, it.code, it.name, it.price, it.taxFree]
|
||||
);
|
||||
}
|
||||
const wh = await client.query(`SELECT objid FROM momo_warehouses WHERE wh_type='STOCK' LIMIT 1`);
|
||||
const whObjid = wh.rows[0].objid;
|
||||
for (const it of items) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||
VALUES ($1, $2, (SELECT objid FROM momo_items WHERE item_code = $3), 100, NOW())
|
||||
ON CONFLICT (wh_objid, item_objid) DO UPDATE SET qty = 100, update_date = NOW()`,
|
||||
[`TEST-STK-${it.code}`, whObjid, it.code]
|
||||
);
|
||||
}
|
||||
log(" 품목 3개 + 재고 100개씩 시드 완료");
|
||||
|
||||
// ===== 2. 가입 =====
|
||||
log("2. 회원가입");
|
||||
const sup = await api("/api/auth/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
companyName: "테스트거래처(wace)",
|
||||
ceoName: "박철현",
|
||||
bizNo: "123-45-67890",
|
||||
phone: "010-1234-5678",
|
||||
}),
|
||||
});
|
||||
if (!sup.body.success) fail(`가입 실패: ${JSON.stringify(sup.body)}`);
|
||||
log(` ✔ 가입 성공 (USER 세션 발급, cookie=${cookieJar.slice(0, 30)}...)`);
|
||||
|
||||
// ===== 3. 품목 조회 (USER 세션) =====
|
||||
log("3. 품목 검색 (USER)");
|
||||
const list = await api("/api/m/items/list", { method: "POST", body: JSON.stringify({ keyword: "TEST" }) });
|
||||
const visible = (list.body.RESULTLIST || []).filter((r) => r.ITEM_CODE.startsWith("TEST-"));
|
||||
if (visible.length !== 3) fail(`품목 조회 실패: ${visible.length}개 (기대 3개)`);
|
||||
log(` ✔ ${visible.length}개 품목 노출, 재고 ${visible[0].STOCK_QTY}`);
|
||||
|
||||
// ===== 4. 발주 작성 =====
|
||||
log("4. 발주 요청");
|
||||
const order = await api("/api/m/orders/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
lines: [
|
||||
{ itemObjid: visible.find((x) => x.ITEM_CODE === "TEST-EGG-01").OBJID, qty: 30 },
|
||||
{ itemObjid: visible.find((x) => x.ITEM_CODE === "TEST-CHKN-01").OBJID, qty: 20 },
|
||||
{ itemObjid: visible.find((x) => x.ITEM_CODE === "TEST-CLEAN-01").OBJID, qty: 11 },
|
||||
],
|
||||
memo: "E2E 테스트 발주",
|
||||
}),
|
||||
});
|
||||
if (!order.body.success) fail(`발주 실패: ${JSON.stringify(order.body)}`);
|
||||
log(` ✔ 발주번호: ${order.body.orderNo}, objId: ${order.body.objId}`);
|
||||
|
||||
// ===== 5. 어드민 로그인 (시드 관리자) =====
|
||||
log("5. 관리자 로그인");
|
||||
cookieJar = ""; // USER 세션 클리어
|
||||
const adminLogin = await api("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId: "admin@momo.com", password: "admin1234" }),
|
||||
});
|
||||
if (!adminLogin.body.success) {
|
||||
// 시드 비밀번호 해시가 환경에 따라 다를 수 있어 → DB에서 비밀번호 재설정
|
||||
log(" 시드 비번 불일치 — bcrypt 재설정");
|
||||
const bcrypt = (await import("bcryptjs")).default;
|
||||
const hash = await bcrypt.hash("admin1234", 10);
|
||||
await client.query(`UPDATE momo_users SET password_hash = $1 WHERE email = 'admin@momo.com'`, [hash]);
|
||||
const retry = await api("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId: "admin@momo.com", password: "admin1234" }),
|
||||
});
|
||||
if (!retry.body.success) fail(`관리자 로그인 실패: ${JSON.stringify(retry.body)}`);
|
||||
}
|
||||
log(` ✔ 관리자 세션 발급`);
|
||||
|
||||
// ===== 6. 승인 + 메일 발송 =====
|
||||
log("6. 발주 승인 (재고차감 + 메일발송)");
|
||||
const approve = await api("/api/m/orders/approve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ objid: order.body.objId }),
|
||||
});
|
||||
if (!approve.body.success) fail(`승인 실패: ${JSON.stringify(approve.body)}`);
|
||||
log(` ✔ 승인 완료, mailSent=${approve.body.mailSent}, mailError=${approve.body.mailError ?? "(none)"}`);
|
||||
|
||||
// ===== 7. 결과 검증 =====
|
||||
log("7. 후속 검증");
|
||||
const r1 = await client.query(`SELECT status, total_amount, total_taxfree, total_taxable FROM momo_orders WHERE objid = $1`, [order.body.objId]);
|
||||
log(` 주문 상태: ${r1.rows[0].status} (기대 APPROVED)`);
|
||||
log(` 금액: 면세 ${r1.rows[0].total_taxfree} / 과세 ${r1.rows[0].total_taxable} / 합계 ${r1.rows[0].total_amount}`);
|
||||
if (r1.rows[0].status !== "APPROVED") fail("주문 상태 불일치");
|
||||
|
||||
const r2 = await client.query(`SELECT qty FROM momo_stocks WHERE wh_objid = $1 AND item_objid = (SELECT objid FROM momo_items WHERE item_code = 'TEST-EGG-01')`, [whObjid]);
|
||||
log(` M 유정란 재고: ${r2.rows[0].qty} (기대 70 = 100-30)`);
|
||||
if (Number(r2.rows[0].qty) !== 70) fail("재고 차감 불일치");
|
||||
|
||||
const r3 = await client.query(`SELECT to_email, subject, status, error_msg FROM momo_mail_logs WHERE ref_objid = $1 ORDER BY regdate DESC LIMIT 1`, [order.body.objId]);
|
||||
log(` 메일 로그: to=${r3.rows[0].to_email}, status=${r3.rows[0].status}, subject=${r3.rows[0].subject}`);
|
||||
if (r3.rows[0].error_msg) log(` 메일 에러: ${r3.rows[0].error_msg}`);
|
||||
|
||||
log("✔ E2E 테스트 완료");
|
||||
await client.end();
|
||||
@@ -0,0 +1,68 @@
|
||||
// SMTP 단독 테스트 — DB 의존 없이 메일 발송 확인
|
||||
// 사용법: node scripts/test-smtp.mjs <받는사람이메일>
|
||||
import nodemailer from "nodemailer";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.join(__dirname, "..", ".env.development");
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
const to = process.argv[2] || "chpark@wace.me";
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = Number(process.env.SMTP_PORT || 465);
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
const from = process.env.SMTP_FROM || user;
|
||||
|
||||
console.log("[smtp]", { host, port, user, from, to });
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
console.error("SMTP 환경변수가 누락되었습니다.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host, port, secure: port === 465,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
connectionTimeout: 15000,
|
||||
socketTimeout: 20000,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("[smtp] verify connection...");
|
||||
await transporter.verify();
|
||||
console.log("[smtp] ✔ verify OK");
|
||||
|
||||
console.log("[smtp] sending test mail...");
|
||||
const info = await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: "[모모유통] SMTP 연결 테스트",
|
||||
html: `
|
||||
<div style="font-family:'Apple SD Gothic Neo','Malgun Gothic',sans-serif;padding:24px;color:#0f172a">
|
||||
<h2 style="color:#0f766e">모모유통 메일 서버 테스트</h2>
|
||||
<p>이 메일이 도착했다면 SMTP 발송이 정상 작동하는 것입니다.</p>
|
||||
<ul>
|
||||
<li>발송 호스트: <b>${host}:${port}</b></li>
|
||||
<li>발송 계정: <b>${user}</b></li>
|
||||
<li>발송 시각: ${new Date().toLocaleString("ko-KR")}</li>
|
||||
</ul>
|
||||
<p style="color:#64748b;font-size:12px;margin-top:24px">실제 발주 승인 시에는 이 메일에 거래명세표 엑셀(.xlsx) 파일이 첨부됩니다.</p>
|
||||
</div>`,
|
||||
text: "모모유통 SMTP 테스트 메일입니다.",
|
||||
});
|
||||
console.log("[smtp] ✔ sent:", info.messageId);
|
||||
console.log("[smtp] response:", info.response);
|
||||
} catch (err) {
|
||||
console.error("[smtp] ✖ FAILED");
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 발주 승인 시 발송될 거래명세표 메일을 그대로 시뮬레이션
|
||||
// (DB 없이 실제 운영 코드와 동일한 buildStatementHtml/Xlsx 사용)
|
||||
import nodemailer from "nodemailer";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.join(__dirname, "..", ".env.development");
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 운영 코드와 동일한 함수 (excel-statement.ts 미니 포팅) =====
|
||||
const fmt = (n) => Math.round(n);
|
||||
const escapeHtml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
const formatNumber = (n) => n.toLocaleString("ko-KR");
|
||||
|
||||
function buildStatementXlsx(input) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
const aoa = [];
|
||||
aoa.push(["거 래 명 세 표"]);
|
||||
aoa.push([]);
|
||||
aoa.push(["발주번호", input.orderNo, "", "발주일자", input.orderDate]);
|
||||
aoa.push([]);
|
||||
aoa.push(["[공급받는자]"]);
|
||||
aoa.push(["업체명", input.customer.companyName, "대표자", input.customer.ceoName ?? "-"]);
|
||||
aoa.push(["사업자번호", input.customer.bizNo ?? "-", "전화번호", input.customer.phone ?? "-"]);
|
||||
aoa.push([]);
|
||||
aoa.push(["[공급자]"]);
|
||||
aoa.push(["업체명", input.supplier.companyName, "계좌번호", input.supplier.bankAccount ?? "-"]);
|
||||
aoa.push(["전화번호", input.supplier.phone ?? "-", "이메일", input.supplier.email ?? "-"]);
|
||||
aoa.push([]);
|
||||
aoa.push(["순번", "품명", "구분", "수량", "단위", "단가", "공급가액", "세액", "합계"]);
|
||||
for (const it of input.items) {
|
||||
aoa.push([it.seq, it.itemName, it.isTaxFree ? "면세" : "과세", it.qty, it.unit || "EA",
|
||||
fmt(it.unitPrice), fmt(it.supplyAmount), fmt(it.vatAmount), fmt(it.totalAmount)]);
|
||||
}
|
||||
aoa.push([]);
|
||||
aoa.push(["", "", "", "", "", "면세 합계", fmt(input.totals.taxFree)]);
|
||||
aoa.push(["", "", "", "", "", "과세 공급가", fmt(input.totals.taxable)]);
|
||||
aoa.push(["", "", "", "", "", "세액 합계", fmt(input.totals.vat)]);
|
||||
aoa.push(["", "", "", "", "", "총 합계 (VAT포함)", fmt(input.totals.total)]);
|
||||
const ws = XLSX.utils.aoa_to_sheet(aoa);
|
||||
ws["!cols"] = [{ wch: 6 }, { wch: 28 }, { wch: 6 }, { wch: 8 }, { wch: 6 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 14 }];
|
||||
ws["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 8 } }];
|
||||
XLSX.utils.book_append_sheet(wb, ws, "거래명세표");
|
||||
return XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
}
|
||||
|
||||
function buildStatementHtml(input) {
|
||||
const rows = input.items.map((it) => `
|
||||
<tr>
|
||||
<td style="text-align:center;border:1px solid #cbd5e1;padding:7px">${it.seq}</td>
|
||||
<td style="border:1px solid #cbd5e1;padding:7px">${escapeHtml(it.itemName)}</td>
|
||||
<td style="text-align:center;color:${it.isTaxFree ? "#7c3aed" : "#e11d48"};border:1px solid #cbd5e1;padding:7px">${it.isTaxFree ? "면세" : "과세"}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${it.qty}</td>
|
||||
<td style="text-align:center;border:1px solid #cbd5e1;padding:7px">${escapeHtml(it.unit || "EA")}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.unitPrice))}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.supplyAmount))}</td>
|
||||
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${it.isTaxFree ? "-" : formatNumber(fmt(it.vatAmount))}</td>
|
||||
<td style="text-align:right;font-weight:600;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.totalAmount))}</td>
|
||||
</tr>`).join("");
|
||||
return `<!doctype html><html lang="ko"><body style="font-family:'Apple SD Gothic Neo','Malgun Gothic',sans-serif;color:#0f172a;padding:24px;background:#fff">
|
||||
<h2 style="text-align:center;letter-spacing:8px;margin:0 0 16px">거 래 명 세 표</h2>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:16px">
|
||||
<div><div><b>발주번호</b> ${escapeHtml(input.orderNo)}</div><div><b>발주일자</b> ${escapeHtml(input.orderDate)}</div></div>
|
||||
<div style="text-align:right"><div><b>공급자</b> ${escapeHtml(input.supplier.companyName)}</div>
|
||||
<div>${escapeHtml(input.supplier.phone ?? "")} · ${escapeHtml(input.supplier.email ?? "")}</div></div>
|
||||
</div>
|
||||
<div style="border:1px solid #cbd5e1;padding:10px;margin-bottom:14px;font-size:13px">
|
||||
<b>${escapeHtml(input.customer.companyName)}</b> 귀하
|
||||
${input.customer.ceoName ? ` · 대표 ${escapeHtml(input.customer.ceoName)}` : ""}
|
||||
${input.customer.bizNo ? ` · 사업자번호 ${escapeHtml(input.customer.bizNo)}` : ""}
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="background:#f1f5f9">
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">순번</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">품명</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">구분</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">수량</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">단위</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">단가</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">공급가액</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">세액</th>
|
||||
<th style="border:1px solid #cbd5e1;padding:8px">합계</th>
|
||||
</tr></thead>
|
||||
<tbody style="font-variant-numeric:tabular-nums">${rows}</tbody>
|
||||
</table>
|
||||
<table style="margin-top:14px;margin-left:auto;font-size:13px;font-variant-numeric:tabular-nums">
|
||||
<tr><td style="padding:4px 12px;color:#7c3aed">면세 합계</td><td style="padding:4px 0;text-align:right;min-width:140px">₩ ${formatNumber(fmt(input.totals.taxFree))}</td></tr>
|
||||
<tr><td style="padding:4px 12px;color:#e11d48">과세 공급가</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.taxable))}</td></tr>
|
||||
<tr><td style="padding:4px 12px">세액 합계</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.vat))}</td></tr>
|
||||
<tr><td style="padding:8px 12px;font-weight:700;border-top:2px solid #0f172a">총 합계 (VAT 포함)</td><td style="padding:8px 0;text-align:right;font-weight:700;border-top:2px solid #0f172a">₩ ${formatNumber(fmt(input.totals.total))}</td></tr>
|
||||
</table>
|
||||
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:12px;color:#475569">위와 같이 계산합니다. — 모모유통</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// ===== 가짜 발주 데이터 (스크린샷 첨부 거래명세표 그대로) =====
|
||||
const items = [
|
||||
{ seq: 1, itemName: "M 유정란", unit: "EA", qty: 30, unitPrice: 10000, isTaxFree: true },
|
||||
{ seq: 2, itemName: "M 꽃계탕", unit: "EA", qty: 20, unitPrice: 4500, isTaxFree: true },
|
||||
{ seq: 3, itemName: "빨강 탈취제", unit: "EA", qty: 11, unitPrice: 9200, isTaxFree: false },
|
||||
{ seq: 4, itemName: "파랑 탈취제", unit: "EA", qty: 11, unitPrice: 9200, isTaxFree: false },
|
||||
{ seq: 5, itemName: "초록 탈취제", unit: "EA", qty: 3, unitPrice: 9200, isTaxFree: false },
|
||||
];
|
||||
for (const it of items) {
|
||||
const total = Math.round(it.unitPrice * it.qty);
|
||||
if (it.isTaxFree) { it.supplyAmount = total; it.vatAmount = 0; it.totalAmount = total; }
|
||||
else { const s = Math.round(total / 1.1); it.supplyAmount = s; it.vatAmount = total - s; it.totalAmount = total; }
|
||||
}
|
||||
const totals = items.reduce((a, it) => ({
|
||||
supply: a.supply + it.supplyAmount,
|
||||
vat: a.vat + it.vatAmount,
|
||||
total: a.total + it.totalAmount,
|
||||
taxFree: a.taxFree + (it.isTaxFree ? it.supplyAmount : 0),
|
||||
taxable: a.taxable + (it.isTaxFree ? 0 : it.supplyAmount),
|
||||
}), { supply: 0, vat: 0, total: 0, taxFree: 0, taxable: 0 });
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const stmt = {
|
||||
orderNo: `ORD-${today.replace(/-/g, "")}-TEST`,
|
||||
orderDate: today,
|
||||
customer: { companyName: "수원거래처(테스트)", ceoName: "박철현", bizNo: "123-45-67890", phone: "010-1234-5678" },
|
||||
supplier: {
|
||||
companyName: "모모유통",
|
||||
bankAccount: process.env.MOMO_BANK_ACCOUNT,
|
||||
phone: process.env.MOMO_PHONE,
|
||||
email: process.env.SMTP_FROM ?? "chpark@coa-soft.com",
|
||||
},
|
||||
items, totals,
|
||||
};
|
||||
|
||||
const to = process.argv[2] || "chpark@wace.me";
|
||||
const port = Number(process.env.SMTP_PORT || 465);
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST, port,
|
||||
secure: port === 465,
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
console.log("[stmt-mail] sending to:", to);
|
||||
const html = buildStatementHtml(stmt);
|
||||
const xlsx = buildStatementXlsx(stmt);
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to,
|
||||
subject: `[모모유통] 발주 ${stmt.orderNo} 승인되었습니다 (테스트)`,
|
||||
html,
|
||||
attachments: [{
|
||||
filename: `거래명세표_${stmt.orderNo}.xlsx`,
|
||||
content: xlsx,
|
||||
contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
}],
|
||||
});
|
||||
console.log("[stmt-mail] ✔ sent:", info.messageId);
|
||||
console.log("[stmt-mail] response:", info.response);
|
||||
console.log("[stmt-mail] totals:", { taxFree: totals.taxFree, taxable: totals.taxable, vat: totals.vat, total: totals.total });
|
||||
@@ -0,0 +1,16 @@
|
||||
## 역할
|
||||
비인증 사용자의 로그인 페이지. 세션 쿠키 기반 인증 후 `/dashboard`로 리다이렉트.
|
||||
|
||||
## 공통 패턴
|
||||
- 단일 페이지: `login/page.tsx` (`"use client"`)
|
||||
- `POST /api/auth/login` → `{ userId, password }` 전송, `{ success, message }` 응답
|
||||
- 성공: `router.push("/dashboard")`, 실패: `Swal.fire({ icon: "error" })`
|
||||
|
||||
## 숨겨진 스펙
|
||||
- 다크 배경: `bg-[#1e2432]`, 흰색 폼 카드
|
||||
- 빈값 체크: 클라이언트에서만 (`!userId || !password`)
|
||||
- 로딩 상태: 버튼 disabled + 텍스트 "로그인 중..."
|
||||
- autoFocus: ID 필드에 자동 포커스
|
||||
- autoComplete: `"username"`, `"current-password"` 설정
|
||||
|
||||
@MISTAKES.md
|
||||
@@ -0,0 +1,9 @@
|
||||
# (auth) 오답노트
|
||||
|
||||
<!-- 형식:
|
||||
### YYYY-MM-DD — 실패 요약 제목
|
||||
**증상**: 어떤 오동작이 나타났는지
|
||||
**원인**: 왜 그랬는지
|
||||
**해결**: 어떻게 고쳤는지
|
||||
**교훈**: 다음에 조심할 것 (한 줄)
|
||||
-->
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState, 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 Swal from "sweetalert2";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [userId, setUserId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!userId || !password) {
|
||||
Swal.fire({ icon: "warning", title: "아이디와 비밀번호를 입력하세요." });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
router.push(data.redirectTo || "/dashboard");
|
||||
} else {
|
||||
Swal.fire({
|
||||
icon: "error",
|
||||
title: "로그인 실패",
|
||||
text: data.message || "아이디 또는 비밀번호를 확인하세요.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
Swal.fire({ icon: "error", title: "서버 오류", text: "잠시 후 다시 시도하세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||
{/* 좌측: 브랜드 히어로 패널 */}
|
||||
<div className="relative lg:flex-1 lg:min-h-screen overflow-hidden bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-10 py-16 lg:py-0 flex flex-col justify-between">
|
||||
{/* 배경 패턴 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%), radial-gradient(circle at 50% 50%, rgba(60,179,113,0.18) 0, transparent 55%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255,255,255,0.04) 1px, transparent 0), linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 0)",
|
||||
backgroundSize: "48px 48px",
|
||||
}}
|
||||
/>
|
||||
{/* 반짝이 파티클 */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-[12%] left-[18%] w-1 h-1 rounded-full bg-emerald-200/80 animate-pulse" />
|
||||
<div className="absolute top-[25%] right-[22%] w-2 h-2 rounded-full bg-emerald-300/60 animate-pulse" style={{ animationDelay: "1s" }} />
|
||||
<div className="absolute bottom-[28%] left-[35%] w-1.5 h-1.5 rounded-full bg-emerald-200/70 animate-pulse" style={{ animationDelay: "2s" }} />
|
||||
<div className="absolute top-[60%] right-[15%] w-1 h-1 rounded-full bg-emerald-100/80 animate-pulse" style={{ animationDelay: "0.5s" }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col justify-center py-12 lg:py-0">
|
||||
<img src="/momo-logo.svg" alt="MOMO" className="w-[280px] lg:w-[360px] mb-6 drop-shadow-[0_8px_24px_rgba(0,0,0,0.35)]" />
|
||||
<h2 className="text-white text-3xl lg:text-4xl font-bold mb-3 tracking-tight">
|
||||
모모유통 <span className="text-emerald-200">유통관리 ERP</span>
|
||||
</h2>
|
||||
<p className="text-emerald-100/80 text-base lg:text-lg leading-relaxed max-w-lg">
|
||||
발주 · 입고 · 명세서 · 정산까지 —<br />
|
||||
유통 업무의 모든 흐름을 한 곳에서.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-emerald-100/70 text-[12px] space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={13} className="shrink-0" />
|
||||
<span><span className="text-emerald-200 font-semibold mr-2">본사</span>경기도 의왕시 벌모루길 46 B동</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={13} className="shrink-0" />
|
||||
<span><span className="text-emerald-200 font-semibold mr-2">지사</span>경기도 김포시 고촌읍 김포대로 451번길 210</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-1 text-emerald-100/60">
|
||||
<span className="flex items-center gap-1.5"><Phone size={12} />010-6624-5315</span>
|
||||
<span className="flex items-center gap-1.5"><Mail size={12} />momo8443@daum.net</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 로그인 폼 */}
|
||||
<div className="lg:flex-1 flex items-center justify-center px-6 py-16 lg:py-0 bg-slate-50">
|
||||
<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">
|
||||
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||
WELCOME BACK
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">로그인</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
계정 정보를 입력하고 유통관리 ERP에 접속하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">
|
||||
아이디
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<User
|
||||
size={16}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition-colors"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="아이디를 입력하세요"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
className="w-full h-12 pl-11 pr-4 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">
|
||||
비밀번호
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<Lock
|
||||
size={16}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition-colors"
|
||||
/>
|
||||
<input
|
||||
type={showPw ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
autoComplete="current-password"
|
||||
className="w-full h-12 pl-11 pr-12 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full h-12 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white text-sm font-bold tracking-wide shadow-lg shadow-emerald-600/25 hover:shadow-emerald-600/40 hover:-translate-y-0.5 active:translate-y-0 transition-all disabled:opacity-60 disabled:cursor-not-allowed disabled:translate-y-0 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||
로그인 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
로그인
|
||||
<ArrowRight size={16} className="group-hover:translate-x-0.5 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
아직 계정이 없으신가요?{" "}
|
||||
<Link href="/signup" className="text-emerald-700 font-semibold hover:underline">
|
||||
회원가입
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-6 border-t border-slate-200 text-center">
|
||||
<p className="text-[11px] text-slate-400 tracking-wide">
|
||||
© 2026 MOMO DISTRIBUTION. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Swal from "sweetalert2";
|
||||
import { Mail, Lock, Building2, User as UserIcon, Phone, FileText, ArrowRight, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
companyName: "",
|
||||
ceoName: "",
|
||||
bizNo: "",
|
||||
phone: "",
|
||||
});
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.email || !form.password || !form.companyName) {
|
||||
Swal.fire({ icon: "warning", title: "필수 항목을 입력하세요." });
|
||||
return;
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
Swal.fire({ icon: "warning", title: "비밀번호는 8자 이상이어야 합니다." });
|
||||
return;
|
||||
}
|
||||
if (form.password !== form.passwordConfirm) {
|
||||
Swal.fire({ icon: "warning", title: "비밀번호가 일치하지 않습니다." });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
companyName: form.companyName,
|
||||
ceoName: form.ceoName,
|
||||
bizNo: form.bizNo,
|
||||
phone: form.phone,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await Swal.fire({
|
||||
icon: "success",
|
||||
title: "가입이 완료되었습니다",
|
||||
text: "이제 발주를 시작하실 수 있습니다.",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
router.push("/m/dashboard");
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "가입 실패", text: data.message });
|
||||
}
|
||||
} catch {
|
||||
Swal.fire({ icon: "error", title: "서버 오류", text: "잠시 후 다시 시도하세요." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col lg:flex-row bg-white">
|
||||
{/* 좌측 브랜드 */}
|
||||
<div className="relative lg:flex-1 lg:min-h-screen overflow-hidden bg-gradient-to-br from-[#0d3b24] via-[#1b5e3a] to-[#0f4a2a] px-10 py-16 lg:py-0 flex flex-col justify-center">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle at 20% 30%, rgba(126,217,167,0.25) 0, transparent 40%), radial-gradient(circle at 80% 70%, rgba(46,139,87,0.3) 0, transparent 45%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<Link href="/" className="inline-flex items-center gap-2.5 mb-12 hover:opacity-80 transition">
|
||||
<img src="/momo-icon.svg" alt="" className="w-9 h-9" />
|
||||
<span className="text-white/95 text-sm font-bold tracking-widest">MOMO DISTRIBUTION</span>
|
||||
</Link>
|
||||
<h2 className="text-white text-4xl font-bold mb-4 tracking-tight">
|
||||
지금 가입하고<br />
|
||||
<span className="text-emerald-200">발주를 시작하세요</span>
|
||||
</h2>
|
||||
<p className="text-emerald-100/80 leading-relaxed max-w-md mb-8">
|
||||
이메일과 업체명만 입력하면 바로 사용 가능합니다. 가입 즉시 모모유통의 모든 품목을 검색하고 발주할 수 있습니다.
|
||||
</p>
|
||||
<ul className="space-y-2 text-emerald-100/80 text-sm">
|
||||
<li>· 가입비 · 월 사용료 없음</li>
|
||||
<li>· 안드로이드 앱 무료 제공 (APK)</li>
|
||||
<li>· 거래명세표 자동 발송 (메일 + 엑셀)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 가입 폼 */}
|
||||
<div className="lg:flex-1 flex items-center justify-center px-6 py-12 lg:py-16 bg-slate-50">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||
SIGN UP
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">회원가입</h1>
|
||||
<p className="text-slate-500 text-sm">
|
||||
이미 계정이 있으신가요?{" "}
|
||||
<Link href="/login" className="text-emerald-700 font-semibold hover:underline">로그인</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Field icon={<Mail size={16} />} label="이메일 *" type="email" value={form.email} onChange={set("email")} placeholder="you@company.com" autoComplete="email" autoFocus />
|
||||
<div>
|
||||
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">비밀번호 *</label>
|
||||
<div className="relative group">
|
||||
<Lock size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition" />
|
||||
<input
|
||||
type={showPw ? "text" : "password"}
|
||||
value={form.password}
|
||||
onChange={set("password")}
|
||||
placeholder="8자 이상"
|
||||
autoComplete="new-password"
|
||||
className="w-full h-11 pl-11 pr-12 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPw((v) => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-700" tabIndex={-1}>
|
||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Field icon={<Lock size={16} />} label="비밀번호 확인 *" type={showPw ? "text" : "password"} value={form.passwordConfirm} onChange={set("passwordConfirm")} placeholder="비밀번호 재입력" autoComplete="new-password" />
|
||||
<Field icon={<Building2 size={16} />} label="업체명 *" value={form.companyName} onChange={set("companyName")} placeholder="(주)거래처 또는 매장명" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field icon={<UserIcon size={16} />} label="대표자" value={form.ceoName} onChange={set("ceoName")} placeholder="홍길동" />
|
||||
<Field icon={<Phone size={16} />} label="연락처" value={form.phone} onChange={set("phone")} placeholder="010-0000-0000" />
|
||||
</div>
|
||||
<Field icon={<FileText size={16} />} label="사업자등록번호" value={form.bizNo} onChange={set("bizNo")} placeholder="000-00-00000 (선택)" />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full h-12 mt-2 rounded-xl bg-gradient-to-r from-emerald-700 to-emerald-600 text-white text-sm font-bold tracking-wide shadow-lg shadow-emerald-600/25 hover:shadow-emerald-600/40 hover:-translate-y-0.5 active:translate-y-0 transition-all disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||
처리 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
가입하기 <ArrowRight size={16} className="group-hover:translate-x-0.5 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-[11px] text-slate-400 text-center pt-2">
|
||||
가입하시면 <Link href="/" className="underline hover:text-slate-600">서비스 이용약관</Link>에 동의하시는 것으로 간주됩니다.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field(props: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[12px] font-semibold text-slate-600 mb-2 tracking-wide">{props.label}</label>
|
||||
<div className="relative group">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-600 transition">
|
||||
{props.icon}
|
||||
</span>
|
||||
<input
|
||||
type={props.type ?? "text"}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
placeholder={props.placeholder}
|
||||
autoComplete={props.autoComplete}
|
||||
autoFocus={props.autoFocus}
|
||||
className="w-full h-11 pl-11 pr-4 rounded-xl border border-slate-200 bg-white text-sm text-slate-900 placeholder-slate-400 shadow-sm focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/10 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
## 역할
|
||||
인증된 사용자 전용 업무 페이지 영역. 영업, 생산, 구매, 재고, 품질, 비용 등 70개 이상의 업무 페이지를 포함. 공통 레이아웃(Header + Sidebar + Content)으로 감싸진 대시보드 시스템.
|
||||
|
||||
## 공통 패턴
|
||||
|
||||
### 페이지 구조
|
||||
모든 페이지는 `"use client"` 클라이언트 컴포넌트. 구성:
|
||||
1. 제목: `<h2 className="text-lg font-bold text-gray-800 mb-4">{제목}</h2>`
|
||||
2. 검색 폼: `SearchForm` + `SearchField` + `CodeSelect`/`Input`
|
||||
3. 버튼 영역: 조회, 등록, 삭제 등
|
||||
4. 데이터 그리드: `DataGrid` + `Pagination`
|
||||
|
||||
### 데이터 조회
|
||||
```tsx
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/{domain}/{resource}", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, category_cd, ... }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [dependencies]);
|
||||
```
|
||||
|
||||
### 팝업 처리
|
||||
```tsx
|
||||
window.open(url, "formPopup", "width=1200,height=550,left=...,top=...");
|
||||
```
|
||||
|
||||
### 행 삭제
|
||||
```tsx
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "항목을 선택하세요." });
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## 연결 고리
|
||||
- API: `POST /api/{domain}/{resource}` (RESULTLIST 응답)
|
||||
- Store: `useAuthStore()` (user, logout), `useMenuStore()` (메뉴 상태)
|
||||
- 컴포넌트: DataGrid, SearchForm, SearchField, CodeSelect, Input, Button, Pagination
|
||||
- 유틸: `numberWithCommas()`, `cn()`
|
||||
|
||||
## 숨겨진 스펙
|
||||
- 데이터 타입: `Record<string, unknown>[]`
|
||||
- DataGrid 높이: `calc(100vh - 350px)` 패턴 (페이지별 350~400px 차감)
|
||||
- 년도 필터: `Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)`
|
||||
- 셀 클릭: `col.cellClick` 콜백으로 상세 폼 팝업
|
||||
- formatter `"money"` → 통화 형식
|
||||
- JSP 원본 주석: `// salesMgmt/salesMgmtContractList.jsp 대응`
|
||||
- 에러처리 미비: `if (res.ok)` 체크만, try-catch 없는 경우 많음
|
||||
|
||||
@MISTAKES.md
|
||||
@@ -0,0 +1,9 @@
|
||||
# (main) 페이지 오답노트
|
||||
|
||||
<!-- 형식:
|
||||
### YYYY-MM-DD — 실패 요약 제목
|
||||
**증상**: 어떤 오동작이 나타났는지
|
||||
**원인**: 왜 그랬는지
|
||||
**해결**: 어떻게 고쳤는지
|
||||
**교훈**: 다음에 조심할 것 (한 줄)
|
||||
-->
|
||||
@@ -0,0 +1,63 @@
|
||||
"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";
|
||||
|
||||
// admin/adminUserList.jsp 대응 - 관리자 (사용자관리)
|
||||
export default function AdminPage() {
|
||||
const [searchName, setSearchName] = useState("");
|
||||
const [searchDept, setSearchDept] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "사번", field: "SABUN", width: 100, hozAlign: "center" },
|
||||
{ title: "아이디", field: "USER_ID", width: 120, hozAlign: "left",
|
||||
cellClick: (row) => window.open(`/admin/user-form?userId=${row.USER_ID}`, "userForm", "width=700,height=600") },
|
||||
{ title: "이름", field: "USER_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "부서", field: "DEPT_NAME", width: 120, hozAlign: "center" },
|
||||
{ title: "직급", field: "POSITION_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "이메일", field: "EMAIL", width: 180, hozAlign: "left" },
|
||||
{ title: "연락처", field: "TEL", width: 130, hozAlign: "center" },
|
||||
{ title: "유형", field: "USER_TYPE_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "권한", field: "AUTH_NAME", width: 150, hozAlign: "left" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_name: searchName, dept_name: searchDept }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [searchName, searchDept]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
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("/admin/user-form", "userForm", "width=700,height=600")}>등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="이름">
|
||||
<Input value={searchName} onChange={(e) => setSearchName(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="부서">
|
||||
<Input value={searchDept} onChange={(e) => setSearchDept(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/store/auth-store";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Loading } from "@/components/ui/loading";
|
||||
|
||||
// mainFS.jsp 대응 - 프레임셋 → Sidebar + Header + Content 레이아웃
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, fetchUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message="로딩 중..." />;
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* 사이드바 (menu.jsp 대응) */}
|
||||
<Sidebar />
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 헤더 (header.jsp 대응) */}
|
||||
<Header />
|
||||
|
||||
{/* 콘텐츠 (contents_page iframe 대응) */}
|
||||
<main className="flex-1 overflow-hidden p-4 flex flex-col min-h-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"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 { numberWithCommas } from "@/lib/utils";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// 원본: /purchaseOrder/purchaseOrderStatusAmountBySupply.do
|
||||
// 발주관리 > 업체별_입고요청월 금액현황
|
||||
export default function AmountStatusPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [partnerObjid, setPartnerObjid] = useState("");
|
||||
const [salesMngUserId, setSalesMngUserId] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [sums, setSums] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [supplyOptions, setSupplyOptions] = useState<Option[]>([]);
|
||||
const [userOptions, setUserOptions] = useState<Option[]>([]);
|
||||
|
||||
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 columns: GridColumn[] = [
|
||||
{ title: "공급업체", field: "SUPPLY_NAME", width: 180, hozAlign: "left", frozen: true },
|
||||
{ title: "1월", field: "M01", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "2월", field: "M02", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "3월", field: "M03", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "4월", field: "M04", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "5월", field: "M05", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "6월", field: "M06", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "7월", field: "M07", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "8월", field: "M08", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "9월", field: "M09", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "10월", field: "M10", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "11월", field: "M11", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "12월", field: "M12", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "합계", field: "TOTAL_SUPPLY_UNIT_PRICE", width: 140, hozAlign: "right", formatter: "money" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/order/amount-status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
project_no: projectNo,
|
||||
partner_objid: partnerObjid,
|
||||
sales_mng_user_id: salesMngUserId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
setSums(json.SUMS || {});
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
Swal.fire("오류", j.message || "조회 실패", "error");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, projectNo, partnerObjid, salesMngUserId]);
|
||||
|
||||
const handleReset = () => {
|
||||
setYear(new Date().getFullYear().toString());
|
||||
setProjectNo("");
|
||||
setPartnerObjid("");
|
||||
setSalesMngUserId("");
|
||||
};
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// 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" variant="secondary" onClick={handleReset}>초기화</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"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{Array.from({ length: 6 }, (_, 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-[180px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="공급업체">
|
||||
<SearchableSelect
|
||||
options={supplyOptions}
|
||||
value={partnerObjid}
|
||||
onChange={setPartnerObjid}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="구매담당">
|
||||
<SearchableSelect
|
||||
options={userOptions}
|
||||
value={salesMngUserId}
|
||||
onChange={setSalesMngUserId}
|
||||
className="w-[140px]"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<div className="flex items-center gap-6 mb-3 text-sm font-bold text-red-600">
|
||||
<span>총 합계(원) : {numberWithCommas(Number(sums.TOTAL_SUPPLY_UNIT_PRICE ?? 0))}</span>
|
||||
</div>
|
||||
|
||||
<DataGrid columns={columns} data={data} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"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 Swal from "sweetalert2";
|
||||
import { ApprovalButton } from "@/components/approval/ApprovalButton";
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
// 원본: /purchaseOrder/purchaseOrderList_new.do
|
||||
export default function OrderListPage() {
|
||||
const [year, setYear] = useState("");
|
||||
const [customerCd, setCustomerCd] = useState("");
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [purchaseOrderNo, setPurchaseOrderNo] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [orderTypeCd, setOrderTypeCd] = useState("");
|
||||
const [deliveryStartDate, setDeliveryStartDate] = useState("");
|
||||
const [deliveryEndDate, setDeliveryEndDate] = useState("");
|
||||
const [partnerObjid, setPartnerObjid] = useState("");
|
||||
const [salesMngUserId, setSalesMngUserId] = useState("");
|
||||
const [regStartDate, setRegStartDate] = useState("");
|
||||
const [regEndDate, setRegEndDate] = useState("");
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [poClientId, setPoClientId] = useState("");
|
||||
const [apprStatus, setApprStatus] = useState("");
|
||||
const [partSpec, setPartSpec] = useState("");
|
||||
|
||||
const [customerOptions, setCustomerOptions] = useState<Option[]>([]);
|
||||
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/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/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 url = objId ? `/order/list/form?objId=${objId}&action=view` : "/order/list/form";
|
||||
const w = 1460;
|
||||
const h = 1050;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, `orderForm_${objId || "new"}`, `width=${w},height=${h},left=${left},top=${top}`);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "발주No",
|
||||
field: "PURCHASE_ORDER_NO",
|
||||
width: 130,
|
||||
hozAlign: "left",
|
||||
frozen: true,
|
||||
cellClick: (row) => openOrderForm(String(row.OBJID || "")),
|
||||
},
|
||||
{ title: "년도", field: "CM_YEAR", width: 60, hozAlign: "center" },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 130, hozAlign: "left" },
|
||||
{ title: "고객사프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "당사프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "left" },
|
||||
{ title: "부품", field: "TYPE_NAME", width: 70, hozAlign: "center" },
|
||||
{ title: "구분", field: "ORDER_TYPE_CD_NAME", width: 70, hozAlign: "center" },
|
||||
{ title: "동시", field: "MULTI_YN_MAKED", width: 50, hozAlign: "center" },
|
||||
{ title: "발주서제목", field: "TITLE", width: 220, hozAlign: "left" },
|
||||
{ title: "납품장소", field: "DELIVERY_PLACE_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "검수방법", field: "INSPECT_METHOD_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "결재조건", field: "PAYMENT_TERMS_NAME", width: 110, hozAlign: "center" },
|
||||
{ title: "입고요청일", field: "DELIVERY_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "공급업체", field: "PARTNER_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "구매담당", field: "SALES_MNG_USER_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "발주일", field: "REGDATE", width: 100, hozAlign: "center" },
|
||||
{ title: "발주금액", field: "TOTAL_PRICE_ALL", width: 110, hozAlign: "right", formatter: "money" },
|
||||
{ title: "상태", field: "APPR_STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/order/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
customer_cd: customerCd,
|
||||
project_no: projectNo,
|
||||
unit_code: unitCode,
|
||||
purchase_order_no: purchaseOrderNo,
|
||||
type,
|
||||
order_type_cd: orderTypeCd,
|
||||
delivery_start_date: deliveryStartDate,
|
||||
delivery_end_date: deliveryEndDate,
|
||||
partner_objid: partnerObjid,
|
||||
sales_mng_user_id: salesMngUserId,
|
||||
reg_start_date: regStartDate,
|
||||
reg_end_date: regEndDate,
|
||||
part_no: partNo,
|
||||
part_name: partName,
|
||||
po_client_id: poClientId,
|
||||
appr_status: apprStatus,
|
||||
part_spec: partSpec,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
Swal.fire("오류", j.message || "조회 실패", "error");
|
||||
}
|
||||
}, [
|
||||
year, customerCd, projectNo, unitCode, purchaseOrderNo, type, orderTypeCd,
|
||||
deliveryStartDate, deliveryEndDate, partnerObjid, salesMngUserId,
|
||||
regStartDate, regEndDate, partNo, partName, poClientId, apprStatus, partSpec,
|
||||
]);
|
||||
|
||||
const masterSelectedIds = selectedRows
|
||||
.filter((r) => r.MULTI_YN !== "Y" || r.MULTI_MASTER_YN === "Y")
|
||||
.map((r) => String(r.OBJID));
|
||||
|
||||
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" onClick={() => openOrderForm()}>발주서작성</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
<ApprovalButton
|
||||
objIds={masterSelectedIds}
|
||||
targetType="PURCHASE_ORDER"
|
||||
title={`발주 결재 요청 (${masterSelectedIds.length}건)`}
|
||||
description={selectedRows.map((r) => `${r.PURCHASE_ORDER_NO} - ${r.TITLE}`).join("\n")}
|
||||
onSuccess={fetchData}
|
||||
disabled={masterSelectedIds.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
className="h-9 w-[90px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{Array.from({ length: 6 }, (_, i) => new Date().getFullYear() - i).map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<SearchableSelect options={customerOptions} value={customerCd} onChange={setCustomerCd} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛">
|
||||
<Input value={unitCode} onChange={(e) => setUnitCode(e.target.value)} className="w-[130px]" placeholder="유닛코드" />
|
||||
</SearchField>
|
||||
<SearchField label="발주No">
|
||||
<Input value={purchaseOrderNo} onChange={(e) => setPurchaseOrderNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="발주부품">
|
||||
<SearchableCodeSelect codeId="0001068" value={type} onChange={setType} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="발주구분">
|
||||
<SearchableCodeSelect codeId="0001406" value={orderTypeCd} onChange={setOrderTypeCd} className="w-[130px]" />
|
||||
</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="공급업체">
|
||||
<SearchableSelect options={supplyOptions} value={partnerObjid} onChange={setPartnerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="구매담당">
|
||||
<SearchableSelect options={userOptions} value={salesMngUserId} onChange={setSalesMngUserId} className="w-[130px]" />
|
||||
</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="품번">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input value={partName} onChange={(e) => setPartName(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="발주처">
|
||||
<SearchableSelect options={supplyOptions} value={poClientId} onChange={setPoClientId} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="상태">
|
||||
<select
|
||||
value={apprStatus}
|
||||
onChange={(e) => setApprStatus(e.target.value)}
|
||||
className="h-9 w-[120px] rounded border border-gray-300 bg-white px-3 text-sm"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="create">작성중</option>
|
||||
<option value="inProcess">결재중</option>
|
||||
<option value="reject">반려</option>
|
||||
<option value="complete">결재완료</option>
|
||||
<option value="cancel">취소</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="규격">
|
||||
<Input value={partSpec} onChange={(e) => setPartSpec(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"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 { FolderCell } from "@/components/ui/folder-cell";
|
||||
import { numberWithCommas } from "@/lib/utils";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const openBomPopup = (objId: string) => {
|
||||
if (!objId) return;
|
||||
const w = 1800, h = 800;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/purchase/bom/structure?objId=${objId}&readonly=1`,
|
||||
`bomStructure_${objId}`,
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
// 원본: /purchaseOrder/purchaseOrderStatusByProject.do (발주관리_현황)
|
||||
export default function OrderStatusPage() {
|
||||
const [year, setYear] = useState("");
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [sums, setSums] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 110, hozAlign: "left", frozen: true },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 110, hozAlign: "left", frozen: true },
|
||||
{ title: "유닛명", field: "UNIT_PART_NAME", width: 220, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 130, hozAlign: "left" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "발주현황",
|
||||
columns: [
|
||||
{
|
||||
title: "구매BOM",
|
||||
field: "BOM_CNT",
|
||||
width: 80,
|
||||
hozAlign: "center",
|
||||
formatter: (cell, row) => (
|
||||
<FolderCell
|
||||
count={cell}
|
||||
onClick={() => openBomPopup(String(row.BOM_REPORT_OBJID || ""))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: "전체수량", field: "TOTAL_BOM_PART_CNT", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "발주품수", field: "TOTAL_PO_PART_CNT", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "미발주품수", field: "NON_PO_PART_CNT", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{
|
||||
title: "발주율",
|
||||
field: "RATE_PO",
|
||||
width: 80,
|
||||
hozAlign: "right",
|
||||
formatter: (cell) => `${Number(cell) || 0}%`,
|
||||
},
|
||||
{ title: "구매품", field: "PRICE_PT_1", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "제작품", field: "PRICE_PT_2", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "사급품", field: "PRICE_PT_3", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "기타", field: "PRICE_PT_ETC", width: 100, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "재발주현황",
|
||||
columns: [
|
||||
{ title: "건수", field: "RE_COUNT", width: 70, hozAlign: "right", formatter: "money" },
|
||||
{ title: "금액(원)", field: "RE_PRICE", width: 120, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "턴키현황",
|
||||
columns: [
|
||||
{ title: "건수", field: "TURNKEY_COUNT", width: 70, hozAlign: "right", formatter: "money" },
|
||||
{ title: "금액(원)", field: "TURNKEY_PRICE", width: 120, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{ title: "총발주금액(원)", field: "TOTAL_PRICE_ALL", width: 140, hozAlign: "right", formatter: "money" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/order/status", {
|
||||
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 || []);
|
||||
setSums(json.SUMS || {});
|
||||
} else {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
Swal.fire("오류", j.message || "조회 실패", "error");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, projectNo]);
|
||||
|
||||
const handleReset = () => {
|
||||
setYear("");
|
||||
setProjectNo("");
|
||||
};
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
Swal.fire("알림", "Excel 다운로드 기능은 준비 중입니다.", "info");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// 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" variant="secondary" onClick={handleReset}>초기화</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"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{Array.from({ length: 6 }, (_, 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-[200px]"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<div className="flex items-center gap-6 mb-3 text-sm font-bold text-red-600">
|
||||
<span>총발주금액(원) : {numberWithCommas(Number(sums.TOTAL_PRICE_ALL_SUM ?? 0))}</span>
|
||||
<span>단일발주금액(원) : {numberWithCommas(Number(sums.SINGLE_PRICE_SUM ?? 0))}</span>
|
||||
</div>
|
||||
|
||||
<DataGrid columns={columns} data={data} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"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";
|
||||
|
||||
// partmgmt/partmgmtList.jsp 대응 - 부품관리(PART)
|
||||
export default function PartMgmtPage() {
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [partType, setPartType] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "파트번호", field: "PART_NO", width: 150, hozAlign: "left",
|
||||
cellClick: (row) => openPartDetail(String(row.OBJID)) },
|
||||
{ title: "파트명", field: "PART_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "파트유형", field: "PART_TYPE_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "규격", field: "SPEC", width: 130, hozAlign: "left" },
|
||||
{ title: "재질", field: "MATERIAL_NAME", width: 100, hozAlign: "left" },
|
||||
{ title: "UNIT", field: "UNIT_NAME", width: 70, hozAlign: "center" },
|
||||
{ title: "중량", field: "WEIGHT", width: 80, hozAlign: "right" },
|
||||
{ title: "업체", field: "MAKER", width: 120, hozAlign: "left" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "등록일", field: "REGDATE", width: 100, hozAlign: "center" },
|
||||
{ title: "2D", field: "DRAWING_2D_CNT", width: 40, hozAlign: "center" },
|
||||
{ title: "3D", field: "DRAWING_3D_CNT", width: 40, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/part-mgmt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ part_no: partNo, part_name: partName, part_type: partType }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [partNo, partName, partType]);
|
||||
|
||||
const openPartDetail = (objId: string) => {
|
||||
window.open(`/product-mgmt/form?objId=${objId}`, "partDetail", "width=1100,height=800");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">부품관리 (PART)</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => window.open("/product-mgmt/form", "partForm", "width=1100,height=800")}>등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="파트번호">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="파트명">
|
||||
<Input value={partName} onChange={(e) => setPartName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="파트유형">
|
||||
<CodeSelect codeId="0000062" value={partType} onChange={setPartType} placeholder="전체" className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} showCheckbox />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"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";
|
||||
|
||||
// partMgmt/partRegisterList.jsp 대응 - Part 등록
|
||||
export default function PartRegisterPage() {
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [spec, setSpec] = useState("");
|
||||
const [maker, setMaker] = useState("");
|
||||
const [categoryCode, setCategoryCode] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "파트번호", field: "PART_NO", width: 140, hozAlign: "left",
|
||||
cellClick: (row) => window.open(`/product/part-register/form?objId=${row.OBJID}`, "partDetail", "width=900,height=600") },
|
||||
{ title: "파트명", field: "PART_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "규격", field: "SPEC", width: 150, hozAlign: "left" },
|
||||
{ title: "단위", field: "UNIT_NAME", width: 60, hozAlign: "center" },
|
||||
{ title: "카테고리", field: "CATEGORY_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "제조사", field: "MAKER", width: 120, hozAlign: "left" },
|
||||
{ title: "단가", field: "UNIT_PRICE", width: 100, hozAlign: "right", formatter: "money" },
|
||||
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/part/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
part_no: partNo,
|
||||
part_name: partName,
|
||||
spec,
|
||||
maker,
|
||||
category_code: categoryCode,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [partNo, partName, spec, maker, categoryCode]);
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">Part 등록</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => window.open("/product/part-register/form", "partForm", "width=900,height=600")}>등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="파트번호">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="파트명">
|
||||
<Input value={partName} onChange={(e) => setPartName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="규격">
|
||||
<Input value={spec} onChange={(e) => setSpec(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="제조사">
|
||||
<Input value={maker} onChange={(e) => setMaker(e.target.value)} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="카테고리">
|
||||
<CodeSelect codeId="PART_CATEGORY" value={categoryCode} onChange={setCategoryCode} className="w-[130px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import StandardCodePage from "@/components/procurement-std/StandardCodePage";
|
||||
|
||||
// 구매품표준관리 - 코드1
|
||||
export default function Code1Page() {
|
||||
return (
|
||||
<StandardCodePage
|
||||
title="구매품표준관리 - 대분류(CODE1)"
|
||||
codeGroupId="CODE1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import StandardCodePage from "@/components/procurement-std/StandardCodePage";
|
||||
|
||||
// 구매품표준관리 - 코드2
|
||||
export default function Code2Page() {
|
||||
return (
|
||||
<StandardCodePage
|
||||
title="구매품표준관리 - 중분류(CODE2)"
|
||||
codeGroupId="CODE2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import StandardCodePage from "@/components/procurement-std/StandardCodePage";
|
||||
|
||||
// 구매품표준관리 - 코드3
|
||||
export default function Code3Page() {
|
||||
return (
|
||||
<StandardCodePage
|
||||
title="구매품표준관리 - Maker(CODE3)"
|
||||
codeGroupId="CODE3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import StandardCodePage from "@/components/procurement-std/StandardCodePage";
|
||||
|
||||
// 구매품표준관리 - 코드4
|
||||
export default function Code4Page() {
|
||||
return (
|
||||
<StandardCodePage
|
||||
title="구매품표준관리 - 품명(CODE4)"
|
||||
codeGroupId="CODE4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import StandardCodePage from "@/components/procurement-std/StandardCodePage";
|
||||
|
||||
// 구매품표준관리 - 코드5
|
||||
export default function Code5Page() {
|
||||
return (
|
||||
<StandardCodePage
|
||||
title="구매품표준관리 - 규격(CODE5)"
|
||||
codeGroupId="CODE5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import StandardCodePage from "@/components/procurement-std/StandardCodePage";
|
||||
|
||||
// 구매품표준관리 - 자재코드
|
||||
export default function MaterialCodePage() {
|
||||
return (
|
||||
<StandardCodePage
|
||||
title="구매품표준관리 - 자재코드"
|
||||
codeGroupId="MATERIAL_CODE"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"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 { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// productmgmtList.jsp 대응 - 제품마스터
|
||||
export default function ProductMgmtPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [productCategory, setProductCategory] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
const countPerPage = 20;
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "사업부", field: "PRODUCT_CATEGORY_NAME", width: 120,
|
||||
cellClick: (row) => openProductFormPopup(String(row.OBJID)) },
|
||||
{ title: "제품군", field: "PRODUCT_TYPE_NAME", width: 100, hozAlign: "left" },
|
||||
{ title: "제품명", field: "PRODUCT_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "제품코드", field: "PRODUCT_CODE", width: 130, hozAlign: "left" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "등록일", field: "REGDATE", width: 110, hozAlign: "center" },
|
||||
{ title: "생산여부", field: "PRODUCTION_FLAG_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "첨부", field: "FILE_CNT", width: 60, hozAlign: "center",
|
||||
formatter: (_cell, row) => Number(row.FILE_CNT || 0) > 0 ? "📎" : "" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async (page = 1) => {
|
||||
const res = await fetch("/api/product-mgmt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, product_category: productCategory, page, countPerPage }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
setTotalCount(json.TOTAL_CNT || 0);
|
||||
setCurrentPage(page);
|
||||
}
|
||||
}, [year, productCategory]);
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("선택한 항목이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "삭제 확인",
|
||||
text: `${selectedRows.length}건을 삭제하시겠습니까?`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "삭제",
|
||||
cancelButtonText: "취소",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
const res = await fetch("/api/product-mgmt", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objIds: selectedRows.map((r) => r.OBJID) }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
Swal.fire({ icon: "success", title: "삭제되었습니다.", timer: 1500, showConfirmButton: false });
|
||||
fetchData(currentPage);
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: json.message || "삭제 실패" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openProductFormPopup = (objId?: string) => {
|
||||
// TODO: 모달 다이얼로그로 대체 (기존 window.open popup 대응)
|
||||
const url = objId
|
||||
? `/product-mgmt/form?objId=${objId}`
|
||||
: `/product-mgmt/form?actionType=regist`;
|
||||
window.open(url, "productForm", "width=850,height=480");
|
||||
};
|
||||
|
||||
// 페이지 진입 시 자동 로드
|
||||
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={() => openProductFormPopup()}>등록</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>삭제</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleSearch}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 (productmgmtList.jsp #plmSearchZon 대응) */}
|
||||
<SearchForm onSearch={handleSearch}>
|
||||
<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="사업부">
|
||||
<CodeSelect
|
||||
codeId="0000917"
|
||||
value={productCategory}
|
||||
onChange={setProductCategory}
|
||||
placeholder="전체"
|
||||
className="w-[150px]"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
{/* 그리드 (Tabulator/plm_table 대응) */}
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
onSelectionChange={setSelectedRows}
|
||||
|
||||
/>
|
||||
|
||||
{/* 페이징 */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
maxPage={Math.ceil(totalCount / countPerPage)}
|
||||
totalCount={totalCount}
|
||||
onPageChange={(page) => fetchData(page)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"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 { FolderCell } from "@/components/ui/folder-cell";
|
||||
import * as XLSX from "xlsx";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
// 제품관리_BOM 조회 (원본: partMng/structureAscendingList.jsp)
|
||||
// 고객사 → 프로젝트번호 → 유닛명 캐스케이드 + 품번/LEVEL 필터로 BOM 정전개 트리 조회
|
||||
const LEVEL_COLORS = [
|
||||
"", "#fde9d9", "#daeef3", "#e4dfec", "#ebf1de", "#f2f2f2",
|
||||
"#f2dcdb", "#eeece1", "#dce6f1", "#FFFFEB", "#ffffff",
|
||||
];
|
||||
|
||||
export default function BomListPage() {
|
||||
const [customerCd, setCustomerCd] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [searchPartNo, setSearchPartNo] = useState("");
|
||||
const [searchLevel, setSearchLevel] = useState("");
|
||||
|
||||
const [data, setData] = useState<Row[]>([]);
|
||||
const [maxLevel, setMaxLevel] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 접힌 노드: CHILD_OBJID 를 키로 사용 (해당 노드의 모든 하위 행을 숨김)
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const [customers, setCustomers] = useState<{ value: string; label: string }[]>([]);
|
||||
const [projects, setProjects] = useState<{ value: string; label: string }[]>([]);
|
||||
const [units, setUnits] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
// 고객사 로드
|
||||
useEffect(() => {
|
||||
fetch("/api/common/supply-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setCustomers((j.RESULTLIST || []).map((r: Row) => ({
|
||||
value: String(r.OBJID), label: String(r.SUPPLY_NAME || r.OBJID),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 고객사 변경 → 프로젝트
|
||||
useEffect(() => {
|
||||
setProjectName(""); setUnitCode("");
|
||||
fetch("/api/common/project-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customer_cd: customerCd }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setProjects((j.RESULTLIST || []).map((r: Row) => ({
|
||||
value: String(r.OBJID), label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, [customerCd]);
|
||||
|
||||
// 프로젝트 변경 → 유닛
|
||||
useEffect(() => {
|
||||
setUnitCode("");
|
||||
if (!projectName) { setUnits([]); return; }
|
||||
fetch("/api/common/unit-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract_objid: projectName }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setUnits((j.RESULTLIST || []).map((r: Row) => ({
|
||||
value: String(r.OBJID), label: String(r.UNIT_NAME || r.TASK_NAME),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, [projectName]);
|
||||
|
||||
const openPartDetail = (partObjId: string) => {
|
||||
const w = 600, h = 700;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(`/product/popup/part-form?objId=${encodeURIComponent(partObjId)}&readOnly=true`,
|
||||
"partMngDetail", `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
};
|
||||
|
||||
const openFilePopup = (objId: string, docType: string, docTypeName: string) => {
|
||||
const w = 800, h = 335;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/common/files?targetObjId=${encodeURIComponent(objId)}&docType=${encodeURIComponent(docType)}&docTypeName=${encodeURIComponent(docTypeName)}`,
|
||||
"fileRegistPopUp", `width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
// 조상 체인 계산용 인덱스 (CHILD_OBJID → data index)
|
||||
const indexByChildObjid = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
data.forEach((r, i) => m.set(String(r.CHILD_OBJID), i));
|
||||
return m;
|
||||
}, [data]);
|
||||
|
||||
// 각 row 의 조상 CHILD_OBJID 리스트 (루트→자신 바로 위)
|
||||
const ancestorsOf = useCallback((row: Row): string[] => {
|
||||
const chain: string[] = [];
|
||||
let cur: Row | undefined = row;
|
||||
while (cur && cur.PARENT_OBJID) {
|
||||
const p = String(cur.PARENT_OBJID);
|
||||
chain.push(p);
|
||||
const idx = indexByChildObjid.get(p);
|
||||
cur = idx !== undefined ? data[idx] : undefined;
|
||||
}
|
||||
return chain;
|
||||
}, [data, indexByChildObjid]);
|
||||
|
||||
// 접힌 조상이 하나라도 있으면 숨김
|
||||
const visibleData = useMemo(
|
||||
() => (collapsed.size === 0
|
||||
? data
|
||||
: data.filter((r) => !ancestorsOf(r).some((a) => collapsed.has(a)))),
|
||||
[data, collapsed, ancestorsOf],
|
||||
);
|
||||
|
||||
const toggleNode = useCallback((childObjid: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(childObjid)) next.delete(childObjid);
|
||||
else next.add(childObjid);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandAll = () => setCollapsed(new Set());
|
||||
const collapseAllLevel1 = () => {
|
||||
// LEV 1 중 자식이 있는 노드만 접기 (원본 JSP 기본 동작 상응)
|
||||
const ids = new Set<string>();
|
||||
data.forEach((r) => {
|
||||
if (Number(r.LEV ?? 0) === 1 && Number(r.LEAF ?? 1) === 0) {
|
||||
ids.add(String(r.CHILD_OBJID));
|
||||
}
|
||||
});
|
||||
setCollapsed(ids);
|
||||
};
|
||||
|
||||
// 레벨 컬럼 동적 생성 (1, 2, 3 ... MAX_LEVEL)
|
||||
const levelColumns: GridColumn[] = Array.from({ length: Math.max(maxLevel, 1) }, (_, i) => ({
|
||||
title: String(i + 1), field: `_LEV_${i + 1}`, width: 22, hozAlign: "center",
|
||||
formatter: (_cell, row) => Number(row.LEV ?? 0) === i + 1 ? "*" : "",
|
||||
}));
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "", field: "_TREE", width: 28, hozAlign: "center",
|
||||
formatter: (_cell, row) => {
|
||||
if (Number(row.LEAF ?? 1) !== 0) return "";
|
||||
const id = String(row.CHILD_OBJID);
|
||||
const isCollapsed = collapsed.has(id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); toggleNode(id); }}
|
||||
className="inline-flex h-4 w-4 items-center justify-center border border-gray-400 text-[11px] leading-none font-bold text-gray-700 hover:bg-gray-100"
|
||||
aria-label={isCollapsed ? "펴기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "+" : "−"}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
...levelColumns,
|
||||
{
|
||||
title: "품번", field: "PART_NO", width: 140, hozAlign: "left",
|
||||
cellClick: (row) => openPartDetail(String(row.PART_OBJID)),
|
||||
},
|
||||
{ title: "품명", field: "PART_NAME", width: 180, hozAlign: "left" },
|
||||
{ title: "수량", field: "QTY", width: 55, hozAlign: "center" },
|
||||
{
|
||||
title: "3D", field: "FILE_3D_CNT", width: 40, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFilePopup(String(row.PART_OBJID), "3D_CAD", "3D CAD 첨부파일"),
|
||||
},
|
||||
{
|
||||
title: "2D", field: "FILE_2D_CNT", width: 40, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFilePopup(String(row.PART_OBJID), "2D_DRAWING_CAD", "2D(Drawing) CAD 첨부파일"),
|
||||
},
|
||||
{
|
||||
title: "PDF", field: "FILE_PDF_CNT", width: 40, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFilePopup(String(row.PART_OBJID), "2D_PDF_CAD", "2D(PDF) CAD 첨부파일"),
|
||||
},
|
||||
{ title: "재질", field: "MATERIAL", width: 100, hozAlign: "left" },
|
||||
{ title: "사양(규격)", field: "SPEC", width: 130, hozAlign: "left" },
|
||||
{ title: "후처리", field: "POST_PROCESSING", width: 120, hozAlign: "left" },
|
||||
{ title: "MAKER", field: "MAKER", width: 90, hozAlign: "left" },
|
||||
{ title: "Revision", field: "REVISION", width: 70, hozAlign: "center" },
|
||||
{ title: "EO No", field: "EO_NO", width: 85, hozAlign: "center" },
|
||||
{ title: "EO Date", field: "EO_DATE", width: 90, hozAlign: "center" },
|
||||
{ title: "PART구분", field: "PART_TYPE_TITLE", width: 90, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left" },
|
||||
];
|
||||
|
||||
const validate = () => {
|
||||
// 품번만 입력해도 검색 허용 — 품번이 있으면 고객사/프로젝트/유닛 미선택 가능
|
||||
if (searchPartNo) return true;
|
||||
if (!customerCd) { Swal.fire("알림", "고객사를 선택하거나 품번을 입력해 주세요.", "warning"); return false; }
|
||||
if (!projectName) { Swal.fire("알림", "프로젝트번호를 선택해 주세요.", "warning"); return false; }
|
||||
if (!unitCode) { Swal.fire("알림", "유닛명을 선택해 주세요.", "warning"); return false; }
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async (skipValidate = false) => {
|
||||
if (!skipValidate && !validate()) return;
|
||||
if (!searchPartNo && (!customerCd || !projectName || !unitCode)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/product/bom/ascending", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customer_cd: customerCd,
|
||||
project_name: projectName,
|
||||
unit_code: unitCode,
|
||||
search_partNo: searchPartNo,
|
||||
search_level: searchLevel,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
setData(j.RESULTLIST || []);
|
||||
setMaxLevel(Number(j.MAX_LEVEL || 0));
|
||||
setCollapsed(new Set());
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [customerCd, projectName, unitCode, searchPartNo, searchLevel]);
|
||||
|
||||
const handleReset = () => {
|
||||
setCustomerCd(""); setProjectName(""); setUnitCode("");
|
||||
setSearchPartNo(""); setSearchLevel("");
|
||||
setData([]); setMaxLevel(0);
|
||||
setCollapsed(new Set());
|
||||
};
|
||||
|
||||
const handleExcel = () => {
|
||||
if (data.length === 0) { Swal.fire("알림", "다운로드할 데이터가 없습니다.", "warning"); return; }
|
||||
const levelHeaders = Array.from({ length: Math.max(maxLevel, 1) }, (_, i) => String(i + 1));
|
||||
const header = [
|
||||
...levelHeaders, "품번", "품명", "수량", "3D", "2D", "PDF",
|
||||
"재질", "사양(규격)", "후처리", "MAKER", "Revision",
|
||||
"EO No", "EO Date", "PART구분", "비고",
|
||||
];
|
||||
const body = data.map((r) => {
|
||||
const lev = Number(r.LEV ?? 0);
|
||||
const markers = Array.from({ length: Math.max(maxLevel, 1) }, (_, i) => (lev === i + 1 ? "*" : ""));
|
||||
return [
|
||||
...markers, r.PART_NO ?? "", r.PART_NAME ?? "", r.QTY ?? "",
|
||||
Number(r.FILE_3D_CNT ?? 0), Number(r.FILE_2D_CNT ?? 0), Number(r.FILE_PDF_CNT ?? 0),
|
||||
r.MATERIAL ?? "", r.SPEC ?? "", r.POST_PROCESSING ?? "",
|
||||
r.MAKER ?? "", r.REVISION ?? "", r.EO_NO ?? "",
|
||||
r.EO_DATE ?? "", r.PART_TYPE_TITLE ?? "", r.REMARK ?? "",
|
||||
];
|
||||
});
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, ...body]);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "BOM_REPORT_정전개");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
XLSX.writeFile(wb, `BOM_REPORT_정전개_${today}.xlsx`);
|
||||
};
|
||||
|
||||
// 행별 배경색(레벨별)
|
||||
const rowClass = (row: Row): string => {
|
||||
const lev = Number(row.LEV ?? 0);
|
||||
const bg = LEVEL_COLORS[Math.min(lev, LEVEL_COLORS.length - 1)];
|
||||
return bg ? "" : "";
|
||||
void bg; // 배경색은 인라인 스타일 쓰지 않고 기본 유지 (선택적)
|
||||
};
|
||||
void rowClass;
|
||||
|
||||
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={expandAll} disabled={data.length === 0}>전체 펴기</Button>
|
||||
<Button size="sm" variant="secondary" onClick={collapseAllLevel1} disabled={data.length === 0}>전체 접기</Button>
|
||||
<Button size="sm" onClick={() => fetchData()}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleReset}>초기화</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcel}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={() => fetchData()}>
|
||||
<SearchField label="고객사 *">
|
||||
<SearchableSelect options={customers} value={customerCd} onChange={setCustomerCd} className="w-[180px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호 *">
|
||||
<SearchableSelect options={projects} value={projectName} onChange={setProjectName} className="w-[220px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛명 *">
|
||||
<SearchableSelect options={units} value={unitCode} onChange={setUnitCode} className="w-[300px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품번">
|
||||
<Input value={searchPartNo} onChange={(e) => setSearchPartNo(e.target.value)} className="w-[200px]" />
|
||||
</SearchField>
|
||||
<SearchField label="LEVEL">
|
||||
<select value={searchLevel} onChange={(e) => setSearchLevel(e.target.value)}
|
||||
className="h-9 w-[80px] rounded border border-gray-300 bg-white px-2 text-sm">
|
||||
<option value="">전체</option>
|
||||
{[1, 2, 3, 4, 5, 6].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={visibleData} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
"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 { FolderCell } from "@/components/ui/folder-cell";
|
||||
import * as XLSX from "xlsx";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 제품관리_PART 및 구조등록 (원본: partMng/structureList.jsp)
|
||||
export default function BomRegisterPage() {
|
||||
const [customerCd, setCustomerCd] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [searchUnitName, setSearchUnitName] = useState("");
|
||||
const [searchWriter, setSearchWriter] = useState("");
|
||||
const [deployFrom, setDeployFrom] = useState("");
|
||||
const [deployTo, setDeployTo] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [customers, setCustomers] = useState<{ value: string; label: string }[]>([]);
|
||||
const [projects, setProjects] = useState<{ value: string; label: string }[]>([]);
|
||||
const [units, setUnits] = useState<{ value: string; label: string }[]>([]);
|
||||
const [users, setUsers] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
// 고객사 목록 (supply_mng)
|
||||
useEffect(() => {
|
||||
fetch("/api/common/supply-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setCustomers((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID), label: String(r.SUPPLY_NAME || r.OBJID),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then((r) => r.json())
|
||||
.then((j) => setUsers((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.USER_ID), label: String(r.USER_NAME || ""),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 고객사 변경 시 프로젝트 로드
|
||||
useEffect(() => {
|
||||
setProjectName(""); setUnitCode("");
|
||||
fetch("/api/common/project-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customer_cd: customerCd }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setProjects((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID), label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, [customerCd]);
|
||||
|
||||
// 프로젝트 변경 시 유닛 로드
|
||||
useEffect(() => {
|
||||
setUnitCode("");
|
||||
if (!projectName) { setUnits([]); return; }
|
||||
fetch("/api/common/unit-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract_objid: projectName }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setUnits((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID), label: String(r.UNIT_NAME || r.TASK_NAME),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, [projectName]);
|
||||
|
||||
const openChangeDesignNote = (objIds: string) => {
|
||||
const w = 1000, h = 200;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/product/popup/change-design-note?objId=${encodeURIComponent(objIds)}`,
|
||||
"changeDesignNotePopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const openStructureDetail = (objId: string) => {
|
||||
window.open(
|
||||
`/product/popup/set-structure?objId=${encodeURIComponent(objId)}`,
|
||||
"setStructurePopup",
|
||||
"width=1880,height=900,resizable=yes,scrollbars=yes",
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "고객사프로젝트명", field: "CUSTOMER_PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 270, hozAlign: "left" },
|
||||
{
|
||||
title: "E-BOM", field: "BOM_CNT", width: 80, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openStructureDetail(String(row.OBJID)),
|
||||
},
|
||||
{ title: "등록자", field: "DEPT_USER_NAME", width: 120, hozAlign: "center" },
|
||||
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "배포일", field: "DEPLOY_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "Version", field: "REVISION", width: 85, hozAlign: "center" },
|
||||
{ title: "배포사유", field: "NOTE", hozAlign: "left" },
|
||||
{ title: "상태", field: "STATUS_TITLE", width: 100, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/product/bom", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "structure",
|
||||
customer_cd: customerCd,
|
||||
project_name: projectName,
|
||||
unit_code: unitCode,
|
||||
SEARCH_UNIT_NAME: searchUnitName,
|
||||
SEARCH_WRITER: searchWriter,
|
||||
SEARCH_DEPLOY_DATE_FROM: deployFrom,
|
||||
SEARCH_DEPLOY_DATE_TO: deployTo,
|
||||
status,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
setData(j.RESULTLIST || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [customerCd, projectName, unitCode, searchUnitName, searchWriter, deployFrom, deployTo, status]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (selectedRows.length === 0) { Swal.fire("알림", "선택된 내용이 없습니다.", "warning"); return; }
|
||||
const isDeployed = selectedRows.some((r) => r.STATUS === "deploy");
|
||||
if (isDeployed) { Swal.fire("알림", "배포완료건은 배포 할 수 없습니다.", "warning"); return; }
|
||||
|
||||
// 동시배포 체크 (MULTI_MASTER_OBJID 동일해야 동시배포 가능)
|
||||
if (selectedRows.length > 1) {
|
||||
let prevMaster = "";
|
||||
let onlyMulti = true;
|
||||
for (const r of selectedRows) {
|
||||
const master = String(r.MULTI_MASTER_OBJID || r.OBJID);
|
||||
if (!prevMaster || master === prevMaster) prevMaster = master;
|
||||
else { onlyMulti = false; break; }
|
||||
}
|
||||
if (!onlyMulti) {
|
||||
Swal.fire("알림", "한번에 한개의 배포만 가능합니다.(동시 프로젝트 등록중인 건만 동시배포 가능합니다.)", "warning");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const c = await Swal.fire({
|
||||
title: "선택된 내용을 배포하시겠습니까?", icon: "warning",
|
||||
showCancelButton: true, confirmButtonText: "확인", cancelButtonText: "취소",
|
||||
});
|
||||
if (!c.isConfirmed) return;
|
||||
openChangeDesignNote(selectedRows.map((r) => String(r.OBJID)).join(","));
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedRows.length === 0) { Swal.fire("알림", "선택된 내용이 없습니다.", "warning"); return; }
|
||||
const blocked = selectedRows.some((r) => r.STATUS === "deploy" || r.STATUS === "changeDesign");
|
||||
if (blocked) { Swal.fire("알림", "배포완료/설계변경미배포 건은 삭제 할 수 없습니다.", "warning"); return; }
|
||||
|
||||
const c = await Swal.fire({
|
||||
title: "선택한 정보를 삭제하시겠습니까?", icon: "warning",
|
||||
showCancelButton: true, confirmButtonText: "확인", cancelButtonText: "취소",
|
||||
});
|
||||
if (!c.isConfirmed) return;
|
||||
|
||||
const res = await fetch("/api/product/bom/structure-delete", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objIds: selectedRows.map((r) => String(r.OBJID)) }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
|
||||
fetchData();
|
||||
} else {
|
||||
Swal.fire("오류", j.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcelDownload = () => {
|
||||
if (data.length === 0) {
|
||||
Swal.fire("알림", "다운로드할 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const header = [
|
||||
"프로젝트번호", "고객사", "고객사프로젝트명", "유닛명", "E-BOM",
|
||||
"등록자", "등록일", "배포일", "Version", "배포사유", "상태",
|
||||
];
|
||||
const body = data.map((r) => [
|
||||
r.PROJECT_NO ?? "", r.CUSTOMER_NAME ?? "", r.CUSTOMER_PROJECT_NAME ?? "",
|
||||
r.UNIT_NAME ?? "", Number(r.BOM_CNT ?? 0), r.DEPT_USER_NAME ?? "",
|
||||
r.REG_DATE ?? "", r.DEPLOY_DATE ?? "", r.REVISION ?? "",
|
||||
r.NOTE ?? "", r.STATUS_TITLE ?? "",
|
||||
]);
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, ...body]);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "PART 및 구조등록");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
XLSX.writeFile(wb, `PART_및_구조등록_${today}.xlsx`);
|
||||
};
|
||||
|
||||
const handleSaveExcel = () => {
|
||||
if (selectedRows.length > 1) { Swal.fire("알림", "단건만 등록 가능합니다.", "warning"); return; }
|
||||
if (selectedRows.length === 1) {
|
||||
const r = selectedRows[0];
|
||||
if (String(r.STATUS || "") !== "create") {
|
||||
Swal.fire("알림", "등록중인 건만 등록/추가 할 수 있습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const qs = new URLSearchParams({
|
||||
customer_cd: String(r.CUSTOMER_OBJID || ""),
|
||||
project_name: String(r.CONTRACT_OBJID || ""),
|
||||
unit_code: String(r.UNIT_CODE || ""),
|
||||
BOM_REPORT_OBJID: String(r.OBJID || ""),
|
||||
}).toString();
|
||||
window.open(`/product/popup/bom-excel-import?${qs}`, "openBomReportExcelImportPopUp",
|
||||
"width=1920,height=860,resizable=yes,scrollbars=yes");
|
||||
return;
|
||||
}
|
||||
if (!customerCd) { Swal.fire("알림", "고객사를 선택해 주세요.", "warning"); return; }
|
||||
if (!projectName) { Swal.fire("알림", "프로젝트를 선택해 주세요.", "warning"); return; }
|
||||
if (!unitCode) { Swal.fire("알림", "유닛명을 선택해 주세요.", "warning"); return; }
|
||||
const qs = new URLSearchParams({
|
||||
customer_cd: customerCd, project_name: projectName, unit_code: unitCode,
|
||||
}).toString();
|
||||
window.open(`/product/popup/bom-excel-import?${qs}`, "openBomReportExcelImportPopUp",
|
||||
"width=1920,height=860,resizable=yes,scrollbars=yes");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">제품관리_PART 및 구조등록</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleDeploy}>배포</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>삭제</Button>
|
||||
<Button size="sm" onClick={handleSaveExcel}>구조등록</Button>
|
||||
<Button size="sm" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
setCustomerCd(""); setProjectName(""); setUnitCode("");
|
||||
setSearchUnitName(""); setSearchWriter("");
|
||||
setDeployFrom(""); setDeployTo(""); setStatus("");
|
||||
}}>초기화</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcelDownload}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="고객사">
|
||||
<SearchableSelect options={customers} value={customerCd} onChange={setCustomerCd} className="w-[200px]" />
|
||||
</SearchField>
|
||||
<SearchField label="프로젝트번호">
|
||||
<SearchableSelect options={projects} value={projectName} onChange={setProjectName} className="w-[230px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛명">
|
||||
<SearchableSelect options={units} value={unitCode} onChange={setUnitCode} className="w-[300px]" />
|
||||
</SearchField>
|
||||
<SearchField label="공통유닛명">
|
||||
<Input value={searchUnitName} onChange={(e) => setSearchUnitName(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="등록자">
|
||||
<SearchableSelect options={users} value={searchWriter} onChange={setSearchWriter} className="w-[190px]" />
|
||||
</SearchField>
|
||||
<SearchField label="배포일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={deployFrom} onChange={(e) => setDeployFrom(e.target.value)} className="w-[140px]" />
|
||||
<span>~</span>
|
||||
<Input type="date" value={deployTo} onChange={(e) => setDeployTo(e.target.value)} className="w-[140px]" />
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="상태">
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}
|
||||
className="h-9 w-[170px] rounded border border-gray-300 bg-white px-2 text-sm">
|
||||
<option value="">선택</option>
|
||||
<option value="create">등록중</option>
|
||||
<option value="changeDesign">설계변경미배포</option>
|
||||
<option value="deploy">배포완료</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns} data={data} showCheckbox loading={loading}
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"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 { ExcelDownloadButton } from "@/components/ui/excel-download-button";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 제품관리_설계변경 리스트 (원본: partMng/partMngHisList.jsp)
|
||||
export default function DesignChangePage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [year, setYear] = useState("");
|
||||
const [contractObjid, setContractObjid] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [partNo, setPartNo] = useState("");
|
||||
const [partName, setPartName] = useState("");
|
||||
const [changeOption, setChangeOption] = useState("");
|
||||
const [eoStart, setEoStart] = useState("");
|
||||
const [eoEnd, setEoEnd] = useState("");
|
||||
const [changeType, setChangeType] = useState("");
|
||||
const [partType, setPartType] = useState("");
|
||||
const [writerId, setWriterId] = useState("");
|
||||
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projects, setProjects] = useState<{ value: string; label: string }[]>([]);
|
||||
const [units, setUnits] = useState<{ value: string; label: string }[]>([]);
|
||||
const [users, setUsers] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
// 프로젝트 목록 로드
|
||||
useEffect(() => {
|
||||
fetch("/api/common/project-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setProjects((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID), label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 프로젝트 변경 시 유닛 목록 로드
|
||||
useEffect(() => {
|
||||
if (!contractObjid) { setUnits([]); return; }
|
||||
fetch("/api/common/unit-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract_objid: contractObjid }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setUnits((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.OBJID), label: String(r.UNIT_NAME || r.TASK_NAME),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
setUnitCode("");
|
||||
}, [contractObjid]);
|
||||
|
||||
// 사용자 목록 로드
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then((r) => r.json())
|
||||
.then((j) => setUsers((j.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.USER_ID), label: String(r.USER_NAME || ""),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const openHisDetail = (objId: string) => {
|
||||
const w = 800, h = 550;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/product/popup/part-his-detail?objId=${encodeURIComponent(objId)}`,
|
||||
"partMngHisDetailPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "EO No", field: "EO_NO", width: 85, hozAlign: "left" },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 100, hozAlign: "left" },
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "모품번", field: "PARENT_PART_INFO", width: 150, hozAlign: "left" },
|
||||
{
|
||||
title: "품번", field: "PART_NO", width: 150, hozAlign: "left",
|
||||
cellClick: (row) => openHisDetail(String(row.OBJID)),
|
||||
},
|
||||
{ title: "품명", field: "PART_NAME", hozAlign: "left" },
|
||||
{ title: "수량", field: "QTY", width: 55, hozAlign: "center" },
|
||||
{ title: "변경수량", field: "QTY_TEMP", width: 75, hozAlign: "center" },
|
||||
{ title: "EO구분", field: "CHANGE_TYPE_NAME", width: 75, hozAlign: "center" },
|
||||
{ title: "EO사유", field: "CHANGE_OPTION_NAME", width: 75, hozAlign: "center" },
|
||||
{ title: "Revision", field: "REVISION", width: 85, hozAlign: "center" },
|
||||
{ title: "EO Date", field: "EO_DATE", width: 85, hozAlign: "center" },
|
||||
{ title: "PART구분", field: "PART_TYPE_NAME", width: 90, hozAlign: "center" },
|
||||
{ title: "담당자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "실행일", field: "HIS_REG_DATE_TITLE", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/product/design-change", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
Year: year,
|
||||
contract_objid: contractObjid,
|
||||
unit_code: unitCode,
|
||||
part_no: partNo,
|
||||
part_name: partName,
|
||||
change_option: changeOption,
|
||||
eo_start_date: eoStart,
|
||||
eo_end_date: eoEnd,
|
||||
change_type: changeType,
|
||||
part_type: partType,
|
||||
writer_id: writerId,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} catch {
|
||||
Swal.fire("오류", "데이터 조회 중 오류가 발생했습니다.", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, contractObjid, unitCode, partNo, partName, changeOption, eoStart, eoEnd, changeType, partType, writerId]);
|
||||
|
||||
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={fetchData}>조회</Button>
|
||||
<ExcelDownloadButton data={data} columns={columns} filename="제품관리_설계변경리스트" />
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
setYear(""); setContractObjid(""); setUnitCode("");
|
||||
setPartNo(""); setPartName(""); setChangeOption("");
|
||||
setEoStart(""); setEoEnd(""); setChangeType(""); setPartType(""); setWriterId("");
|
||||
}}>초기화</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-2 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={projects} value={contractObjid} onChange={setContractObjid} className="w-[200px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛명">
|
||||
<SearchableSelect options={units} value={unitCode} onChange={setUnitCode} className="w-[220px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품번">
|
||||
<Input value={partNo} onChange={(e) => setPartNo(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input value={partName} onChange={(e) => setPartName(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="EO사유">
|
||||
<SearchableCodeSelect codeId="0000318" value={changeOption} onChange={setChangeOption} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="EO Date">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={eoStart} onChange={(e) => setEoStart(e.target.value)} className="w-[140px]" />
|
||||
<span>~</span>
|
||||
<Input type="date" value={eoEnd} onChange={(e) => setEoEnd(e.target.value)} className="w-[140px]" />
|
||||
</div>
|
||||
</SearchField>
|
||||
<SearchField label="EO구분">
|
||||
<SearchableCodeSelect codeId="0001054" value={changeType} onChange={setChangeType} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="PART 구분">
|
||||
<SearchableCodeSelect codeId="0000062" value={partType} onChange={setPartType} className="w-[120px]" />
|
||||
</SearchField>
|
||||
<SearchField label="담당자">
|
||||
<SearchableSelect options={users} value={writerId} onChange={setWriterId} className="w-[140px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { SearchForm, SearchField } from "@/components/layout/search-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { FolderCell } from "@/components/ui/folder-cell";
|
||||
import { ExcelDownloadButton } from "@/components/ui/excel-download-button";
|
||||
import type { ExcelColumn } from "@/lib/excel-export";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type CodeOpt = { CODE_ID: string; CODE_NAME: string };
|
||||
|
||||
// 제품관리_설변대상 PART조회 (원본: partMng/partMngChangeList.jsp)
|
||||
export default function PartChangePage() {
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [searchPartObjid, setSearchPartObjid] = useState("");
|
||||
|
||||
const [data, setData] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [projects, setProjects] = useState<{ value: string; label: string }[]>([]);
|
||||
const [units, setUnits] = useState<{ value: string; label: string }[]>([]);
|
||||
const [parts, setParts] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
const [partTypes, setPartTypes] = useState<CodeOpt[]>([]);
|
||||
const [changeTypes, setChangeTypes] = useState<CodeOpt[]>([]);
|
||||
const [changeOptions, setChangeOptions] = useState<CodeOpt[]>([]);
|
||||
|
||||
// 공통코드 로드 (API 응답이 소문자 키라 대문자로 정규화)
|
||||
useEffect(() => {
|
||||
const loadCode = async (codeId: string): Promise<CodeOpt[]> => {
|
||||
const res = await fetch("/api/common/code-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeId }),
|
||||
});
|
||||
const j = await res.json();
|
||||
return (j.data || []).map((r: Record<string, unknown>) => ({
|
||||
CODE_ID: String(r.code_id || r.CODE_ID || ""),
|
||||
CODE_NAME: String(r.code_name || r.CODE_NAME || ""),
|
||||
}));
|
||||
};
|
||||
loadCode("0000062").then(setPartTypes);
|
||||
loadCode("0001054").then(setChangeTypes); // PART_CHANGE_TYPE_CODE
|
||||
loadCode("0000318").then(setChangeOptions); // PART_CHANGE_OPTION_CODE
|
||||
}, []);
|
||||
|
||||
// 프로젝트 목록
|
||||
useEffect(() => {
|
||||
fetch("/api/common/project-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setProjects((j.RESULTLIST || []).map((r: Row) => ({
|
||||
value: String(r.OBJID), label: String(r.LABEL || r.PROJECT_NO || r.OBJID),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 프로젝트 변경 시 유닛
|
||||
useEffect(() => {
|
||||
setUnitCode("");
|
||||
setSearchPartObjid("");
|
||||
if (!projectName) { setUnits([]); return; }
|
||||
fetch("/api/common/unit-list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contract_objid: projectName }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => setUnits((j.RESULTLIST || []).map((r: Row) => ({
|
||||
value: String(r.OBJID), label: String(r.UNIT_NAME || r.TASK_NAME),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, [projectName]);
|
||||
|
||||
// 유닛 변경 시 PART 목록
|
||||
useEffect(() => {
|
||||
setSearchPartObjid("");
|
||||
if (!projectName || !unitCode) { setParts([]); return; }
|
||||
fetch("/api/product/part-change", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "list", project_name: projectName, unit_code: unitCode }),
|
||||
}).then((r) => r.json())
|
||||
.then((j) => {
|
||||
const uniq = new Map<string, string>();
|
||||
(j.RESULTLIST || []).forEach((r: Row) => {
|
||||
const id = String(r.OBJID);
|
||||
uniq.set(id, `${r.PART_NO ?? ""} ${r.PART_NAME ?? ""}`);
|
||||
});
|
||||
setParts([...uniq.entries()].map(([value, label]) => ({ value, label })));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [projectName, unitCode]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
Swal.fire("알림", "프로젝트번호를 선택하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
if (!unitCode) {
|
||||
Swal.fire("알림", "유닛명을 선택하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
if (!searchPartObjid) {
|
||||
Swal.fire("알림", "품번을 선택하세요.", "warning");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/product/part-change", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "list",
|
||||
project_name: projectName,
|
||||
unit_code: unitCode,
|
||||
SEARCH_PART_OBJID: searchPartObjid,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
setData(j.RESULTLIST || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectName, unitCode, searchPartObjid]);
|
||||
|
||||
const updateCell = (idx: number, field: string, value: string) => {
|
||||
setData((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
// 같은 품번끼리 동기화 (원본 cellEdited 동작)
|
||||
const partNo = next[idx].PART_NO;
|
||||
for (let i = 0; i < next.length; i++) {
|
||||
if (i !== idx && next[i].PART_NO === partNo) {
|
||||
next[i] = { ...next[i], [field]: value };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 같은 PART_NO가 여러 행(동시 적용 BOM)으로 올 때 첫 번째만 편집 가능.
|
||||
// updateCell 이 같은 PART_NO 나머지 행도 동기화하므로 하나만 활성화해도 충분.
|
||||
const editableIdxSet = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const s = new Set<number>();
|
||||
data.forEach((row, idx) => {
|
||||
if (String(row.Q_STATUS || "") !== "deploy") return;
|
||||
const partNo = String(row.PART_NO ?? "");
|
||||
if (!seen.has(partNo)) {
|
||||
seen.add(partNo);
|
||||
s.add(idx);
|
||||
}
|
||||
});
|
||||
return s;
|
||||
}, [data]);
|
||||
|
||||
const isEditable = (idx: number) => editableIdxSet.has(idx);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (data.length === 0) {
|
||||
Swal.fire("알림", "저장할 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const allDeploy = data.every((r) => String(r.Q_STATUS || "") === "deploy");
|
||||
if (!allDeploy) {
|
||||
Swal.fire("알림", "설변중인 파트입니다. 배포후에 다시 설계변경 할 수 있습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const allFilled = data.every((r) =>
|
||||
String(r.CHANGE_TYPE || "") !== "" && String(r.CHANGE_OPTION || "") !== ""
|
||||
);
|
||||
if (!allFilled) {
|
||||
Swal.fire("알림", "EO구분, EO사유는 필수 입력입니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const c = await Swal.fire({
|
||||
title: "저장하시겠습니까?", icon: "question", showCancelButton: true,
|
||||
confirmButtonText: "확인", cancelButtonText: "취소",
|
||||
});
|
||||
if (!c.isConfirmed) return;
|
||||
const res = await fetch("/api/product/part-change", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "save", rows: data }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
|
||||
fetchData();
|
||||
} else {
|
||||
Swal.fire("오류", j.message || "저장 실패", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const openPartForm = (objId: string) => {
|
||||
const w = 600, h = 500;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(`/product/popup/part-form?objId=${encodeURIComponent(objId)}`, "partMngPopUp",
|
||||
`width=${w},height=${h},left=${left},top=${top}`);
|
||||
};
|
||||
|
||||
const openFilePopup = (objId: string, docType: string, docTypeName: string) => {
|
||||
const w = 800, h = 335;
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(
|
||||
`/common/files?targetObjId=${encodeURIComponent(objId)}&docType=${encodeURIComponent(docType)}&docTypeName=${encodeURIComponent(docTypeName)}`,
|
||||
"fileRegistPopUp", `width=${w},height=${h},left=${left},top=${top}`,
|
||||
);
|
||||
};
|
||||
|
||||
const yellowHeader = "px-2 py-1 text-[#FFBB00] border border-gray-200 font-semibold text-center whitespace-nowrap";
|
||||
const normalHeader = "px-2 py-1 border border-gray-200 font-semibold text-center whitespace-nowrap";
|
||||
// frozen 셀/헤더 공통 스타일 — 인라인 스타일로 sticky/bg/zIndex 강제 적용 (border-collapse:separate 와 조합)
|
||||
const freezeHeader = (left: number): React.CSSProperties => ({
|
||||
position: "sticky", left, top: 0, zIndex: 30, backgroundColor: "#F9FAFB",
|
||||
});
|
||||
const freezeCell = (left: number): React.CSSProperties => ({
|
||||
position: "sticky", left, zIndex: 20, backgroundColor: "#FFFFFF",
|
||||
});
|
||||
|
||||
// Excel 컬럼 정의 (원본 partMngChangeList 헤더 순서 그대로)
|
||||
const excelColumns: ExcelColumn[] = useMemo(() => {
|
||||
const codeName = (list: CodeOpt[], id: string) =>
|
||||
list.find((c) => c.CODE_ID === id)?.CODE_NAME ?? id;
|
||||
return [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO" },
|
||||
{ title: "유닛명", field: "UNIT_NAME" },
|
||||
{ title: "모품번", field: "PARENT_PART_INFO" },
|
||||
{ title: "품번", field: "PART_NO" },
|
||||
{ title: "품명", field: "PART_NAME" },
|
||||
{ title: "수량", field: "Q_QTY" },
|
||||
{ title: "3D", excelFormatter: (r) => Number(r.CU01_CNT ?? 0) },
|
||||
{ title: "2D", excelFormatter: (r) => Number(r.CU02_CNT ?? 0) },
|
||||
{ title: "PDF", excelFormatter: (r) => Number(r.CU03_CNT ?? 0) },
|
||||
{ title: "EO구분", excelFormatter: (r) => codeName(changeTypes, String(r.CHANGE_TYPE ?? "")) },
|
||||
{ title: "EO사유", excelFormatter: (r) => codeName(changeOptions, String(r.CHANGE_OPTION ?? "")) },
|
||||
{ title: "PART구분", excelFormatter: (r) => codeName(partTypes, String(r.PART_TYPE ?? "")) },
|
||||
{ title: "재질", field: "MATERIAL" },
|
||||
{ title: "사양(규격)", field: "SPEC" },
|
||||
{ title: "후처리", field: "POST_PROCESSING" },
|
||||
{ title: "MAKER", field: "MAKER" },
|
||||
{ title: "대분류", field: "MAJOR_CATEGORY" },
|
||||
{ title: "중분류", field: "SUB_CATEGORY" },
|
||||
{ title: "Revision", field: "REVISION" },
|
||||
{ title: "EO No", field: "EO_NO" },
|
||||
{ title: "EO Date", field: "EO_DATE" },
|
||||
{ title: "비고", field: "REMARK" },
|
||||
];
|
||||
}, [changeTypes, changeOptions, partTypes]);
|
||||
|
||||
const codeSel = useMemo(() => (opts: CodeOpt[], val: string, onChange: (v: string) => void, disabled: boolean) => (
|
||||
<select value={val || ""} onChange={(e) => onChange(e.target.value)} disabled={disabled}
|
||||
className="w-full h-7 text-xs border border-gray-300 rounded px-1 disabled:bg-gray-100">
|
||||
<option value="">선택</option>
|
||||
{opts.map((o) => <option key={o.CODE_ID} value={o.CODE_ID}>{o.CODE_NAME}</option>)}
|
||||
</select>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">제품관리_설변대상 PART조회</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>설계변경저장</Button>
|
||||
<Button size="sm" onClick={fetchData}>조회</Button>
|
||||
<ExcelDownloadButton
|
||||
data={data}
|
||||
columns={excelColumns}
|
||||
filename="제품관리_설변대상PART조회"
|
||||
sheetName="설변대상PART"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
setProjectName(""); setUnitCode(""); setSearchPartObjid(""); setData([]);
|
||||
}}>초기화</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="프로젝트번호">
|
||||
<SearchableSelect options={projects} value={projectName} onChange={setProjectName} className="w-[230px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛명">
|
||||
<SearchableSelect options={units} value={unitCode} onChange={setUnitCode} className="w-[260px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품번">
|
||||
<SearchableSelect options={parts} value={searchPartObjid} onChange={setSearchPartObjid} className="w-[230px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<div className="relative border border-gray-200 rounded bg-white overflow-auto" style={{ maxHeight: "calc(100vh - 310px)" }}>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/70 backdrop-blur-[1px]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 border-4 border-gray-300 border-t-primary rounded-full animate-spin" />
|
||||
<span className="text-sm font-semibold text-gray-700">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table className="text-xs table-fixed border-collapse" style={{ width: 2295 }}>
|
||||
<colgroup>
|
||||
<col style={{ width: 100 }} />
|
||||
<col style={{ width: 200 }} />
|
||||
<col style={{ width: 125 }} />
|
||||
<col style={{ width: 125 }} />
|
||||
<col style={{ width: 270 }} />
|
||||
<col style={{ width: 60 }} />
|
||||
<col style={{ width: 45 }} />
|
||||
<col style={{ width: 45 }} />
|
||||
<col style={{ width: 55 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 80 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 90 }} />
|
||||
<col style={{ width: 120 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={normalHeader} style={freezeHeader(0)}>프로젝트번호</th>
|
||||
<th className={normalHeader} style={freezeHeader(100)}>유닛명</th>
|
||||
<th className={normalHeader} style={freezeHeader(300)}>모품번</th>
|
||||
<th className={normalHeader} style={freezeHeader(425)}>품번</th>
|
||||
<th className={normalHeader} style={freezeHeader(550)}>품명</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>수량</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>3D</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>2D</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>PDF</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>EO구분</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>EO사유</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>PART구분</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>재질</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>사양(규격)</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>후처리</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>MAKER</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>대분류</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>중분류</th>
|
||||
<th className={normalHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>Revision</th>
|
||||
<th className={normalHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>EO No</th>
|
||||
<th className={normalHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>EO Date</th>
|
||||
<th className={yellowHeader} style={{ position: "sticky", top: 0, zIndex: 10, backgroundColor: "#F9FAFB" }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr><td colSpan={22} className="text-center py-10 text-gray-400">데이터가 없습니다.</td></tr>
|
||||
) : data.map((row, idx) => {
|
||||
const editable = isEditable(idx);
|
||||
const frozenCell = "px-2 py-1 border border-gray-200";
|
||||
return (
|
||||
<tr key={idx}>
|
||||
<td className={frozenCell} style={freezeCell(0)}>{String(row.PROJECT_NO ?? "")}</td>
|
||||
<td className={frozenCell} style={freezeCell(100)}>{String(row.UNIT_NAME ?? "")}</td>
|
||||
<td className={frozenCell} style={freezeCell(300)}>{String(row.PARENT_PART_INFO ?? "")}</td>
|
||||
<td className={frozenCell} style={freezeCell(425)}>
|
||||
<a className="text-primary hover:underline cursor-pointer" onClick={() => openPartForm(String(row.OBJID))}>
|
||||
{String(row.PART_NO ?? "")}
|
||||
</a>
|
||||
</td>
|
||||
<td className={frozenCell} style={freezeCell(550)}>{String(row.PART_NAME ?? "")}</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">
|
||||
{editable ? (
|
||||
<input type="number" value={String(row.Q_QTY ?? "")} onChange={(e) => updateCell(idx, "Q_QTY", e.target.value)}
|
||||
className="w-full h-6 text-xs border border-gray-300 rounded px-1 text-right" />
|
||||
) : String(row.Q_QTY ?? "")}
|
||||
</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">
|
||||
<FolderCell count={row.CU01_CNT} onClick={() => openFilePopup(String(row.OBJID), "3D_CAD", "3D CAD 첨부파일")} />
|
||||
</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">
|
||||
<FolderCell count={row.CU02_CNT} onClick={() => openFilePopup(String(row.OBJID), "2D_DRAWING_CAD", "2D(Drawing) CAD 첨부파일")} />
|
||||
</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">
|
||||
<FolderCell count={row.CU03_CNT} onClick={() => openFilePopup(String(row.OBJID), "2D_PDF_CAD", "2D(PDF) CAD 첨부파일")} />
|
||||
</td>
|
||||
<td className="px-1 py-1 border border-gray-200">{codeSel(changeTypes, String(row.CHANGE_TYPE ?? ""), (v) => updateCell(idx, "CHANGE_TYPE", v), !editable)}</td>
|
||||
<td className="px-1 py-1 border border-gray-200">{codeSel(changeOptions, String(row.CHANGE_OPTION ?? ""), (v) => updateCell(idx, "CHANGE_OPTION", v), !editable)}</td>
|
||||
<td className="px-1 py-1 border border-gray-200">{codeSel(partTypes, String(row.PART_TYPE ?? ""), (v) => updateCell(idx, "PART_TYPE", v), !editable)}</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.MATERIAL ?? "")} onChange={(e) => updateCell(idx, "MATERIAL", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.SPEC ?? "")} onChange={(e) => updateCell(idx, "SPEC", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.POST_PROCESSING ?? "")} onChange={(e) => updateCell(idx, "POST_PROCESSING", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.MAKER ?? "")} onChange={(e) => updateCell(idx, "MAKER", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.MAJOR_CATEGORY ?? "")} onChange={(e) => updateCell(idx, "MAJOR_CATEGORY", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.SUB_CATEGORY ?? "")} onChange={(e) => updateCell(idx, "SUB_CATEGORY", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">{String(row.REVISION ?? "")}</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">{String(row.EO_NO ?? "")}</td>
|
||||
<td className="px-2 py-1 border border-gray-200 text-center">{String(row.EO_DATE ?? "")}</td>
|
||||
<td className="px-1 py-1 border border-gray-200">
|
||||
<Input value={String(row.REMARK ?? "")} onChange={(e) => updateCell(idx, "REMARK", e.target.value)} disabled={!editable} className="h-6 text-xs" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"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 { FolderCell } from "@/components/ui/folder-cell";
|
||||
import * as XLSX from "xlsx";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 제품관리_PART 조회 (원본: partMng/partMngList.jsp)
|
||||
export default function PartListPage() {
|
||||
const [searchPartNo, setSearchPartNo] = useState("");
|
||||
const [searchPartName, setSearchPartName] = useState("");
|
||||
const [searchRevision, setSearchRevision] = useState("0"); // 원본 JSP 기본값 = all
|
||||
const [searchMaterial, setSearchMaterial] = useState("");
|
||||
const [searchSpec, setSearchSpec] = useState("");
|
||||
const [searchPartType, setSearchPartType] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const centerPopup = (url: string, name: string, w: number, h: number) => {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},menubars=no,scrollbars=yes,resizable=yes`);
|
||||
};
|
||||
|
||||
const openPartDetail = (objId: string) => {
|
||||
centerPopup(`/product/popup/part-form?objId=${encodeURIComponent(objId)}`, "partMngPopUp", 900, 600);
|
||||
};
|
||||
|
||||
const openFilePopup = (objId: string, docType: string, docTypeName: string) => {
|
||||
centerPopup(
|
||||
`/common/files?targetObjId=${encodeURIComponent(objId)}&docType=${encodeURIComponent(docType)}&docTypeName=${encodeURIComponent(docTypeName)}`,
|
||||
"fileRegistPopUp",
|
||||
800,
|
||||
335,
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "품번", field: "PART_NO", width: 125, hozAlign: "left",
|
||||
cellClick: (row) => openPartDetail(String(row.OBJID)),
|
||||
},
|
||||
{
|
||||
title: "품명", field: "PART_NAME", hozAlign: "left",
|
||||
cellClick: (row) => openPartDetail(String(row.OBJID)),
|
||||
},
|
||||
{ title: "수량", field: "BOM_QTY", width: 50, hozAlign: "center" },
|
||||
{
|
||||
title: "3D", field: "CU01_CNT", width: 45, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFilePopup(String(row.OBJID), "3D_CAD", "3D CAD 첨부파일"),
|
||||
},
|
||||
{
|
||||
title: "2D", field: "CU02_CNT", width: 45, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFilePopup(String(row.OBJID), "2D_DRAWING_CAD", "2D(Drawing) CAD 첨부파일"),
|
||||
},
|
||||
{
|
||||
title: "PDF", field: "CU03_CNT", width: 55, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFilePopup(String(row.OBJID), "2D_PDF_CAD", "2D(PDF) CAD 첨부파일"),
|
||||
},
|
||||
{ title: "재질", field: "MATERIAL", width: 90, hozAlign: "left" },
|
||||
{ title: "사양(규격)", field: "SPEC", width: 90, hozAlign: "left" },
|
||||
{ title: "후처리", field: "POST_PROCESSING", width: 80, hozAlign: "left" },
|
||||
{ title: "MAKER", field: "MAKER", width: 80, hozAlign: "left" },
|
||||
{ title: "대분류", field: "MAJOR_CATEGORY", width: 80, hozAlign: "left" },
|
||||
{ title: "중분류", field: "SUB_CATEGORY", width: 80, hozAlign: "left" },
|
||||
{ title: "Revision", field: "REVISION", width: 80, hozAlign: "center" },
|
||||
{ title: "EO No", field: "EO_NO", width: 90, hozAlign: "center" },
|
||||
{ title: "EO Date", field: "EO_DATE", width: 80, hozAlign: "center" },
|
||||
{ title: "PART구분", field: "PART_TYPE_TITLE", width: 88, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", width: 80, hozAlign: "left" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/product/part", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "list",
|
||||
SEARCH_PART_NO: searchPartNo,
|
||||
SEARCH_PART_NAME: searchPartName,
|
||||
SEARCH_REVISION_RELEASE: searchRevision,
|
||||
SEARCH_MATERIAL: searchMaterial,
|
||||
SEARCH_SPEC: searchSpec,
|
||||
SEARCH_PART_TYPE: searchPartType,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} catch {
|
||||
Swal.fire("오류", "데이터 조회 중 오류가 발생했습니다.", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchPartNo, searchPartName, searchRevision, searchMaterial, searchSpec, searchPartType]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleExcel = () => {
|
||||
if (data.length === 0) {
|
||||
Swal.fire("알림", "다운로드할 데이터가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const header = [
|
||||
"품번", "품명", "수량", "3D", "2D", "PDF", "재질", "사양(규격)",
|
||||
"후처리", "MAKER", "대분류", "중분류", "Revision", "EO No",
|
||||
"EO Date", "PART구분", "비고",
|
||||
];
|
||||
const body = data.map((r) => [
|
||||
r.PART_NO ?? "", r.PART_NAME ?? "", r.BOM_QTY ?? "",
|
||||
r.CU01_CNT ?? 0, r.CU02_CNT ?? 0, r.CU03_CNT ?? 0,
|
||||
r.MATERIAL ?? "", r.SPEC ?? "", r.POST_PROCESSING ?? "",
|
||||
r.MAKER ?? "", r.MAJOR_CATEGORY ?? "", r.SUB_CATEGORY ?? "",
|
||||
r.REVISION ?? "", r.EO_NO ?? "", r.EO_DATE ?? "",
|
||||
r.PART_TYPE_TITLE ?? "", r.REMARK ?? "",
|
||||
]);
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, ...body]);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "PART 조회");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
XLSX.writeFile(wb, `PART_조회_${today}.xlsx`);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchPartNo(""); setSearchPartName(""); setSearchRevision("0");
|
||||
setSearchMaterial(""); setSearchSpec(""); setSearchPartType("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">제품관리_PART 조회</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleReset}>초기화</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcel}>Excel Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="품번">
|
||||
<Input value={searchPartNo} onChange={(e) => setSearchPartNo(e.target.value)} className="w-[194px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input value={searchPartName} onChange={(e) => setSearchPartName(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="Revision">
|
||||
<select
|
||||
value={searchRevision}
|
||||
onChange={(e) => setSearchRevision(e.target.value)}
|
||||
className="h-9 w-[150px] rounded border border-gray-300 bg-white px-2 text-sm"
|
||||
>
|
||||
<option value="1">current</option>
|
||||
<option value="0">all</option>
|
||||
</select>
|
||||
</SearchField>
|
||||
<SearchField label="재질">
|
||||
<Input value={searchMaterial} onChange={(e) => setSearchMaterial(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="사양(규격)">
|
||||
<Input value={searchSpec} onChange={(e) => setSearchSpec(e.target.value)} className="w-[150px]" />
|
||||
</SearchField>
|
||||
<SearchField label="부품 유형">
|
||||
<SearchableCodeSelect codeId="0000062" value={searchPartType} onChange={setSearchPartType} className="w-[150px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"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 { FolderCell } from "@/components/ui/folder-cell";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
// 제품관리_PART 등록 (원본: partMng/partMngTempList.jsp)
|
||||
export default function PartRegisterPage() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [searchYear, setSearchYear] = useState("");
|
||||
const [searchPartNo, setSearchPartNo] = useState("");
|
||||
const [searchPartName, setSearchPartName] = useState("");
|
||||
const [writer, setWriter] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [users, setUsers] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => setUsers((d.RESULTLIST || []).map((r: Record<string, unknown>) => ({
|
||||
value: String(r.USER_ID), label: String(r.USER_NAME || ""),
|
||||
}))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const centerPopup = (url: string, name: string, w: number, h: number) => {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},menubars=no,scrollbars=yes,resizable=yes`);
|
||||
};
|
||||
|
||||
const openPartForm = (objId?: string) => {
|
||||
const url = objId
|
||||
? `/product/popup/part-form?objId=${encodeURIComponent(objId)}`
|
||||
: `/product/popup/part-form`;
|
||||
centerPopup(url, "partMngPopUp", 900, 600);
|
||||
};
|
||||
|
||||
const openFileRegist = (objId: string, docType: string, docTypeName: string) => {
|
||||
centerPopup(
|
||||
`/common/files?targetObjId=${encodeURIComponent(objId)}&docType=${encodeURIComponent(docType)}&docTypeName=${encodeURIComponent(docTypeName)}`,
|
||||
"fileRegistPopUp",
|
||||
800,
|
||||
300,
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "순", field: "RNUM", width: 50, hozAlign: "center" },
|
||||
{ title: "품명", field: "PART_NAME", width: 240, hozAlign: "left", frozen: true },
|
||||
{ title: "모품번", field: "PARENT_PART_INFO", width: 120, hozAlign: "left", frozen: true },
|
||||
{
|
||||
title: "품번", field: "PART_NO", width: 160, hozAlign: "left", frozen: true,
|
||||
cellClick: (row) => openPartForm(String(row.OBJID)),
|
||||
},
|
||||
{ title: "수량", field: "Q_QTY", width: 70, hozAlign: "right" },
|
||||
{
|
||||
title: "3D", field: "CU01_CNT", width: 60, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFileRegist(String(row.OBJID), "3D_CAD", "3D CAD 첨부파일"),
|
||||
},
|
||||
{
|
||||
title: "2D", field: "CU02_CNT", width: 60, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFileRegist(String(row.OBJID), "2D_DRAWING_CAD", "2D(Drawing) CAD 첨부파일"),
|
||||
},
|
||||
{
|
||||
title: "PDF", field: "CU03_CNT", width: 60, hozAlign: "center",
|
||||
formatter: (cell) => <FolderCell count={cell} />,
|
||||
cellClick: (row) => openFileRegist(String(row.OBJID), "2D_PDF_CAD", "2D(PDF) CAD 첨부파일"),
|
||||
},
|
||||
{ title: "재질", field: "MATERIAL", width: 100, hozAlign: "left" },
|
||||
{ title: "사양(규격)", field: "SPEC", width: 180, hozAlign: "left" },
|
||||
{ title: "후처리", field: "POST_PROCESSING", width: 90, hozAlign: "left" },
|
||||
{ title: "MAKER", field: "MAKER", width: 90, hozAlign: "left" },
|
||||
{ title: "대분류", field: "MAJOR_CATEGORY", width: 110, hozAlign: "left" },
|
||||
{ title: "중분류", field: "SUB_CATEGORY", width: 110, hozAlign: "left" },
|
||||
{ title: "Revision", field: "REVISION", width: 80, hozAlign: "center" },
|
||||
{ title: "EO No", field: "EO_NO", width: 90, hozAlign: "center" },
|
||||
{ title: "EO Date", field: "EO_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "PART 구분", field: "PART_TYPE_TITLE", width: 100, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", width: 120, hozAlign: "left" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/product/part", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "register",
|
||||
SEARCH_YEAR: searchYear,
|
||||
SEARCH_PART_NO: searchPartNo,
|
||||
SEARCH_PART_NAME: searchPartName,
|
||||
WRITER: writer,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
setSelectedRows([]);
|
||||
}
|
||||
} catch {
|
||||
Swal.fire("오류", "데이터 조회 중 오류가 발생했습니다.", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchYear, searchPartNo, searchPartName, writer]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "선택된 Part가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const r = await Swal.fire({
|
||||
title: "같은 Part는 동시 확정됩니다. 선택된 Part를 확정하시겠습니까?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "확인",
|
||||
cancelButtonText: "취소",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
|
||||
const res = await fetch("/api/product/part/deploy", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rows: selectedRows }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
await Swal.fire({ icon: "success", title: json.message, timer: 1200, showConfirmButton: false });
|
||||
fetchData();
|
||||
} else {
|
||||
Swal.fire("오류", json.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedRows.length === 0) {
|
||||
Swal.fire("알림", "선택된 Part가 없습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
const r = await Swal.fire({
|
||||
title: "선택된 Part를 삭제하시겠습니까?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#3085d6",
|
||||
cancelButtonColor: "#d33",
|
||||
confirmButtonText: "확인",
|
||||
cancelButtonText: "취소",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
|
||||
const res = await fetch("/api/product/part/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objIds: selectedRows.map((row) => String(row.OBJID)) }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
await Swal.fire({ icon: "success", title: json.message, timer: 1200, showConfirmButton: false });
|
||||
fetchData();
|
||||
} else {
|
||||
Swal.fire("오류", json.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcelPopup = () => {
|
||||
centerPopup(
|
||||
"/product/popup/part-excel-import",
|
||||
"openPartExcelImportPopUp",
|
||||
1520,
|
||||
860,
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchYear(""); setSearchPartNo(""); setSearchPartName(""); setWriter("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">제품관리_PART 등록</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleDeploy}>확정</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>삭제</Button>
|
||||
<Button size="sm" onClick={() => openPartForm()}>등록</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleExcelPopup}>등록(Excel Upload)</Button>
|
||||
<Button size="sm" onClick={fetchData}>조회</Button>
|
||||
<Button size="sm" variant="secondary" onClick={handleReset}>초기화</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="년도">
|
||||
<select value={searchYear} onChange={(e) => setSearchYear(e.target.value)}
|
||||
className="h-9 w-[120px] rounded border border-gray-300 bg-white px-2 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={searchPartNo} onChange={(e) => setSearchPartNo(e.target.value)} className="w-[180px]" />
|
||||
</SearchField>
|
||||
<SearchField label="품명">
|
||||
<Input value={searchPartName} onChange={(e) => setSearchPartName(e.target.value)} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="등록자">
|
||||
<SearchableSelect options={users} value={writer} onChange={setWriter} className="w-[160px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
loading={loading}
|
||||
|
||||
onSelectionChange={setSelectedRows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"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 Swal from "sweetalert2";
|
||||
|
||||
// 사양관리
|
||||
export default function ProductSpecPage() {
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [specName, setSpecName] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트번호", field: "PROJECT_NO", width: 130, hozAlign: "center",
|
||||
cellClick: (row) => {
|
||||
window.open(
|
||||
`/product/spec/detail?objId=${row.OBJID}`,
|
||||
"specDetail",
|
||||
"width=1000,height=700"
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "프로젝트명", field: "PROJECT_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "사양명", field: "SPEC_NAME", width: 200, hozAlign: "left" },
|
||||
{ title: "사양값", field: "SPEC_VALUE", width: 150, hozAlign: "left" },
|
||||
{ title: "단위", field: "UNIT", width: 80, hozAlign: "center" },
|
||||
{ title: "비고", field: "REMARK", width: 200, hozAlign: "left" },
|
||||
{ title: "등록일", field: "REG_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "등록자", field: "REG_USER_NAME", width: 100, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/product/spec", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ project_no: projectNo, spec_name: specName }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
} catch {
|
||||
Swal.fire("오류", "데이터 조회 중 오류가 발생했습니다.", "error");
|
||||
}
|
||||
}, [projectNo, specName]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const { value: form } = await Swal.fire({
|
||||
title: "사양 등록",
|
||||
html: `
|
||||
<div style="text-align:left;font-size:13px">
|
||||
<label style="display:block;margin-bottom:4px">프로젝트번호</label>
|
||||
<input id="swal-pn" class="swal2-input" style="width:100%;margin:0 0 8px" />
|
||||
<label style="display:block;margin-bottom:4px">사양명 <span style="color:red">*</span></label>
|
||||
<input id="swal-sn" class="swal2-input" style="width:100%;margin:0 0 8px" />
|
||||
<label style="display:block;margin-bottom:4px">사양값</label>
|
||||
<input id="swal-sv" class="swal2-input" style="width:100%;margin:0 0 8px" />
|
||||
<label style="display:block;margin-bottom:4px">단위</label>
|
||||
<input id="swal-un" class="swal2-input" style="width:100%;margin:0" />
|
||||
</div>`,
|
||||
showCancelButton: true, confirmButtonText: "저장", cancelButtonText: "취소",
|
||||
preConfirm: () => ({
|
||||
project_no: (document.getElementById("swal-pn") as HTMLInputElement)?.value,
|
||||
spec_name: (document.getElementById("swal-sn") as HTMLInputElement)?.value,
|
||||
spec_value: (document.getElementById("swal-sv") as HTMLInputElement)?.value,
|
||||
unit: (document.getElementById("swal-un") as HTMLInputElement)?.value,
|
||||
}),
|
||||
});
|
||||
if (!form || !form.spec_name) return;
|
||||
const res = await fetch("/api/product/spec/save", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actionType: "regist", ...form }),
|
||||
});
|
||||
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 handleDelete = () => {
|
||||
Swal.fire("알림", "항목을 선택한 뒤 다시 시도하세요 (리스트 삭제는 별도 구현 예정)", "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={handleAdd}>등록</Button>
|
||||
<Button size="sm" variant="danger" onClick={handleDelete}>삭제</Button>
|
||||
<Button size="sm" variant="secondary" onClick={fetchData}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchForm onSearch={fetchData}>
|
||||
<SearchField label="프로젝트번호">
|
||||
<Input value={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[130px]" />
|
||||
</SearchField>
|
||||
<SearchField label="사양명">
|
||||
<Input value={specName} onChange={(e) => setSpecName(e.target.value)} className="w-[200px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} showCheckbox />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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";
|
||||
import { FolderCell } from "@/components/ui/folder-cell";
|
||||
|
||||
// productionplanning/inspectionMgmtList.jsp 대응 - 생산관리_검사관리
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionInspectionPage() {
|
||||
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 openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openInspection = (objId: string) =>
|
||||
openPopup(`/production/inspection-popup?objId=${encodeURIComponent(objId)}`, "inspection", 1300, 700);
|
||||
const openFiles = (targetObjId: string, docType: string, docTypeName: string) =>
|
||||
openPopup(
|
||||
`/common/files?targetObjId=${encodeURIComponent(targetObjId)}&docType=${encodeURIComponent(docType)}&docTypeName=${encodeURIComponent(docTypeName)}`,
|
||||
"filePopup", 800, 500
|
||||
);
|
||||
|
||||
const handleRegister = () => {
|
||||
if (selected.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "선택된 내용이 없습니다." });
|
||||
return;
|
||||
}
|
||||
if (selected.length > 1) {
|
||||
Swal.fire({ icon: "warning", title: "한번에 1개의 내용만 등록 가능합니다." });
|
||||
return;
|
||||
}
|
||||
openInspection(String(selected[0].OBJID));
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "center", frozen: true,
|
||||
cellClick: (row) => openProjectForm(String(row.OBJID)) },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "당사프로젝트명", field: "PROJECT_NAME", width: 250, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 130, hozAlign: "center" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 110, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "검사결과",
|
||||
columns: [
|
||||
{ title: "체크리스트", field: "INSPECTION_CNT", width: 110, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => openInspection(String(row.OBJID)) },
|
||||
{ title: "입회검사", field: "ADMISSION_INSPECTION_CNT", width: 110, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => openFiles(String(row.OBJID), "ADMISSION_INSPECTION_FILE", "입회검사") },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/inspection", {
|
||||
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]);
|
||||
|
||||
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={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={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[180px]" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"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";
|
||||
|
||||
// productionplanning/issuemgmtList.jsp 대응 - 생산관리_이슈관리
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionIssuePage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [unitCode, setUnitCode] = useState("");
|
||||
const [issueCategory, setIssueCategory] = useState("");
|
||||
const [issueType, setIssueType] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
const [selected, setSelected] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openIssueForm = (objId: string) =>
|
||||
openPopup(`/production/issue/form?objId=${encodeURIComponent(objId)}`, "issueForm", 1100, 800);
|
||||
|
||||
const handleRegister = () => openIssueForm("");
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/issue", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
year, project_no: projectNo, unit_code: unitCode,
|
||||
issue_category: issueCategory, issue_type: issueType,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, unitCode, issueCategory, issueType]);
|
||||
|
||||
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 handleAction = async (action: "delete" | "release") => {
|
||||
if (selected.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "선택된 대상이 없습니다." });
|
||||
return;
|
||||
}
|
||||
const targets = selected.filter((r) => String(r.STATUS || "") === "write");
|
||||
if (targets.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "등록중인 데이터만 처리 가능합니다." });
|
||||
return;
|
||||
}
|
||||
const title = action === "delete" ? "선택된 데이터를 삭제하시겠습니까?" : "선택된 데이터를 배포하시겠습니까?";
|
||||
const r = await Swal.fire({
|
||||
title, icon: "warning",
|
||||
showCancelButton: true, confirmButtonText: "확인", cancelButtonText: "취소",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
|
||||
const objIds = targets.map((t) => String(t.OBJID));
|
||||
const res = await fetch("/api/production/issue/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actionType: action, objIds }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
await Swal.fire({ icon: "success", title: j.message || "완료", timer: 1000, showConfirmButton: false });
|
||||
fetchData();
|
||||
} else {
|
||||
Swal.fire("오류", j.message || "처리 실패", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{ title: "이슈번호", field: "ISSUE_NO", width: 110, hozAlign: "center",
|
||||
cellClick: (row) => openIssueForm(String(row.OBJID)) },
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 110, hozAlign: "center",
|
||||
cellClick: (row) => openProjectForm(String(row.PROJECT_OBJID)) },
|
||||
{ title: "유닛명", field: "UNIT_CODE_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "품번", field: "PART_NO", width: 120, hozAlign: "left" },
|
||||
{ title: "품명", field: "PART_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "이슈구분", field: "ISSUE_CATEGORY_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "이슈유형", field: "ISSUE_TYPE_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "이슈내용", field: "CONTENT", width: 220, hozAlign: "left" },
|
||||
{ title: "등록일", field: "REG_DATE_TEXT", width: 100, hozAlign: "center" },
|
||||
{ title: "등록자", field: "WRITER_NAME", width: 80, hozAlign: "center" },
|
||||
{ title: "설계담당자", field: "DESIGN_USERID_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "구매담당자", field: "PURCHASE_USERID_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "품질담당자", field: "QUALITY_USERID_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "생산담당자", field: "PRODUCTION_USERID_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "조치결과", field: "DESIGN_RESULT_NAME", width: 100, hozAlign: "center" },
|
||||
{ title: "조치일", field: "DESIGN_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
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="danger" onClick={() => handleAction("delete")}>삭제</Button>
|
||||
<Button size="sm" onClick={() => handleAction("release")}>배포</Button>
|
||||
<Button size="sm" onClick={handleRegister}>등록</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={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[160px]" />
|
||||
</SearchField>
|
||||
<SearchField label="유닛명">
|
||||
<Input value={unitCode} onChange={(e) => setUnitCode(e.target.value)} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="이슈구분">
|
||||
<CodeSelect codeId="ISSUE_CATEGORY" value={issueCategory} onChange={setIssueCategory} className="w-[140px]" />
|
||||
</SearchField>
|
||||
<SearchField label="이슈유형">
|
||||
<CodeSelect codeId={issueCategory || "ISSUE_TYPE"} value={issueType} onChange={setIssueType} className="w-[140px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={data}
|
||||
showCheckbox
|
||||
|
||||
onSelectionChange={setSelected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"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";
|
||||
|
||||
// ProductionMng/ProdMgmList.jsp 대응 - 생산관리
|
||||
export default function ProductionPage() {
|
||||
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",
|
||||
cellClick: (row) => window.open(`/sales/contract/form?objId=${row.OBJID}`, "prodDetail", "width=1200,height=800") },
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "생산수량", field: "PROD_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "완료수량", field: "COMPLETE_QTY", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "진행율", field: "PROGRESS_RATE", width: 80, hozAlign: "center",
|
||||
formatter: (_cell, row) => `${row.PROGRESS_RATE || 0}%` },
|
||||
{ title: "시작일", field: "START_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "종료일", field: "END_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "상태", field: "STATUS_NAME", width: 80, hozAlign: "center" },
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production", {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"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 { FolderCell } from "@/components/ui/folder-cell";
|
||||
|
||||
// productionplanning/planningList.jsp 대응 - 생산관리_생산계획수립
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionPlanningPage() {
|
||||
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 openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openAssemblyWbs = (objId: string) =>
|
||||
openPopup(`/project/wbs-task?objId=${encodeURIComponent(objId)}`, "wbsTask", 900, 800);
|
||||
const openSetupWbs = (objId: string) =>
|
||||
openPopup(`/project/wbs-setup?objId=${encodeURIComponent(objId)}`, "wbsSetup", 1100, 750);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "center", frozen: true,
|
||||
cellClick: (row) => openProjectForm(String(row.OBJID)) },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "당사프로젝트명", field: "PROJECT_NAME", width: 250, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 110, hozAlign: "center" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 110, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "조립(▤)",
|
||||
columns: [
|
||||
{ title: "WBS", field: "WBS_CNT", width: 70, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => openAssemblyWbs(String(row.OBJID)) },
|
||||
{
|
||||
title: "계획",
|
||||
columns: [
|
||||
{ title: "시작일", field: "PRODUCE_PLAN_START", width: 100, hozAlign: "center" },
|
||||
{ title: "종료일", field: "PRODUCE_PLAN_END", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "실적",
|
||||
columns: [
|
||||
{ title: "시작일", field: "PRODUCE_ACT_START", width: 100, hozAlign: "center" },
|
||||
{ title: "종료일", field: "PRODUCE_ACT_END", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "셋업(▤)",
|
||||
columns: [
|
||||
{ title: "WBS", field: "SETUP_WBS_CNT", width: 70, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => openSetupWbs(String(row.OBJID)) },
|
||||
{
|
||||
title: "계획",
|
||||
columns: [
|
||||
{ title: "시작일", field: "SETUP_PLAN_START", width: 100, hozAlign: "center" },
|
||||
{ title: "종료일", field: "SETUP_PLAN_END", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "실적",
|
||||
columns: [
|
||||
{ title: "시작일", field: "SETUP_ACT_START", width: 100, hozAlign: "center" },
|
||||
{ title: "종료일", field: "SETUP_ACT_END", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/planning", {
|
||||
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">
|
||||
<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={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[180px]" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"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";
|
||||
|
||||
// productionplanning/processperformanceList.jsp 대응 - 생산관리_공정실적관리
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionProcessPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
const [projectNo, setProjectNo] = useState("");
|
||||
const [customerObjid, setCustomerObjid] = useState("");
|
||||
const [data, setData] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
const openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openAssemblyPopup = (row: Record<string, unknown>) =>
|
||||
openPopup(
|
||||
`/production/assembly-popup?objId=${encodeURIComponent(String(row.OBJID || ""))}`,
|
||||
"assemblyList", 1300, 800
|
||||
);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "center", frozen: true,
|
||||
cellClick: (row) => openProjectForm(String(row.CONTRACT_OBJID)) },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "당사프로젝트명", field: "PROJECT_NAME", width: 250, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "DUE_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 110, hozAlign: "center" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "조립(제작)",
|
||||
columns: [
|
||||
{
|
||||
title: "E-BOM/구매 BOM",
|
||||
columns: [
|
||||
{ title: "유닛명", field: "UNIT_NAME", width: 270, hozAlign: "left" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "생산BOM",
|
||||
columns: [
|
||||
{ title: "조립총수", field: "BOM_CNT", width: 90, hozAlign: "right", formatter: "money",
|
||||
cellClick: (row) => openAssemblyPopup(row) },
|
||||
{ title: "조립품수", field: "ASSING_CNT", width: 90, hozAlign: "right", formatter: "money",
|
||||
cellClick: (row) => openAssemblyPopup(row) },
|
||||
{ title: "공정율(%)", field: "AS_RATE", width: 90, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "작업공수(H)",
|
||||
columns: [
|
||||
{ title: "투입공수", field: "TOTAL_SUM", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "자사", field: "INSOURCING_SUM", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "외주", field: "OUTSOURCING_SUM", width: 80, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/process", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ year, project_no: projectNo, customer_objid: customerObjid }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json.RESULTLIST || []);
|
||||
}
|
||||
}, [year, projectNo, customerObjid]);
|
||||
|
||||
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">
|
||||
<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={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[180px]" />
|
||||
</SearchField>
|
||||
<SearchField label="고객사">
|
||||
<CodeSelect codeId="CUSTOMER" value={customerObjid} onChange={setCustomerObjid} className="w-[160px]" />
|
||||
</SearchField>
|
||||
</SearchForm>
|
||||
|
||||
<DataGrid columns={columns} data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"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";
|
||||
import { FolderCell } from "@/components/ui/folder-cell";
|
||||
|
||||
// productionplanning/releaseMgmtList.jsp 대응 - 생산관리_출고관리
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionReleasePage() {
|
||||
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 openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openReleaseForm = (row: Record<string, unknown>) => {
|
||||
const rObj = row.RELEASE_OBJID ? String(row.RELEASE_OBJID) : "";
|
||||
const qs = new URLSearchParams({
|
||||
parentObjId: String(row.OBJID || ""),
|
||||
product: String(row.PRODUCT || ""),
|
||||
productGroup: String(row.PRODUCT_GROUP || ""),
|
||||
...(rObj ? { objId: rObj } : {}),
|
||||
}).toString();
|
||||
openPopup(`/production/release/form?${qs}`, "releaseForm", 900, 500);
|
||||
};
|
||||
const openInspection = (objId: string) =>
|
||||
openPopup(`/production/inspection-popup?objId=${encodeURIComponent(objId)}`, "inspection", 1300, 700);
|
||||
const openFiles = (targetObjId: string, docType: string, docTypeName: string) =>
|
||||
openPopup(
|
||||
`/common/files?targetObjId=${encodeURIComponent(targetObjId)}&docType=${encodeURIComponent(docType)}&docTypeName=${encodeURIComponent(docTypeName)}`,
|
||||
"filePopup", 800, 500
|
||||
);
|
||||
|
||||
const handleRegister = () => {
|
||||
if (selected.length === 0) {
|
||||
Swal.fire({ icon: "warning", title: "선택된 내용이 없습니다." });
|
||||
return;
|
||||
}
|
||||
if (selected.length > 1) {
|
||||
Swal.fire({ icon: "warning", title: "한번에 1개의 내용만 등록 가능합니다." });
|
||||
return;
|
||||
}
|
||||
openReleaseForm(selected[0]);
|
||||
};
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "center", frozen: true,
|
||||
cellClick: (row) => openProjectForm(String(row.OBJID)) },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 140, hozAlign: "left" },
|
||||
{ title: "당사프로젝트명", field: "PROJECT_NAME", width: 230, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 130, hozAlign: "center" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "출고관리",
|
||||
columns: [
|
||||
{
|
||||
title: "검사결과",
|
||||
columns: [
|
||||
{ title: "체크리스트", field: "INSPECTION_CNT", width: 100, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => openInspection(String(row.OBJID)) },
|
||||
{ title: "입회검사", field: "ADMISSION_INSPECTION_CNT", width: 100, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => openFiles(String(row.OBJID), "ADMISSION_INSPECTION_FILE", "입회검사") },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "출고내역",
|
||||
columns: [
|
||||
{ title: "출하지시서", field: "RELEASE_ORDER_CNT", width: 100, hozAlign: "center",
|
||||
formatter: (v) => <FolderCell count={v} />,
|
||||
cellClick: (row) => {
|
||||
const rObj = row.RELEASE_OBJID ? String(row.RELEASE_OBJID) : "";
|
||||
if (!rObj) {
|
||||
Swal.fire({ icon: "info", title: "출고 등록 후 첨부 가능합니다." });
|
||||
return;
|
||||
}
|
||||
openFiles(rObj, "RELEASE_ORDER", "출하지시서");
|
||||
} },
|
||||
{ title: "출고일", field: "RELEASE_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "담당자", field: "RELEASE_WRITER", width: 100, hozAlign: "center" },
|
||||
{ title: "상태", field: "RELEASE_STATUS_TITLE", width: 90, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/release", {
|
||||
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]);
|
||||
|
||||
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={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={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[180px]" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"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";
|
||||
|
||||
// productionplanning/setupmgmtList.jsp 대응 - 생산관리_셋업관리
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionSetupPage() {
|
||||
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 openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openSetupWbs = (objId: string) =>
|
||||
openPopup(`/project/wbs-setup?objId=${encodeURIComponent(objId)}`, "wbsSetup", 1200, 800);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "center", frozen: true,
|
||||
cellClick: (row) => openProjectForm(String(row.OBJID)) },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 150, hozAlign: "left" },
|
||||
{ title: "당사프로젝트명", field: "PROJECT_NAME", width: 240, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 110, hozAlign: "center" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 100, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "셋업",
|
||||
columns: [
|
||||
{
|
||||
title: "셋업WBS",
|
||||
columns: [
|
||||
{ title: "TASK총수", field: "TASK_CNT", width: 90, hozAlign: "right", formatter: "money",
|
||||
cellClick: (row) => openSetupWbs(String(row.OBJID)) },
|
||||
{ title: "완료TASK", field: "COMPLETE_CNT", width: 90, hozAlign: "right", formatter: "money" },
|
||||
{ title: "공정율(%)", field: "SETUP_RATE", width: 90, hozAlign: "right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "투입인원(명)",
|
||||
columns: [
|
||||
{ title: "자사", field: "EMPLOYEES_IN", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "외주", field: "EMPLOYEES_OUT", width: 80, hozAlign: "right", formatter: "money" },
|
||||
{ title: "(계)", field: "EMPLOYEES_TOTAL", width: 80, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/setup", {
|
||||
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">
|
||||
<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={projectNo} onChange={(e) => setProjectNo(e.target.value)} className="w-[180px]" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"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";
|
||||
|
||||
// productionplanning/planningdashboard.jsp 대응 - 생산관리_현황
|
||||
function openPopup(url: string, name: string, w: number, h: number) {
|
||||
const left = (window.screen.width - w) / 2;
|
||||
const top = (window.screen.height - h) / 2;
|
||||
window.open(url, name, `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
}
|
||||
|
||||
export default function ProductionStatusPage() {
|
||||
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 openProjectForm = (objId: string) =>
|
||||
openPopup(`/project/modify?objId=${encodeURIComponent(objId)}`, "projectForm", 520, 650);
|
||||
const openAssemblyWbs = (objId: string) =>
|
||||
openPopup(`/project/wbs-task?objId=${encodeURIComponent(objId)}`, "wbsTask", 900, 800);
|
||||
const openSetupWbs = (objId: string) =>
|
||||
openPopup(`/project/wbs-setup?objId=${encodeURIComponent(objId)}`, "wbsSetup", 1100, 750);
|
||||
const openIssueList = (objId: string, status: string) =>
|
||||
openPopup(
|
||||
`/production/issue-popup?status=${encodeURIComponent(status)}&project_no=${encodeURIComponent(objId)}`,
|
||||
"issueList", 1720, 900
|
||||
);
|
||||
|
||||
const columns: GridColumn[] = [
|
||||
{
|
||||
title: "프로젝트정보",
|
||||
columns: [
|
||||
{ title: "프로젝트번호", field: "PROJECT_NO", width: 120, hozAlign: "center", frozen: true,
|
||||
cellClick: (row) => openProjectForm(String(row.OBJID)) },
|
||||
{ title: "고객사", field: "CUSTOMER_NAME", width: 130, hozAlign: "left" },
|
||||
{ title: "당사프로젝트명", field: "PROJECT_NAME", width: 260, hozAlign: "left" },
|
||||
{ title: "요청납기일", field: "REQ_DEL_DATE", width: 100, hozAlign: "center" },
|
||||
{ title: "셋업지", field: "SETUP", width: 130, hozAlign: "center" },
|
||||
{ title: "PM", field: "PM_USER_NAME", width: 90, hozAlign: "center" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "진척관리",
|
||||
columns: [
|
||||
{
|
||||
title: "조립(제작)",
|
||||
columns: [
|
||||
{ title: "공정율(%)", field: "ASSEMBLY_RATE", width: 90, hozAlign: "center",
|
||||
cellClick: (row) => openAssemblyWbs(String(row.OBJID)) },
|
||||
{ title: "종료일", field: "ASSEMBLY_DATE_END", width: 100, hozAlign: "center" },
|
||||
{ title: "투입공수(H)", field: "ASSEMBLY_EMPLOYEES_TOTAL", width: 100, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "셋업",
|
||||
columns: [
|
||||
{ title: "공정율(%)", field: "SETUP_RATE", width: 90, hozAlign: "center",
|
||||
cellClick: (row) => openSetupWbs(String(row.OBJID)) },
|
||||
{ title: "종료일", field: "SETUP_ACT_END", width: 100, hozAlign: "center" },
|
||||
{ title: "투입공수(H)", field: "EMPLOYEES_TOTAL", width: 100, hozAlign: "right", formatter: "money" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "이슈관리",
|
||||
columns: [
|
||||
{ title: "조치율(%)", field: "ISSUE_RATE", width: 90, hozAlign: "center" },
|
||||
{ title: "발생", field: "ISSUE_CNT", width: 80, hozAlign: "center",
|
||||
cellClick: (row) => openIssueList(String(row.OBJID), "all") },
|
||||
{ title: "조치", field: "COMP_CNT", width: 80, hozAlign: "center",
|
||||
cellClick: (row) => openIssueList(String(row.OBJID), "complete") },
|
||||
{ title: "미결", field: "MISS_CNT", width: 80, hozAlign: "center",
|
||||
cellClick: (row) => openIssueList(String(row.OBJID), "late") },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const res = await fetch("/api/production/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]);
|
||||
|
||||
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) => 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-[180px]" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||