Compare commits
76 Commits
64c77b36ea
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 13ac11cc0a | |||
| 069d998c9d | |||
| e782cb4ea7 | |||
| 5582c72467 | |||
| b678d7c37f | |||
| 5b0fe2f1a1 | |||
| 6ae0d90b97 | |||
| 09e2f06a14 | |||
| a107b06826 | |||
| 506db5ff07 | |||
| 0b04a05b26 | |||
| ec8c9edcc9 | |||
| 7ff3500844 | |||
| 84624baf8e | |||
| d728c55e2a | |||
| 55c98b6b95 | |||
| ca53042eb4 | |||
| e2c6a50b46 | |||
| cb43fe1257 | |||
| 8f3761aeae | |||
| d30dcbada4 | |||
| 9f361c4e6a | |||
| fde2c9c2e9 | |||
| 162e1d22a9 | |||
| 1806c75ecb | |||
| 8ab5223790 | |||
| fa1027fa1b | |||
| 32fc12e283 | |||
| 8562c4f65d | |||
| bec9c5e5a6 | |||
| 8c6539d6cb | |||
| 269e779129 | |||
| 3cf22513ca | |||
| 33ce9482dd | |||
| ce1014e6bc | |||
| 652e53c3e8 | |||
| 5c7730d4a1 | |||
| 57f0c3426f | |||
| bcbf3de24c | |||
| 2f16457ac6 | |||
| fdabeec4e3 | |||
| f25948185b | |||
| 7d39c2edec | |||
| 90e286dc4d | |||
| 2e3ea610b7 | |||
| c11c5733de | |||
| 478cde8caf | |||
| 574c053077 | |||
| 800fbd1da4 | |||
| 2607e0610f | |||
| 150335cf87 | |||
| d4e823f1fa | |||
| b75fda5901 | |||
| d9ea7550b0 | |||
| 4113a034f4 | |||
| f9c1dc140b | |||
| 17dfd23cfc | |||
| 45f6b3b174 | |||
| 867bc510da | |||
| 69b84db4bb | |||
| 1d33761003 | |||
| 96e2c49506 | |||
| 34c7e2f964 | |||
| 9e707be5ac | |||
| 6918426264 | |||
| 151de492ad | |||
| d4af2dc064 | |||
| d2c1dfa2a0 | |||
| a4eaf9b770 | |||
| 57ea186061 | |||
| 004b2dff1f | |||
| 550867ba0c | |||
| afacc9a7db | |||
| 9c58b022e2 | |||
| d7b6c7d029 | |||
| 316e3fe8d1 |
@@ -15,3 +15,28 @@ PUBLIC_BASE_URL=https://dbx.no1king.freeddns.org
|
||||
CS_ORDER_URL=/corm/
|
||||
# 비워두면 같은 도메인의 /orderlist/ 경로(NPM 리버스 프록시)로 자동 연결됨
|
||||
CUSTOMER_ORDER_LIST_URL=/orderlist/
|
||||
|
||||
# ─── 개인경비 모듈 (expense_db) ───
|
||||
# 설정하면 PostgreSQL 사용, 미설정 시 DATA_DIR/expense.json 사용.
|
||||
# DB/역할 생성: scripts/sql/expense_db_init.sql 참고.
|
||||
# EXPENSE_DB_URL=postgresql://expense_app:replace-me@postgres-db:5432/expense_db
|
||||
|
||||
# ─── 쿠팡 밀크런 모듈 (cupang_db) ───
|
||||
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||
# DB/역할/스키마/센터 seed 생성: scripts/sql/cupang_db_init.sql 참고.
|
||||
# CUPANG_DB_URL=postgresql://cupang_app:replace-me@postgres-db:5432/cupang_db
|
||||
|
||||
# ─── 휴가 관리 모듈 (vacation_db) ───
|
||||
# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음).
|
||||
# DB/역할/스키마/공휴일 seed 생성: scripts/sql/vacation_db_init.sql 참고.
|
||||
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
|
||||
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db
|
||||
|
||||
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||
# itemcode_db 실제 테이블: single_items(낱개) / set_items(세트)
|
||||
# 컬럼: item_code, sabangnet_code, name
|
||||
# 읽기 전용 역할 itemcode_ro 를 먼저 생성하고(아래 DSN), 낱개+세트 UNION 검색 SQL 사용:
|
||||
# ITEMCODE_DB_URL=postgresql://itemcode_ro:replace-me@postgres-db:5432/itemcode_db
|
||||
# ITEMCODE_SEARCH_SQL=SELECT item_code AS code, name AS name, '낱개' AS type FROM single_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s UNION ALL SELECT item_code AS code, name AS name, '세트' AS type FROM set_items WHERE item_code ILIKE %(q)s OR name ILIKE %(q)s ORDER BY code ASC LIMIT %(limit)s
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 런타임 데이터 (사용자 이메일/경비 등 PII — 커밋 금지. 운영은 DATA_DIR 볼륨)
|
||||
app/data/
|
||||
|
||||
# 로컬 전용 스크립트 (자격증명 포함 가능)
|
||||
push-gitea.bat
|
||||
run-local.bat
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# main-app ERP 프로젝트 작업 기준
|
||||
|
||||
> 이 문서는 Claude Code가 이 저장소에서 작업할 때 가장 먼저 확인하는 기준 문서입니다.
|
||||
> 작업 시작 전, 아래 "반드시 먼저 읽을 문서"를 모두 확인한 뒤 작업을 시작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 반드시 먼저 읽을 문서
|
||||
|
||||
Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아래 문서를 순서대로 읽고 맥락을 확보**한 뒤 작업한다.
|
||||
|
||||
1. `docs/PROJECT_OVERVIEW.md` — 프로젝트 정의, 기능 범위, 연동 대상
|
||||
2. `docs/SERVER_ARCHITECTURE.md` — 서버 구성도와 네트워크 흐름
|
||||
3. `docs/DATABASES.md` — PostgreSQL DB 구성과 명명 규칙
|
||||
4. `docs/DEPLOYMENT.md` — 배포 경로, 서비스 실행 방식, 복구 절차
|
||||
|
||||
문서 간 내용이 충돌하면 위의 우선순위(1 → 4)를 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 한 줄 정의
|
||||
|
||||
`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다.
|
||||
|
||||
담당 영역:
|
||||
|
||||
- 주문관리
|
||||
- 상품코드 매칭
|
||||
- 재고관리
|
||||
- CS관리
|
||||
- 반품관리
|
||||
- 외부 쇼핑몰 API 연동 (카페24, 네이버 스마트스토어, 사방넷 등)
|
||||
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
||||
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
||||
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
||||
|
||||
상세는 `docs/PROJECT_OVERVIEW.md`.
|
||||
|
||||
---
|
||||
|
||||
## 개발 원칙
|
||||
|
||||
- 기존 코드를 수정하기 전 관련 파일을 먼저 읽고 구조를 파악한다.
|
||||
- 위험 명령은 **반드시 사용자 확인 후** 실행한다 (아래 "위험 명령" 절 참고).
|
||||
- `.env`, API 키, DB 비밀번호, OAuth Secret, 토큰은 절대 Git에 올리지 않는다.
|
||||
- 신규 DB가 필요하면 **승인 요청 후** 생성하며, DB명은 반드시 `_db`로 끝낸다 (예: `inventory_db`).
|
||||
- 예전 문서/코드의 `orderlist_app`은 현재 기준 `orderlist_db`이다. 발견 시 수정 대상.
|
||||
|
||||
---
|
||||
|
||||
## 위험 명령 (사용자 확인 없이 실행 금지)
|
||||
|
||||
아래 명령은 **반드시 사용자에게 의도를 설명하고 명시적 승인을 받은 뒤** 실행한다.
|
||||
|
||||
| 분류 | 명령 예시 |
|
||||
| --- | --- |
|
||||
| 파일 삭제 | `rm -rf`, `Remove-Item -Recurse -Force` |
|
||||
| DB 파괴 | `DROP DATABASE`, `DROP TABLE`, `DROP SCHEMA` |
|
||||
| 데이터 삭제 | `TRUNCATE`, 조건 없는 대량 `DELETE`, `UPDATE` |
|
||||
| Docker 파괴 | `docker volume rm`, `docker volume prune`, `docker system prune -a --volumes` |
|
||||
| Git 파괴 | `git reset --hard`, `git push --force`, `git clean -fd`, `git branch -D` |
|
||||
| 운영 초기화 | 운영 DB 덤프 덮어쓰기, 마이그레이션 롤백 |
|
||||
|
||||
원칙:
|
||||
|
||||
1. 실행 전 현재 상태 확인 명령을 먼저 보여준다 (예: `docker ps`, `\l`, `git status`).
|
||||
2. 백업 존재 여부와 위치를 명시한다.
|
||||
3. 실행 후 결과 확인 절차를 같이 제시한다.
|
||||
|
||||
---
|
||||
|
||||
## 서버 작업 원칙
|
||||
|
||||
- 배포, DB 복구, Docker 작업 전에는 현재 상태 확인 명령을 먼저 제안한다.
|
||||
- PostgreSQL 작업 전에는 DB명, 컨테이너명, 포트, 백업 위치를 확인한다.
|
||||
- 운영 서버 경로와 개발 PC 경로를 혼동하지 않는다.
|
||||
- 개발 PC: `G:\내 드라이브\프로젝트\Main-app`
|
||||
- **운영 서버 (main-app): `/opt/www/main`** ← 본 프로젝트 경로
|
||||
- 참고용 (같은 호스트 내 다른 서비스 경로): `/opt/dbx-corm`, `/opt/dbx-orderlist`
|
||||
- 명령 예시·문서 작성 시 main-app 경로는 **반드시 `/opt/www/main`** 사용. 상세는 `docs/DEPLOYMENT.md`.
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 / 비밀값
|
||||
|
||||
- `.env`, `.env.local`, `.env.production` 은 **Git에 절대 커밋하지 않는다**.
|
||||
- 예시 파일(`*.example`)만 커밋한다.
|
||||
- 비밀값 유출이 의심되면 즉시 회전(rotate)을 권고한다.
|
||||
- 신규 환경변수 추가 시 `*.example` 파일과 본 문서(또는 `docs/DEPLOYMENT.md`)에 변수 설명을 함께 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
## 디렉터리 구조 (요약)
|
||||
|
||||
```
|
||||
Main-app/
|
||||
├─ app/ FastAPI 앱 소스
|
||||
├─ docs/ 운영/설계 문서 (작업 전 필독)
|
||||
├─ scripts/ 배포·유지보수 스크립트
|
||||
├─ skills/ Claude Code 규칙/스킬
|
||||
├─ docker-compose.yml 운영 컴포즈
|
||||
├─ docker-compose.local.yml 로컬 컴포즈
|
||||
├─ Dockerfile
|
||||
├─ requirements.txt
|
||||
└─ CLAUDE.md ← 이 문서
|
||||
```
|
||||
@@ -0,0 +1,357 @@
|
||||
# Ui — Style Reference
|
||||
> Monochromatic architectural blueprint – precise, functional forms on a stark, bright canvas.
|
||||
|
||||
**Theme:** light
|
||||
|
||||
This design system feels like a finely tuned machine, presenting a clean and precise interface with a stark black-and-white aesthetic. The visual mood is serious and functional, achieved through a dominant achromatic palette and very subtle elevation. Geometric balance is created by mixing hard 10-14px radii for cards and inputs with highly rounded (near-pill) buttons and badges, suggesting both structure and approachability. The use of a custom sans-serif font across all elements with meticulous letter-spacing creates a unified, crisp typographic voice.
|
||||
|
||||
## Tokens — Colors
|
||||
|
||||
| Name | Value | Token | Role |
|
||||
|------|-------|-------|------|
|
||||
| Canvas White | `#ffffff` | `--color-canvas-white` | Page background, primary card surfaces, popovers. The foundational bright base. |
|
||||
| Ghost Gray | `#f2f2f2` | `--color-ghost-gray` | Secondary background for segmented sections or subtle card differentiation. Lighter than default background. |
|
||||
| Subtle Ash | `#e5e5e5` | `--color-subtle-ash` | Border colors for inputs, cards, and dividers. Provides definition without harshness. |
|
||||
| Midtone Gray | `#737373` | `--color-midtone-gray` | Muted text, placeholder text in inputs, secondary icons. Recedes into the background. |
|
||||
| Rich Black | `#0a0a0a` | `--color-rich-black` | Primary text color for body copy, standard icons, badges with white text. High contrast for readability. |
|
||||
| Deep Black | `#000000` | `--color-deep-black` | Headings, active state button backgrounds, highlighted text. The darkest tone for strong emphasis. |
|
||||
| Callout Red | `#c22b10` | `--color-callout-red` | Destructive actions, error states. A muted, serious red. |
|
||||
| Success Green | `#10c22b` | `--color-success-green` | Success states, positive confirmations. A muted, serious green. |
|
||||
|
||||
## Tokens — Typography
|
||||
|
||||
### Geist — Primary brand font for all UI text, headings, and body. Its varied weights and precise tracking create a modern, technical feel. · `--font-geist`
|
||||
- **Substitute:** Inter
|
||||
- **Weights:** 400, 500, 600
|
||||
- **Sizes:** 12px, 13px, 14px, 16px, 18px, 48px
|
||||
- **Line height:** 1.00, 1.10, 1.20, 1.33, 1.38, 1.43, 1.50, 1.56, 1.63, 2.00
|
||||
- **Letter spacing:** -0.0500em at 48px, -0.0250em at 18px
|
||||
- **Role:** Primary brand font for all UI text, headings, and body. Its varied weights and precise tracking create a modern, technical feel.
|
||||
|
||||
### Geist Mono — Used for code snippets or specific input fields requiring monospaced characters. Reinforces a technical aesthetic. · `--font-geist-mono`
|
||||
- **Substitute:** IBM Plex Mono
|
||||
- **Weights:** 400
|
||||
- **Sizes:** 14px
|
||||
- **Line height:** 1.43
|
||||
- **Letter spacing:** normal
|
||||
- **Role:** Used for code snippets or specific input fields requiring monospaced characters. Reinforces a technical aesthetic.
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Role | Size | Line Height | Letter Spacing | Token |
|
||||
|------|------|-------------|----------------|-------|
|
||||
| caption | 12px | 1.5 | — | `--text-caption` |
|
||||
| body | 14px | 1.43 | — | `--text-body` |
|
||||
| heading | 18px | 1.33 | -0.45px | `--text-heading` |
|
||||
| display | 48px | 1 | -2.4px | `--text-display` |
|
||||
|
||||
## Tokens — Spacing & Shapes
|
||||
|
||||
**Density:** compact
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
| Name | Value | Token |
|
||||
|------|-------|-------|
|
||||
| 4 | 4px | `--spacing-4` |
|
||||
| 5 | 5px | `--spacing-5` |
|
||||
| 6 | 6px | `--spacing-6` |
|
||||
| 8 | 8px | `--spacing-8` |
|
||||
| 10 | 10px | `--spacing-10` |
|
||||
| 12 | 12px | `--spacing-12` |
|
||||
| 16 | 16px | `--spacing-16` |
|
||||
| 20 | 20px | `--spacing-20` |
|
||||
| 24 | 24px | `--spacing-24` |
|
||||
| 32 | 32px | `--spacing-32` |
|
||||
| 40 | 40px | `--spacing-40` |
|
||||
| 80 | 80px | `--spacing-80` |
|
||||
| 83 | 83px | `--spacing-83` |
|
||||
|
||||
### Border Radius
|
||||
|
||||
| Element | Value |
|
||||
|---------|-------|
|
||||
| pill | 9999px |
|
||||
| badge | 26px |
|
||||
| cards | 14px |
|
||||
| input | 10px |
|
||||
| buttons | 10px |
|
||||
| default | 10px |
|
||||
|
||||
### Shadows
|
||||
|
||||
| Name | Value | Token |
|
||||
|------|-------|-------|
|
||||
| subtle | `lab(100 0 0) 0px 0px 0px 2px` | `--shadow-subtle` |
|
||||
| subtle-2 | `oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0...` | `--shadow-subtle-2` |
|
||||
|
||||
### Layout
|
||||
|
||||
- **Section gap:** 83px
|
||||
- **Card padding:** 16px
|
||||
- **Element gap:** 8px
|
||||
|
||||
## Components
|
||||
|
||||
### Primary Action Button
|
||||
**Role:** Call to action.
|
||||
|
||||
Solid Deep Black (#000000) background with Canvas White (#ffffff) text. Features a 10px border-radius, 8px vertical padding, and 48px horizontal padding, making it a prominent rectangular element.
|
||||
|
||||
### Ghost Button
|
||||
**Role:** Secondary or tertiary actions, often within groups.
|
||||
|
||||
Transparent background with Rich Black (#0a0a0a) text. Uses a 9999px border-radius for a pill shape, with no explicit padding defined by variants, implying content-based sizing.
|
||||
|
||||
### Split Button Left
|
||||
**Role:** Left segment of a grouped button control.
|
||||
|
||||
Canvas White (#ffffff) background with Deep Black (#000000) text. Features a 10px border-radius on the left, 0px on the right, and 10px horizontal padding. Borders in Subtle Ash (#e5e5e5).
|
||||
|
||||
### Split Button Right
|
||||
**Role:** Right segment of a grouped button control.
|
||||
|
||||
Canvas White (#ffffff) background with Deep Black (#000000) text. Features a 10px border-radius on the right, 0px on the left. Borders in Subtle Ash (#e5e5e5).
|
||||
|
||||
### Elevated Card
|
||||
**Role:** Containers for distinct content blocks, forms, or data.
|
||||
|
||||
Canvas White (#ffffff) background with a 14px border-radius. Features a subtle shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px, providing minimal elevation. Inner content padding is 16px.
|
||||
|
||||
### Plain Input Field
|
||||
**Role:** Standard text input.
|
||||
|
||||
Transparent background with Rich Black (#0a0a0a) text. Defined by a 1px Subtle Ash (#e5e5e5) border and a 10px border-radius. Inner padding is 4px vertical, 10px horizontal.
|
||||
|
||||
### Segmented Input Left
|
||||
**Role:** Left segment of a grouped input control.
|
||||
|
||||
Transparent background with Rich Black (#0a0a0a) text. Features a 10px border-radius on the left and 0px on the right. Defined by a 1px Subtle Ash (#e5e5e5) border. Inner padding is 4px vertical, 10px horizontal.
|
||||
|
||||
### Inverse Tag Badge
|
||||
**Role:** Highlighting status or category, with high contrast.
|
||||
|
||||
Deep Black (#171717) background with Canvas White (#ffffff) text. Features a 26px border-radius, creating a pill shape. Padding is 2px vertical, 8px horizontal.
|
||||
|
||||
### Neutral Tag Badge
|
||||
**Role:** Subtle categorization or status.
|
||||
|
||||
Ghost Gray (#f2f2f2) background with Rich Black (#0a0a0a) text. Features a 26px border-radius, creating a pill shape. Padding is 2px vertical, 8px horizontal.
|
||||
|
||||
### Outline Tag Badge
|
||||
**Role:** Very subtle categorization or option.
|
||||
|
||||
Transparent background with Rich Black (#0a0a0a) text. Features a 26px border-radius and a Light Ash (#a1a1a1) border. Padding is 2px vertical, 8px horizontal.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use Deep Black (#000000) for primary headings and active states to command attention.
|
||||
- Apply Subtle Ash (#e5e5e5) for all primary borders and dividers to maintain a subtle visual separation.
|
||||
- Ensure input fields and cards consistently use a 10px or 14px border-radius, respectively, for geometric stability.
|
||||
- Employ Geist font universally, leveraging its 400, 500, and 600 weights to establish clear hierarchy without introducing new typefaces.
|
||||
- Maintain a default element gap of 8px, but use 16px for card inner padding to create adequate breathing room for content.
|
||||
- Utilize 9999px or 26px border-radius for all interactive buttons and badges to create a soft, approachable pill shape.
|
||||
|
||||
### Don't
|
||||
- Avoid using highly saturated colors; stick to the achromatic scale and the two semantic reds and greens.
|
||||
- Do not introduce additional font families; the current choices are sufficient for all typographic needs.
|
||||
- Refrain from using strong, multi-directional shadows; rely on minimal 1px shadows or simple borders for elevation.
|
||||
- Do not deviate from the established border-radius values; the mix of sharp 0px (in split elements), 10px, 14px, and 9999px is intentional.
|
||||
- Don't add excessive padding or margin; the design favors a compact density with specific, calculated spacing.
|
||||
- Avoid decorative gradients; the brand's aesthetic is built on flat colors and subtle depth.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Level | Name | Value | Purpose |
|
||||
|-------|------|-------|---------|
|
||||
| 0 | Canvas White | `#ffffff` | Primary page background and base surface for most content. |
|
||||
| 1 | Elevated Card | `#ffffff` | Content cards and distinct sections that require a subtle lift, defined by borders or minimal shadow. |
|
||||
| 2 | Search/Input Field | `#ffffff` | Interactive elements like search bars and inputs, often bordered. |
|
||||
| 3 | Popovers/Overlays | `#ffffff` | Transient UI elements that appear above other content. |
|
||||
| 4 | Ghost Gray Background | `#f2f2f2` | Used as a background color for secondary buttons or badges, indicating a slightly lower hierarchy. |
|
||||
|
||||
## Elevation
|
||||
|
||||
- **Elevated Card:** `oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px`
|
||||
- **Focus Ring:** `lab(100 0 0) 0px 0px 0px 2px`
|
||||
|
||||
## Imagery
|
||||
|
||||
The visual language is purely utilitarian and functional. No photography or complex illustrations are present. Icons are monochromatic, typically black stroke or fill on white backgrounds, aligning with the stark aesthetic. Product components are presented directly, with an emphasis on UI elements rather than lifestyle or marketing visuals. Imagery's role is explanatory (via icons) or for showcasing UI components, maintaining a text-dominant layout. There are no decorative visuals.
|
||||
|
||||
## Layout
|
||||
|
||||
The page maintains a centered, contained layout with a maximum visible width, creating a focused content area. The hero section features a prominent, centered headline and subtext over the Canvas White background, followed by centrally aligned CTA buttons. Sections below are arranged in a multi-column grid, showcasing various UI components (forms, cards, controls). The rhythm is consistent vertical spacing, creating an organized, information-dense display. Navigation is a sticky top-bar with compact links and utility actions.
|
||||
|
||||
## Agent Prompt Guide
|
||||
|
||||
### Quick Color Reference
|
||||
- **Text Primary:** #0a0a0a
|
||||
- **Text Muted:** #737373
|
||||
- **Background:** #ffffff
|
||||
- **CTA Background:** #000000
|
||||
- **Border:** #e5e5e5
|
||||
- **Accent (Semantic Red):** #c22b10
|
||||
|
||||
### Example Component Prompts
|
||||
1. **Create a Hero Section:** Canvas White (#ffffff) background. Headline 'The Foundation for your Design System' in Geist weight 600, 48px, line-height 1.0, letter-spacing -2.4px, color Deep Black (#000000). Subtext 'A set of beautifully designed components that you can customize...' in Geist weight 400, 18px, line-height 1.33, letter-spacing -0.45px, color Rich Black (#0a0a0a). Below this, a Primary Action Button labeled 'New Project' and a Ghost Button labeled 'View Components'. Section gap 83px.
|
||||
2. **Generate an Elevated Card:** Canvas White (#ffffff) background, 14px border-radius, with shadow 'oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px'. Inside, use 16px internal padding. Title 'Payment Method' in Geist weight 500, 16px, Rich Black (#0a0a0a). Body text 'All transactions are secure...' in Geist weight 400, 14px, Midtone Gray (#737373). Include a Plain Input Field with label 'Name on Card'.
|
||||
3. **Design a Form Input Group:** Two segmented input fields. The first is a Segmented Input Left for 'Card Number', with placeholder '1234 5678 9012 3456'. The second is a Plain Input Field for 'CVV', placeholder '123'. Both bordered with Subtle Ash (#e5e5e5).
|
||||
4. **Create a Navigation Bar:** Canvas White (#ffffff) background. Left aligned: 'Docs', 'Components', 'Blocks', 'Charts', 'Directory', 'Create' as text links in Geist weight 400, 14px, Rich Black (#0a0a0a). Right aligned: a Plain Input Field 'Search documentation...' and a Primary Action Button labeled '+ New'. Elements within the nav should have 8px element gap between them.
|
||||
|
||||
## Similar Brands
|
||||
|
||||
- **Vercel** — Dominant use of a stark black-and-white achromatic palette, clean typography, and focus on developer tools and component showcasing.
|
||||
- **Linear** — Systematic grid-based UI, minimal use of color, and high-fidelity, component-driven interaction patterns.
|
||||
- **Figma** — Functional, dark-mode leaning interfaces with strong typography and precise spacing, emphasizing tool-like utility.
|
||||
- **Revolut (early UI)** — Modern, crisp UI with strong geometric shapes, restrained use of color for status, and emphasis on clear data presentation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-canvas-white: #ffffff;
|
||||
--color-ghost-gray: #f2f2f2;
|
||||
--color-subtle-ash: #e5e5e5;
|
||||
--color-midtone-gray: #737373;
|
||||
--color-rich-black: #0a0a0a;
|
||||
--color-deep-black: #000000;
|
||||
--color-callout-red: #c22b10;
|
||||
--color-success-green: #10c22b;
|
||||
|
||||
/* Typography — Font Families */
|
||||
--font-geist: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Typography — Scale */
|
||||
--text-caption: 12px;
|
||||
--leading-caption: 1.5;
|
||||
--text-body: 14px;
|
||||
--leading-body: 1.43;
|
||||
--text-heading: 18px;
|
||||
--leading-heading: 1.33;
|
||||
--tracking-heading: -0.45px;
|
||||
--text-display: 48px;
|
||||
--leading-display: 1;
|
||||
--tracking-display: -2.4px;
|
||||
|
||||
/* Typography — Weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-4: 4px;
|
||||
--spacing-5: 5px;
|
||||
--spacing-6: 6px;
|
||||
--spacing-8: 8px;
|
||||
--spacing-10: 10px;
|
||||
--spacing-12: 12px;
|
||||
--spacing-16: 16px;
|
||||
--spacing-20: 20px;
|
||||
--spacing-24: 24px;
|
||||
--spacing-32: 32px;
|
||||
--spacing-40: 40px;
|
||||
--spacing-80: 80px;
|
||||
--spacing-83: 83px;
|
||||
|
||||
/* Layout */
|
||||
--section-gap: 83px;
|
||||
--card-padding: 16px;
|
||||
--element-gap: 8px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-3xl: 26px;
|
||||
--radius-full: 9996px;
|
||||
--radius-full-2: 9999px;
|
||||
--radius-full-3: 159981px;
|
||||
--radius-full-4: 159984px;
|
||||
|
||||
/* Named Radii */
|
||||
--radius-pill: 9999px;
|
||||
--radius-badge: 26px;
|
||||
--radius-cards: 14px;
|
||||
--radius-input: 10px;
|
||||
--radius-buttons: 10px;
|
||||
--radius-default: 10px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-subtle: lab(100 0 0) 0px 0px 0px 2px;
|
||||
--shadow-subtle-2: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||
|
||||
/* Surfaces */
|
||||
--surface-canvas-white: #ffffff;
|
||||
--surface-elevated-card: #ffffff;
|
||||
--surface-searchinput-field: #ffffff;
|
||||
--surface-popoversoverlays: #ffffff;
|
||||
--surface-ghost-gray-background: #f2f2f2;
|
||||
}
|
||||
```
|
||||
|
||||
### Tailwind v4
|
||||
|
||||
```css
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-canvas-white: #ffffff;
|
||||
--color-ghost-gray: #f2f2f2;
|
||||
--color-subtle-ash: #e5e5e5;
|
||||
--color-midtone-gray: #737373;
|
||||
--color-rich-black: #0a0a0a;
|
||||
--color-deep-black: #000000;
|
||||
--color-callout-red: #c22b10;
|
||||
--color-success-green: #10c22b;
|
||||
|
||||
/* Typography */
|
||||
--font-geist: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Typography — Scale */
|
||||
--text-caption: 12px;
|
||||
--leading-caption: 1.5;
|
||||
--text-body: 14px;
|
||||
--leading-body: 1.43;
|
||||
--text-heading: 18px;
|
||||
--leading-heading: 1.33;
|
||||
--tracking-heading: -0.45px;
|
||||
--text-display: 48px;
|
||||
--leading-display: 1;
|
||||
--tracking-display: -2.4px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-4: 4px;
|
||||
--spacing-5: 5px;
|
||||
--spacing-6: 6px;
|
||||
--spacing-8: 8px;
|
||||
--spacing-10: 10px;
|
||||
--spacing-12: 12px;
|
||||
--spacing-16: 16px;
|
||||
--spacing-20: 20px;
|
||||
--spacing-24: 24px;
|
||||
--spacing-32: 32px;
|
||||
--spacing-40: 40px;
|
||||
--spacing-80: 80px;
|
||||
--spacing-83: 83px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-3xl: 26px;
|
||||
--radius-full: 9996px;
|
||||
--radius-full-2: 9999px;
|
||||
--radius-full-3: 159981px;
|
||||
--radius-full-4: 159984px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-subtle: lab(100 0 0) 0px 0px 0px 2px;
|
||||
--shadow-subtle-2: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||
}
|
||||
```
|
||||
+364
-39
@@ -5,12 +5,41 @@ from typing import Any
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
from pydantic import BaseModel
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from .modules.cupang import build_cupang_store, build_itemcode_reader
|
||||
from .modules.cupang import router as cupang_router
|
||||
from .modules.expense import CategoryStore, build_expense_store
|
||||
from .modules.expense import router as expense_router
|
||||
from .modules.vacation import build_vacation_store
|
||||
from .modules.vacation import router as vacation_router
|
||||
from .store import (
|
||||
APPROVER_KEYS,
|
||||
MODULE_KEYS,
|
||||
SUPER_ADMIN_EMAIL,
|
||||
UserStore,
|
||||
allowed_modules,
|
||||
has_module,
|
||||
is_admin,
|
||||
)
|
||||
|
||||
# 권한 키 한글 라벨 (admin.html / 사이드바 공용)
|
||||
MODULE_LABELS: dict[str, str] = {
|
||||
"corm": "CORM",
|
||||
"order": "Order",
|
||||
"expense": "개인경비",
|
||||
"vacation": "휴가",
|
||||
"cupang": "쿠팡 밀크런",
|
||||
"expense_approver": "개인경비",
|
||||
"vacation_approver": "휴가",
|
||||
}
|
||||
|
||||
# OMS(orderlist) 와 공유하는 세션 키. SessionMiddleware 의 session_cookie 도 동일 이름.
|
||||
SESSION_COOKIE_DEFAULT = "session"
|
||||
|
||||
@@ -20,14 +49,17 @@ load_dotenv()
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ALLOWED_DOMAIN = "dbxcorp.co.kr"
|
||||
|
||||
# ALLOWED_EMAILS 는 운영 시 .env 의 ALLOWED_EMAILS (쉼표 구분)로 덮어쓸 수 있다.
|
||||
# env 가 비어 있으면 아래 기본 목록을 그대로 사용 — 기존 운영 호환.
|
||||
DEFAULT_ALLOWED_EMAILS = (
|
||||
"king@dbxcorp.co.kr",
|
||||
"julie@dbxcorp.co.kr",
|
||||
"ellen@dbxcorp.co.kr",
|
||||
"bj@dbxcorp.co.kr",
|
||||
)
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""사용자/권한 JSON 저장소 위치. 컨테이너 재배포에도 살아남도록
|
||||
DATA_DIR 환경변수로 마운트된 볼륨을 가리킬 수 있다."""
|
||||
override = os.getenv("DATA_DIR", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
return BASE_DIR / "data"
|
||||
|
||||
|
||||
DATA_DIR = _data_dir()
|
||||
|
||||
|
||||
def env(name: str, default: str = "") -> str:
|
||||
@@ -35,14 +67,6 @@ def env(name: str, default: str = "") -> str:
|
||||
return value if value else default
|
||||
|
||||
|
||||
def _parse_email_list(raw: str) -> set[str]:
|
||||
return {e.strip().lower() for e in raw.split(",") if e.strip()}
|
||||
|
||||
|
||||
_env_emails = _parse_email_list(env("ALLOWED_EMAILS", ""))
|
||||
ALLOWED_EMAILS: set[str] = _env_emails or _parse_email_list(",".join(DEFAULT_ALLOWED_EMAILS))
|
||||
|
||||
|
||||
def _require_session_secret() -> str:
|
||||
secret = env("SESSION_SECRET_KEY", "")
|
||||
if not secret:
|
||||
@@ -78,8 +102,44 @@ app.add_middleware(
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||
|
||||
# 모듈별 templates 디렉토리를 추가로 검색하도록 ChoiceLoader 설정.
|
||||
# 신규 모듈 추가 시 아래 리스트에 `BASE_DIR / "modules" / "<name>" / "templates"` 만 추가.
|
||||
_MODULE_TEMPLATE_DIRS = [
|
||||
BASE_DIR / "modules" / "expense" / "templates",
|
||||
BASE_DIR / "modules" / "cupang" / "templates",
|
||||
BASE_DIR / "modules" / "vacation" / "templates",
|
||||
]
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
templates.env.loader = ChoiceLoader(
|
||||
[
|
||||
FileSystemLoader(str(BASE_DIR / "templates")),
|
||||
*[FileSystemLoader(str(p)) for p in _MODULE_TEMPLATE_DIRS if p.exists()],
|
||||
]
|
||||
)
|
||||
|
||||
oauth = build_google_oauth()
|
||||
user_store = UserStore(DATA_DIR / "users.json")
|
||||
|
||||
# 모듈별 데이터 저장소.
|
||||
# EXPENSE_DB_URL 가 있으면 expense_db(PostgreSQL), 없으면 JSON 파일.
|
||||
app.state.data_dir = DATA_DIR # 모듈에서 첨부 저장 경로 등으로 참조
|
||||
app.state.expense_store = build_expense_store(
|
||||
dsn=env("EXPENSE_DB_URL") or None,
|
||||
json_path=DATA_DIR / "expense.json",
|
||||
)
|
||||
# 분류(category) 설정 — 관리자가 추가/삭제, 저장 즉시 반영. 항상 JSON 파일.
|
||||
app.state.expense_category_store = CategoryStore(DATA_DIR / "expense_categories.json")
|
||||
# 쿠팡 밀크런: CUPANG_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||
# 상품 검색은 itemcode_db 읽기 전용(미설정 시 수동 입력 폴백).
|
||||
app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None)
|
||||
app.state.itemcode_reader = build_itemcode_reader()
|
||||
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None)
|
||||
|
||||
# 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄.
|
||||
app.include_router(expense_router)
|
||||
app.include_router(cupang_router)
|
||||
app.include_router(vacation_router)
|
||||
|
||||
|
||||
def public_url_for(request: Request, route_name: str) -> str:
|
||||
@@ -89,11 +149,11 @@ def public_url_for(request: Request, route_name: str) -> str:
|
||||
return str(request.url_for(route_name))
|
||||
|
||||
|
||||
def get_user(request: Request) -> dict[str, Any] | None:
|
||||
def get_session_user(request: Request) -> dict[str, Any] | None:
|
||||
"""세션에서 로그인 사용자(이메일/이름/사진) 추출. OMS SSO 호환."""
|
||||
user = request.session.get("user")
|
||||
if isinstance(user, dict):
|
||||
return user
|
||||
# OMS 가 top-level user_email 만 채워놓은 SSO 세션도 인정
|
||||
email = request.session.get("user_email")
|
||||
if email:
|
||||
return {
|
||||
@@ -104,6 +164,27 @@ def get_user(request: Request) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user_record(request: Request) -> dict[str, Any] | None:
|
||||
"""세션 + 저장소를 합쳐 권한이 포함된 사용자 레코드 반환."""
|
||||
sess = get_session_user(request)
|
||||
if not sess:
|
||||
return None
|
||||
rec = user_store.get(sess["email"])
|
||||
if rec is None:
|
||||
# 세션은 살아있지만 저장소에 없음 — 세션 무효화
|
||||
return None
|
||||
return rec
|
||||
|
||||
|
||||
def require_admin(request: Request) -> dict[str, Any]:
|
||||
rec = get_current_user_record(request)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not is_admin(rec):
|
||||
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
|
||||
return rec
|
||||
|
||||
|
||||
def safe_next(raw: str | None) -> str:
|
||||
"""Open redirect 방지: 같은 호스트의 절대 경로만 허용."""
|
||||
if not raw:
|
||||
@@ -144,35 +225,182 @@ def is_allowed_google_user(userinfo: dict[str, Any]) -> tuple[bool, str]:
|
||||
return False, "Google 계정 이메일 인증이 확인되지 않았습니다."
|
||||
if domain != ALLOWED_DOMAIN:
|
||||
return False, "회사 Google Workspace 계정만 접속할 수 있습니다."
|
||||
if email not in ALLOWED_EMAILS:
|
||||
return False, "접속 허용 목록에 없는 계정입니다."
|
||||
return True, ""
|
||||
|
||||
|
||||
# ── ERP 메뉴 정의 ──────────────────────────────────────────────
|
||||
# 각 항목: key(권한키), title, description, url(env override), status(ready|preparing), category
|
||||
def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
items = [
|
||||
{
|
||||
"key": "corm",
|
||||
"title": "CORM",
|
||||
"subtitle": "CS · 발주 · 반품 · 코드관리",
|
||||
"description": "고객 응대와 발주/반품, 코드 관리 업무를 한 곳에서 처리합니다.",
|
||||
"url": env("CS_ORDER_URL", "/corm/"),
|
||||
"health_url": "/corm/health/db",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "order",
|
||||
"title": "Order",
|
||||
"subtitle": "고객 주문 데이터베이스",
|
||||
"description": "고객 주문 내역을 조회·검색하고 관련 데이터를 관리합니다.",
|
||||
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
|
||||
"health_url": "/orderlist/health/db",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "expense",
|
||||
"title": "개인경비",
|
||||
"subtitle": "Personal Expense",
|
||||
"description": "법인카드/개인경비 사용 내역을 등록·증빙하고 정산을 신청합니다.",
|
||||
"url": "/expense/",
|
||||
"health_url": "/expense/health",
|
||||
"status": "ready",
|
||||
"category": "관리",
|
||||
},
|
||||
{
|
||||
"key": "cupang",
|
||||
"title": "쿠팡 밀크런",
|
||||
"subtitle": "Coupang Milk-run",
|
||||
"description": "쿠팡 밀크런 출고 일정·박스 계산·입고센터를 달력에서 관리합니다.",
|
||||
"url": "/cupang/",
|
||||
"health_url": "/cupang/health",
|
||||
"status": "ready",
|
||||
"category": "운영",
|
||||
},
|
||||
{
|
||||
"key": "vacation",
|
||||
"title": "휴가",
|
||||
"subtitle": "Vacation",
|
||||
"description": "연차/반차/특별휴가 신청과 잔여일수, 결재 현황을 달력에서 관리합니다.",
|
||||
"url": "/vacation/",
|
||||
"health_url": "/vacation/health",
|
||||
"status": "ready",
|
||||
"category": "관리",
|
||||
},
|
||||
]
|
||||
allowed = allowed_modules(user_rec)
|
||||
for item in items:
|
||||
item["allowed"] = item["key"] in allowed
|
||||
return items
|
||||
|
||||
|
||||
def _icon_svg(name: str) -> str:
|
||||
"""좌측 사이드바용 인라인 아이콘. 외부 의존 없는 작은 SVG."""
|
||||
paths = {
|
||||
"home": '<path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/>',
|
||||
"expense": '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><path d="M7 15h4"/>',
|
||||
"vacation": '<path d="M8 2v4"/><path d="M16 2v4"/><rect x="3" y="6" width="18" height="15" rx="2"/><path d="M3 11h18"/>',
|
||||
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
||||
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
||||
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
|
||||
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||
}
|
||||
body = paths.get(name, paths["modules"])
|
||||
return (
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" '
|
||||
'stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
||||
f"{body}</svg>"
|
||||
)
|
||||
|
||||
|
||||
def build_erp_nav(
|
||||
user_rec: dict[str, Any], active: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""좌측 사이드바 메뉴. 모듈 정의(_menu_items_for)와 동기화한다.
|
||||
|
||||
각 항목: key, label, group, url, target, icon, active, disabled, disabled_reason.
|
||||
"""
|
||||
items: list[dict[str, Any]] = [
|
||||
{
|
||||
"key": "home",
|
||||
"label": "홈",
|
||||
"group": "ERP",
|
||||
"url": "/",
|
||||
"target": "_self",
|
||||
"icon": _icon_svg("home"),
|
||||
},
|
||||
]
|
||||
for m in _menu_items_for(user_rec):
|
||||
if not m["allowed"]:
|
||||
continue
|
||||
target = "_blank" if m["url"].startswith("http") else "_self"
|
||||
items.append(
|
||||
{
|
||||
"key": m["key"],
|
||||
"label": m["title"],
|
||||
"group": m["category"],
|
||||
"url": m["url"] if m["status"] == "ready" else "#",
|
||||
"target": target,
|
||||
"icon": _icon_svg(m["key"]),
|
||||
"disabled": m["status"] != "ready",
|
||||
"disabled_reason": "준비중" if m["status"] != "ready" else None,
|
||||
}
|
||||
)
|
||||
# 같은 그룹끼리 묶이도록 정렬(그룹 헤더 중복 방지). 그룹 내 순서는 유지(stable).
|
||||
group_order = {"ERP": 0, "운영": 1, "관리": 2}
|
||||
items.sort(key=lambda it: group_order.get(it["group"], 9))
|
||||
for it in items:
|
||||
it["active"] = it["key"] == active
|
||||
return items
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
user = get_user(request)
|
||||
if not user:
|
||||
sess = get_session_user(request)
|
||||
if not sess:
|
||||
return render_template(request, "login.html")
|
||||
user_rec = user_store.get(sess["email"])
|
||||
if user_rec is None:
|
||||
# 도메인은 통과했으나 저장소에 없음 — 세션 정리 후 재로그인
|
||||
request.session.clear()
|
||||
return render_template(request, "login.html")
|
||||
|
||||
menu_items = [
|
||||
# 슈퍼 관리자 → 업무 모듈 선택 화면(main.html).
|
||||
# 일반 사용자 → ERP 메인(좌측 메뉴 + 우측 콘텐츠).
|
||||
if user_rec.get("is_super_admin"):
|
||||
menu_items = _menu_items_for(user_rec)
|
||||
return render_template(
|
||||
request,
|
||||
"main.html",
|
||||
{
|
||||
"user": user_rec,
|
||||
"menu_items": menu_items,
|
||||
"is_admin": is_admin(user_rec),
|
||||
},
|
||||
)
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"erp_home.html",
|
||||
{
|
||||
"title": "CS, 발주, 반품, 코드관리",
|
||||
"description": "CS, 발주, 반품, 코드관리 페이지로 이동",
|
||||
"url": env("CS_ORDER_URL", "/corm/"),
|
||||
"health_url": "/corm/health/db",
|
||||
"user": user_rec,
|
||||
"is_admin": is_admin(user_rec),
|
||||
"nav_items": build_erp_nav(user_rec, active="home"),
|
||||
"page_title": "ERP 홈",
|
||||
"page_subtitle": "오늘의 업무를 시작하세요.",
|
||||
},
|
||||
{
|
||||
"title": "고객 주문 데이터베이스",
|
||||
"description": "고객 주문 데이터베이스 페이지로 이동",
|
||||
"url": env("CUSTOMER_ORDER_LIST_URL", "/orderlist/"),
|
||||
"health_url": "/orderlist/health/db",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@app.get("/modules", response_class=HTMLResponse)
|
||||
async def modules_page(request: Request) -> HTMLResponse:
|
||||
"""업무 모듈 선택 화면. 슈퍼 관리자가 사이드바에서 다시 진입할 때 사용."""
|
||||
user_rec = get_current_user_record(request)
|
||||
if user_rec is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
return render_template(
|
||||
request,
|
||||
"main.html",
|
||||
{"user": user, "menu_items": menu_items},
|
||||
{
|
||||
"user": user_rec,
|
||||
"menu_items": _menu_items_for(user_rec),
|
||||
"is_admin": is_admin(user_rec),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +414,6 @@ async def login(request: Request):
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# SSO: ?next= 로 들어온 목적지를 OAuth 콜백 뒤에 사용하기 위해 세션에 임시 보관
|
||||
request.session["_post_login_next"] = safe_next(request.query_params.get("next"))
|
||||
|
||||
redirect_uri = public_url_for(request, "auth_google")
|
||||
@@ -226,11 +453,14 @@ async def auth_google(request: Request):
|
||||
email = str(userinfo.get("email", "")).lower().strip()
|
||||
name = userinfo.get("name") or email
|
||||
picture = userinfo.get("picture", "") or ""
|
||||
|
||||
# 저장소에 사용자 등록/갱신 (신규는 권한 0 — 관리자가 부여)
|
||||
user_store.upsert_login(email=email, name=name, picture=picture)
|
||||
|
||||
# OMS 와 공유하는 top-level 키 (SSO 계약)
|
||||
request.session["user_email"] = email
|
||||
request.session["user_name"] = name
|
||||
request.session["user_picture"] = picture
|
||||
# 기존 코드/템플릿 호환용 dict
|
||||
request.session["user"] = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
@@ -249,3 +479,98 @@ async def logout(request: Request) -> RedirectResponse:
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── 관리자 페이지 ──────────────────────────────────────────────
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_page(request: Request) -> HTMLResponse:
|
||||
rec = get_current_user_record(request)
|
||||
if rec is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not is_admin(rec):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "관리자만 접근할 수 있는 페이지입니다."},
|
||||
status_code=403,
|
||||
)
|
||||
users = sorted(user_store.list_all(), key=lambda u: (u["email"] != SUPER_ADMIN_EMAIL, u["email"]))
|
||||
return render_template(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
"user": rec,
|
||||
"users": users,
|
||||
"module_keys": list(MODULE_KEYS),
|
||||
"module_labels": MODULE_LABELS,
|
||||
"approver_keys": list(APPROVER_KEYS),
|
||||
"super_admin_email": SUPER_ADMIN_EMAIL,
|
||||
"is_admin": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── 관리자 API ──────────────────────────────────────────────
|
||||
class UpdatePermissionsBody(BaseModel):
|
||||
role: str | None = None
|
||||
modules: dict[str, bool] | None = None
|
||||
|
||||
|
||||
class CreateUserBody(BaseModel):
|
||||
email: str
|
||||
name: str = ""
|
||||
role: str = "user"
|
||||
modules: dict[str, bool] | None = None
|
||||
|
||||
|
||||
@app.get("/api/users")
|
||||
async def api_list_users(_: dict[str, Any] = Depends(require_admin)) -> JSONResponse:
|
||||
return JSONResponse({"users": user_store.list_all()})
|
||||
|
||||
|
||||
@app.post("/api/users")
|
||||
async def api_create_user(
|
||||
body: CreateUserBody,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
rec = user_store.create_user(
|
||||
email=body.email,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
modules=body.modules,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"user": rec}, status_code=201)
|
||||
|
||||
|
||||
@app.put("/api/users/{email}")
|
||||
async def api_update_user(
|
||||
email: str,
|
||||
body: UpdatePermissionsBody,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
rec = user_store.update_permissions(
|
||||
email=email, role=body.role, modules=body.modules
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return JSONResponse({"user": rec})
|
||||
|
||||
|
||||
@app.delete("/api/users/{email}")
|
||||
async def api_delete_user(
|
||||
email: str,
|
||||
_: dict[str, Any] = Depends(require_admin),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
user_store.delete(email)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""쿠팡 밀크런(cupang) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/cupang)
|
||||
- 저장소: `db.py` (cupang_db / PostgreSQL 전용) + `store.py` (상수/계산)
|
||||
- 상품검색: `itemcode.py` (itemcode_db 읽기 전용)
|
||||
- 템플릿: `templates/cupang/`
|
||||
|
||||
데이터 저장은 cupang_db 전용이다. CUPANG_DB_URL 미설정 시 build_cupang_store 는
|
||||
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import DEFAULT_CENTERS, SHIP_METHODS, STATUSES, compute_boxes
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"STATUSES",
|
||||
"SHIP_METHODS",
|
||||
"DEFAULT_CENTERS",
|
||||
"compute_boxes",
|
||||
"build_cupang_store",
|
||||
"build_itemcode_reader",
|
||||
]
|
||||
|
||||
|
||||
def build_cupang_store(*, dsn: str | None) -> Any:
|
||||
"""CUPANG_DB_URL 이 있으면 CupangDBStore, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import CupangDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return CupangDBStore(dsn)
|
||||
|
||||
|
||||
def build_itemcode_reader() -> Any:
|
||||
"""itemcode_db 읽기 전용 상품 검색 리더. 설정 없으면 비활성(enabled=False)."""
|
||||
from .itemcode import ItemcodeReader # 지연 import
|
||||
|
||||
return ItemcodeReader()
|
||||
@@ -0,0 +1,626 @@
|
||||
"""cupang_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `CUPANG_DB_URL`
|
||||
(예: postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db)
|
||||
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||
`scripts/sql/cupang_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||
앱 계정(cupang_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
|
||||
박스 수 계산은 서버에서 `store.compute_boxes` 로 재계산하여 저장한다.
|
||||
클라이언트가 보낸 박스 수는 신뢰하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
from .store import STATUSES, compute_boxes
|
||||
|
||||
|
||||
class CupangDBStore:
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 입고센터 (cupang_centers)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_centers(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_centers {where} "
|
||||
"ORDER BY active DESC, sort_order ASC, name ASC"
|
||||
).fetchall()
|
||||
return [self._center_serialize(r) for r in rows]
|
||||
|
||||
def get_center(self, *, center_id: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cupang_centers WHERE id = %s", (center_id,)
|
||||
).fetchone()
|
||||
return self._center_serialize(row) if row else None
|
||||
|
||||
def create_center(self, *, name: str, sort_order: int = 0) -> dict[str, Any]:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("센터명 필수")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_centers (name, sort_order)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET active = TRUE, sort_order = EXCLUDED.sort_order
|
||||
RETURNING *
|
||||
""",
|
||||
(name, sort_order),
|
||||
).fetchone()
|
||||
return self._center_serialize(row)
|
||||
|
||||
def update_center(
|
||||
self,
|
||||
*,
|
||||
center_id: int,
|
||||
name: str | None = None,
|
||||
active: bool | None = None,
|
||||
sort_order: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
sets: list[str] = []
|
||||
params: list[Any] = []
|
||||
if name is not None:
|
||||
n = name.strip()
|
||||
if not n:
|
||||
raise ValueError("센터명은 비울 수 없습니다.")
|
||||
sets.append("name = %s")
|
||||
params.append(n)
|
||||
if active is not None:
|
||||
sets.append("active = %s")
|
||||
params.append(bool(active))
|
||||
if sort_order is not None:
|
||||
sets.append("sort_order = %s")
|
||||
params.append(int(sort_order))
|
||||
if not sets:
|
||||
raise ValueError("변경할 값이 없습니다.")
|
||||
params.append(center_id)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
f"UPDATE cupang_centers SET {', '.join(sets)} "
|
||||
"WHERE id = %s RETURNING *",
|
||||
params,
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(center_id)
|
||||
return self._center_serialize(row)
|
||||
|
||||
def center_in_use(self, *, center_id: int) -> bool:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM cupang_shipments WHERE center_id = %s LIMIT 1",
|
||||
(center_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def delete_center(self, *, center_id: int) -> dict[str, Any]:
|
||||
"""사용 중이면 hard delete 하지 않고 active=false 로 비활성화한다.
|
||||
|
||||
반환: {"deleted": bool, "deactivated": bool}
|
||||
"""
|
||||
if self.center_in_use(center_id=center_id):
|
||||
self.update_center(center_id=center_id, active=False)
|
||||
return {"deleted": False, "deactivated": True}
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_centers WHERE id = %s", (center_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(center_id)
|
||||
return {"deleted": True, "deactivated": False}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 박스 입수량 규칙 (cupang_box_rules)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_box_rules(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_box_rules {where} "
|
||||
"ORDER BY product_code ASC"
|
||||
).fetchall()
|
||||
return [self._rule_serialize(r) for r in rows]
|
||||
|
||||
def upsert_box_rule(
|
||||
self,
|
||||
*,
|
||||
product_code: str,
|
||||
units_per_box: int,
|
||||
product_name_snapshot: str = "",
|
||||
box_name: str = "쿠팡박스",
|
||||
memo: str = "",
|
||||
) -> dict[str, Any]:
|
||||
code = (product_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("제품코드 필수")
|
||||
upb = int(units_per_box)
|
||||
if upb <= 0:
|
||||
raise ValueError("박스당 입수량은 1 이상이어야 합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_box_rules
|
||||
(product_code, product_name_snapshot, box_name, units_per_box, memo)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (product_code) DO UPDATE
|
||||
SET product_name_snapshot = EXCLUDED.product_name_snapshot,
|
||||
box_name = EXCLUDED.box_name,
|
||||
units_per_box = EXCLUDED.units_per_box,
|
||||
memo = EXCLUDED.memo,
|
||||
active = TRUE
|
||||
RETURNING *
|
||||
""",
|
||||
(code, product_name_snapshot.strip(), (box_name or "쿠팡박스").strip(), upb, memo.strip()),
|
||||
).fetchone()
|
||||
return self._rule_serialize(row)
|
||||
|
||||
def delete_box_rule(self, *, rule_id: int) -> None:
|
||||
"""완전 삭제(hard). 라인의 box_rule_id 는 ON DELETE 미설정이므로
|
||||
참조 중이면 FK 위반 가능 → 참조 라인의 box_rule_id 를 먼저 NULL 처리."""
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
conn.execute(
|
||||
"UPDATE cupang_shipment_lines SET box_rule_id = NULL WHERE box_rule_id = %s",
|
||||
(rule_id,),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_box_rules WHERE id = %s", (rule_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(rule_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 제품명 카탈로그 (cupang_products)
|
||||
# itemcode_db 에서 가져와 등록한 제품 목록. 폼의 제품명 드롭다운 소스.
|
||||
# 제품명 선택 → product_code 자동 채움.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_products(self, *, include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
where = "" if include_inactive else "WHERE active = TRUE"
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_products {where} "
|
||||
"ORDER BY product_code ASC"
|
||||
).fetchall()
|
||||
return [self._product_serialize(r) for r in rows]
|
||||
|
||||
def upsert_product(
|
||||
self, *, product_code: str, product_name: str, sort_order: int = 0
|
||||
) -> dict[str, Any]:
|
||||
code = (product_code or "").strip()
|
||||
name = (product_name or "").strip()
|
||||
if not code or not name:
|
||||
raise ValueError("제품코드와 제품명 모두 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_products (product_code, product_name, sort_order)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (product_code) DO UPDATE
|
||||
SET product_name = EXCLUDED.product_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
active = TRUE
|
||||
RETURNING *
|
||||
""",
|
||||
(code, name, sort_order),
|
||||
).fetchone()
|
||||
return self._product_serialize(row)
|
||||
|
||||
def set_product_active(self, *, product_id: int, active: bool) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE cupang_products SET active = %s WHERE id = %s",
|
||||
(bool(active), product_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(product_id)
|
||||
|
||||
def delete_product(self, *, product_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_products WHERE id = %s", (product_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(product_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 출고 묶음 (cupang_shipments + cupang_shipment_lines)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_shipments(
|
||||
self,
|
||||
*,
|
||||
year: int | None = None,
|
||||
month: int | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""헤더 목록(라인 제외). 달력/리스트 표시에 사용.
|
||||
|
||||
year+month 가 주어지면 작성일/출고일/센터입고일 중 하나라도 해당 월에
|
||||
걸치는 묶음을 모두 포함한다(달력 표시용).
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if year and month:
|
||||
clauses.append(
|
||||
"(date_trunc('month', document_date) = make_date(%s, %s, 1)"
|
||||
" OR date_trunc('month', ship_date) = make_date(%s, %s, 1)"
|
||||
" OR date_trunc('month', center_arrival_date) = make_date(%s, %s, 1))"
|
||||
)
|
||||
params.extend([year, month, year, month, year, month])
|
||||
if date_from:
|
||||
clauses.append("ship_date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("ship_date <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM cupang_shipments {where} "
|
||||
"ORDER BY ship_date ASC, id ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._shipment_serialize(r) for r in rows]
|
||||
|
||||
def get_shipment(self, *, shipment_id: int, with_lines: bool = True) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM cupang_shipments WHERE id = %s", (shipment_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
head = self._shipment_serialize(row)
|
||||
if with_lines:
|
||||
line_rows = conn.execute(
|
||||
"SELECT * FROM cupang_shipment_lines WHERE shipment_id = %s "
|
||||
"ORDER BY line_no ASC",
|
||||
(shipment_id,),
|
||||
).fetchall()
|
||||
head["lines"] = [self._line_serialize(r) for r in line_rows]
|
||||
return head
|
||||
|
||||
def create_shipment(
|
||||
self, *, created_by: str, header: dict[str, Any], lines: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_header(header)
|
||||
norm_lines = self._normalize_lines(lines)
|
||||
if not norm_lines:
|
||||
raise ValueError("품목 라인이 최소 1개 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_shipments
|
||||
(created_by, document_date, ship_date,
|
||||
center_arrival_date, center_id, center_name_snapshot,
|
||||
ship_method, outbound_summary, worker, status, memo)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
created_by.lower().strip(),
|
||||
h["document_date"],
|
||||
h["ship_date"],
|
||||
h["center_arrival_date"],
|
||||
h["center_id"],
|
||||
h["center_name_snapshot"],
|
||||
h["ship_method"],
|
||||
h["outbound_summary"],
|
||||
h["worker"],
|
||||
h["status"],
|
||||
h["memo"],
|
||||
),
|
||||
).fetchone()
|
||||
shipment_id = row["id"]
|
||||
self._insert_lines(conn, shipment_id, norm_lines)
|
||||
return self.get_shipment(shipment_id=shipment_id)
|
||||
|
||||
def update_shipment(
|
||||
self, *, shipment_id: int, header: dict[str, Any], lines: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_header(header)
|
||||
norm_lines = self._normalize_lines(lines)
|
||||
if not norm_lines:
|
||||
raise ValueError("품목 라인이 최소 1개 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
with conn.transaction():
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE cupang_shipments
|
||||
SET document_date = %s, ship_date = %s,
|
||||
center_arrival_date = %s, center_id = %s,
|
||||
center_name_snapshot = %s, ship_method = %s,
|
||||
outbound_summary = %s, worker = %s, memo = %s
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
h["document_date"],
|
||||
h["ship_date"],
|
||||
h["center_arrival_date"],
|
||||
h["center_id"],
|
||||
h["center_name_snapshot"],
|
||||
h["ship_method"],
|
||||
h["outbound_summary"],
|
||||
h["worker"],
|
||||
h["memo"],
|
||||
shipment_id,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(shipment_id)
|
||||
# 라인 전체 교체 (CASCADE 아님 — 명시적 삭제 후 재삽입)
|
||||
conn.execute(
|
||||
"DELETE FROM cupang_shipment_lines WHERE shipment_id = %s",
|
||||
(shipment_id,),
|
||||
)
|
||||
self._insert_lines(conn, shipment_id, norm_lines)
|
||||
return self.get_shipment(shipment_id=shipment_id)
|
||||
|
||||
def set_status(self, *, shipment_id: int, status: str) -> dict[str, Any]:
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"허용되지 않는 상태: {status}")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"UPDATE cupang_shipments SET status = %s WHERE id = %s RETURNING *",
|
||||
(status, shipment_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(shipment_id)
|
||||
return self._shipment_serialize(row)
|
||||
|
||||
def soft_delete(self, *, shipment_id: int) -> dict[str, Any]:
|
||||
"""운영 안전을 위한 기본 삭제 — status='취소'."""
|
||||
return self.set_status(shipment_id=shipment_id, status="취소")
|
||||
|
||||
def hard_delete(self, *, shipment_id: int) -> None:
|
||||
"""완전 삭제(라인은 ON DELETE CASCADE). 취소 처리로 충분하므로 기본 미사용."""
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM cupang_shipments WHERE id = %s", (shipment_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(shipment_id)
|
||||
|
||||
def _insert_lines(self, conn: Any, shipment_id: int, lines: list[dict[str, Any]]) -> None:
|
||||
for idx, ln in enumerate(lines, start=1):
|
||||
calc = compute_boxes(ln["quantity"], ln.get("units_per_box"))
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cupang_shipment_lines
|
||||
(shipment_id, line_no, product_code, product_name_snapshot,
|
||||
quantity, box_rule_id, units_per_box, calculated_boxes,
|
||||
remainder_units, manual_box_text, memo)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
shipment_id,
|
||||
idx,
|
||||
ln["product_code"],
|
||||
ln["product_name_snapshot"],
|
||||
ln["quantity"],
|
||||
ln.get("box_rule_id"),
|
||||
calc["units_per_box"],
|
||||
calc["required_boxes"],
|
||||
calc["remainder_units"],
|
||||
ln.get("manual_box_text", ""),
|
||||
ln.get("memo", ""),
|
||||
),
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 달력 집계
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def calendar_counts(self, *, year: int, month: int) -> dict[str, dict[str, int]]:
|
||||
"""해당 월의 날짜별 작성/출고/입고 건수.
|
||||
|
||||
반환: { "YYYY-MM-DD": {"document": n, "ship": n, "arrival": n} }
|
||||
취소 상태는 제외한다.
|
||||
"""
|
||||
first = (year, month)
|
||||
out: dict[str, dict[str, int]] = {}
|
||||
|
||||
def _accumulate(rows: list[dict[str, Any]], key: str) -> None:
|
||||
for r in rows:
|
||||
d = r["d"]
|
||||
ds = d.isoformat() if isinstance(d, date) else str(d)
|
||||
out.setdefault(ds, {"document": 0, "ship": 0, "arrival": 0})
|
||||
out[ds][key] = int(r["c"])
|
||||
|
||||
with self._pool.connection() as conn:
|
||||
doc = conn.execute(
|
||||
"SELECT document_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||
"WHERE status <> '취소' AND date_trunc('month', document_date) = make_date(%s,%s,1) "
|
||||
"GROUP BY 1",
|
||||
first,
|
||||
).fetchall()
|
||||
ship = conn.execute(
|
||||
"SELECT ship_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||
"WHERE status <> '취소' AND date_trunc('month', ship_date) = make_date(%s,%s,1) "
|
||||
"GROUP BY 1",
|
||||
first,
|
||||
).fetchall()
|
||||
arr = conn.execute(
|
||||
"SELECT center_arrival_date AS d, COUNT(*) AS c FROM cupang_shipments "
|
||||
"WHERE status <> '취소' AND date_trunc('month', center_arrival_date) = make_date(%s,%s,1) "
|
||||
"GROUP BY 1",
|
||||
first,
|
||||
).fetchall()
|
||||
_accumulate(doc, "document")
|
||||
_accumulate(ship, "ship")
|
||||
_accumulate(arr, "arrival")
|
||||
return out
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 정규화 / 직렬화 helpers
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@staticmethod
|
||||
def _normalize_header(header: dict[str, Any]) -> dict[str, Any]:
|
||||
def _d(key: str) -> str:
|
||||
v = str(header.get(key) or "").strip()
|
||||
return v
|
||||
|
||||
document_date = _d("document_date")
|
||||
ship_date = _d("ship_date")
|
||||
center_arrival_date = _d("center_arrival_date")
|
||||
if not document_date or not ship_date or not center_arrival_date:
|
||||
raise ValueError("작성일/출고일/센터입고일은 필수입니다.")
|
||||
|
||||
status = str(header.get("status") or "작성중").strip()
|
||||
if status not in STATUSES:
|
||||
status = "작성중"
|
||||
|
||||
center_id_raw = header.get("center_id")
|
||||
try:
|
||||
center_id = int(center_id_raw) if center_id_raw not in (None, "", "0") else None
|
||||
except (TypeError, ValueError):
|
||||
center_id = None
|
||||
|
||||
return {
|
||||
"document_date": document_date,
|
||||
"ship_date": ship_date,
|
||||
"center_arrival_date": center_arrival_date,
|
||||
"center_id": center_id,
|
||||
"center_name_snapshot": str(header.get("center_name_snapshot") or "").strip(),
|
||||
"ship_method": str(header.get("ship_method") or "택배").strip() or "택배",
|
||||
"outbound_summary": str(header.get("outbound_summary") or "").strip(),
|
||||
"worker": str(header.get("worker") or "").strip(),
|
||||
"status": status,
|
||||
"memo": str(header.get("memo") or "").strip(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw in lines or []:
|
||||
code = str(raw.get("product_code") or "").strip()
|
||||
name = str(raw.get("product_name_snapshot") or raw.get("product_name") or "").strip()
|
||||
try:
|
||||
qty = int(raw.get("quantity") or 0)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
if not code or qty <= 0:
|
||||
continue # 빈 라인 스킵
|
||||
upb_raw = raw.get("units_per_box")
|
||||
try:
|
||||
upb = int(upb_raw) if upb_raw not in (None, "", "0") else None
|
||||
except (TypeError, ValueError):
|
||||
upb = None
|
||||
rule_id_raw = raw.get("box_rule_id")
|
||||
try:
|
||||
rule_id = int(rule_id_raw) if rule_id_raw not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
rule_id = None
|
||||
out.append(
|
||||
{
|
||||
"product_code": code,
|
||||
"product_name_snapshot": name or code,
|
||||
"quantity": qty,
|
||||
"units_per_box": upb,
|
||||
"box_rule_id": rule_id,
|
||||
"manual_box_text": str(raw.get("manual_box_text") or "").strip(),
|
||||
"memo": str(raw.get("memo") or "").strip(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _center_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["active"] = bool(out.get("active", True))
|
||||
out["sort_order"] = int(out.get("sort_order", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _rule_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["units_per_box"] = int(out.get("units_per_box", 0))
|
||||
out["active"] = bool(out.get("active", True))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _product_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["active"] = bool(out.get("active", True))
|
||||
out["sort_order"] = int(out.get("sort_order", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _shipment_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["center_id"] = int(out["center_id"]) if out.get("center_id") is not None else None
|
||||
for k in ("document_date", "ship_date", "center_arrival_date"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _line_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["line_no"] = int(out.get("line_no", 0))
|
||||
out["quantity"] = int(out.get("quantity", 0))
|
||||
for k in ("box_rule_id", "units_per_box", "calculated_boxes", "remainder_units"):
|
||||
v = out.get(k)
|
||||
out[k] = int(v) if v is not None else None
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
@@ -0,0 +1,62 @@
|
||||
"""대한민국 공휴일 판정 (달력 색상용).
|
||||
|
||||
- 고정 양력 공휴일은 매년 동일 → 연도 무관 판정.
|
||||
- 음력 공휴일(설날/부처님오신날/추석)과 대체공휴일은 매년 달라짐 →
|
||||
연도별 dict(`_LUNAR_AND_SUBSTITUTE`)에 명시. 새 연도는 KASI 발표값을 추가한다.
|
||||
|
||||
미수록 연도는 고정 양력 공휴일만 빨강 처리된다(음력/대체는 누락).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
# 매년 동일한 양력 공휴일 (month, day)
|
||||
_FIXED_SOLAR: set[tuple[int, int]] = {
|
||||
(1, 1), # 신정
|
||||
(3, 1), # 삼일절
|
||||
(5, 5), # 어린이날
|
||||
(6, 6), # 현충일
|
||||
(8, 15), # 광복절
|
||||
(10, 3), # 개천절
|
||||
(10, 9), # 한글날
|
||||
(12, 25), # 성탄절
|
||||
}
|
||||
|
||||
# 연도별 음력 공휴일 + 대체공휴일 (ISO 날짜 문자열). KASI 발표 기준.
|
||||
_LUNAR_AND_SUBSTITUTE: dict[int, set[str]] = {
|
||||
2025: {
|
||||
"2025-01-28", "2025-01-29", "2025-01-30", # 설날 연휴
|
||||
"2025-03-03", # 삼일절 대체(3/1 토)
|
||||
"2025-05-06", # 부처님오신날 대체(5/5 겹침)
|
||||
"2025-05-05", # 부처님오신날(어린이날과 동일일)
|
||||
"2025-10-06", "2025-10-07", "2025-10-08", # 추석 연휴
|
||||
"2025-10-08", # 추석 대체 가능
|
||||
},
|
||||
2026: {
|
||||
"2026-02-16", "2026-02-17", "2026-02-18", # 설날 연휴 (설날 2/17)
|
||||
"2026-03-02", # 삼일절 대체 (3/1 일)
|
||||
"2026-05-24", # 부처님오신날 (일)
|
||||
"2026-05-25", # 부처님오신날 대체
|
||||
"2026-08-17", # 광복절 대체 (8/15 토)
|
||||
"2026-09-24", "2026-09-25", "2026-09-26", # 추석 연휴 (추석 9/25)
|
||||
"2026-09-28", # 추석 대체 (9/26 토)
|
||||
"2026-10-05", # 개천절 대체 (10/3 토)
|
||||
},
|
||||
2027: {
|
||||
"2027-02-06", "2027-02-07", "2027-02-08", # 설날 연휴 (설날 2/7)
|
||||
"2027-02-09", # 설날 대체 (2/7 일)
|
||||
"2027-05-13", # 부처님오신날 (목)
|
||||
"2027-08-16", # 광복절 대체 (8/15 일)
|
||||
"2027-09-14", "2027-09-15", "2027-09-16", # 추석 연휴 (추석 9/15)
|
||||
"2027-10-04", # 개천절 대체 (10/3 일)
|
||||
"2027-10-11", # 한글날 대체 (10/9 토)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_holiday(d: date) -> bool:
|
||||
"""공휴일(일요일 제외)이면 True. 일/토 색상은 요일로 따로 판정한다."""
|
||||
if (d.month, d.day) in _FIXED_SOLAR:
|
||||
return True
|
||||
return d.isoformat() in _LUNAR_AND_SUBSTITUTE.get(d.year, set())
|
||||
@@ -0,0 +1,155 @@
|
||||
r"""itemcode_db 읽기 전용 상품 검색.
|
||||
|
||||
cupang 모듈은 itemcode_db 의 상품(낱개코드/세트코드)을 **읽기만** 한다.
|
||||
cupang_db 에 상품을 복제 저장하지 않는다. 출고 라인에는 product_code 와
|
||||
product_name_snapshot 만 보존한다.
|
||||
|
||||
⚠️ itemcode_db 의 실제 테이블/컬럼명은 이 저장소(main-app)에 정의되어 있지 않다.
|
||||
운영 서버에서 다음으로 먼저 구조를 확인한 뒤 환경변수를 설정해야 한다:
|
||||
|
||||
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\dt"
|
||||
docker exec -it postgres-db psql -U postgres -d itemcode_db -c "\d <테이블명>"
|
||||
|
||||
환경변수 (모두 미설정 시 검색 비활성 → 폼에서 수동 입력으로 폴백):
|
||||
|
||||
ITEMCODE_DB_URL 읽기 전용 DSN. 예: postgresql://itemcode_ro:<pwd>@postgres-db:5432/itemcode_db
|
||||
ITEMCODE_SEARCH_SQL (선택) 검색 SQL 직접 지정. 아래 자동 생성 대신 사용.
|
||||
반드시 code, name, type 컬럼을 별칭으로 반환하고,
|
||||
%(q)s 파라미터를 LIKE 패턴으로 받는다.
|
||||
|
||||
자동 생성용 (ITEMCODE_SEARCH_SQL 미설정 시):
|
||||
ITEMCODE_TABLE 검색 대상 테이블/뷰 (예: products 또는 item_master)
|
||||
ITEMCODE_CODE_COL 코드 컬럼명 (기본: product_code)
|
||||
ITEMCODE_NAME_COL 상품명 컬럼명 (기본: product_name)
|
||||
ITEMCODE_TYPE_COL (선택) 단품/세트 구분 컬럼명. 없으면 type 은 빈 문자열.
|
||||
|
||||
낱개코드와 세트코드가 별도 테이블이면 ITEMCODE_SEARCH_SQL 에 UNION 으로 직접 작성한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("cupang.itemcode")
|
||||
|
||||
# 안전한 SQL 식별자(테이블/컬럼)만 허용 — 인젝션 방지.
|
||||
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
|
||||
|
||||
|
||||
def _ident(value: str, *, what: str) -> str:
|
||||
v = (value or "").strip()
|
||||
if not _IDENT_RE.match(v):
|
||||
raise ValueError(f"안전하지 않은 {what} 식별자: {value!r}")
|
||||
return v
|
||||
|
||||
|
||||
class ItemcodeReader:
|
||||
"""itemcode_db 읽기 전용 커넥션 풀 + 상품 검색.
|
||||
|
||||
설정이 없거나 불완전하면 `enabled=False` 로 두고, search()는 빈 리스트를 반환한다.
|
||||
앱 부팅이나 cupang 모듈 진입을 막지 않는다.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pool: Any = None
|
||||
self._sql: str | None = None
|
||||
self.enabled = False
|
||||
self.reason = ""
|
||||
self.last_error = "" # 마지막 조회 오류(진단용, 비밀값 없음)
|
||||
self._configure()
|
||||
|
||||
def _configure(self) -> None:
|
||||
dsn = os.getenv("ITEMCODE_DB_URL", "").strip()
|
||||
if not dsn:
|
||||
self.reason = "ITEMCODE_DB_URL 미설정 — 상품 검색 비활성(수동 입력 사용)."
|
||||
return
|
||||
|
||||
custom_sql = os.getenv("ITEMCODE_SEARCH_SQL", "").strip()
|
||||
if custom_sql:
|
||||
self._sql = custom_sql
|
||||
else:
|
||||
table = os.getenv("ITEMCODE_TABLE", "").strip()
|
||||
if not table:
|
||||
self.reason = (
|
||||
"ITEMCODE_TABLE(또는 ITEMCODE_SEARCH_SQL) 미설정 — "
|
||||
"상품 검색 비활성(수동 입력 사용)."
|
||||
)
|
||||
return
|
||||
try:
|
||||
table_id = _ident(table, what="테이블")
|
||||
code_col = _ident(os.getenv("ITEMCODE_CODE_COL", "product_code"), what="코드 컬럼")
|
||||
name_col = _ident(os.getenv("ITEMCODE_NAME_COL", "product_name"), what="상품명 컬럼")
|
||||
type_col_raw = os.getenv("ITEMCODE_TYPE_COL", "").strip()
|
||||
type_expr = _ident(type_col_raw, what="구분 컬럼") if type_col_raw else "''"
|
||||
except ValueError as exc:
|
||||
self.reason = f"itemcode 검색 설정 오류: {exc}"
|
||||
return
|
||||
self._sql = (
|
||||
f"SELECT {code_col} AS code, {name_col} AS name, {type_expr} AS type "
|
||||
f"FROM {table_id} "
|
||||
f"WHERE {code_col} ILIKE %(q)s OR {name_col} ILIKE %(q)s "
|
||||
f"ORDER BY {code_col} ASC LIMIT %(limit)s"
|
||||
)
|
||||
|
||||
# 풀은 lazy open — 부팅 시 itemcode_db 가 잠시 끊겨도 죽지 않게.
|
||||
try:
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=1,
|
||||
max_size=3,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
self.enabled = True
|
||||
self.reason = ""
|
||||
except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음
|
||||
self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}"
|
||||
|
||||
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""code/name 부분 일치 검색. 반환: [{"code","name","type"}].
|
||||
|
||||
비활성 상태이거나 조회 실패 시 빈 리스트(예외 비전파 — UI 는 수동 입력 폴백).
|
||||
"""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
return self._run(f"%{q}%", limit)
|
||||
|
||||
def list_all(self, *, limit: int = 2000) -> list[dict[str, Any]]:
|
||||
"""전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트용."""
|
||||
return self._run("%", limit)
|
||||
|
||||
def _run(self, like: str, limit: int) -> list[dict[str, Any]]:
|
||||
if not self.enabled or not self._pool or not self._sql:
|
||||
return []
|
||||
try:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
self._sql, {"q": like, "limit": int(limit)}
|
||||
).fetchall()
|
||||
self.last_error = ""
|
||||
except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음. 원인은 로그 + last_error.
|
||||
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||
logger.exception("itemcode 조회 실패 (SQL/스키마 확인 필요)")
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
out.append(
|
||||
{
|
||||
"code": str(r.get("code") or "").strip(),
|
||||
"name": str(r.get("name") or "").strip(),
|
||||
"type": str(r.get("type") or "").strip(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def close(self) -> None:
|
||||
if self._pool is not None:
|
||||
self._pool.close()
|
||||
@@ -0,0 +1,701 @@
|
||||
"""쿠팡 밀크런 모듈 라우터.
|
||||
|
||||
- 경로: /cupang
|
||||
- 권한: 로그인 + `cupang` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||
- 데이터: CupangDBStore (cupang_db / PostgreSQL) 전용.
|
||||
CUPANG_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||
- 상품 검색: itemcode_db 읽기 전용(ItemcodeReader). 미설정 시 수동 입력 폴백.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _calendar
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.timezone import today_kst
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from .holidays import is_holiday
|
||||
from .store import SHIP_METHODS
|
||||
|
||||
router = APIRouter(prefix="/cupang", tags=["cupang"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 공용 헬퍼
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _store(request: Request) -> Any:
|
||||
"""CupangDBStore 또는 None(CUPANG_DB_URL 미설정)."""
|
||||
return getattr(request.app.state, "cupang_store", None)
|
||||
|
||||
|
||||
def _itemcode(request: Request) -> Any:
|
||||
return getattr(request.app.state, "itemcode_reader", None)
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "cupang"):
|
||||
raise HTTPException(status_code=403, detail="쿠팡 밀크런 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": "쿠팡 밀크런 모듈이 아직 설정되지 않았습니다. "
|
||||
"CUPANG_DB_URL 환경변수를 설정하고 scripts/sql/cupang_db_init.sql 로 "
|
||||
"cupang_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||
"""로그인+권한+store 점검을 한 번에. 페이지 핸들러 진입부에서 사용."""
|
||||
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not has_module(user, "cupang"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "쿠팡 밀크런 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return _render_config_needed(request, user)
|
||||
return store, user
|
||||
|
||||
|
||||
def _parse_lines(lines_json: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
data = json.loads(lines_json or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="라인 데이터 형식 오류")
|
||||
if not isinstance(data, list):
|
||||
raise HTTPException(status_code=400, detail="라인 데이터는 배열이어야 합니다.")
|
||||
return data
|
||||
|
||||
|
||||
def _ym(request: Request) -> tuple[int, int]:
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or today.month)
|
||||
except ValueError:
|
||||
year, month = today.year, today.month
|
||||
if not (1 <= month <= 12):
|
||||
year, month = today.year, today.month
|
||||
return year, month
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 메인 — 월간 달력 + 선택일 출고 리스트
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
|
||||
year, month = _ym(request)
|
||||
counts = store.calendar_counts(year=year, month=month)
|
||||
shipments = store.list_shipments(year=year, month=month)
|
||||
|
||||
# 선택 날짜 (기본: 오늘이 이번 달이면 오늘, 아니면 1일)
|
||||
sel = request.query_params.get("date") or ""
|
||||
today = today_kst()
|
||||
if not sel:
|
||||
sel = today.isoformat() if (today.year == year and today.month == month) else f"{year:04d}-{month:02d}-01"
|
||||
|
||||
# 선택일에 걸친 묶음(출고일 기준 우선, 작성/입고 포함)
|
||||
sel_shipments = [
|
||||
s for s in shipments
|
||||
if sel in (s.get("ship_date"), s.get("document_date"), s.get("center_arrival_date"))
|
||||
]
|
||||
# 각 묶음에 품목 요약(제품명/수량) 첨부 — hover 툴팁용
|
||||
for s in sel_shipments:
|
||||
full = store.get_shipment(shipment_id=s["id"])
|
||||
s["tip_items"] = [
|
||||
{"name": ln.get("product_name_snapshot") or ln.get("product_code"),
|
||||
"qty": ln.get("quantity", 0)}
|
||||
for ln in (full.get("lines") if full else [])
|
||||
]
|
||||
|
||||
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||
weeks = cal.monthdatescalendar(year, month)
|
||||
cal_weeks = [
|
||||
[
|
||||
{
|
||||
"date": d.isoformat(),
|
||||
"day": d.day,
|
||||
"in_month": d.month == month,
|
||||
"is_today": d == today,
|
||||
"is_selected": d.isoformat() == sel,
|
||||
"is_sunday": d.weekday() == 6,
|
||||
"is_saturday": d.weekday() == 5,
|
||||
"is_holiday": is_holiday(d),
|
||||
"counts": counts.get(d.isoformat(), {}),
|
||||
}
|
||||
for d in week
|
||||
]
|
||||
for week in weeks
|
||||
]
|
||||
|
||||
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런",
|
||||
"page_subtitle": f"{year}년 {month}월 출고 일정",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"prev_y": prev_y, "prev_m": prev_m,
|
||||
"next_y": next_y, "next_m": next_m,
|
||||
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||
"cal_weeks": cal_weeks,
|
||||
"selected_date": sel,
|
||||
"sel_shipments": sel_shipments,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 출고 묶음 — 등록 / 수정 / 상세
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _form_context(request: Request, store: Any, user: dict[str, Any]) -> dict[str, Any]:
|
||||
from app.main import build_erp_nav # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
reader = _itemcode(request)
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"centers": sorted(store.list_centers(), key=lambda c: c["name"]),
|
||||
"box_rules": store.list_box_rules(),
|
||||
"products": store.list_products(),
|
||||
"ship_methods": list(SHIP_METHODS),
|
||||
"search_enabled": bool(reader and reader.enabled),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_form(request: Request) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
ctx = _form_context(request, store, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "쿠팡 밀크런 — 신규 등록",
|
||||
"page_subtitle": "공통 헤더 1개 + 품목 라인",
|
||||
"mode": "new",
|
||||
"shipment": None,
|
||||
"default_date": today_kst().isoformat(),
|
||||
}
|
||||
)
|
||||
return render_template(request, "cupang/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
async def create(
|
||||
request: Request,
|
||||
lines_json: str = Form("[]"),
|
||||
document_date: str = Form(...),
|
||||
ship_date: str = Form(...),
|
||||
center_arrival_date: str = Form(...),
|
||||
center_id: str = Form(""),
|
||||
center_name_snapshot: str = Form(""),
|
||||
ship_method: str = Form("택배"),
|
||||
outbound_summary: str = Form(""),
|
||||
worker: str = Form(""),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
header = {
|
||||
"document_date": document_date,
|
||||
"ship_date": ship_date,
|
||||
"center_arrival_date": center_arrival_date,
|
||||
"center_id": center_id,
|
||||
"center_name_snapshot": center_name_snapshot,
|
||||
"ship_method": ship_method,
|
||||
"outbound_summary": outbound_summary,
|
||||
"worker": worker,
|
||||
"memo": memo,
|
||||
}
|
||||
try:
|
||||
ship = store.create_shipment(
|
||||
created_by=user["email"], header=header, lines=_parse_lines(lines_json)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/cupang/{ship['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{shipment_id:int}", response_class=HTMLResponse)
|
||||
async def detail(request: Request, shipment_id: int) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
ship = store.get_shipment(shipment_id=shipment_id)
|
||||
if not ship:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/detail.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": f"출고 #{ship['id']}",
|
||||
"page_subtitle": f"{ship['ship_date']} · {ship['center_name_snapshot']}",
|
||||
"shipment": ship,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{shipment_id:int}/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request, shipment_id: int) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
ship = store.get_shipment(shipment_id=shipment_id)
|
||||
if not ship:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "출고 묶음을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
ctx = _form_context(request, store, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": f"출고 #{ship['id']} 수정",
|
||||
"page_subtitle": "헤더/라인 수정 후 저장",
|
||||
"mode": "edit",
|
||||
"shipment": ship,
|
||||
"default_date": ship["document_date"],
|
||||
}
|
||||
)
|
||||
return render_template(request, "cupang/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/{shipment_id:int}/edit")
|
||||
async def update(
|
||||
request: Request,
|
||||
shipment_id: int,
|
||||
lines_json: str = Form("[]"),
|
||||
document_date: str = Form(...),
|
||||
ship_date: str = Form(...),
|
||||
center_arrival_date: str = Form(...),
|
||||
center_id: str = Form(""),
|
||||
center_name_snapshot: str = Form(""),
|
||||
ship_method: str = Form("택배"),
|
||||
outbound_summary: str = Form(""),
|
||||
worker: str = Form(""),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
header = {
|
||||
"document_date": document_date,
|
||||
"ship_date": ship_date,
|
||||
"center_arrival_date": center_arrival_date,
|
||||
"center_id": center_id,
|
||||
"center_name_snapshot": center_name_snapshot,
|
||||
"ship_method": ship_method,
|
||||
"outbound_summary": outbound_summary,
|
||||
"worker": worker,
|
||||
"memo": memo,
|
||||
}
|
||||
try:
|
||||
store.update_shipment(
|
||||
shipment_id=shipment_id, header=header, lines=_parse_lines(lines_json)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{shipment_id:int}/delete")
|
||||
async def delete(
|
||||
request: Request,
|
||||
shipment_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""운영 안전: 기본은 status='취소' soft delete."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.soft_delete(shipment_id=shipment_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url=f"/cupang/{shipment_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{shipment_id:int}/hard-delete")
|
||||
async def hard_delete(
|
||||
request: Request,
|
||||
shipment_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""완전 삭제(헤더+라인 CASCADE). 달력으로 복귀."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.hard_delete(shipment_id=shipment_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="출고 묶음을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 입고센터 관리
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/centers", response_class=HTMLResponse)
|
||||
async def centers_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
centers = sorted(store.list_centers(include_inactive=True), key=lambda c: c["name"])
|
||||
# 사용 중 여부 표시
|
||||
for c in centers:
|
||||
c["in_use"] = store.center_in_use(center_id=c["id"])
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/centers.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 입고센터 관리",
|
||||
"page_subtitle": "추가 · 수정 · 비활성화. 사용 중 센터는 삭제되지 않고 비활성화됩니다.",
|
||||
"centers": centers,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/centers")
|
||||
async def center_create(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
sort_order: int = Form(0),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.create_center(name=name, sort_order=sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||
|
||||
|
||||
@router.post("/centers/{center_id}/edit")
|
||||
async def center_edit(
|
||||
request: Request,
|
||||
center_id: int,
|
||||
name: str = Form(""),
|
||||
active: str = Form(""),
|
||||
sort_order: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
kwargs: dict[str, Any] = {"center_id": center_id}
|
||||
if name.strip():
|
||||
kwargs["name"] = name
|
||||
if active != "":
|
||||
kwargs["active"] = active in ("1", "true", "on", "True")
|
||||
if sort_order.strip():
|
||||
try:
|
||||
kwargs["sort_order"] = int(sort_order)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
store.update_center(**kwargs)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||
|
||||
|
||||
@router.post("/centers/{center_id}/delete")
|
||||
async def center_delete(
|
||||
request: Request,
|
||||
center_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.delete_center(center_id=center_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="센터를 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/centers", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 박스 입수량 관리
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/box-rules", response_class=HTMLResponse)
|
||||
async def box_rules_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/box_rules.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 박스 입수량",
|
||||
"page_subtitle": "제품코드별 쿠팡박스 1박스당 입수량 설정",
|
||||
"box_rules": store.list_box_rules(include_inactive=True),
|
||||
"products": store.list_products(),
|
||||
"search_enabled": bool((_itemcode(request)) and _itemcode(request).enabled),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/box-rules")
|
||||
async def box_rule_upsert(
|
||||
request: Request,
|
||||
product_code: str = Form(...),
|
||||
units_per_box: int = Form(...),
|
||||
product_name_snapshot: str = Form(""),
|
||||
box_name: str = Form("쿠팡박스"),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.upsert_box_rule(
|
||||
product_code=product_code,
|
||||
units_per_box=units_per_box,
|
||||
product_name_snapshot=product_name_snapshot,
|
||||
box_name=box_name,
|
||||
memo=memo,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||
|
||||
|
||||
@router.post("/box-rules/{rule_id}/delete")
|
||||
async def box_rule_delete(
|
||||
request: Request,
|
||||
rule_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.delete_box_rule(rule_id=rule_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="규칙을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/box-rules", status_code=303)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/products", response_class=HTMLResponse)
|
||||
async def products_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
reader = _itemcode(request)
|
||||
return render_template(
|
||||
request,
|
||||
"cupang/products.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="cupang"),
|
||||
"page_title": "쿠팡 밀크런 — 설정 (제품명)",
|
||||
"page_subtitle": "왼쪽 itemcode_db 목록에서 선택해 등록하면 폼 드롭다운에 노출됩니다.",
|
||||
"products": store.list_products(include_inactive=True),
|
||||
"registered_codes": [p["product_code"] for p in store.list_products(include_inactive=True)],
|
||||
"search_enabled": bool(reader and reader.enabled),
|
||||
"search_reason": (reader.reason if reader else ""),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/products/all")
|
||||
async def product_all(
|
||||
request: Request, _: dict[str, Any] = Depends(_require_user)
|
||||
) -> JSONResponse:
|
||||
"""itemcode_db 전체 상품 목록(낱개+세트). 설정 화면 왼쪽 리스트 소스."""
|
||||
reader = _itemcode(request)
|
||||
results = reader.list_all() if reader else []
|
||||
return JSONResponse(
|
||||
{
|
||||
"enabled": bool(reader and reader.enabled),
|
||||
"reason": (reader.reason if reader else "itemcode 리더 미초기화"),
|
||||
"error": (getattr(reader, "last_error", "") if reader else ""),
|
||||
"count": len(results),
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/products/bulk")
|
||||
async def product_bulk(
|
||||
request: Request,
|
||||
items: list[dict[str, Any]] = Body(..., embed=True),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> JSONResponse:
|
||||
"""선택한 상품들을 일괄 등록(upsert). body: {"items":[{"code","name"}, ...]}."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
added = 0
|
||||
for it in items:
|
||||
code = str(it.get("code") or "").strip()
|
||||
name = str(it.get("name") or "").strip()
|
||||
if not code or not name:
|
||||
continue
|
||||
try:
|
||||
store.upsert_product(product_code=code, product_name=name)
|
||||
added += 1
|
||||
except ValueError:
|
||||
continue
|
||||
return JSONResponse({"ok": True, "added": added})
|
||||
|
||||
|
||||
@router.post("/products")
|
||||
async def product_upsert(
|
||||
request: Request,
|
||||
product_code: str = Form(...),
|
||||
product_name: str = Form(...),
|
||||
sort_order: int = Form(0),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.upsert_product(
|
||||
product_code=product_code, product_name=product_name, sort_order=sort_order
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.post("/products/{product_id:int}/active")
|
||||
async def product_set_active(
|
||||
request: Request,
|
||||
product_id: int,
|
||||
active: str = Form(...),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.set_product_active(
|
||||
product_id=product_id, active=active in ("1", "true", "on", "True")
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.post("/products/{product_id:int}/delete")
|
||||
async def product_delete(
|
||||
request: Request,
|
||||
product_id: int,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""완전 삭제(hard delete)."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="cupang_db 미설정")
|
||||
try:
|
||||
store.delete_product(product_id=product_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="제품을 찾을 수 없습니다.")
|
||||
return RedirectResponse(url="/cupang/products", status_code=303)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "cupang"}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""쿠팡 밀크런 모듈 상수 및 공용 헬퍼.
|
||||
|
||||
- 데이터 저장은 cupang_db(PostgreSQL) 전용이다(`db.py`).
|
||||
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||
CUPANG_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||
- 이 모듈에는 DB/JSON 양쪽이 공유하는 상수와 순수 계산 헬퍼만 둔다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
# 출고 묶음 상태 (expense 의 STATUSES 패턴과 동일하게 한글 라벨 그대로 저장)
|
||||
STATUSES: tuple[str, ...] = (
|
||||
"작성중",
|
||||
"출고준비",
|
||||
"출고완료",
|
||||
"센터입고완료",
|
||||
"취소",
|
||||
)
|
||||
|
||||
# 출고방식 기본 후보 (자유 입력 허용, 아래는 select 기본값)
|
||||
SHIP_METHODS: tuple[str, ...] = ("택배", "직접배송", "화물", "파렛트", "기타")
|
||||
|
||||
# 초기 입고센터 seed — cupang_db_init.sql 에도 동일 목록을 INSERT 한다.
|
||||
# 화면에서 추가/수정/비활성화 가능. 사용 중인 센터는 hard delete 하지 않는다.
|
||||
DEFAULT_CENTERS: tuple[str, ...] = (
|
||||
"대구3",
|
||||
"인천32",
|
||||
"이천1",
|
||||
"인천42",
|
||||
"인천26",
|
||||
"인천16",
|
||||
"인천28",
|
||||
"안성8",
|
||||
"천안8(RC)",
|
||||
"시흥2",
|
||||
"인천36",
|
||||
"MGMH5",
|
||||
"XRC10(RC)",
|
||||
"인천14",
|
||||
"경기광주5",
|
||||
"경기광주3",
|
||||
"XRC06(RC)",
|
||||
"용인1",
|
||||
"인천30",
|
||||
"마장1",
|
||||
"안성4",
|
||||
"대구6",
|
||||
"전라광주2",
|
||||
"창원1",
|
||||
"고양1",
|
||||
"동탄1",
|
||||
"이천4",
|
||||
"XRC09(RC)",
|
||||
)
|
||||
|
||||
|
||||
def compute_boxes(quantity: int, units_per_box: int | None) -> dict[str, Any]:
|
||||
"""수량 + 박스당 입수량으로 필요한 박스 수를 계산한다.
|
||||
|
||||
클라이언트 계산을 신뢰하지 않고 서버에서 이 함수로 재계산한다.
|
||||
|
||||
- units_per_box 가 없거나 0 이하면 "미설정" — 자동 계산하지 않는다.
|
||||
- full_boxes = quantity // units_per_box
|
||||
- remainder_units = quantity % units_per_box
|
||||
- required_boxes = ceil(quantity / units_per_box)
|
||||
"""
|
||||
try:
|
||||
qty = int(quantity)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
|
||||
upb: int | None
|
||||
try:
|
||||
upb = int(units_per_box) if units_per_box is not None else None
|
||||
except (TypeError, ValueError):
|
||||
upb = None
|
||||
|
||||
if not upb or upb <= 0 or qty <= 0:
|
||||
return {
|
||||
"configured": False,
|
||||
"units_per_box": upb if (upb and upb > 0) else None,
|
||||
"full_boxes": None,
|
||||
"remainder_units": None,
|
||||
"required_boxes": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"configured": True,
|
||||
"units_per_box": upb,
|
||||
"full_boxes": qty // upb,
|
||||
"remainder_units": qty % upb,
|
||||
"required_boxes": math.ceil(qty / upb),
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-brule-layout">
|
||||
|
||||
<!-- 왼쪽: 추가/수정 (product_code UNIQUE → upsert) -->
|
||||
<div class="erp-card cpg-form-card cpg-brule-add">
|
||||
<div class="cpg-card-head">
|
||||
<h2>박스 입수량 추가 / 수정</h2>
|
||||
<span class="erp-muted">같은 제품코드는 덮어씁니다.</span>
|
||||
</div>
|
||||
<form method="post" action="/cupang/box-rules">
|
||||
<input type="hidden" name="product_name_snapshot" id="brule-name-snap" />
|
||||
<div class="cpg-brule-fields">
|
||||
<div class="cpg-brule-row">
|
||||
<label class="erp-field"><span>제품명 *</span>
|
||||
<select class="erp-select cpg-brule-name" id="brule-name" required>
|
||||
<option value="">— 제품명 선택 —</option>
|
||||
{% for p in products %}
|
||||
<option value="{{ p.product_code }}" data-name="{{ p.product_name }}">{{ p.product_name }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<label class="erp-field"><span>제품코드</span>
|
||||
<input class="erp-input cpg-brule-code" type="text" name="product_code" id="brule-code" required placeholder="자동" /></label>
|
||||
</div>
|
||||
<div class="cpg-brule-row">
|
||||
<label class="erp-field"><span>박스이름</span>
|
||||
<input class="erp-input cpg-brule-box" type="text" name="box_name" value="쿠팡박스" /></label>
|
||||
<label class="erp-field"><span>박스당 입수량 *</span>
|
||||
<span class="cpg-upb-wrap">
|
||||
<input class="erp-input cpg-brule-upb" type="number" name="units_per_box" min="1" required />
|
||||
<span class="cpg-upb-unit">개</span>
|
||||
</span></label>
|
||||
</div>
|
||||
<div class="cpg-brule-row">
|
||||
<label class="erp-field cpg-brule-memo-field"><span>메모</span>
|
||||
<input class="erp-input cpg-brule-memo" type="text" name="memo" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="erp-page-actions" style="margin-top:12px;">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if not products %}
|
||||
<p class="erp-muted"><a href="/cupang/products">설정에서 제품명을 먼저 등록</a>하면 드롭다운에 표시됩니다.</p>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var sel = document.getElementById("brule-name");
|
||||
var code = document.getElementById("brule-code");
|
||||
var snap = document.getElementById("brule-name-snap");
|
||||
if (!sel) return;
|
||||
sel.addEventListener("change", function () {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
code.value = sel.value;
|
||||
snap.value = opt ? (opt.getAttribute("data-name") || "") : "";
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 목록 -->
|
||||
<div class="erp-card cpg-form-card cpg-brule-list">
|
||||
<div class="cpg-card-head"><h2>입수량 규칙 ({{ box_rules|length }})</h2></div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>제품명</th><th>제품코드</th><th>박스명</th><th>입수량</th><th>메모</th><th>동작</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in box_rules %}
|
||||
<tr>
|
||||
<td>{{ r.product_name_snapshot or '—' }}</td>
|
||||
<td>{{ r.product_code }}</td>
|
||||
<td>{{ r.box_name }}</td>
|
||||
<td>{{ r.units_per_box }}개</td>
|
||||
<td>{{ r.memo or '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/cupang/box-rules/{{ r.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('이 규칙을 삭제합니다. 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not box_rules %}
|
||||
<tr><td colspan="6" class="erp-muted">등록된 입수량 규칙이 없습니다.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /cpg-brule-layout -->
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-center-layout">
|
||||
|
||||
<!-- 왼쪽: 입고센터 추가 -->
|
||||
<div class="erp-card cpg-form-card cpg-center-add">
|
||||
<h2 class="cpg-center-add-title">입고센터 추가</h2>
|
||||
<form method="post" action="/cupang/centers" class="cpg-inline-form">
|
||||
<input class="erp-input" type="text" name="name" placeholder="센터명 (예: 대구3)" required style="flex:1 1 auto;min-width:0" />
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 센터 목록 (높이 900 고정, 내부 스크롤) -->
|
||||
<div class="erp-card cpg-form-card cpg-center-listcard">
|
||||
<div class="cpg-card-head">
|
||||
<h2>센터 목록 ({{ centers|length }})</h2>
|
||||
<span class="erp-muted">사용 중 센터는 삭제 시 비활성화됩니다.</span>
|
||||
</div>
|
||||
|
||||
<div class="cpg-center-list">
|
||||
{% for c in centers %}
|
||||
<div class="cpg-center-row {% if not c.active %}is-inactive{% endif %}">
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-center-edit">
|
||||
<input class="erp-input cpg-center-name" type="text" name="name" value="{{ c.name }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm" title="이름 저장">저장</button>
|
||||
</form>
|
||||
<span class="cpg-center-state">
|
||||
{% if c.in_use %}<span class="erp-badge erp-badge-inverse cpg-mini">사용중</span>{% endif %}
|
||||
{% if c.active %}<span class="erp-badge erp-badge-success cpg-mini">활성</span>
|
||||
{% else %}<span class="erp-badge erp-badge-neutral cpg-mini">비활성</span>{% endif %}
|
||||
</span>
|
||||
<span class="cpg-center-act">
|
||||
{% if c.active %}
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="0" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">비활성</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/edit" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="1" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline cpg-btn-sm">활성</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/cupang/centers/{{ c.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('{% if c.in_use %}사용 중 → 비활성화됩니다.{% else %}삭제합니다.{% endif %} 계속?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger cpg-btn-sm">삭제</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions cpg-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/?date={{ shipment.ship_date }}">◀◀ 달력</a>
|
||||
|
||||
<form method="post" action="/cupang/{{ shipment.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('이 출고 묶음을 취소 처리합니다(상태=취소). 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">취소 처리</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/cupang/{{ shipment.id }}/hard-delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('이 출고를 완전 삭제합니다(복구 불가, 품목 포함). 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
|
||||
<a class="erp-btn erp-btn-primary cpg-push-right" href="/cupang/{{ shipment.id }}/edit">수정</a>
|
||||
</div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head">
|
||||
<h2>출고 #{{ shipment.id }}
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if shipment.status == '출고완료' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif shipment.status == '센터입고완료' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif shipment.status == '취소' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }}">{{ shipment.status }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<dl class="cpg-detail-grid">
|
||||
<div><dt>작성일</dt><dd>{{ shipment.document_date }}</dd></div>
|
||||
<div><dt>출고일</dt><dd>{{ shipment.ship_date }}</dd></div>
|
||||
<div><dt>센터입고일</dt><dd>{{ shipment.center_arrival_date }}</dd></div>
|
||||
<div><dt>입고센터</dt><dd>{{ shipment.center_name_snapshot or '—' }}</dd></div>
|
||||
<div><dt>출고방식</dt><dd>{{ shipment.ship_method }}</dd></div>
|
||||
<div><dt>작업자</dt><dd>{{ shipment.worker or '—' }}</dd></div>
|
||||
<div class="cpg-full"><dt>출고/박스 요약</dt><dd>{{ shipment.outbound_summary or '—' }}</dd></div>
|
||||
<div class="cpg-full"><dt>메모</dt><dd>{{ shipment.memo or '—' }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- 라인 -->
|
||||
<div class="erp-card cpg-form-card">
|
||||
<div class="cpg-card-head"><h2>품목 ({{ shipment.lines|length }})</h2></div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>제품코드</th><th>제품명</th><th>수량</th>
|
||||
<th>입수량</th><th>필요박스</th><th>잔량</th><th>수동보정</th><th>메모</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ln in shipment.lines %}
|
||||
<tr>
|
||||
<td>{{ ln.line_no }}</td>
|
||||
<td>{{ ln.product_code }}</td>
|
||||
<td>{{ ln.product_name_snapshot }}</td>
|
||||
<td>{{ ln.quantity }}</td>
|
||||
<td>{{ ln.units_per_box if ln.units_per_box else '미설정' }}</td>
|
||||
<td>{{ ln.calculated_boxes if ln.calculated_boxes is not none else '—' }}</td>
|
||||
<td>{{ ln.remainder_units if ln.remainder_units is not none else '—' }}</td>
|
||||
<td>{{ ln.manual_box_text or '—' }}</td>
|
||||
<td>{{ ln.memo or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
{% set action = '/cupang/new' if mode == 'new' else '/cupang/' ~ shipment.id ~ '/edit' %}
|
||||
<form id="cpg-form" method="post" action="{{ action }}">
|
||||
<input type="hidden" name="lines_json" id="cpg-lines-json" value="[]" />
|
||||
|
||||
<div class="cpg-form-2col">
|
||||
|
||||
<!-- ── 공통 헤더 (왼쪽) ── -->
|
||||
<div class="erp-card cpg-form-card cpg-form-head">
|
||||
<div class="cpg-card-head"><h2>공통 헤더</h2></div>
|
||||
<div class="cpg-header-grid">
|
||||
<label class="erp-field"><span>작성일 *</span>
|
||||
<input class="erp-input" type="date" name="document_date" required
|
||||
value="{{ shipment.document_date if shipment else default_date }}" /></label>
|
||||
<label class="erp-field"><span>출고일 *</span>
|
||||
<input class="erp-input" type="date" name="ship_date" required
|
||||
value="{{ shipment.ship_date if shipment else default_date }}" /></label>
|
||||
<label class="erp-field"><span>센터입고일 *</span>
|
||||
<input class="erp-input" type="date" name="center_arrival_date" required
|
||||
value="{{ shipment.center_arrival_date if shipment else default_date }}" /></label>
|
||||
|
||||
<label class="erp-field"><span>입고센터</span>
|
||||
<select class="erp-select" name="center_id" id="cpg-center-select">
|
||||
<option value="">— 선택 —</option>
|
||||
{% for c in centers %}
|
||||
<option value="{{ c.id }}" data-name="{{ c.name }}"
|
||||
{% if shipment and shipment.center_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
<!-- 센터 스냅샷(자유 입력 허용: 과거 명칭 보존/센터 미등록 시) -->
|
||||
<input type="hidden" name="center_name_snapshot" id="cpg-center-name"
|
||||
value="{{ shipment.center_name_snapshot if shipment else '' }}" />
|
||||
|
||||
<label class="erp-field"><span>출고방식</span>
|
||||
<select class="erp-select" name="ship_method">
|
||||
{% for m in ship_methods %}
|
||||
<option value="{{ m }}" {% if shipment and shipment.ship_method == m %}selected{% endif %}>{{ m }}</option>
|
||||
{% endfor %}
|
||||
</select></label>
|
||||
|
||||
<label class="erp-field"><span>작업자</span>
|
||||
<input class="erp-input" type="text" name="worker"
|
||||
value="{{ shipment.worker if shipment else '' }}" /></label>
|
||||
</div>
|
||||
|
||||
<label class="erp-field cpg-full"><span>출고/박스 요약 (수동 보정 메모)</span>
|
||||
<input class="erp-input" type="text" name="outbound_summary"
|
||||
placeholder="예: 쿠팡박스 50, 6호상자 1, (50번 박스)"
|
||||
value="{{ shipment.outbound_summary if shipment else '' }}" /></label>
|
||||
<label class="erp-field cpg-full"><span>메모</span>
|
||||
<textarea class="erp-input" name="memo" rows="2">{{ shipment.memo if shipment else '' }}</textarea></label>
|
||||
|
||||
<div class="erp-page-actions cpg-form-actions">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">저장</button>
|
||||
{% if mode == 'edit' %}
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/{{ shipment.id }}">취소</a>
|
||||
{% else %}
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/">취소</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 품목 라인 (오른쪽) ── -->
|
||||
<div class="erp-card cpg-form-card cpg-form-lines">
|
||||
<div class="cpg-card-head cpg-lines-head">
|
||||
<h2>품목 라인</h2>
|
||||
<span class="erp-muted">
|
||||
제품명 선택 시 제품코드 자동 입력. 수량 입력 시 박스 수 자동 계산.
|
||||
{% if not products %}<a href="/cupang/products">설정에서 제품명 먼저 등록</a>{% endif %}
|
||||
</span>
|
||||
<div class="cpg-lines-btns">
|
||||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-add-line">+ 라인 추가</button>
|
||||
<button type="button" class="erp-btn erp-btn-danger" id="cpg-del-line">선택 라인 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap cpg-lines-scroll">
|
||||
<table class="erp-table cpg-lines">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cpg-check-col"><input type="checkbox" id="cpg-check-all" title="전체 선택" /></th>
|
||||
<th>제품명</th><th>제품코드</th><th>수량</th>
|
||||
<th>입수량</th><th>박스 계산</th><th>라인메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cpg-lines-body"><!-- JS 렌더 --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /cpg-form-2col -->
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script type="application/json" id="cpg-init-lines">
|
||||
{% if shipment and shipment.lines %}{{ shipment.lines | tojson }}{% else %}[]{% endif %}
|
||||
</script>
|
||||
<script type="application/json" id="cpg-box-rules">
|
||||
{{ box_rules | tojson }}
|
||||
</script>
|
||||
<script type="application/json" id="cpg-products">
|
||||
{{ products | tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/cupang.js?v=20260530p" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,139 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<!-- 페이지 액션 (달력 컬럼 폭에 맞춤: 신규 좌측 / 설정 달력 오른쪽 끝) -->
|
||||
<div class="cpg-actions-grid">
|
||||
<div class="cpg-actions-main">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/new">+ 신규 등록</a>
|
||||
<span class="cpg-settings-btns">
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="cpg-actions-spacer"></div>
|
||||
</div>
|
||||
|
||||
<div class="cpg-layout">
|
||||
<!-- ── 왼쪽: 큰 월간 달력 ── -->
|
||||
<div class="erp-card cpg-cal-card">
|
||||
<div class="cpg-cal-head">
|
||||
<a class="erp-btn erp-btn-outline cpg-nav-btn"
|
||||
href="/cupang/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||
<h2 class="cpg-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||
<a class="erp-btn erp-btn-outline cpg-nav-btn"
|
||||
href="/cupang/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-cal-grid">
|
||||
{% for wd in weekdays %}
|
||||
<div class="cpg-cal-wd {% if loop.index0 == 0 %}cpg-sun{% elif loop.index0 == 6 %}cpg-sat{% endif %}">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for week in cal_weeks %}
|
||||
{% for cell in week %}
|
||||
<a class="cpg-cal-cell
|
||||
{% if not cell.in_month %}cpg-out{% endif %}
|
||||
{% if cell.is_today %}cpg-today{% endif %}
|
||||
{% if cell.is_selected %}cpg-selected{% endif %}
|
||||
{% if cell.is_sunday or cell.is_holiday %}cpg-red{% elif cell.is_saturday %}cpg-blue{% endif %}"
|
||||
href="/cupang/?year={{ year }}&month={{ month }}&date={{ cell.date }}">
|
||||
<span class="cpg-cal-day">{{ cell.day }}</span>
|
||||
<span class="cpg-cal-badges">
|
||||
{% if cell.counts.document %}<span class="erp-badge erp-badge-neutral cpg-mini">작성 {{ cell.counts.document }}</span>{% endif %}
|
||||
{% if cell.counts.ship %}<span class="erp-badge erp-badge-inverse cpg-mini">출고 {{ cell.counts.ship }}</span>{% endif %}
|
||||
{% if cell.counts.arrival %}<span class="erp-badge erp-badge-success cpg-mini">입고 {{ cell.counts.arrival }}</span>{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 선택일 출고 묶음 리스트 ── -->
|
||||
<div class="erp-card cpg-list-card">
|
||||
<div class="cpg-list-head">
|
||||
<h2>{{ selected_date }} 출고</h2>
|
||||
<span class="erp-muted">{{ sel_shipments|length }}건</span>
|
||||
</div>
|
||||
|
||||
{% if sel_shipments %}
|
||||
<ul class="cpg-list">
|
||||
{% for s in sel_shipments %}
|
||||
<li class="cpg-list-item">
|
||||
<a href="/cupang/{{ s.id }}" class="cpg-list-link cpg-hover-item"
|
||||
data-items='{{ s.tip_items | tojson }}'>
|
||||
<div class="cpg-list-top">
|
||||
<strong>{{ s.center_name_snapshot or '센터 미지정' }}</strong>
|
||||
</div>
|
||||
<div class="cpg-list-meta erp-muted">
|
||||
출고 {{ s.ship_date }} · 입고 {{ s.center_arrival_date }} · {{ s.ship_method }}
|
||||
{% if s.outbound_summary %}<br>{{ s.outbound_summary }}{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="erp-muted cpg-empty">선택한 날짜의 출고 묶음이 없습니다.
|
||||
<a href="/cupang/new">신규 등록</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- hover 툴팁 (마우스 따라다님, 커서 왼쪽) -->
|
||||
<div id="cpg-hover-tip" class="cpg-hover-tip" hidden></div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var tip = document.getElementById("cpg-hover-tip");
|
||||
if (!tip) return;
|
||||
|
||||
function buildHtml(items) {
|
||||
if (!items || !items.length) return '<div class="cpg-tip-empty">품목 없음</div>';
|
||||
var html = "";
|
||||
items.forEach(function (it) {
|
||||
var d1 = document.createElement("div");
|
||||
d1.className = "cpg-tip-row";
|
||||
var n = document.createElement("span"); n.className = "cpg-tip-name"; n.textContent = it.name || "";
|
||||
var q = document.createElement("span"); q.className = "cpg-tip-qty"; q.textContent = (it.qty != null ? it.qty : 0) + "개";
|
||||
d1.appendChild(n); d1.appendChild(q);
|
||||
html += d1.outerHTML;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
// 커서 왼쪽에 표시
|
||||
var w = tip.offsetWidth || 200;
|
||||
var x = e.clientX - w - 14;
|
||||
if (x < 6) x = e.clientX + 16; // 화면 왼쪽 벗어나면 오른쪽으로
|
||||
var y = e.clientY + 12;
|
||||
var maxY = window.innerHeight - tip.offsetHeight - 8;
|
||||
if (y > maxY) y = maxY;
|
||||
tip.style.left = x + "px";
|
||||
tip.style.top = y + "px";
|
||||
}
|
||||
|
||||
document.querySelectorAll(".cpg-hover-item").forEach(function (el) {
|
||||
el.addEventListener("mouseenter", function (e) {
|
||||
var items = [];
|
||||
try { items = JSON.parse(el.getAttribute("data-items") || "[]"); } catch (_) {}
|
||||
tip.innerHTML = buildHtml(items);
|
||||
tip.hidden = false;
|
||||
move(e);
|
||||
});
|
||||
el.addEventListener("mousemove", move);
|
||||
el.addEventListener("mouseleave", function () { tip.hidden = true; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260530p" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="cpg">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||||
</div>
|
||||
|
||||
<div class="cpg-prod-layout">
|
||||
|
||||
<!-- ── 왼쪽: 미라네 주방 상품 목록 (다중선택 → 등록) ── -->
|
||||
<div class="erp-card cpg-form-card cpg-prod-left">
|
||||
<div class="cpg-card-head">
|
||||
<h2>미라네 주방 상품</h2>
|
||||
<span class="erp-muted">
|
||||
{% if search_enabled %}선택(다중) 후 등록. 이미 등록된 항목은 진한 회색.{% else %}
|
||||
검색 비활성: {{ search_reason }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if search_enabled %}
|
||||
<div class="cpg-inline-form" style="margin-bottom:10px;">
|
||||
<input class="erp-input" type="text" id="cpg-prod-q" placeholder="이름/코드 필터" style="min-width:200px" />
|
||||
<button type="button" class="erp-btn erp-btn-primary" id="cpg-prod-register">선택 등록</button>
|
||||
</div>
|
||||
<div id="cpg-src-list" class="cpg-src-list"><p class="erp-muted">불러오는 중…</p></div>
|
||||
{% else %}
|
||||
<p class="erp-muted">ITEMCODE_DB_URL / ITEMCODE_SEARCH_SQL 설정 후 사용 가능합니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 등록된 제품명 ── -->
|
||||
<div class="erp-card cpg-form-card cpg-prod-right">
|
||||
<div class="cpg-card-head"><h2>등록된 제품명 ({{ products|length }})</h2>
|
||||
<span class="erp-muted">폼의 제품명 드롭다운에 노출</span></div>
|
||||
<div class="erp-table-wrap cpg-reg-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>제품명</th><th>제품코드</th><th>상태</th><th>동작</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in products %}
|
||||
<tr {% if not p.active %}style="opacity:.55"{% endif %}>
|
||||
<td>{{ p.product_name }}</td>
|
||||
<td>{{ p.product_code }}</td>
|
||||
<td>{% if p.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
||||
<td>
|
||||
<div class="cpg-row-actions">
|
||||
{% if p.active %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="0" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">비활성</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/active" class="cpg-inline-form">
|
||||
<input type="hidden" name="active" value="1" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">활성</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/cupang/products/{{ p.id }}/delete" class="cpg-inline-form"
|
||||
onsubmit="return confirm('완전 삭제합니다. 계속할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not products %}
|
||||
<tr><td colspan="4" class="erp-muted">등록된 제품명이 없습니다. 왼쪽에서 선택해 등록하세요.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if search_enabled %}
|
||||
<script type="application/json" id="cpg-registered">{{ registered_codes | tojson }}</script>
|
||||
<script>
|
||||
(function () {
|
||||
var listBox = document.getElementById("cpg-src-list");
|
||||
var filter = document.getElementById("cpg-prod-q");
|
||||
var regBtn = document.getElementById("cpg-prod-register");
|
||||
var registered = {};
|
||||
try { (JSON.parse(document.getElementById("cpg-registered").textContent || "[]")).forEach(function(c){ registered[c]=true; }); } catch(e){}
|
||||
var all = [];
|
||||
var selected = {};
|
||||
|
||||
function esc(s){ var d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
|
||||
|
||||
function render() {
|
||||
var term = (filter.value || "").trim().toLowerCase();
|
||||
var rows = all.filter(function (it) {
|
||||
if (!term) return true;
|
||||
return (it.code + " " + it.name).toLowerCase().indexOf(term) >= 0;
|
||||
});
|
||||
if (!rows.length) { listBox.innerHTML = '<p class="erp-muted">결과 없음</p>'; return; }
|
||||
var html = "";
|
||||
rows.forEach(function (it) {
|
||||
var cls = "cpg-src-item";
|
||||
if (registered[it.code]) cls += " cpg-registered";
|
||||
if (selected[it.code]) cls += " is-selected";
|
||||
html += '<div class="' + cls + '" data-code="' + esc(it.code) + '">' +
|
||||
'<span class="cpg-src-name">' + esc(it.name) + '</span>' +
|
||||
'<span class="cpg-src-code">' + esc(it.code) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
listBox.innerHTML = html;
|
||||
}
|
||||
|
||||
listBox.addEventListener("click", function (e) {
|
||||
var item = e.target.closest(".cpg-src-item");
|
||||
if (!item) return;
|
||||
var code = item.getAttribute("data-code");
|
||||
if (selected[code]) { delete selected[code]; item.classList.remove("is-selected"); }
|
||||
else { selected[code] = true; item.classList.add("is-selected"); }
|
||||
});
|
||||
|
||||
filter.addEventListener("input", render);
|
||||
|
||||
regBtn.addEventListener("click", function () {
|
||||
var items = all.filter(function (it) { return selected[it.code]; })
|
||||
.map(function (it) { return { code: it.code, name: it.name }; });
|
||||
if (!items.length) { alert("등록할 상품을 선택하세요."); return; }
|
||||
regBtn.disabled = true;
|
||||
fetch("/cupang/products/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: items })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function () { location.reload(); })
|
||||
.catch(function () { regBtn.disabled = false; alert("등록 실패"); });
|
||||
});
|
||||
|
||||
fetch("/cupang/api/products/all")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
all = (data && data.results) || [];
|
||||
if (!all.length) {
|
||||
var msg = "itemcode_db 결과 없음";
|
||||
if (data && data.error) { msg += " — 조회 오류: " + esc(data.error); }
|
||||
else if (data && !data.enabled && data.reason) { msg += " — " + esc(data.reason); }
|
||||
else { msg += " (테이블이 비었거나 검색 SQL 조건 불일치)"; }
|
||||
listBox.innerHTML = '<p class="erp-muted">' + msg + '</p>';
|
||||
return;
|
||||
}
|
||||
render();
|
||||
})
|
||||
.catch(function () { listBox.innerHTML = '<p class="erp-muted">목록 로드 실패</p>'; });
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""개인경비(expense) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/expense)
|
||||
- 저장소: `store.py` (DATA_DIR/expense.json, 향후 expense_db 후보)
|
||||
- 템플릿: `templates/expense/index.html`
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .categories import DEFAULT_CATEGORIES, CategoryStore
|
||||
from .router import router
|
||||
from .store import CATEGORIES, METHODS, STATUSES, ExpenseStore
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"ExpenseStore",
|
||||
"CategoryStore",
|
||||
"DEFAULT_CATEGORIES",
|
||||
"CATEGORIES",
|
||||
"METHODS",
|
||||
"STATUSES",
|
||||
"build_expense_store",
|
||||
]
|
||||
|
||||
|
||||
def build_expense_store(*, dsn: str | None, json_path: Path) -> Any:
|
||||
"""env 의 EXPENSE_DB_URL 이 있으면 DB 저장소, 없으면 JSON 저장소.
|
||||
|
||||
DB 저장소 실패(드라이버 미설치/접속 실패) 시 예외를 그대로 전파한다 —
|
||||
의도치 않게 JSON 으로 폴백해 운영 데이터가 갈라지는 것을 막기 위함.
|
||||
"""
|
||||
if dsn:
|
||||
from .db import ExpenseDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return ExpenseDBStore(dsn)
|
||||
return ExpenseStore(json_path)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""개인경비 분류(category) 설정 저장소.
|
||||
|
||||
- 저장 위치: DATA_DIR/expense_categories.json
|
||||
- 관리자만 추가/삭제. 저장 즉시 사용자 등록 폼/집계에 반영.
|
||||
- JSON 파일 기반 — expense_db(PostgreSQL) 모드와 무관하게 동작(설정값이라
|
||||
트랜잭션 데이터와 분리). DB 스키마 변경(superuser SQL) 불필요.
|
||||
- 동시성: ExpenseStore 와 동일 패턴(threading.Lock + temp→rename 원자적 쓰기).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 최초 1회 시드. 기존 하드코딩 CATEGORIES 와 동일.
|
||||
DEFAULT_CATEGORIES: tuple[str, ...] = (
|
||||
"식대", "교통", "숙박", "비품", "접대", "통신", "기타",
|
||||
)
|
||||
|
||||
|
||||
class CategoryStore:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._lock = threading.Lock()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._path.exists():
|
||||
self._write_atomic({"categories": list(DEFAULT_CATEGORIES)})
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {}
|
||||
cats = data.get("categories")
|
||||
if not isinstance(cats, list) or not cats:
|
||||
data["categories"] = list(DEFAULT_CATEGORIES)
|
||||
else:
|
||||
# 문자열만, 공백 제거, 중복 제거(순서 유지)
|
||||
seen: set[str] = set()
|
||||
clean: list[str] = []
|
||||
for c in cats:
|
||||
name = str(c).strip()
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
clean.append(name)
|
||||
data["categories"] = clean or list(DEFAULT_CATEGORIES)
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".expense_categories.", suffix=".json.tmp",
|
||||
dir=str(self._path.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def list(self) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._read()["categories"])
|
||||
|
||||
def add(self, name: str) -> list[str]:
|
||||
name = str(name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("분류명을 입력하세요.")
|
||||
if len(name) > 30:
|
||||
raise ValueError("분류명은 30자 이하로 입력하세요.")
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if name in data["categories"]:
|
||||
raise ValueError(f"이미 존재하는 분류입니다: {name}")
|
||||
data["categories"].append(name)
|
||||
self._write_atomic(data)
|
||||
return list(data["categories"])
|
||||
|
||||
def delete(self, name: str) -> list[str]:
|
||||
name = str(name or "").strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if name not in data["categories"]:
|
||||
raise KeyError(name)
|
||||
if len(data["categories"]) <= 1:
|
||||
raise ValueError("분류는 최소 1개 이상이어야 합니다.")
|
||||
data["categories"] = [c for c in data["categories"] if c != name]
|
||||
self._write_atomic(data)
|
||||
return list(data["categories"])
|
||||
@@ -0,0 +1,534 @@
|
||||
"""expense_db PostgreSQL 저장소.
|
||||
|
||||
JSON 저장소(`ExpenseStore`)와 같은 인터페이스를 제공하여, 라우터 코드를
|
||||
바꾸지 않고도 교체할 수 있다.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`)
|
||||
- 연결 정보: 환경변수 `EXPENSE_DB_URL` (예: postgresql://user:pwd@host:5432/expense_db)
|
||||
- 스키마: `scripts/sql/expense_db_init.sql` 로 사전 초기화한다. 본 클래스는
|
||||
앱 부팅 시 `CREATE TABLE IF NOT EXISTS`로 보강만 한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
from .store import APPROVED_STATUSES, CATEGORIES, METHODS, STATUSES
|
||||
|
||||
|
||||
class ExpenseDBStore:
|
||||
"""`ExpenseStore` 와 동일한 메서드 시그니처.
|
||||
|
||||
스키마(테이블/인덱스/트리거)는 앱이 직접 만들지 않는다.
|
||||
`scripts/sql/expense_db_init.sql` 과 `expense_db_002_*.sql` 을 통해
|
||||
superuser 가 사전 적용한다. 앱 계정(expense_app)은 SELECT/INSERT/UPDATE/DELETE
|
||||
권한만 받기 때문에 PostgreSQL 15+ 의 strict public-schema 정책과 충돌하지 않음.
|
||||
|
||||
연결 풀은 lazy open — 부팅 시점에 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ── 조회 ──
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE owner = %s "
|
||||
"ORDER BY spent_at DESC, created_at DESC",
|
||||
(email,),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE id = %s AND owner = %s",
|
||||
(item_id, owner),
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
# ── 변경 ──
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
norm = self._normalize(payload)
|
||||
if not norm["spent_at"]:
|
||||
raise ValueError("spent_at 필수")
|
||||
status = (payload.get("status") or "작성중").strip()
|
||||
item_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO expense_items
|
||||
(id, owner, spent_at, category, method, merchant, amount, memo, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
owner,
|
||||
norm["spent_at"],
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
status,
|
||||
),
|
||||
).fetchone()
|
||||
return self._serialize(row)
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
norm = self._normalize(payload)
|
||||
status = payload.get("status")
|
||||
with self._pool.connection() as conn:
|
||||
if status:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET spent_at = %s, category = %s, method = %s,
|
||||
merchant = %s, amount = %s, memo = %s, status = %s
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
norm["spent_at"] or None,
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
status,
|
||||
item_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET spent_at = %s, category = %s, method = %s,
|
||||
merchant = %s, amount = %s, memo = %s
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
norm["spent_at"] or None,
|
||||
norm["category"],
|
||||
norm["method"],
|
||||
norm["merchant"],
|
||||
norm["amount"],
|
||||
norm["memo"],
|
||||
item_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(item_id)
|
||||
return self._serialize(row)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM expense_items WHERE id = %s AND owner = %s",
|
||||
(item_id, owner),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(item_id)
|
||||
|
||||
# ── 워크플로 ──
|
||||
def submit(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||
return self._transition_owner(
|
||||
item_id=item_id, owner=owner, from_status="작성중", to_status="제출"
|
||||
)
|
||||
|
||||
def revert_to_draft(self, *, item_id: str, owner: str) -> dict[str, Any]:
|
||||
"""반려 또는 제출 상태에서 본인이 작성중으로 되돌림."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = '작성중',
|
||||
reject_reason = NULL,
|
||||
decided_at = NULL,
|
||||
approver_email = NULL
|
||||
WHERE id = %s AND owner = %s
|
||||
AND status IN ('제출', '반려')
|
||||
RETURNING *
|
||||
""",
|
||||
(item_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("작성중 으로 되돌릴 수 없는 상태입니다.")
|
||||
return self._serialize(row)
|
||||
|
||||
def approve(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||
return self._transition_approver(
|
||||
item_id=item_id,
|
||||
approver_email=approver_email,
|
||||
from_statuses=("제출",),
|
||||
to_status="승인",
|
||||
)
|
||||
|
||||
def reject(
|
||||
self, *, item_id: str, approver_email: str, reason: str
|
||||
) -> dict[str, Any]:
|
||||
if not reason.strip():
|
||||
raise ValueError("반려 사유 필수")
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = '반려',
|
||||
approver_email = %s,
|
||||
decided_at = now(),
|
||||
reject_reason = %s
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, reason.strip(), item_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 항목 없음")
|
||||
return self._serialize(row)
|
||||
|
||||
def settle(self, *, item_id: str, approver_email: str) -> dict[str, Any]:
|
||||
return self._transition_approver(
|
||||
item_id=item_id,
|
||||
approver_email=approver_email,
|
||||
from_statuses=("승인",),
|
||||
to_status="정산완료",
|
||||
)
|
||||
|
||||
def _transition_owner(
|
||||
self, *, item_id: str, owner: str, from_status: str, to_status: str
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = %s
|
||||
WHERE id = %s AND owner = %s AND status = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(to_status, item_id, owner, from_status),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"전이 불가: {from_status} → {to_status}")
|
||||
return self._serialize(row)
|
||||
|
||||
def _transition_approver(
|
||||
self,
|
||||
*,
|
||||
item_id: str,
|
||||
approver_email: str,
|
||||
from_statuses: tuple[str, ...],
|
||||
to_status: str,
|
||||
) -> dict[str, Any]:
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE expense_items
|
||||
SET status = %s,
|
||||
approver_email = %s,
|
||||
decided_at = now()
|
||||
WHERE id = %s AND status = ANY(%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(to_status, approver_email, item_id, list(from_statuses)),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"전이 불가 → {to_status}")
|
||||
return self._serialize(row)
|
||||
|
||||
# ── 승인자 대기열 ──
|
||||
def list_pending_approval(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE status = '제출' "
|
||||
"ORDER BY created_at ASC"
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def get_any(self, *, item_id: str) -> dict[str, Any] | None:
|
||||
"""승인자/관리자용 — owner 무시하고 단일 조회."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_items WHERE id = %s", (item_id,)
|
||||
).fetchone()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
# ── 첨부 ──
|
||||
def add_attachment(
|
||||
self,
|
||||
*,
|
||||
item_id: str,
|
||||
owner: str,
|
||||
kind: str,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
) -> dict[str, Any]:
|
||||
if kind not in ("receipt", "other"):
|
||||
raise ValueError("kind 는 receipt|other")
|
||||
owner = owner.lower().strip()
|
||||
att_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO expense_attachments
|
||||
(id, item_id, owner, kind, filename, stored_path,
|
||||
content_type, size_bytes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
att_id,
|
||||
item_id,
|
||||
owner,
|
||||
kind,
|
||||
filename,
|
||||
stored_path,
|
||||
content_type,
|
||||
size_bytes,
|
||||
),
|
||||
).fetchone()
|
||||
return self._att_serialize(row)
|
||||
|
||||
def list_attachments(self, *, item_id: str) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM expense_attachments WHERE item_id = %s "
|
||||
"ORDER BY uploaded_at ASC",
|
||||
(item_id,),
|
||||
).fetchall()
|
||||
return [self._att_serialize(r) for r in rows]
|
||||
|
||||
def get_attachment(self, *, att_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM expense_attachments WHERE id = %s", (att_id,)
|
||||
).fetchone()
|
||||
return self._att_serialize(row) if row else None
|
||||
|
||||
def delete_attachment(self, *, att_id: str, owner: str) -> dict[str, Any]:
|
||||
"""삭제된 행 반환 (파일 정리용 stored_path 포함)."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"DELETE FROM expense_attachments "
|
||||
"WHERE id = %s AND owner = %s RETURNING *",
|
||||
(att_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(att_id)
|
||||
return self._att_serialize(row)
|
||||
|
||||
@staticmethod
|
||||
def _att_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
v = out.get("uploaded_at")
|
||||
if isinstance(v, datetime):
|
||||
out["uploaded_at"] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
out["size_bytes"] = int(out.get("size_bytes", 0))
|
||||
return out
|
||||
|
||||
# ── 승인완료 (월별, 전 직원) ──
|
||||
def list_approved(self, *, year: int, month: int) -> list[dict[str, Any]]:
|
||||
"""해당 월(spent_at)의 승인완료 항목 — 전 직원."""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM expense_items
|
||||
WHERE status = ANY(%s)
|
||||
AND EXTRACT(YEAR FROM spent_at) = %s
|
||||
AND EXTRACT(MONTH FROM spent_at) = %s
|
||||
ORDER BY owner ASC, spent_at ASC, created_at ASC
|
||||
""",
|
||||
(list(APPROVED_STATUSES), year, month),
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
def approved_attachments(
|
||||
self, *, year: int, month: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""해당 월 승인완료 항목의 첨부 — zip 다운로드용.
|
||||
|
||||
uploaded_at(datetime), spent_at(date) 를 가공 없이 반환(파일명 생성용).
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT a.id, a.item_id, a.owner, a.kind, a.filename,
|
||||
a.stored_path, a.content_type, a.uploaded_at,
|
||||
i.spent_at, i.category, i.method, i.amount, i.merchant
|
||||
FROM expense_attachments a
|
||||
JOIN expense_items i ON i.id = a.item_id
|
||||
WHERE i.status = ANY(%s)
|
||||
AND EXTRACT(YEAR FROM i.spent_at) = %s
|
||||
AND EXTRACT(MONTH FROM i.spent_at) = %s
|
||||
ORDER BY a.owner ASC, a.uploaded_at ASC
|
||||
""",
|
||||
(list(APPROVED_STATUSES), year, month),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── 집계 (월별) ──
|
||||
def monthly_summary(
|
||||
self, *, email: str, year: int
|
||||
) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT to_char(spent_at, 'YYYY-MM') AS month,
|
||||
category,
|
||||
COUNT(*) AS cnt,
|
||||
COALESCE(SUM(amount), 0) AS total
|
||||
FROM expense_items
|
||||
WHERE owner = %s AND EXTRACT(YEAR FROM spent_at) = %s
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
""",
|
||||
(email, year),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"month": r["month"],
|
||||
"category": r["category"],
|
||||
"count": int(r["cnt"]),
|
||||
"total": int(r["total"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_for_export(
|
||||
self,
|
||||
*,
|
||||
email: str | None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""엑셀 내보내기용. email=None 이면 전체 (승인자/관리자용)."""
|
||||
clauses = []
|
||||
params: list[Any] = []
|
||||
if email:
|
||||
clauses.append("owner = %s")
|
||||
params.append(email.lower().strip())
|
||||
if date_from:
|
||||
clauses.append("spent_at >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("spent_at <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM expense_items {where} "
|
||||
f"ORDER BY spent_at ASC, created_at ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._serialize(r) for r in rows]
|
||||
|
||||
# ── 요약 ──
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
email = email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
head = conn.execute(
|
||||
"SELECT COUNT(*) AS count, COALESCE(SUM(amount), 0) AS total "
|
||||
"FROM expense_items WHERE owner = %s",
|
||||
(email,),
|
||||
).fetchone()
|
||||
status_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS c FROM expense_items "
|
||||
"WHERE owner = %s GROUP BY status",
|
||||
(email,),
|
||||
).fetchall()
|
||||
cat_rows = conn.execute(
|
||||
"SELECT category, COALESCE(SUM(amount), 0) AS s "
|
||||
"FROM expense_items WHERE owner = %s GROUP BY category",
|
||||
(email,),
|
||||
).fetchall()
|
||||
by_status = {s: 0 for s in STATUSES}
|
||||
for r in status_rows:
|
||||
by_status[r["status"]] = int(r["c"])
|
||||
by_category = {c: 0 for c in CATEGORIES}
|
||||
for r in cat_rows:
|
||||
by_category[r["category"]] = int(r["s"])
|
||||
return {
|
||||
"count": int(head["count"]) if head else 0,
|
||||
"total": int(head["total"]) if head else 0,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
# ── helpers ──
|
||||
@staticmethod
|
||||
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
if isinstance(out.get("spent_at"), date):
|
||||
out["spent_at"] = out["spent_at"].isoformat()
|
||||
for k in ("created_at", "updated_at", "decided_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
out["amount"] = int(out.get("amount", 0))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
b = dict(base or {})
|
||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||
b["category"] = category or "기타"
|
||||
b["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
b["amount"] = 0
|
||||
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||
return b
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
"""개인경비 항목 JSON 저장소.
|
||||
|
||||
- 저장 위치: DATA_DIR/expense.json
|
||||
- 사용자(email) 단위 소유. 본인 항목만 조회/수정/삭제.
|
||||
- 향후 expense_db(PostgreSQL)로 마이그레이션 예정. 현재는 DB 생성 승인 전이라 JSON 사용.
|
||||
- 동시성: 프로세스 내 threading.Lock + 원자적 쓰기(temp → rename). UserStore 와 동일 패턴.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.timezone import now_kst_iso
|
||||
|
||||
CATEGORIES: tuple[str, ...] = ("식대", "교통", "숙박", "비품", "접대", "통신", "기타")
|
||||
METHODS: tuple[str, ...] = ("법인카드", "개인지출", "현금")
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "정산완료")
|
||||
# 승인 완료(결재 승인 이후) 상태 — "승인완료" 집계/내보내기 대상.
|
||||
APPROVED_STATUSES: tuple[str, ...] = ("승인", "정산완료")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return now_kst_iso()
|
||||
|
||||
|
||||
class ExpenseStore:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._lock = threading.Lock()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._path.exists():
|
||||
self._write_atomic({"items": []})
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {"items": []}
|
||||
if not isinstance(data.get("items"), list):
|
||||
data["items"] = []
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".expense.", suffix=".json.tmp", dir=str(self._path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def list_for(self, email: str) -> list[dict[str, Any]]:
|
||||
email = email.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [it for it in data["items"] if it.get("owner") == email]
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
return list(self._read()["items"])
|
||||
|
||||
def get(self, *, item_id: str, owner: str) -> dict[str, Any] | None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for it in data["items"]:
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
return dict(it)
|
||||
return None
|
||||
|
||||
def create(self, *, owner: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
item = self._normalize(payload)
|
||||
item["id"] = uuid.uuid4().hex[:12]
|
||||
item["owner"] = owner
|
||||
item["status"] = payload.get("status") or "작성중"
|
||||
item["created_at"] = _now_iso()
|
||||
item["updated_at"] = item["created_at"]
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
data["items"].append(item)
|
||||
self._write_atomic(data)
|
||||
return item
|
||||
|
||||
def update(
|
||||
self, *, item_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
for idx, it in enumerate(data["items"]):
|
||||
if it["id"] == item_id and it.get("owner") == owner:
|
||||
new = self._normalize(payload, base=it)
|
||||
new["id"] = it["id"]
|
||||
new["owner"] = it["owner"]
|
||||
new["created_at"] = it.get("created_at", _now_iso())
|
||||
new["status"] = payload.get("status") or it.get("status", "작성중")
|
||||
new["updated_at"] = _now_iso()
|
||||
data["items"][idx] = new
|
||||
self._write_atomic(data)
|
||||
return new
|
||||
raise KeyError(item_id)
|
||||
|
||||
def delete(self, *, item_id: str, owner: str) -> None:
|
||||
owner = owner.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
before = len(data["items"])
|
||||
data["items"] = [
|
||||
it
|
||||
for it in data["items"]
|
||||
if not (it["id"] == item_id and it.get("owner") == owner)
|
||||
]
|
||||
if len(data["items"]) == before:
|
||||
raise KeyError(item_id)
|
||||
self._write_atomic(data)
|
||||
|
||||
def summary_for(self, email: str) -> dict[str, Any]:
|
||||
items = self.list_for(email)
|
||||
total = sum(int(i.get("amount", 0)) for i in items)
|
||||
by_status = {s: sum(1 for i in items if i.get("status") == s) for s in STATUSES}
|
||||
by_category = {
|
||||
c: sum(int(i.get("amount", 0)) for i in items if i.get("category") == c)
|
||||
for c in CATEGORIES
|
||||
}
|
||||
return {
|
||||
"count": len(items),
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_category": by_category,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize(
|
||||
payload: dict[str, Any], base: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
b = dict(base or {})
|
||||
b["spent_at"] = str(payload.get("spent_at") or b.get("spent_at") or "").strip()
|
||||
category = str(payload.get("category") or b.get("category") or "기타").strip()
|
||||
method = str(payload.get("method") or b.get("method") or "법인카드").strip()
|
||||
# 분류는 관리자 설정으로 동적 추가되므로 고정 목록 검증 없이 그대로 저장.
|
||||
b["category"] = category or "기타"
|
||||
b["method"] = method if method in METHODS else "법인카드"
|
||||
b["merchant"] = str(payload.get("merchant") or b.get("merchant") or "").strip()
|
||||
try:
|
||||
b["amount"] = max(0, int(payload.get("amount") or b.get("amount") or 0))
|
||||
except (TypeError, ValueError):
|
||||
b["amount"] = 0
|
||||
b["memo"] = str(payload.get("memo") or b.get("memo") or "").strip()
|
||||
return b
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-ex-approved">
|
||||
|
||||
<!-- ── 상단 액션 + 월 선택 ── -->
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||
<form id="mon-form" method="get" action="/expense/approved" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||
<input type="month" name="month" value="{{ month }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
<a class="erp-btn erp-btn-primary" href="/expense/api/approved/attachments.zip?month={{ month }}">전부 다운로드(zip)</a>
|
||||
</div>
|
||||
|
||||
<!-- ── 직원별 합계 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>직원별 승인 금액 합계</h2>
|
||||
<span class="erp-muted">{{ month }} · 총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>이름</th>
|
||||
<th>이메일</th>
|
||||
<th style="width:100px; text-align:right;">건수</th>
|
||||
<th style="width:160px; text-align:right;">합계금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in owner_summary %}
|
||||
<tr>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="erp-muted">{{ r.owner }}</td>
|
||||
<td style="text-align:right;">{{ r.count }}</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(r.total) }} 원</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if owner_summary %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="2" style="text-align:right;">합계</th>
|
||||
<th style="text-align:right;">{{ count }}</th>
|
||||
<th style="text-align:right;">{{ "{:,}".format(grand_total) }} 원</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 승인완료 항목 목록 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>승인완료 항목 ({{ count }}건)</h2>
|
||||
<span class="erp-muted">전 직원 · 사용일 기준 {{ month }}</span>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="appr-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:110px;">사용일</th>
|
||||
<th style="width:120px;">이름</th>
|
||||
<th style="width:90px;">분류</th>
|
||||
<th style="width:100px;">수단</th>
|
||||
<th>가맹점/메모</th>
|
||||
<th style="width:120px; text-align:right;">금액</th>
|
||||
<th style="width:90px;">상태</th>
|
||||
<th style="width:60px; text-align:center;">첨부</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.owner_name }}<div class="erp-row-sub">{{ it.owner }}</div></td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>{{ it.method }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="text-align:right; font-variant-numeric: tabular-nums;">{{ "{:,}".format(it.amount) }} 원</td>
|
||||
<td>
|
||||
{% if it.status == '정산완료' %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="erp-empty">해당 월 승인완료 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display:flex; gap:var(--sp-8); margin-bottom:var(--sp-16); flex-wrap:wrap; align-items:center; }
|
||||
.erp-ex-approved .erp-row-sub { font-size:12px; color:var(--color-text-muted, #888); }
|
||||
#appr-table td { vertical-align: middle; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
// 월 선택 변경 시 자동 조회
|
||||
const monInput = document.querySelector('#mon-form input[name="month"]');
|
||||
if (monInput) monInput.addEventListener("change", () => monInput.form.submit());
|
||||
|
||||
// 첨부 보기 (공용 뷰어)
|
||||
const tbody = document.querySelector("#appr-table tbody");
|
||||
if (tbody) {
|
||||
tbody.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button.js-view-att");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
const id = tr.dataset.id;
|
||||
const who = tr.children[1].textContent.trim();
|
||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${who}` });
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,418 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-expense">
|
||||
|
||||
<!-- ── 페이지 액션 ── -->
|
||||
<div class="erp-page-actions">
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/pending">
|
||||
승인 대기 {% if pending_count %}<strong style="margin-left:6px;">{{ pending_count }}</strong>{% endif %}
|
||||
</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/approved">승인완료</a>
|
||||
{% endif %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=mine">엑셀(내 항목)</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||
{% endif %}
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/settings">⚙ 설정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── 요약 카드 ── -->
|
||||
<div class="erp-summary-grid">
|
||||
{% for status in statuses %}
|
||||
<div class="erp-summary-card erp-summary-card--mini">
|
||||
<span class="erp-summary-label">{{ status }}</span>
|
||||
<strong class="erp-summary-value erp-summary-value--sm">
|
||||
{{ summary.by_status.get(status, 0) }}
|
||||
</strong>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── 등록(좌) / 내역(우) 2단 ── -->
|
||||
<div class="ex-two-col">
|
||||
|
||||
<!-- ── 입력 폼 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 등록</h2>
|
||||
<span class="erp-muted">필수 항목 입력 후 등록. 등록 후 항목별로 첨부/제출.</span>
|
||||
</div>
|
||||
<form id="ex-form" class="erp-form-grid">
|
||||
<input type="hidden" name="id" />
|
||||
<label class="erp-field"><span>사용일</span><input type="date" name="spent_at" required /></label>
|
||||
<label class="erp-field"><span>분류</span>
|
||||
<select name="category" required>
|
||||
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field"><span>결제수단</span>
|
||||
<select name="method" required>
|
||||
{% for m in methods %}<option value="{{ m }}">{{ m }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-field"><span>금액</span>
|
||||
<input type="number" name="amount" min="0" step="1" required placeholder="원" />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide"><span>가맹점/사용처</span>
|
||||
<input type="text" name="merchant" placeholder="예) 스타벅스 강남점" required />
|
||||
</label>
|
||||
<label class="erp-field erp-field-wide"><span>메모</span>
|
||||
<input type="text" name="memo" placeholder="비고" />
|
||||
</label>
|
||||
<div class="erp-form-actions">
|
||||
<button type="reset" class="erp-btn erp-btn-outline" id="ex-reset">초기화</button>
|
||||
<button type="submit" class="erp-btn erp-btn-primary" id="ex-submit">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ── 항목 목록 ── -->
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>경비 내역</h2>
|
||||
<div style="display: flex; align-items: center; gap: var(--sp-12); flex-wrap: wrap;">
|
||||
<form method="get" action="/expense/" id="ex-month-form" style="display:flex; gap:var(--sp-8); align-items:center; margin:0;">
|
||||
<input type="month" name="month" value="{{ month }}" />
|
||||
</form>
|
||||
<span class="ex-month-stat" id="ex-count">{{ items | length }}건</span>
|
||||
<span class="ex-month-stat">합계 <strong>{{ "{:,}".format(month_total) }}</strong> 원</span>
|
||||
{% if supports_workflow %}
|
||||
<button id="ex-bulk-submit" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||
선택 항목 제출 (<span id="ex-bulk-count">0</span>)
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="ex-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 36px; text-align: center;">
|
||||
<input type="checkbox" id="ex-select-all" title="전체 선택" />
|
||||
</th>
|
||||
<th style="width: 110px;">사용일</th>
|
||||
<th style="width: 90px;">분류</th>
|
||||
<th style="width: 100px;">수단</th>
|
||||
<th>가맹점</th>
|
||||
<th style="width: 120px;">금액</th>
|
||||
<th style="width: 90px;">상태</th>
|
||||
<th style="width: 240px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ex-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}" data-status="{{ it.status }}">
|
||||
<td style="text-align: center;">
|
||||
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||
<input type="checkbox" class="js-select" />
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>{{ it.method }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
{% if it.reject_reason %}
|
||||
<div class="erp-row-sub" style="color: var(--color-callout-red);">반려: {{ it.reject_reason }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(it.amount) }} 원
|
||||
</td>
|
||||
<td>
|
||||
{% if it.status == '작성중' or it.status == '반려' %}
|
||||
<span class="erp-badge erp-badge-outline">{{ it.status }}</span>
|
||||
{% elif it.status == '제출' %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% elif it.status == '승인' %}
|
||||
<span class="erp-badge erp-badge-inverse">{{ it.status }}</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-neutral">{{ it.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right;" class="js-actions">
|
||||
{% if it.status in ('작성중', '반려') and supports_workflow %}
|
||||
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||
영수증<input type="file" class="js-upload" data-kind="receipt" hidden />
|
||||
</label>
|
||||
<label class="erp-btn erp-btn-outline erp-btn-sm">
|
||||
기타 파일<input type="file" class="js-upload" data-kind="other" hidden />
|
||||
</label>
|
||||
{% endif %}
|
||||
{% if it.status in ('작성중', '반려') %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-edit">수정</button>
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-delete">삭제</button>
|
||||
{% elif it.status == '제출' and supports_workflow %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-revert">취소(작성중)</button>
|
||||
{% endif %}
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-view-att" title="첨부 보기">📎</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr id="ex-empty"><td colspan="8" class="erp-empty">등록된 경비가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /ex-two-col -->
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions {
|
||||
display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); flex-wrap: wrap;
|
||||
}
|
||||
#ex-tbody td { vertical-align: middle; }
|
||||
|
||||
/* 경비 내역 월 통계(건수/합계) 강조 */
|
||||
.ex-month-stat { font-size: 18px; font-weight: 600; color: var(--color-text, #1a1a1a); }
|
||||
.ex-month-stat strong { font-size: 20px; }
|
||||
#ex-tbody .js-actions { white-space: nowrap; }
|
||||
#ex-tbody .js-actions > * { margin-left: 4px; vertical-align: middle; }
|
||||
|
||||
/* 상단 요약 카드: 세로 70px 고정 (grid 행 높이 고정 → stretch 무력화) */
|
||||
.erp-expense .erp-summary-grid {
|
||||
grid-auto-rows: 70px !important;
|
||||
margin-bottom: var(--sp-20); /* 등록 폼과 간격 */
|
||||
}
|
||||
.erp-expense .erp-summary-card {
|
||||
height: 70px !important; min-height: 0 !important; max-height: 70px;
|
||||
padding: 8px 14px; justify-content: center; gap: 2px; overflow: hidden;
|
||||
}
|
||||
.erp-expense .erp-summary-card--mini { padding: 8px 14px; }
|
||||
/* 요약줄과 2단 블록 사이 간격 */
|
||||
.ex-two-col { margin-top: var(--sp-20); }
|
||||
|
||||
/* 경비 내역 테이블: 전부 가운데 정렬 + 한 줄(2줄 방지) + 너비 자동 */
|
||||
#ex-table { table-layout: auto; }
|
||||
#ex-table th, #ex-table td {
|
||||
text-align: center !important;
|
||||
white-space: nowrap;
|
||||
width: auto !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
#ex-table th:first-child, #ex-table td:first-child { width: 36px !important; }
|
||||
#ex-table td > div { white-space: nowrap; } /* 가맹점/메모 줄바꿈 방지 */
|
||||
#ex-tbody .js-actions { text-align: center !important; }
|
||||
#ex-tbody .js-actions > * { margin: 0 2px; }
|
||||
|
||||
/* 첫 행(헤더) 모서리 라운드 제거 — 라운드는 .erp-table-wrap 에 걸려 있음 */
|
||||
.erp-expense .erp-table-wrap { border-radius: 0 !important; box-shadow: none; }
|
||||
|
||||
/* 첨부(클립) 아이콘 크게 */
|
||||
#ex-tbody .js-view-att { font-size: 22px !important; line-height: 1; padding: 2px 8px; }
|
||||
.erp-expense .erp-summary-value { font-size: 20px; line-height: 1.1; }
|
||||
.erp-expense .erp-summary-value--sm { font-size: 18px; }
|
||||
|
||||
/* 경비 등록(좌) / 경비 내역(우) 2단 */
|
||||
.ex-two-col {
|
||||
display: grid; grid-template-columns: 520px minmax(0, 1fr);
|
||||
gap: var(--sp-16); align-items: start;
|
||||
}
|
||||
.ex-two-col > .erp-card-block { margin: 0; }
|
||||
/* 좌측 폼은 2열로 (가맹점/메모는 전체폭) */
|
||||
.ex-two-col .erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||
.ex-two-col .erp-form-grid .erp-field-wide,
|
||||
.ex-two-col .erp-form-grid .erp-form-actions { grid-column: 1 / -1; }
|
||||
@media (max-width: 1100px) {
|
||||
.ex-two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const supportsWorkflow = {{ 'true' if supports_workflow else 'false' }};
|
||||
const form = document.getElementById("ex-form");
|
||||
const submitBtn = document.getElementById("ex-submit");
|
||||
const resetBtn = document.getElementById("ex-reset");
|
||||
const tbody = document.getElementById("ex-tbody");
|
||||
const selectAll = document.getElementById("ex-select-all");
|
||||
const bulkBtn = document.getElementById("ex-bulk-submit");
|
||||
const bulkCountEl = document.getElementById("ex-bulk-count");
|
||||
|
||||
function setEditing(id, data) {
|
||||
form.id.value = id || "";
|
||||
if (data) {
|
||||
form.spent_at.value = data.spent_at || "";
|
||||
form.category.value = data.category || "";
|
||||
form.method.value = data.method || "";
|
||||
form.amount.value = data.amount || 0;
|
||||
form.merchant.value = data.merchant || "";
|
||||
form.memo.value = data.memo || "";
|
||||
submitBtn.textContent = "수정 저장";
|
||||
} else {
|
||||
submitBtn.textContent = "등록";
|
||||
}
|
||||
}
|
||||
resetBtn.addEventListener("click", () => setEditing("", null));
|
||||
|
||||
// 필수 입력 검증 — 누락 시 경고창
|
||||
function validateRequired() {
|
||||
const required = [
|
||||
["spent_at", "사용일"],
|
||||
["category", "분류"],
|
||||
["method", "결제수단"],
|
||||
["amount", "금액"],
|
||||
["merchant", "가맹점/사용처"],
|
||||
];
|
||||
const missing = [];
|
||||
for (const [field, label] of required) {
|
||||
const val = (form[field].value || "").trim();
|
||||
if (!val) missing.push([form[field], label]);
|
||||
}
|
||||
// 금액은 0 이하도 미입력 취급
|
||||
if (!missing.some(([f]) => f === form.amount)) {
|
||||
if (!(parseInt(form.amount.value, 10) > 0)) {
|
||||
missing.push([form.amount, "금액(1원 이상)"]);
|
||||
}
|
||||
}
|
||||
if (missing.length) {
|
||||
alert("다음 항목을 입력하세요:\n- " + missing.map(([, l]) => l).join("\n- "));
|
||||
missing[0][0].focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!validateRequired()) return;
|
||||
const id = form.id.value.trim();
|
||||
const payload = {
|
||||
spent_at: form.spent_at.value,
|
||||
category: form.category.value,
|
||||
method: form.method.value,
|
||||
amount: parseInt(form.amount.value || "0", 10),
|
||||
merchant: form.merchant.value,
|
||||
memo: form.memo.value,
|
||||
};
|
||||
const url = id ? `/expense/api/items/${id}` : "/expense/api/items";
|
||||
const method = id ? "PUT" : "POST";
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert(`저장 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 체크박스 상태 갱신
|
||||
function refreshSelection() {
|
||||
if (!bulkBtn) return;
|
||||
const cbs = tbody.querySelectorAll("input.js-select");
|
||||
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||
bulkBtn.disabled = checked.length === 0;
|
||||
if (bulkCountEl) bulkCountEl.textContent = checked.length;
|
||||
if (selectAll && cbs.length > 0) {
|
||||
selectAll.checked = checked.length === cbs.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
refreshSelection();
|
||||
});
|
||||
}
|
||||
|
||||
tbody.addEventListener("change", (e) => {
|
||||
if (e.target.classList.contains("js-select")) refreshSelection();
|
||||
});
|
||||
|
||||
if (bulkBtn) {
|
||||
bulkBtn.addEventListener("click", async () => {
|
||||
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
|
||||
.map((cb) => cb.closest("tr").dataset.id);
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`${ids.length}건을 결재 제출할까요? 제출 후에는 수정 불가.`)) return;
|
||||
bulkBtn.disabled = true;
|
||||
bulkBtn.textContent = "제출 중…";
|
||||
const fails = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${id}/submit`, { method: "POST" });
|
||||
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
|
||||
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
|
||||
}
|
||||
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// 행 동작 (수정/삭제/취소/첨부보기 + 업로드)
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
if (!tr) return;
|
||||
const id = tr.dataset.id;
|
||||
|
||||
if (btn.classList.contains("js-edit")) {
|
||||
const res = await fetch(`/expense/api/items`);
|
||||
if (!res.ok) return;
|
||||
const { items } = await res.json();
|
||||
const found = items.find((x) => x.id === id);
|
||||
if (found) setEditing(id, found);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else if (btn.classList.contains("js-delete")) {
|
||||
if (!confirm("이 항목을 삭제할까요? 첨부도 함께 삭제됩니다.")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}`, { method: "DELETE" });
|
||||
if (res.ok) location.reload(); else alert((await res.json()).detail || "삭제 실패");
|
||||
} else if (btn.classList.contains("js-revert")) {
|
||||
if (!confirm("작성중 상태로 되돌릴까요?")) return;
|
||||
const res = await fetch(`/expense/api/items/${id}/revert`, { method: "POST" });
|
||||
if (res.ok) location.reload(); else alert((await res.json()).detail || "취소 실패");
|
||||
} else if (btn.classList.contains("js-view-att")) {
|
||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${tr.children[1].textContent.trim()}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 첨부 업로드
|
||||
tbody.addEventListener("change", async (e) => {
|
||||
const input = e.target.closest("input.js-upload");
|
||||
if (!input || !input.files.length) return;
|
||||
const tr = input.closest("tr[data-id]");
|
||||
const itemId = tr.dataset.id;
|
||||
const fd = new FormData();
|
||||
fd.append("file", input.files[0]);
|
||||
fd.append("kind", input.dataset.kind || "other");
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${itemId}/attachments`, {
|
||||
method: "POST", body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
input.value = "";
|
||||
alert("첨부 업로드 완료");
|
||||
} catch (err) {
|
||||
alert(`업로드 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!form.spent_at.value) {
|
||||
const d = new Date();
|
||||
form.spent_at.value = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||||
}
|
||||
|
||||
// 월 선택 변경 시 자동 조회
|
||||
const monthInput = document.querySelector('#ex-month-form input[name="month"]');
|
||||
if (monthInput) monthInput.addEventListener("change", () => monthInput.form.submit());
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-pending">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||
<a class="erp-btn erp-btn-outline" href="/expense/api/export.xlsx?scope=all">엑셀(전체)</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>승인 대기 ({{ items | length }}건)</h2>
|
||||
<div style="display: flex; align-items: center; gap: var(--sp-12);">
|
||||
<span class="erp-muted">제출 상태 항목 — 일괄 승인 / 항목별 반려</span>
|
||||
<button id="pend-bulk-approve" class="erp-btn erp-btn-primary erp-btn-sm" disabled>
|
||||
선택 일괄 승인 (<span id="pend-bulk-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 36px; text-align: center;">
|
||||
<input type="checkbox" id="pend-select-all" title="전체 선택" />
|
||||
</th>
|
||||
<th style="width: 110px;">사용일</th>
|
||||
<th style="width: 200px;">소유자</th>
|
||||
<th style="width: 90px;">분류</th>
|
||||
<th>가맹점/메모</th>
|
||||
<th style="width: 120px; text-align: right;">금액</th>
|
||||
<th style="width: 200px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pend-tbody">
|
||||
{% for it in items %}
|
||||
<tr data-id="{{ it.id }}">
|
||||
<td style="text-align: center;">
|
||||
<input type="checkbox" class="js-select" />
|
||||
</td>
|
||||
<td>{{ it.spent_at }}</td>
|
||||
<td>{{ it.owner }}</td>
|
||||
<td>{{ it.category }}</td>
|
||||
<td>
|
||||
<div>{{ it.merchant }}</div>
|
||||
{% if it.memo %}<div class="erp-row-sub">{{ it.memo }}</div>{% endif %}
|
||||
</td>
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(it.amount) }} 원
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-ghost erp-btn-sm js-attach" title="첨부 보기" aria-label="첨부 보기">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-reject">반려</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="erp-empty">대기 항목이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||
#pend-tbody td { vertical-align: middle; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const tbody = document.getElementById("pend-tbody");
|
||||
const selectAll = document.getElementById("pend-select-all");
|
||||
const bulkBtn = document.getElementById("pend-bulk-approve");
|
||||
const bulkCountEl = document.getElementById("pend-bulk-count");
|
||||
|
||||
function refreshSelection() {
|
||||
const cbs = tbody.querySelectorAll("input.js-select");
|
||||
const checked = Array.from(cbs).filter((cb) => cb.checked);
|
||||
bulkBtn.disabled = checked.length === 0;
|
||||
bulkCountEl.textContent = checked.length;
|
||||
if (cbs.length > 0) {
|
||||
selectAll.checked = checked.length === cbs.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < cbs.length;
|
||||
}
|
||||
}
|
||||
|
||||
selectAll.addEventListener("change", () => {
|
||||
tbody.querySelectorAll("input.js-select").forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
refreshSelection();
|
||||
});
|
||||
|
||||
tbody.addEventListener("change", (e) => {
|
||||
if (e.target.classList.contains("js-select")) refreshSelection();
|
||||
});
|
||||
|
||||
bulkBtn.addEventListener("click", async () => {
|
||||
const ids = Array.from(tbody.querySelectorAll("input.js-select:checked"))
|
||||
.map((cb) => cb.closest("tr").dataset.id);
|
||||
if (!ids.length) return;
|
||||
if (!confirm(`${ids.length}건을 일괄 승인할까요?`)) return;
|
||||
bulkBtn.disabled = true;
|
||||
bulkBtn.textContent = "승인 중…";
|
||||
const fails = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${id}/approve`, { method: "POST" });
|
||||
if (!res.ok) fails.push(`${id}: ${(await res.json()).detail || res.status}`);
|
||||
} catch (err) { fails.push(`${id}: ${err.message || err}`); }
|
||||
}
|
||||
if (fails.length) alert("일부 실패:\n" + fails.join("\n"));
|
||||
location.reload();
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-id]");
|
||||
const id = tr.dataset.id;
|
||||
|
||||
if (btn.classList.contains("js-attach")) {
|
||||
const owner = tr.children[2]?.textContent?.trim() || "";
|
||||
window.ErpAttachViewer.openFor(id, { title: `첨부 — ${owner}` });
|
||||
return;
|
||||
}
|
||||
if (btn.classList.contains("js-reject")) {
|
||||
const reason = prompt("반려 사유를 입력하세요:");
|
||||
if (!reason || !reason.trim()) return;
|
||||
const res = await fetch(`/expense/api/items/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
if (res.ok) { tr.remove(); refreshSelection(); }
|
||||
else { alert((await res.json()).detail || "반려 실패"); }
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-reports">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 내 개인경비</a>
|
||||
<form method="get" style="display: inline-flex; gap: 6px; align-items: center;">
|
||||
<label class="erp-muted">연도</label>
|
||||
<input type="number" name="year" value="{{ year }}" min="2000" max="2100"
|
||||
style="width: 100px; padding: 6px 10px; border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input); font-family: inherit;" />
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
<a class="erp-btn erp-btn-outline"
|
||||
href="/expense/api/export.xlsx?from={{ year }}-01-01&to={{ year }}-12-31">엑셀</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>{{ year }}년 월별 / 카테고리별 합계</h2>
|
||||
<span class="erp-muted">총 {{ "{:,}".format(grand_total) }} 원</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 110px;">월</th>
|
||||
{% for c in categories %}
|
||||
<th style="text-align: right;">{{ c }}</th>
|
||||
{% endfor %}
|
||||
<th style="text-align: right; background: var(--color-ghost-gray);">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in months %}
|
||||
{% set row_total = pivot[m].values() | sum %}
|
||||
<tr>
|
||||
<td><strong>{{ m }}</strong></td>
|
||||
{% for c in categories %}
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{% if pivot[m].get(c) %}{{ "{:,}".format(pivot[m][c]) }}{% else %}-{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="text-align: right; font-variant-numeric: tabular-nums; background: var(--color-ghost-gray);">
|
||||
<strong>{{ "{:,}".format(row_total) }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="{{ categories | length + 2 }}" class="erp-empty">데이터 없음</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if months %}
|
||||
<tfoot>
|
||||
<tr style="background: var(--color-ghost-gray);">
|
||||
<th>합계</th>
|
||||
{% for c in categories %}
|
||||
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(cat_totals[c]) }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ "{:,}".format(grand_total) }}
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); align-items: center; flex-wrap: wrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-ex-settings">
|
||||
|
||||
<div class="erp-page-actions">
|
||||
<a class="erp-btn erp-btn-ghost" href="/expense/">← 개인경비</a>
|
||||
</div>
|
||||
|
||||
<div class="erp-card-block" style="max-width: 640px;">
|
||||
<div class="erp-card-block-head">
|
||||
<h2>분류 항목 관리</h2>
|
||||
<span class="erp-muted">추가/삭제 즉시 사용자 등록 폼에 반영됩니다.</span>
|
||||
</div>
|
||||
|
||||
<form id="cat-form" class="erp-form-grid" style="grid-template-columns: 1fr auto; align-items: end; gap: var(--sp-12);">
|
||||
<label class="erp-field"><span>새 분류명</span>
|
||||
<input type="text" name="name" maxlength="30" placeholder="예) 마케팅" required />
|
||||
</label>
|
||||
<div class="erp-form-actions" style="margin: 0;">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap" style="margin-top: var(--sp-16);">
|
||||
<table class="erp-table" id="cat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>분류명</th>
|
||||
<th style="width: 100px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cat-tbody">
|
||||
{% for c in categories %}
|
||||
<tr data-name="{{ c }}">
|
||||
<td>{{ c }}</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="erp-muted" style="margin-top: var(--sp-12);">
|
||||
삭제해도 이미 등록된 경비 항목의 분류는 그대로 유지됩니다. 분류는 최소 1개 이상이어야 합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erp-page-actions { display: flex; gap: var(--sp-8); margin-bottom: var(--sp-16); }
|
||||
#cat-tbody td { vertical-align: middle; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
const form = document.getElementById("cat-form");
|
||||
const tbody = document.getElementById("cat-tbody");
|
||||
|
||||
function rowFor(name) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.name = name;
|
||||
tr.innerHTML =
|
||||
`<td></td>` +
|
||||
`<td style="text-align: right;">` +
|
||||
`<button class="erp-btn erp-btn-outline erp-btn-sm js-del">삭제</button></td>`;
|
||||
tr.children[0].textContent = name;
|
||||
return tr;
|
||||
}
|
||||
|
||||
function render(categories) {
|
||||
tbody.innerHTML = "";
|
||||
categories.forEach((c) => tbody.appendChild(rowFor(c)));
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = form.name.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await fetch("/expense/api/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || res.status);
|
||||
render(data.categories);
|
||||
form.reset();
|
||||
form.name.focus();
|
||||
} catch (err) {
|
||||
alert(`추가 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button.js-del");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr[data-name]");
|
||||
const name = tr.dataset.name;
|
||||
if (!confirm(`분류 "${name}" 을(를) 삭제할까요?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/expense/api/categories/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || res.status);
|
||||
render(data.categories);
|
||||
} catch (err) {
|
||||
alert(`삭제 실패: ${err.message || err}`);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""휴가 관리(vacation) 모듈.
|
||||
|
||||
라우터/저장소/템플릿을 한 디렉토리에서 관리한다.
|
||||
- 라우터: `router.py` (FastAPI APIRouter, prefix=/vacation)
|
||||
- 저장소: `db.py` (vacation_db / PostgreSQL 전용) + `store.py` (상수/일수 계산)
|
||||
- 템플릿: `templates/vacation/`
|
||||
|
||||
데이터 저장은 vacation_db 전용이다. VACATION_DB_URL 미설정 시 build_vacation_store 는
|
||||
None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 보여준다(앱은 죽지 않음).
|
||||
|
||||
권한:
|
||||
- `vacation` : 휴가 관리 접근
|
||||
- `vacation_approver` : 승인/반려 (admin 은 항상 통과)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .router import router
|
||||
from .store import HALF_LABELS, HALVES, STATUSES, VACATION_TYPES, compute_days
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"VACATION_TYPES",
|
||||
"STATUSES",
|
||||
"HALVES",
|
||||
"HALF_LABELS",
|
||||
"compute_days",
|
||||
"build_vacation_store",
|
||||
]
|
||||
|
||||
|
||||
def build_vacation_store(*, dsn: str | None) -> Any:
|
||||
"""VACATION_DB_URL 이 있으면 VacationDBStore, 없으면 None.
|
||||
|
||||
JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시.
|
||||
"""
|
||||
if not dsn:
|
||||
return None
|
||||
from .db import VacationDBStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||
|
||||
return VacationDBStore(dsn)
|
||||
@@ -0,0 +1,509 @@
|
||||
"""vacation_db PostgreSQL 저장소.
|
||||
|
||||
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — expense/cupang 모듈과 동일 패턴.
|
||||
- 연결 정보: 환경변수 `VACATION_DB_URL`
|
||||
(예: postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db)
|
||||
- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다.
|
||||
`scripts/sql/vacation_db_init.sql` 을 superuser 가 사전 적용한다.
|
||||
앱 계정(vacation_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다.
|
||||
- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게.
|
||||
|
||||
휴가 일수는 서버에서 `store.compute_days` 로 재계산하여 저장한다.
|
||||
공휴일 집합은 vacation_holidays(is_red=TRUE)에서 읽어 계산에 넘긴다(holiday provider).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
from .store import EDITABLE_STATUSES, STATUSES, VACATION_TYPES, compute_days, normalize_half
|
||||
|
||||
|
||||
class VacationDBStore:
|
||||
def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5):
|
||||
self._pool = ConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
kwargs={"row_factory": dict_row, "autocommit": True},
|
||||
open=False,
|
||||
)
|
||||
self._pool.open(wait=False)
|
||||
|
||||
def close(self) -> None:
|
||||
self._pool.close()
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 공휴일 (vacation_holidays) — holiday provider
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_holidays(self, *, year: int | None = None) -> list[dict[str, Any]]:
|
||||
clause = ""
|
||||
params: list[Any] = []
|
||||
if year:
|
||||
clause = "WHERE EXTRACT(YEAR FROM holiday_date) = %s"
|
||||
params.append(year)
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vacation_holidays {clause} ORDER BY holiday_date ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._holiday_serialize(r) for r in rows]
|
||||
|
||||
def red_holiday_set(self, *, date_from: str, date_to: str) -> set[str]:
|
||||
"""[date_from, date_to] 범위의 is_red=TRUE 공휴일 ISO 날짜 집합.
|
||||
|
||||
휴가일수 계산/달력 색상의 단일 진실 공급원.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT holiday_date FROM vacation_holidays "
|
||||
"WHERE is_red = TRUE AND holiday_date BETWEEN %s AND %s",
|
||||
(date_from, date_to),
|
||||
).fetchall()
|
||||
out: set[str] = set()
|
||||
for r in rows:
|
||||
d = r["holiday_date"]
|
||||
out.add(d.isoformat() if isinstance(d, date) else str(d))
|
||||
return out
|
||||
|
||||
def upsert_holiday(
|
||||
self,
|
||||
*,
|
||||
holiday_date: str,
|
||||
name: str,
|
||||
kind: str = "public",
|
||||
is_red: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
hd = (holiday_date or "").strip()
|
||||
nm = (name or "").strip()
|
||||
if not hd or not nm:
|
||||
raise ValueError("공휴일 날짜와 이름은 필수입니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_holidays (holiday_date, name, kind, is_red)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (holiday_date) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
kind = EXCLUDED.kind,
|
||||
is_red = EXCLUDED.is_red
|
||||
RETURNING *
|
||||
""",
|
||||
(hd, nm, (kind or "public").strip(), bool(is_red)),
|
||||
).fetchone()
|
||||
return self._holiday_serialize(row)
|
||||
|
||||
def delete_holiday(self, *, holiday_id: int) -> None:
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM vacation_holidays WHERE id = %s", (holiday_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(holiday_id)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 잔여 연차 (vacation_balances)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def get_balance(self, *, user_email: str, year: int) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vacation_balances WHERE user_email = %s AND year = %s",
|
||||
(user_email.lower().strip(), year),
|
||||
).fetchone()
|
||||
return self._balance_serialize(row) if row else None
|
||||
|
||||
def list_balances(self, *, year: int) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_balances WHERE year = %s ORDER BY user_email ASC",
|
||||
(year,),
|
||||
).fetchall()
|
||||
return [self._balance_serialize(r) for r in rows]
|
||||
|
||||
def upsert_balance(
|
||||
self, *, user_email: str, year: int, total_days: float, memo: str = ""
|
||||
) -> dict[str, Any]:
|
||||
email = (user_email or "").lower().strip()
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일이 필요합니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_balances (user_email, year, total_days, memo)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (user_email, year) DO UPDATE
|
||||
SET total_days = EXCLUDED.total_days,
|
||||
memo = EXCLUDED.memo
|
||||
RETURNING *
|
||||
""",
|
||||
(email, year, total_days, (memo or "").strip()),
|
||||
).fetchone()
|
||||
return self._balance_serialize(row)
|
||||
|
||||
def used_days(self, *, user_email: str, year: int) -> float:
|
||||
"""해당 연도 승인된 휴가의 합계 일수(사용 연차). start_date 연도 기준."""
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(days), 0) AS s FROM vacation_requests "
|
||||
"WHERE owner = %s AND status = '승인' "
|
||||
"AND EXTRACT(YEAR FROM start_date) = %s",
|
||||
(user_email.lower().strip(), year),
|
||||
).fetchone()
|
||||
return float(row["s"]) if row else 0.0
|
||||
|
||||
def balance_summary(self, *, user_email: str, year: int) -> dict[str, Any]:
|
||||
bal = self.get_balance(user_email=user_email, year=year)
|
||||
total = float(bal["total_days"]) if bal else 0.0
|
||||
used = self.used_days(user_email=user_email, year=year)
|
||||
return {
|
||||
"year": year,
|
||||
"total_days": total,
|
||||
"used_days": used,
|
||||
"remaining_days": round(total - used, 2),
|
||||
"memo": bal["memo"] if bal else "",
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 휴가 신청 (vacation_requests)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def list_for(self, owner: str) -> list[dict[str, Any]]:
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE owner = %s "
|
||||
"ORDER BY start_date DESC, created_at DESC",
|
||||
(owner,),
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def list_overlapping(self, *, date_from: str, date_to: str) -> list[dict[str, Any]]:
|
||||
"""[date_from, date_to] 와 겹치는 모든 신청(달력 bar 표시용).
|
||||
|
||||
취소 포함(흐리게 표시). 범위가 한 칸이라도 겹치면 포함.
|
||||
"""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests "
|
||||
"WHERE start_date <= %s AND end_date >= %s "
|
||||
"ORDER BY start_date ASC, owner ASC",
|
||||
(date_to, date_from),
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def get_request(self, *, request_id: str) -> dict[str, Any] | None:
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE id = %s", (request_id,)
|
||||
).fetchone()
|
||||
return self._req_serialize(row) if row else None
|
||||
|
||||
def create_request(
|
||||
self, *, owner: str, owner_name: str, payload: dict[str, Any], status: str = "작성중"
|
||||
) -> dict[str, Any]:
|
||||
h = self._normalize_payload(payload)
|
||||
if status not in STATUSES:
|
||||
status = "작성중"
|
||||
days = self._calc_days(h)
|
||||
req_id = uuid.uuid4().hex[:12]
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO vacation_requests
|
||||
(id, owner, owner_name, vacation_type, start_date, end_date,
|
||||
start_half, end_half, days, reason, status)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
req_id,
|
||||
owner.lower().strip(),
|
||||
(owner_name or "").strip(),
|
||||
h["vacation_type"],
|
||||
h["start_date"],
|
||||
h["end_date"],
|
||||
h["start_half"],
|
||||
h["end_half"],
|
||||
days,
|
||||
h["reason"],
|
||||
status,
|
||||
),
|
||||
).fetchone()
|
||||
return self._req_serialize(row)
|
||||
|
||||
def update_request(
|
||||
self, *, request_id: str, owner: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""작성중/반려 상태에서만 본인이 수정 가능."""
|
||||
owner = owner.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if current["owner"] != owner:
|
||||
raise PermissionError("본인 신청만 수정할 수 있습니다.")
|
||||
if current["status"] not in EDITABLE_STATUSES:
|
||||
raise ValueError(f"{current['status']} 상태에서는 수정할 수 없습니다.")
|
||||
h = self._normalize_payload(payload)
|
||||
days = self._calc_days(h)
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET vacation_type = %s, start_date = %s, end_date = %s,
|
||||
start_half = %s, end_half = %s, days = %s, reason = %s,
|
||||
status = '작성중', reject_reason = '',
|
||||
approver_email = NULL, decided_at = NULL
|
||||
WHERE id = %s AND owner = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
h["vacation_type"],
|
||||
h["start_date"],
|
||||
h["end_date"],
|
||||
h["start_half"],
|
||||
h["end_half"],
|
||||
days,
|
||||
h["reason"],
|
||||
request_id,
|
||||
owner,
|
||||
),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(request_id)
|
||||
return self._req_serialize(row)
|
||||
|
||||
# ── 워크플로 ──
|
||||
def submit(self, *, request_id: str, owner: str) -> dict[str, Any]:
|
||||
"""작성중/반려 → 제출 (본인)."""
|
||||
owner = owner.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '제출', reject_reason = '',
|
||||
approver_email = NULL, decided_at = NULL
|
||||
WHERE id = %s AND owner = %s AND status IN ('작성중', '반려')
|
||||
RETURNING *
|
||||
""",
|
||||
(request_id, owner),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("작성중/반려 상태에서만 제출할 수 있습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def approve(self, *, request_id: str, approver_email: str) -> dict[str, Any]:
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '승인', approver_email = %s, decided_at = now(),
|
||||
reject_reason = ''
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, request_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def reject(
|
||||
self, *, request_id: str, approver_email: str, reason: str
|
||||
) -> dict[str, Any]:
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("반려 사유는 필수입니다.")
|
||||
approver_email = approver_email.lower().strip()
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE vacation_requests
|
||||
SET status = '반려', approver_email = %s, decided_at = now(),
|
||||
reject_reason = %s
|
||||
WHERE id = %s AND status = '제출'
|
||||
RETURNING *
|
||||
""",
|
||||
(approver_email, reason.strip(), request_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("제출 상태가 아니거나 신청이 없습니다.")
|
||||
return self._req_serialize(row)
|
||||
|
||||
def cancel(self, *, request_id: str, user_email: str, is_admin: bool) -> dict[str, Any]:
|
||||
"""soft delete — status='취소'. owner 또는 admin 만."""
|
||||
user_email = user_email.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if not is_admin and current["owner"] != user_email:
|
||||
raise PermissionError("본인 신청만 취소할 수 있습니다.")
|
||||
with self._pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
"UPDATE vacation_requests SET status = '취소' WHERE id = %s RETURNING *",
|
||||
(request_id,),
|
||||
).fetchone()
|
||||
return self._req_serialize(row)
|
||||
|
||||
def hard_delete(self, *, request_id: str, user_email: str, is_admin: bool) -> None:
|
||||
"""완전 삭제.
|
||||
|
||||
- 관리자(is_admin): 모든 상태 삭제 가능.
|
||||
- 일반 사용자: 본인 + 승인 전(작성중/제출/반려)만. 승인/취소 건은 불가.
|
||||
"""
|
||||
user_email = user_email.lower().strip()
|
||||
current = self.get_request(request_id=request_id)
|
||||
if not current:
|
||||
raise KeyError(request_id)
|
||||
if not is_admin:
|
||||
if current["owner"] != user_email:
|
||||
raise PermissionError("본인 신청만 삭제할 수 있습니다.")
|
||||
if current["status"] not in ("작성중", "제출", "반려"):
|
||||
raise ValueError("승인/취소된 휴가는 삭제할 수 없습니다. (관리자 문의)")
|
||||
with self._pool.connection() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM vacation_requests WHERE id = %s", (request_id,)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise KeyError(request_id)
|
||||
|
||||
def list_pending(self) -> list[dict[str, Any]]:
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM vacation_requests WHERE status = '제출' "
|
||||
"ORDER BY start_date ASC, created_at ASC"
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
def list_for_export(
|
||||
self, *, date_from: str | None = None, date_to: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if date_from:
|
||||
clauses.append("end_date >= %s")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("start_date <= %s")
|
||||
params.append(date_to)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
with self._pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM vacation_requests {where} "
|
||||
"ORDER BY start_date ASC, owner ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._req_serialize(r) for r in rows]
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 정규화 / 계산 / 직렬화 helpers
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _calc_days(self, h: dict[str, Any]) -> float:
|
||||
holidays = self.red_holiday_set(
|
||||
date_from=h["start_date"], date_to=h["end_date"]
|
||||
)
|
||||
return compute_days(
|
||||
start_date=date.fromisoformat(h["start_date"]),
|
||||
end_date=date.fromisoformat(h["end_date"]),
|
||||
start_half=h["start_half"],
|
||||
end_half=h["end_half"],
|
||||
vacation_type=h["vacation_type"],
|
||||
holidays=holidays,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
vtype = str(payload.get("vacation_type") or "연차").strip()
|
||||
if vtype not in VACATION_TYPES:
|
||||
vtype = "연차"
|
||||
start_date = str(payload.get("start_date") or "").strip()
|
||||
end_date = str(payload.get("end_date") or "").strip()
|
||||
if not start_date:
|
||||
raise ValueError("시작일은 필수입니다.")
|
||||
if not end_date:
|
||||
end_date = start_date
|
||||
# 날짜 형식/순서 검증
|
||||
try:
|
||||
sd = date.fromisoformat(start_date)
|
||||
ed = date.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
raise ValueError("날짜 형식이 올바르지 않습니다 (YYYY-MM-DD).")
|
||||
if ed < sd:
|
||||
raise ValueError("종료일이 시작일보다 빠를 수 없습니다.")
|
||||
start_half = normalize_half(payload.get("start_half"))
|
||||
end_half = normalize_half(payload.get("end_half"))
|
||||
# 반차 종류면 단일 일자로 강제
|
||||
if vtype in ("오전반차", "오후반차"):
|
||||
end_date = start_date
|
||||
start_half = "am" if vtype == "오전반차" else "pm"
|
||||
end_half = start_half
|
||||
return {
|
||||
"vacation_type": vtype,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"start_half": start_half,
|
||||
"end_half": end_half,
|
||||
"reason": str(payload.get("reason") or "").strip(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _num(v: Any) -> float:
|
||||
if isinstance(v, Decimal):
|
||||
return float(v)
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _req_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
for k in ("start_date", "end_date"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, date):
|
||||
out[k] = v.isoformat()
|
||||
for k in ("created_at", "updated_at", "decided_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
out["days"] = cls._num(out.get("days", 0))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _holiday_serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
v = out.get("holiday_date")
|
||||
if isinstance(v, date):
|
||||
out["holiday_date"] = v.isoformat()
|
||||
out["is_red"] = bool(out.get("is_red", True))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def _balance_serialize(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
out = dict(row)
|
||||
out["id"] = int(out["id"])
|
||||
out["year"] = int(out["year"])
|
||||
out["total_days"] = cls._num(out.get("total_days", 0))
|
||||
out["used_days"] = cls._num(out.get("used_days", 0))
|
||||
for k in ("created_at", "updated_at"):
|
||||
v = out.get(k)
|
||||
if isinstance(v, datetime):
|
||||
out[k] = v.astimezone(KST).isoformat(timespec="seconds")
|
||||
return out
|
||||
@@ -0,0 +1,827 @@
|
||||
"""휴가 관리 모듈 라우터.
|
||||
|
||||
- 경로: /vacation
|
||||
- 권한: 로그인 + `vacation` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||
승인/반려는 `vacation_approver` 또는 admin.
|
||||
- 데이터: VacationDBStore (vacation_db / PostgreSQL) 전용.
|
||||
VACATION_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내를 보여준다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _calendar
|
||||
import io
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import (
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
RedirectResponse,
|
||||
StreamingResponse,
|
||||
)
|
||||
|
||||
from app.timezone import now_kst, today_kst
|
||||
|
||||
from .store import HALF_LABELS, HALVES, VACATION_TYPES
|
||||
|
||||
router = APIRouter(prefix="/vacation", tags=["vacation"])
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# 공용 헬퍼
|
||||
# ────────────────────────────────────────────────────────────
|
||||
def _store(request: Request) -> Any:
|
||||
return getattr(request.app.state, "vacation_store", None)
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not has_module(user, "vacation"):
|
||||
raise HTTPException(status_code=403, detail="휴가 관리 모듈 권한이 없습니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _require_approver(request: Request) -> dict[str, Any]:
|
||||
from app.main import get_current_user_record # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
|
||||
if not (is_admin(user) or has_module(user, "vacation_approver")):
|
||||
raise HTTPException(status_code=403, detail="휴가 승인자 권한이 필요합니다.")
|
||||
return user
|
||||
|
||||
|
||||
def _is_approver(request: Request, user: dict[str, Any]) -> bool:
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
return is_admin(user) or has_module(user, "vacation_approver")
|
||||
|
||||
|
||||
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{
|
||||
"reason": "휴가 관리 모듈이 아직 설정되지 않았습니다. "
|
||||
"VACATION_DB_URL 환경변수를 설정하고 scripts/sql/vacation_db_init.sql 로 "
|
||||
"vacation_db 를 초기화한 뒤 컨테이너를 재기동하세요.",
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
def _guard(
|
||||
request: Request,
|
||||
) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse:
|
||||
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용."""
|
||||
from app.main import get_current_user_record, render_template # noqa: WPS433
|
||||
from app.store import has_module, is_admin # noqa: WPS433
|
||||
|
||||
user = get_current_user_record(request)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
if not has_module(user, "vacation"):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return _render_config_needed(request, user)
|
||||
return store, user
|
||||
|
||||
|
||||
def _ym(request: Request) -> tuple[int, int]:
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or today.month)
|
||||
except ValueError:
|
||||
year, month = today.year, today.month
|
||||
if not (1 <= month <= 12):
|
||||
year, month = today.year, today.month
|
||||
return year, month
|
||||
|
||||
|
||||
# 상태별 bar 스타일 클래스 (CSS 와 매핑)
|
||||
_STATUS_CLASS = {
|
||||
"작성중": "vac-bar-draft",
|
||||
"제출": "vac-bar-submit",
|
||||
"승인": "vac-bar-approve",
|
||||
"반려": "vac-bar-reject",
|
||||
"취소": "vac-bar-cancel",
|
||||
}
|
||||
|
||||
|
||||
def _assign_lanes(bars: list[dict[str, Any]]) -> int:
|
||||
"""주(week) 내 bar 들에 겹치지 않는 lane(행) 번호를 그리디 배정.
|
||||
|
||||
bars 는 같은 주의 segment 들. 각 bar 에 'lane' 키를 추가하고, 사용된 lane 수를 반환.
|
||||
"""
|
||||
bars.sort(key=lambda b: (b["start_col"], -b["span"]))
|
||||
lane_end: list[int] = [] # lane 별 마지막 점유 col(포함)
|
||||
for b in bars:
|
||||
placed = False
|
||||
for li, end_col in enumerate(lane_end):
|
||||
if b["start_col"] > end_col:
|
||||
b["lane"] = li
|
||||
lane_end[li] = b["start_col"] + b["span"] - 1
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
b["lane"] = len(lane_end)
|
||||
lane_end.append(b["start_col"] + b["span"] - 1)
|
||||
return len(lane_end)
|
||||
|
||||
|
||||
def _build_calendar(
|
||||
store: Any, year: int, month: int, sel: str
|
||||
) -> dict[str, Any]:
|
||||
"""월간 달력 데이터 + bar lane 레이아웃 계산."""
|
||||
cal = _calendar.Calendar(firstweekday=6) # 일요일 시작
|
||||
weeks_dates = cal.monthdatescalendar(year, month)
|
||||
range_start = weeks_dates[0][0]
|
||||
range_end = weeks_dates[-1][-1]
|
||||
|
||||
red_set = store.red_holiday_set(
|
||||
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||
)
|
||||
holiday_names = {
|
||||
h["holiday_date"]: h["name"]
|
||||
for h in store.list_holidays()
|
||||
if h.get("is_red")
|
||||
}
|
||||
requests = store.list_overlapping(
|
||||
date_from=range_start.isoformat(), date_to=range_end.isoformat()
|
||||
)
|
||||
|
||||
today = today_kst()
|
||||
weeks: list[dict[str, Any]] = []
|
||||
for week in weeks_dates:
|
||||
w_start, w_end = week[0], week[-1]
|
||||
days = [
|
||||
{
|
||||
"date": d.isoformat(),
|
||||
"day": d.day,
|
||||
"in_month": d.month == month,
|
||||
"is_today": d == today,
|
||||
"is_selected": d.isoformat() == sel,
|
||||
"is_sunday": d.weekday() == 6,
|
||||
"is_saturday": d.weekday() == 5,
|
||||
"is_holiday": d.isoformat() in red_set,
|
||||
"holiday_name": holiday_names.get(d.isoformat(), ""),
|
||||
}
|
||||
for d in week
|
||||
]
|
||||
# 이 주에 걸치는 bar segment
|
||||
bars: list[dict[str, Any]] = []
|
||||
for r in requests:
|
||||
rs = date.fromisoformat(r["start_date"])
|
||||
re_ = date.fromisoformat(r["end_date"])
|
||||
if re_ < w_start or rs > w_end:
|
||||
continue
|
||||
seg_start = max(rs, w_start)
|
||||
seg_end = min(re_, w_end)
|
||||
start_col = (seg_start - w_start).days # 0..6
|
||||
span = (seg_end - seg_start).days + 1
|
||||
label = f"{r.get('owner_name') or r.get('owner')} {r['vacation_type']}"
|
||||
bars.append(
|
||||
{
|
||||
"id": r["id"],
|
||||
"label": label,
|
||||
"status": r["status"],
|
||||
"status_class": _STATUS_CLASS.get(r["status"], "vac-bar-draft"),
|
||||
"start_col": start_col,
|
||||
"span": span,
|
||||
"continues_left": rs < w_start,
|
||||
"continues_right": re_ > w_end,
|
||||
"days": r["days"],
|
||||
}
|
||||
)
|
||||
lane_count = _assign_lanes(bars)
|
||||
weeks.append({"days": days, "bars": bars, "lane_count": lane_count})
|
||||
|
||||
return {"weeks": weeks, "requests": requests, "sel_date": sel}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 메인 — 월간 달력 + 선택일 휴가 리스트
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
|
||||
year, month = _ym(request)
|
||||
sel = request.query_params.get("date") or ""
|
||||
today = today_kst()
|
||||
if not sel:
|
||||
sel = (
|
||||
today.isoformat()
|
||||
if (today.year == year and today.month == month)
|
||||
else f"{year:04d}-{month:02d}-01"
|
||||
)
|
||||
|
||||
caldata = _build_calendar(store, year, month, sel)
|
||||
sel_requests = [
|
||||
r
|
||||
for r in caldata["requests"]
|
||||
if r["start_date"] <= sel <= r["end_date"]
|
||||
]
|
||||
|
||||
prev_y, prev_m = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_y, next_m = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
summary = store.balance_summary(user_email=user["email"], year=year)
|
||||
is_approver = _is_approver(request, user)
|
||||
pending_count = len(store.list_pending()) if is_approver else 0
|
||||
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/index.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": is_approver,
|
||||
"pending_count": pending_count,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 관리",
|
||||
"page_subtitle": f"{year}년 {month}월 휴가 달력",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"prev_y": prev_y, "prev_m": prev_m,
|
||||
"next_y": next_y, "next_m": next_m,
|
||||
"today": today.isoformat(),
|
||||
"weekdays": ["일", "월", "화", "수", "목", "금", "토"],
|
||||
"weeks": caldata["weeks"],
|
||||
"selected_date": sel,
|
||||
"sel_requests": sel_requests,
|
||||
"balance": summary,
|
||||
"half_labels": HALF_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 휴가 신청 — 등록 / 수정 / 상세
|
||||
# ════════════════════════════════════════════════════════════
|
||||
def _form_context(request: Request, user: dict[str, Any]) -> dict[str, Any]:
|
||||
from app.main import build_erp_nav # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": _is_approver(request, user),
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"vacation_types": list(VACATION_TYPES),
|
||||
"halves": list(HALVES),
|
||||
"half_labels": HALF_LABELS,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_form(request: Request) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
_store_obj, user = guard
|
||||
ctx = _form_context(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "휴가 신청",
|
||||
"page_subtitle": "휴가 종류 · 기간 · 사유 입력",
|
||||
"mode": "new",
|
||||
"req": None,
|
||||
"default_date": today_kst().isoformat(),
|
||||
}
|
||||
)
|
||||
return render_template(request, "vacation/form.html", ctx)
|
||||
|
||||
|
||||
def _payload_from_form(
|
||||
vacation_type: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
start_half: str,
|
||||
end_half: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"vacation_type": vacation_type,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date or start_date,
|
||||
"start_half": start_half,
|
||||
"end_half": end_half,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/new")
|
||||
async def create(
|
||||
request: Request,
|
||||
vacation_type: str = Form(...),
|
||||
start_date: str = Form(...),
|
||||
end_date: str = Form(""),
|
||||
start_half: str = Form("full"),
|
||||
end_half: str = Form("full"),
|
||||
reason: str = Form(""),
|
||||
action: str = Form("submit"), # "draft" | "submit"
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
status = "작성중" if action == "draft" else "제출"
|
||||
payload = _payload_from_form(
|
||||
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||
)
|
||||
try:
|
||||
req = store.create_request(
|
||||
owner=user["email"],
|
||||
owner_name=user.get("name") or user["email"],
|
||||
payload=payload,
|
||||
status=status,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{req['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/pending", response_class=HTMLResponse)
|
||||
async def pending_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
if not _is_approver(request, user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 승인자 권한이 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
pending = store.list_pending()
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/pending.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": True,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 — 승인 대기",
|
||||
"page_subtitle": f"제출 상태 {len(pending)}건",
|
||||
"items": pending,
|
||||
"half_labels": HALF_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
if not is_admin(user):
|
||||
return render_template(
|
||||
request,
|
||||
"denied.html",
|
||||
{"reason": "휴가 설정은 관리자만 접근할 수 있습니다.", "is_admin": False},
|
||||
status_code=403,
|
||||
)
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
except ValueError:
|
||||
year = today.year
|
||||
balances = store.list_balances(year=year)
|
||||
for b in balances:
|
||||
b["used_days"] = store.used_days(user_email=b["user_email"], year=year)
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/settings.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": True,
|
||||
"is_approver": True,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 — 설정",
|
||||
"page_subtitle": "공휴일 · 연차 잔여 관리",
|
||||
"year": year,
|
||||
"holidays": store.list_holidays(year=year),
|
||||
"balances": balances,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/settings/holidays")
|
||||
async def holiday_upsert(
|
||||
request: Request,
|
||||
holiday_date: str = Form(...),
|
||||
name: str = Form(...),
|
||||
kind: str = Form("public"),
|
||||
is_red: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.upsert_holiday(
|
||||
holiday_date=holiday_date,
|
||||
name=name,
|
||||
kind=kind,
|
||||
is_red=is_red in ("1", "true", "on", "True"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
year = holiday_date[:4]
|
||||
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/holidays/{holiday_id:int}/delete")
|
||||
async def holiday_delete(
|
||||
request: Request,
|
||||
holiday_id: int,
|
||||
year: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.delete_holiday(holiday_id=holiday_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="공휴일을 찾을 수 없습니다.")
|
||||
suffix = f"?year={year}" if year else ""
|
||||
return RedirectResponse(url=f"/vacation/settings{suffix}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/settings/balances")
|
||||
async def balance_upsert(
|
||||
request: Request,
|
||||
user_email: str = Form(...),
|
||||
year: int = Form(...),
|
||||
total_days: float = Form(...),
|
||||
memo: str = Form(""),
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
if not is_admin(user):
|
||||
raise HTTPException(status_code=403, detail="관리자만 가능합니다.")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.upsert_balance(
|
||||
user_email=user_email, year=year, total_days=total_days, memo=memo
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/settings?year={year}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/api/calendar")
|
||||
async def api_calendar(
|
||||
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> JSONResponse:
|
||||
"""달력 비동기 데이터(JSON). year/month 쿼리."""
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
year, month = _ym(request)
|
||||
sel = request.query_params.get("date") or f"{year:04d}-{month:02d}-01"
|
||||
caldata = _build_calendar(store, year, month, sel)
|
||||
return JSONResponse(
|
||||
{
|
||||
"year": year,
|
||||
"month": month,
|
||||
"weeks": caldata["weeks"],
|
||||
"requests": caldata["requests"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export.xlsx")
|
||||
async def export_xlsx(
|
||||
request: Request, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> StreamingResponse:
|
||||
from openpyxl import Workbook # 지연 import
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
|
||||
today = today_kst()
|
||||
try:
|
||||
year = int(request.query_params.get("year") or today.year)
|
||||
month = int(request.query_params.get("month") or 0)
|
||||
except ValueError:
|
||||
year, month = today.year, 0
|
||||
|
||||
if 1 <= month <= 12:
|
||||
last = _calendar.monthrange(year, month)[1]
|
||||
date_from = f"{year:04d}-{month:02d}-01"
|
||||
date_to = f"{year:04d}-{month:02d}-{last:02d}"
|
||||
else:
|
||||
date_from = f"{year:04d}-01-01"
|
||||
date_to = f"{year:04d}-12-31"
|
||||
|
||||
rows = store.list_for_export(date_from=date_from, date_to=date_to)
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "vacation"
|
||||
header = [
|
||||
"신청자", "휴가종류", "시작일", "종료일", "시작구분", "종료구분",
|
||||
"사용일수", "상태", "승인자", "승인/반려일", "사유", "반려사유",
|
||||
]
|
||||
ws.append(header)
|
||||
for r in rows:
|
||||
ws.append([
|
||||
r.get("owner_name") or r.get("owner", ""),
|
||||
r.get("vacation_type", ""),
|
||||
r.get("start_date", ""),
|
||||
r.get("end_date", ""),
|
||||
HALF_LABELS.get(r.get("start_half", "full"), ""),
|
||||
HALF_LABELS.get(r.get("end_half", "full"), ""),
|
||||
r.get("days", 0),
|
||||
r.get("status", ""),
|
||||
r.get("approver_email", "") or "",
|
||||
r.get("decided_at", "") or "",
|
||||
r.get("reason", "") or "",
|
||||
r.get("reject_reason", "") or "",
|
||||
])
|
||||
widths = [24, 10, 12, 12, 8, 8, 8, 8, 24, 22, 30, 30]
|
||||
for col, w in enumerate(widths, start=1):
|
||||
ws.column_dimensions[ws.cell(row=1, column=col).column_letter].width = w
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
buf.seek(0)
|
||||
scope = f"{year}{('%02d' % month) if (1 <= month <= 12) else ''}"
|
||||
fname = f"vacation_{scope}_{now_kst().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "module": "vacation"}
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_class=HTMLResponse)
|
||||
async def detail(request: Request, request_id: str) -> HTMLResponse:
|
||||
from app.main import build_erp_nav, render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
req = store.get_request(request_id=request_id)
|
||||
if not req:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
is_owner = req["owner"] == user["email"]
|
||||
is_approver = _is_approver(request, user)
|
||||
if not (is_owner or is_approver):
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "본인 또는 승인자만 조회할 수 있습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
return render_template(
|
||||
request,
|
||||
"vacation/detail.html",
|
||||
{
|
||||
"user": user,
|
||||
"is_admin": is_admin(user),
|
||||
"is_approver": is_approver,
|
||||
"is_owner": is_owner,
|
||||
"nav_items": build_erp_nav(user, active="vacation"),
|
||||
"page_title": "휴가 상세",
|
||||
"page_subtitle": f"{req['start_date']} · {req['vacation_type']}",
|
||||
"req": req,
|
||||
"half_labels": HALF_LABELS,
|
||||
"can_edit": is_owner and req["status"] in ("작성중", "반려"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{request_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_form(request: Request, request_id: str) -> HTMLResponse:
|
||||
from app.main import render_template # noqa: WPS433
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
guard = _guard(request)
|
||||
if not isinstance(guard, tuple):
|
||||
return guard
|
||||
store, user = guard
|
||||
req = store.get_request(request_id=request_id)
|
||||
if not req:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "휴가 신청을 찾을 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=404,
|
||||
)
|
||||
if req["owner"] != user["email"]:
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": "본인 신청만 수정할 수 있습니다.", "is_admin": is_admin(user)},
|
||||
status_code=403,
|
||||
)
|
||||
if req["status"] not in ("작성중", "반려"):
|
||||
return render_template(
|
||||
request, "denied.html",
|
||||
{"reason": f"{req['status']} 상태에서는 수정할 수 없습니다.", "is_admin": is_admin(user)},
|
||||
status_code=409,
|
||||
)
|
||||
ctx = _form_context(request, user)
|
||||
ctx.update(
|
||||
{
|
||||
"page_title": "휴가 신청 수정",
|
||||
"page_subtitle": "작성중/반려 상태만 수정 가능",
|
||||
"mode": "edit",
|
||||
"req": req,
|
||||
"default_date": req["start_date"],
|
||||
}
|
||||
)
|
||||
return render_template(request, "vacation/form.html", ctx)
|
||||
|
||||
|
||||
@router.post("/{request_id}/edit")
|
||||
async def update(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
vacation_type: str = Form(...),
|
||||
start_date: str = Form(...),
|
||||
end_date: str = Form(""),
|
||||
start_half: str = Form("full"),
|
||||
end_half: str = Form("full"),
|
||||
reason: str = Form(""),
|
||||
action: str = Form("save"), # "save" | "submit"
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
payload = _payload_from_form(
|
||||
vacation_type, start_date, end_date, start_half, end_half, reason
|
||||
)
|
||||
try:
|
||||
store.update_request(request_id=request_id, owner=user["email"], payload=payload)
|
||||
if action == "submit":
|
||||
store.submit(request_id=request_id, owner=user["email"])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/submit")
|
||||
async def submit(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.submit(request_id=request_id, owner=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/approve")
|
||||
async def approve(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_approver),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.approve(request_id=request_id, approver_email=user["email"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/reject")
|
||||
async def reject(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
reject_reason: str = Form(...),
|
||||
user: dict[str, Any] = Depends(_require_approver),
|
||||
) -> RedirectResponse:
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.reject(
|
||||
request_id=request_id, approver_email=user["email"], reason=reject_reason
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/cancel")
|
||||
async def cancel(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.cancel(
|
||||
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
return RedirectResponse(url=f"/vacation/{request_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{request_id}/delete")
|
||||
async def delete(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
user: dict[str, Any] = Depends(_require_user),
|
||||
) -> RedirectResponse:
|
||||
"""완전 삭제 — 승인 전(작성중/제출/반려)만. 달력으로 복귀."""
|
||||
from app.store import is_admin # noqa: WPS433
|
||||
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="vacation_db 미설정")
|
||||
try:
|
||||
store.hard_delete(
|
||||
request_id=request_id, user_email=user["email"], is_admin=is_admin(user)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="휴가 신청을 찾을 수 없습니다.")
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return RedirectResponse(url="/vacation/", status_code=303)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""휴가 관리 모듈 상수 및 순수 계산 헬퍼.
|
||||
|
||||
- 데이터 저장은 vacation_db(PostgreSQL) 전용이다(`db.py`).
|
||||
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
|
||||
VACATION_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
|
||||
- 이 모듈에는 상수와 순수 계산 헬퍼(휴가일수 계산)만 둔다.
|
||||
공휴일 집합은 db.py 가 vacation_holidays 에서 읽어 넘겨준다(holiday provider 분리).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Iterable
|
||||
|
||||
# 휴가 종류 (한글 라벨 그대로 저장)
|
||||
VACATION_TYPES: tuple[str, ...] = (
|
||||
"연차",
|
||||
"오전반차",
|
||||
"오후반차",
|
||||
"병가",
|
||||
"경조",
|
||||
"대체휴무",
|
||||
"기타",
|
||||
)
|
||||
|
||||
# 신청 상태 워크플로
|
||||
STATUSES: tuple[str, ...] = ("작성중", "제출", "승인", "반려", "취소")
|
||||
|
||||
# 시작/종료일 구분
|
||||
HALVES: tuple[str, ...] = ("full", "am", "pm")
|
||||
HALF_LABELS: dict[str, str] = {"full": "종일", "am": "오전", "pm": "오후"}
|
||||
|
||||
# 수정/삭제(취소) 가능한 상태 — 본인 편집 허용
|
||||
EDITABLE_STATUSES: tuple[str, ...] = ("작성중", "반려")
|
||||
|
||||
# 반차 성격의 휴가 종류 (단일 일자 0.5일 강제)
|
||||
_HALF_DAY_TYPES: dict[str, str] = {"오전반차": "am", "오후반차": "pm"}
|
||||
|
||||
|
||||
def _daterange(start: date, end: date) -> Iterable[date]:
|
||||
cur = start
|
||||
while cur <= end:
|
||||
yield cur
|
||||
cur += timedelta(days=1)
|
||||
|
||||
|
||||
def is_working_day(d: date, holidays: set[str]) -> bool:
|
||||
"""주말(토/일)과 공휴일(holidays: ISO 날짜 집합)을 제외하면 근무일."""
|
||||
if d.weekday() >= 5: # 5=토, 6=일
|
||||
return False
|
||||
return d.isoformat() not in holidays
|
||||
|
||||
|
||||
def compute_days(
|
||||
*,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
start_half: str = "full",
|
||||
end_half: str = "full",
|
||||
vacation_type: str = "연차",
|
||||
holidays: set[str] | None = None,
|
||||
) -> float:
|
||||
"""휴가 일수 계산. 서버에서 항상 이 함수로 재계산한다(클라이언트 신뢰 금지).
|
||||
|
||||
- 주말 + 공휴일(is_red) 제외.
|
||||
- 오전/오후 반차는 0.5일.
|
||||
- 같은 날 + 반차면 0.5일.
|
||||
- 여러 날에서 시작/종료가 반차면 시작일/종료일 각각 0.5 차감.
|
||||
- 휴가 종류가 오전반차/오후반차면 단일 일자 0.5일로 강제.
|
||||
"""
|
||||
holidays = holidays or set()
|
||||
if end_date < start_date:
|
||||
return 0.0
|
||||
|
||||
# 반차 종류는 단일 일자 0.5
|
||||
if vacation_type in _HALF_DAY_TYPES:
|
||||
if is_working_day(start_date, holidays):
|
||||
return 0.5
|
||||
return 0.0
|
||||
|
||||
working = [d for d in _daterange(start_date, end_date) if is_working_day(d, holidays)]
|
||||
n = len(working)
|
||||
if n == 0:
|
||||
return 0.0
|
||||
|
||||
if start_date == end_date:
|
||||
return 0.5 if start_half in ("am", "pm") else 1.0
|
||||
|
||||
total = float(n)
|
||||
if start_half in ("am", "pm") and is_working_day(start_date, holidays):
|
||||
total -= 0.5
|
||||
if end_half in ("am", "pm") and is_working_day(end_date, holidays):
|
||||
total -= 0.5
|
||||
return max(0.0, total)
|
||||
|
||||
|
||||
def normalize_half(value: str | None) -> str:
|
||||
v = (value or "full").strip().lower()
|
||||
return v if v in HALVES else "full"
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530j" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
<div class="erp-card vac-detail-card">
|
||||
|
||||
<div class="vac-detail-head">
|
||||
<div>
|
||||
<h2>{{ req.owner_name or req.owner }}</h2>
|
||||
<span class="erp-muted">{{ req.owner }}</span>
|
||||
</div>
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if req.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif req.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif req.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }} vac-detail-status">{{ req.status }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="vac-detail-grid">
|
||||
<div><dt>휴가 종류</dt><dd>{{ req.vacation_type }}</dd></div>
|
||||
<div><dt>사용 일수</dt><dd><strong>{{ req.days }}</strong>일</dd></div>
|
||||
<div><dt>시작일</dt><dd>{{ req.start_date }} ({{ half_labels[req.start_half] }})</dd></div>
|
||||
<div><dt>종료일</dt><dd>{{ req.end_date }} ({{ half_labels[req.end_half] }})</dd></div>
|
||||
{% if req.approver_email %}
|
||||
<div><dt>승인자</dt><dd>{{ req.approver_email }}</dd></div>
|
||||
<div><dt>결재일시</dt><dd>{{ req.decided_at }}</dd></div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if req.reason %}
|
||||
<div class="vac-detail-block">
|
||||
<div class="vac-detail-label">사유</div>
|
||||
<p>{{ req.reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status == '반려' and req.reject_reason %}
|
||||
<div class="vac-detail-block vac-reject-block">
|
||||
<div class="vac-detail-label">반려 사유</div>
|
||||
<p>{{ req.reject_reason }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 승인자 액션 (제출 상태) ── -->
|
||||
{% if is_approver and req.status == '제출' %}
|
||||
<div class="vac-approve-box">
|
||||
<form method="post" action="/vacation/{{ req.id }}/approve" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-primary">승인</button>
|
||||
</form>
|
||||
<form method="post" action="/vacation/{{ req.id }}/reject" class="vac-reject-form">
|
||||
<input class="erp-input" type="text" name="reject_reason" placeholder="반려 사유" required />
|
||||
<button type="submit" class="erp-btn erp-btn-danger">반려</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="erp-page-actions vac-detail-actions">
|
||||
<a class="erp-btn erp-btn-primary" href="/vacation/">◀◀ 달력</a>
|
||||
|
||||
{% if is_owner and req.status in ('작성중', '반려') %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/submit" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">제출</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# 승인 건: 취소처리(owner/admin). 미승인: 삭제(owner/admin). 관리자는 모든 상태 삭제 가능. #}
|
||||
{% if req.status == '승인' and (is_owner or is_admin) %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/cancel" class="vac-inline-form"
|
||||
onsubmit="return confirm('승인된 휴가를 취소 처리할까요?');">
|
||||
<button type="submit" class="erp-btn erp-btn-outline">취소처리</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status in ('작성중', '제출', '반려') and (is_owner or is_admin) %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
|
||||
onsubmit="return confirm('이 휴가 신청을 삭제할까요? (복구 불가)');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin and req.status in ('승인', '취소') %}
|
||||
<form method="post" action="/vacation/{{ req.id }}/delete" class="vac-inline-form"
|
||||
onsubmit="return confirm('[관리자] 이 휴가({{ req.status }})를 완전 삭제할까요? (복구 불가)');">
|
||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<a class="erp-btn erp-btn-outline vac-push-right" href="/vacation/{{ req.id }}/edit">수정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
{% set action = '/vacation/new' if mode == 'new' else '/vacation/' ~ req.id ~ '/edit' %}
|
||||
<form id="vac-form" method="post" action="{{ action }}" class="erp-card vac-form-card">
|
||||
|
||||
<div class="vac-form-grid">
|
||||
<label class="erp-field"><span>휴가 종류 *</span>
|
||||
<select class="erp-select" name="vacation_type" id="vac-type" required>
|
||||
{% for t in vacation_types %}
|
||||
<option value="{{ t }}" {% if req and req.vacation_type == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="erp-field"><span>시작일 *</span>
|
||||
<input class="erp-input" type="date" name="start_date" id="vac-start" required
|
||||
value="{{ req.start_date if req else default_date }}" />
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-start-half-field"><span>시작 구분</span>
|
||||
<select class="erp-select" name="start_half" id="vac-start-half">
|
||||
{% for h in halves %}
|
||||
<option value="{{ h }}" {% if req and req.start_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-end-field"><span>종료일 *</span>
|
||||
<input class="erp-input" type="date" name="end_date" id="vac-end"
|
||||
value="{{ req.end_date if req else default_date }}" />
|
||||
</label>
|
||||
|
||||
<label class="erp-field" id="vac-end-half-field"><span>종료 구분</span>
|
||||
<select class="erp-select" name="end_half" id="vac-end-half">
|
||||
{% for h in halves %}
|
||||
<option value="{{ h }}" {% if req and req.end_half == h %}selected{% endif %}>{{ half_labels[h] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="erp-field vac-full"><span>사유</span>
|
||||
<textarea class="erp-input" name="reason" rows="3"
|
||||
placeholder="휴가 사유를 입력하세요.">{{ req.reason if req else '' }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="vac-days-preview">
|
||||
예상 사용 일수: <strong id="vac-days-out">—</strong>
|
||||
<span class="erp-muted">(주말 제외 · 공휴일은 저장 시 반영)</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-page-actions vac-form-actions">
|
||||
<button type="submit" class="erp-btn erp-btn-primary" name="action" value="submit">제출</button>
|
||||
<button type="submit" class="erp-btn erp-btn-outline" name="action"
|
||||
value="{{ 'save' if mode == 'edit' else 'draft' }}">작성중 저장</button>
|
||||
{% if mode == 'edit' %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/{{ req.id }}">취소</a>
|
||||
{% else %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/">취소</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}<script src="/static/vacation.js?v=20260530i" defer></script>{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
<!-- 페이지 액션 -->
|
||||
<div class="vac-actions">
|
||||
<div class="vac-actions-main">
|
||||
<a class="erp-btn erp-btn-primary" href="/vacation/new">+ 휴가 신청</a>
|
||||
{% if is_approver %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/pending">승인 대기
|
||||
{% if pending_count %}<span class="erp-badge erp-badge-inverse vac-mini">{{ pending_count }}</span>{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/export.xlsx?year={{ year }}&month={{ month }}">엑셀</a>
|
||||
{% if is_admin %}
|
||||
<a class="erp-btn erp-btn-outline" href="/vacation/settings?year={{ year }}">설정</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="vac-balance">
|
||||
<span class="vac-bal-item">총 <strong>{{ balance.total_days }}</strong></span>
|
||||
<span class="vac-bal-sep">·</span>
|
||||
<span class="vac-bal-item">사용 <strong>{{ balance.used_days }}</strong></span>
|
||||
<span class="vac-bal-sep">·</span>
|
||||
<span class="vac-bal-item vac-bal-remain">잔여 <strong>{{ balance.remaining_days }}</strong></span>
|
||||
<span class="erp-muted vac-bal-year">{{ year }}년</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vac-layout">
|
||||
<!-- ── 왼쪽: 월간 달력 ── -->
|
||||
<div class="erp-card vac-cal-card">
|
||||
<div class="vac-cal-head">
|
||||
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||
href="/vacation/?year={{ prev_y }}&month={{ prev_m }}">‹</a>
|
||||
<h2 class="vac-cal-title">{{ year }}년 {{ month }}월</h2>
|
||||
<a class="erp-btn erp-btn-outline vac-nav-btn"
|
||||
href="/vacation/?year={{ next_y }}&month={{ next_m }}">›</a>
|
||||
<a class="erp-btn erp-btn-outline vac-today-btn" href="/vacation/">오늘</a>
|
||||
</div>
|
||||
|
||||
<div class="vac-cal">
|
||||
<div class="vac-wd-row">
|
||||
{% for wd in weekdays %}
|
||||
<div class="vac-wd {% if loop.index0 == 0 %}vac-red{% elif loop.index0 == 6 %}vac-blue{% endif %}">{{ wd }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for week in weeks %}
|
||||
<div class="vac-week">
|
||||
<div class="vac-week-days">
|
||||
{% for cell in week.days %}
|
||||
<a class="vac-day
|
||||
{% if not cell.in_month %}vac-out{% endif %}
|
||||
{% if cell.is_today %}vac-today{% endif %}
|
||||
{% if cell.is_selected %}vac-selected{% endif %}"
|
||||
href="/vacation/?year={{ year }}&month={{ month }}&date={{ cell.date }}"
|
||||
{% if cell.holiday_name %}title="{{ cell.holiday_name }}"{% endif %}>
|
||||
<span class="vac-day-num
|
||||
{% if cell.is_sunday or cell.is_holiday %}vac-red{% elif cell.is_saturday %}vac-blue{% endif %}">{{ cell.day }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="vac-week-bars">
|
||||
{% for bar in week.bars %}
|
||||
<a class="vac-bar {{ bar.status_class }}
|
||||
{% if bar.continues_left %}vac-bar-l{% endif %}
|
||||
{% if bar.continues_right %}vac-bar-r{% endif %}"
|
||||
style="grid-column: {{ bar.start_col + 1 }} / span {{ bar.span }}; grid-row: {{ bar.lane + 1 }};"
|
||||
href="/vacation/{{ bar.id }}"
|
||||
title="{{ bar.label }} ({{ bar.days }}일 · {{ bar.status }})">
|
||||
<span class="vac-bar-label">{{ bar.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="vac-legend">
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-submit"></i>제출</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-approve"></i>승인</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-reject"></i>반려</span>
|
||||
<span class="vac-leg"><i class="vac-dot vac-bar-cancel"></i>취소</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 오른쪽: 선택일 휴가 리스트 ── -->
|
||||
<div class="erp-card vac-list-card">
|
||||
<div class="vac-list-head">
|
||||
<h2>{{ selected_date }}</h2>
|
||||
<span class="erp-muted">{{ sel_requests|length }}건</span>
|
||||
</div>
|
||||
|
||||
{% if sel_requests %}
|
||||
<ul class="vac-list">
|
||||
{% for r in sel_requests %}
|
||||
<li class="vac-list-item">
|
||||
<a href="/vacation/{{ r.id }}" class="vac-list-link">
|
||||
<div class="vac-list-top">
|
||||
<strong>{{ r.owner_name or r.owner }}</strong>
|
||||
{% set badge = 'erp-badge-neutral' %}
|
||||
{% if r.status == '승인' %}{% set badge = 'erp-badge-success' %}
|
||||
{% elif r.status == '제출' %}{% set badge = 'erp-badge-inverse' %}
|
||||
{% elif r.status == '반려' %}{% set badge = 'erp-badge-danger' %}{% endif %}
|
||||
<span class="erp-badge {{ badge }}">{{ r.status }}</span>
|
||||
</div>
|
||||
<div class="vac-list-meta erp-muted">
|
||||
{{ r.vacation_type }} · {{ r.days }}일 ·
|
||||
{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}
|
||||
</div>
|
||||
{% if r.reason %}<div class="vac-list-reason erp-muted">{{ r.reason }}</div>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="erp-muted vac-empty">선택한 날짜의 휴가가 없습니다.
|
||||
<a href="/vacation/new">휴가 신청</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
<div class="erp-card">
|
||||
{% if items %}
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table vac-pending-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청자</th><th>휴가종류</th><th>기간</th><th>일수</th>
|
||||
<th>사유</th><th class="vac-act-col">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in items %}
|
||||
<tr>
|
||||
<td><a href="/vacation/{{ r.id }}">{{ r.owner_name or r.owner }}</a></td>
|
||||
<td>{{ r.vacation_type }}</td>
|
||||
<td>{{ r.start_date }}{% if r.end_date != r.start_date %} ~ {{ r.end_date }}{% endif %}</td>
|
||||
<td>{{ r.days }}</td>
|
||||
<td class="vac-reason-cell erp-muted">{{ r.reason }}</td>
|
||||
<td class="vac-act-col">
|
||||
<div class="vac-pending-acts">
|
||||
<form method="post" action="/vacation/{{ r.id }}/approve" class="vac-inline-form">
|
||||
<button type="submit" class="erp-btn erp-btn-primary erp-btn-sm">승인</button>
|
||||
</form>
|
||||
<form method="post" action="/vacation/{{ r.id }}/reject" class="vac-reject-form">
|
||||
<input class="erp-input erp-input-sm" type="text" name="reject_reason"
|
||||
placeholder="반려 사유" required />
|
||||
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">반려</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="erp-muted">승인 대기 중인 휴가 신청이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block head_extra %}<link rel="stylesheet" href="/static/vacation.css?v=20260530i" />{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="vac">
|
||||
|
||||
<div class="vac-settings-head">
|
||||
<form method="get" action="/vacation/settings" class="vac-year-form">
|
||||
<label class="erp-field vac-year-field"><span>연도</span>
|
||||
<input class="erp-input" type="number" name="year" value="{{ year }}" min="2020" max="2100" />
|
||||
</label>
|
||||
<button type="submit" class="erp-btn erp-btn-outline">조회</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="vac-settings-grid">
|
||||
|
||||
<!-- ── 공휴일 관리 ── -->
|
||||
<div class="erp-card vac-set-card">
|
||||
<div class="vac-card-head"><h2>공휴일 ({{ year }})</h2></div>
|
||||
|
||||
<form method="post" action="/vacation/settings/holidays" class="vac-holiday-form">
|
||||
<label class="erp-field"><span>날짜 *</span>
|
||||
<input class="erp-input" type="date" name="holiday_date" required value="{{ year }}-01-01" />
|
||||
</label>
|
||||
<label class="erp-field"><span>이름 *</span>
|
||||
<input class="erp-input" type="text" name="name" required placeholder="예: 신정" />
|
||||
</label>
|
||||
<label class="erp-field"><span>종류</span>
|
||||
<select class="erp-select" name="kind">
|
||||
<option value="public">공휴일</option>
|
||||
<option value="lunar">음력</option>
|
||||
<option value="substitute">대체</option>
|
||||
<option value="company">회사지정</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="erp-check"><input type="checkbox" name="is_red" checked /> 빨강(달력 표시)</label>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">추가 / 수정</button>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap vac-set-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>날짜</th><th>이름</th><th>종류</th><th>빨강</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in holidays %}
|
||||
<tr>
|
||||
<td>{{ h.holiday_date }}</td>
|
||||
<td>{{ h.name }}</td>
|
||||
<td class="erp-muted">{{ h.kind }}</td>
|
||||
<td>{% if h.is_red %}●{% else %}○{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/vacation/settings/holidays/{{ h.id }}/delete"
|
||||
onsubmit="return confirm('{{ h.holiday_date }} {{ h.name }} 삭제?');">
|
||||
<input type="hidden" name="year" value="{{ year }}" />
|
||||
<button type="submit" class="erp-btn erp-btn-danger erp-btn-sm">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="erp-muted">등록된 공휴일이 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 연차 잔여 관리 ── -->
|
||||
<div class="erp-card vac-set-card">
|
||||
<div class="vac-card-head"><h2>연차 잔여 ({{ year }})</h2></div>
|
||||
|
||||
<form method="post" action="/vacation/settings/balances" class="vac-balance-form">
|
||||
<input type="hidden" name="year" value="{{ year }}" />
|
||||
<label class="erp-field"><span>이메일 *</span>
|
||||
<input class="erp-input" type="email" name="user_email" required
|
||||
placeholder="user@dbxcorp.co.kr" />
|
||||
</label>
|
||||
<label class="erp-field"><span>연차 일수 *</span>
|
||||
<input class="erp-input" type="number" name="total_days" step="0.5" min="0" required value="15" />
|
||||
</label>
|
||||
<label class="erp-field vac-full"><span>메모</span>
|
||||
<input class="erp-input" type="text" name="memo" placeholder="입사일/비고 등" />
|
||||
</label>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">설정</button>
|
||||
</form>
|
||||
|
||||
<div class="erp-table-wrap vac-set-scroll">
|
||||
<table class="erp-table">
|
||||
<thead><tr><th>이메일</th><th>연차</th><th>사용</th><th>메모</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in balances %}
|
||||
<tr>
|
||||
<td>{{ b.user_email }}</td>
|
||||
<td>{{ b.total_days }}</td>
|
||||
<td class="erp-muted">{{ b.used_days }}</td>
|
||||
<td class="erp-muted">{{ b.memo }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="erp-muted">설정된 연차가 없습니다.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="erp-muted vac-set-note">사용 일수는 승인된 휴가 합계로 자동 계산됩니다.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,307 @@
|
||||
/* 쿠팡 밀크런 — DESIGN.md 토큰 준수 (흰 배경 / 검정·회색 / 10·14px radius / 카드 16px) */
|
||||
|
||||
.cpg { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cpg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
/* 맨 오른쪽으로 밀기 (수정 버튼) */
|
||||
.cpg-push-right { margin-left: auto; }
|
||||
/* 달력 상단 설정 버튼 그룹 — 우측 정렬 */
|
||||
.cpg-settings-btns { margin-left: auto; display: inline-flex; gap: 8px; flex-wrap: wrap; }
|
||||
/* 액션바를 달력 레이아웃(좌:달력 1fr / 우:리스트 320)과 같은 그리드로
|
||||
→ 설정 버튼이 달력 컬럼 오른쪽 끝에 정렬 */
|
||||
.cpg-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cpg-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@media (max-width: 980px) {
|
||||
.cpg-actions-grid { grid-template-columns: 1fr; }
|
||||
.cpg-actions-spacer { display: none; }
|
||||
}
|
||||
|
||||
/* ── 레이아웃: 왼쪽 큰 달력 + 오른쪽 리스트 ── */
|
||||
.cpg-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
|
||||
}
|
||||
@media (max-width: 980px) { .cpg-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
.cpg-cal-card, .cpg-list-card { padding: 16px; }
|
||||
.cpg-cal-card { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||||
.cpg-list-card { min-height: 0; overflow-y: auto; }
|
||||
|
||||
.cpg-cal-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 12px; flex-shrink: 0;
|
||||
}
|
||||
.cpg-cal-title { font-size: 18pt; font-weight: 600; letter-spacing: -0.45px; margin: 0; }
|
||||
.cpg-nav-btn { min-width: 36px; padding: 4px 10px; font-size: 18pt; line-height: 1; }
|
||||
|
||||
.cpg-cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: auto repeat(6, 1fr); /* 요일행 auto + 6주 균등 분할 */
|
||||
gap: 6px;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
.cpg-cal-wd {
|
||||
text-align: center; font-size: 15px; font-weight: 600;
|
||||
color: var(--color-midtone-gray); padding: 6px 0;
|
||||
}
|
||||
.cpg-sun { color: var(--color-callout-red); }
|
||||
.cpg-sat { color: #2b5bc2; }
|
||||
|
||||
.cpg-cal-cell {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
min-height: 0; padding: 8px; overflow: hidden;
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.cpg-cal-cell:hover { border-color: var(--color-midtone-gray); }
|
||||
.cpg-out { background: var(--color-ghost-gray); }
|
||||
.cpg-out .cpg-cal-day { color: var(--color-midtone-gray); }
|
||||
.cpg-today { border-color: var(--color-deep-black); }
|
||||
.cpg-selected { box-shadow: 0 0 0 2px var(--color-deep-black); border-color: var(--color-deep-black); }
|
||||
.cpg-cal-day { font-size: 17px; font-weight: 600; }
|
||||
/* 일요일/공휴일 빨강, 토요일 파랑 (당월 셀 우선, 전후월은 옅게) */
|
||||
.cpg-red .cpg-cal-day { color: var(--color-callout-red); }
|
||||
.cpg-blue .cpg-cal-day { color: #2b5bc2; }
|
||||
.cpg-out.cpg-red .cpg-cal-day { color: #e3a59b; }
|
||||
.cpg-out.cpg-blue .cpg-cal-day { color: #9db4dd; }
|
||||
.cpg-cal-badges { display: flex; flex-direction: column; gap: 3px; }
|
||||
.cpg-mini { font-size: 12px; padding: 2px 7px; align-self: flex-start; }
|
||||
|
||||
/* ── 오른쪽 리스트 ── */
|
||||
.cpg-list-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }
|
||||
.cpg-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.cpg-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cpg-list-item { border: 1px solid var(--color-subtle-ash); border-radius: 10px; }
|
||||
.cpg-list-link { display: block; padding: 10px 12px; }
|
||||
.cpg-list-link:hover { background: var(--color-ghost-gray); border-radius: 10px; }
|
||||
.cpg-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cpg-list-meta { font-size: 12px; margin-top: 4px; }
|
||||
.cpg-empty { padding: 16px 0; }
|
||||
|
||||
/* 선택일 출고 hover 툴팁 (마우스 따라다님) */
|
||||
.cpg-hover-tip {
|
||||
position: fixed; z-index: 60; pointer-events: none;
|
||||
background: var(--color-deep-black); color: #fff;
|
||||
border-radius: 10px; padding: 8px 10px;
|
||||
min-width: 160px; max-width: 280px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.22);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cpg-tip-row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
.cpg-tip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cpg-tip-qty { font-weight: 600; flex: 0 0 auto; }
|
||||
.cpg-tip-empty { color: #d4d4d4; }
|
||||
|
||||
/* ── 폼 ── */
|
||||
.cpg-form-card { padding: 16px; margin-bottom: 16px; }
|
||||
|
||||
/* 공통 헤더(좌) + 품목 라인(우) 2열 */
|
||||
/* form 이 .cpg(남은 높이) 안에서 세로로 채우도록 */
|
||||
.cpg > form { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
|
||||
.cpg-form-2col {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
/* 공통 헤더: 너비 450 고정 */
|
||||
.cpg-form-head { flex: 0 0 450px; width: 450px; min-width: 0; margin-bottom: 0; align-self: flex-start; }
|
||||
/* 품목 라인: 너비 900 고정, 높이는 가용 영역 채움, 내부 스크롤 */
|
||||
.cpg-form-lines {
|
||||
flex: 0 0 900px; width: 900px; height: 100%; min-width: 0; margin-bottom: 0;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.cpg-form-lines { flex-basis: auto; width: 100%; }
|
||||
}
|
||||
@media (max-width: 940px) {
|
||||
.cpg-form-head { flex-basis: 100%; width: 100%; }
|
||||
}
|
||||
/* 라인 테이블 영역만 스크롤 */
|
||||
.cpg-lines-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
/* 제목행: 제목 + 설명 + (우측) 추가/삭제 버튼 */
|
||||
.cpg-lines-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.cpg-lines-btns { margin-left: auto; display: flex; gap: 8px; flex: 0 0 auto; }
|
||||
|
||||
/* 저장/취소 — 공통 헤더 카드 하단에 위치(우측 라인 수와 무관) */
|
||||
.cpg-form-actions { margin-top: 16px; }
|
||||
.cpg-card-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.cpg-card-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
|
||||
.cpg-header-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 185px);
|
||||
gap: 12px 16px;
|
||||
}
|
||||
/* 작성일·출고일·센터입고일·입고센터·출고방식·작업자 입력 185px 고정
|
||||
(.cpg .erp-field > .erp-input width:100% 보다 특이도 높게) */
|
||||
.cpg .cpg-header-grid .erp-field > .erp-input,
|
||||
.cpg .cpg-header-grid .erp-field > .erp-select {
|
||||
width: 185px; max-width: 185px; min-width: 185px; box-sizing: border-box;
|
||||
}
|
||||
.cpg-rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px 16px; align-items: end;
|
||||
}
|
||||
/* 그리드 항목 겹침 방지: 칸이 줄어들 때 내용이 밖으로 넘치지 않게 */
|
||||
.cpg-header-grid .erp-field,
|
||||
.cpg-rule-grid .erp-field { min-width: 0; }
|
||||
.cpg .erp-field { display: flex; flex-direction: column; gap: 4px; margin: 0; }
|
||||
.cpg .erp-field > span { font-size: 12px; color: var(--color-midtone-gray); }
|
||||
/* 입력란이 칸 너비를 넘지 않도록 (date/select 포함) */
|
||||
.cpg .erp-field > .erp-input,
|
||||
.cpg .erp-field > .erp-select,
|
||||
.cpg .erp-field > textarea.erp-input { width: 100%; max-width: 100%; box-sizing: border-box; }
|
||||
.cpg-full { grid-column: 1 / -1; }
|
||||
|
||||
/* 검정 배경 버튼 글자는 항상 흰색 (안전 보강) */
|
||||
.cpg .erp-btn-primary, .cpg .erp-btn-primary:visited { color: var(--color-canvas-white); }
|
||||
|
||||
.cpg-inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
|
||||
.cpg-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* ── 입고센터 관리 (좌: 추가 / 우: 목록) ── */
|
||||
.cpg-center-layout {
|
||||
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start;
|
||||
}
|
||||
.cpg-center-add {
|
||||
width: 360px; flex: 0 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.cpg-center-listcard {
|
||||
flex: 1 1 480px; min-width: 0;
|
||||
height: 900px;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.cpg-center-add, .cpg-center-listcard { width: 100%; flex-basis: 100%; }
|
||||
}
|
||||
|
||||
.cpg-center-add-title { font-size: 15px; font-weight: 600; margin: 0 0 8px; }
|
||||
.cpg-btn-sm { padding: 3px 8px; font-size: 12px; }
|
||||
|
||||
/* 목록: 카드 높이 채우고 내부 스크롤 */
|
||||
.cpg-center-list {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.cpg-center-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.cpg-center-row.is-inactive { opacity: .55; }
|
||||
.cpg-center-edit { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||
.cpg-center-name { flex: 1 1 auto; min-width: 0; }
|
||||
.cpg-center-sort { width: 56px; flex: 0 0 auto; }
|
||||
.cpg-center-state { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||
.cpg-center-act { display: inline-flex; gap: 4px; flex: 0 0 auto; }
|
||||
|
||||
/* ── 라인 테이블 ── */
|
||||
.cpg-lines td { vertical-align: middle; }
|
||||
.cpg-lines th.cpg-check-col,
|
||||
.cpg-lines td.cpg-check-col {
|
||||
width: 36px; text-align: center;
|
||||
padding-left: 0; padding-right: 0; vertical-align: middle;
|
||||
}
|
||||
.cpg-check-col input { display: block; margin: 0 auto; cursor: pointer; }
|
||||
.cpg-lines .cpg-name-sel { width: 100%; min-width: 160px; }
|
||||
/* 폭 고정: 제품코드 140px, 수량·입수량 60px */
|
||||
.cpg-lines .cpg-code { width: 140px; min-width: 140px; box-sizing: border-box; }
|
||||
.cpg-lines .cpg-qty,
|
||||
.cpg-lines .cpg-upb { width: 60px; min-width: 60px; box-sizing: border-box; }
|
||||
.cpg-lines .cpg-memo { width: 100%; min-width: 120px; box-sizing: border-box; }
|
||||
.cpg-line-calc { font-size: 13px; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||
.cpg-line-calc.cpg-warn { color: var(--color-callout-red); }
|
||||
.cpg-line-del { cursor: pointer; }
|
||||
|
||||
/* ── 상세 ── */
|
||||
.cpg-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px; margin: 0;
|
||||
}
|
||||
.cpg-detail-grid dt { font-size: 12px; color: var(--color-midtone-gray); }
|
||||
.cpg-detail-grid dd { margin: 2px 0 0; font-size: 14px; }
|
||||
|
||||
/* ── 설정(제품명) 2열 레이아웃 ── */
|
||||
.cpg-prod-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px; align-items: flex-start;
|
||||
}
|
||||
/* 미라네 주방 상품: 폭 500 / 높이 900 고정, 내부 스크롤 */
|
||||
.cpg-prod-left {
|
||||
width: 500px; height: 900px;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 등록된 제품명: 폭 600 / 높이 900 고정, 내부 스크롤 */
|
||||
.cpg-prod-right {
|
||||
width: 600px; height: 900px;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.cpg-prod-left, .cpg-prod-right { width: 100%; }
|
||||
}
|
||||
|
||||
.cpg-src-list {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
|
||||
}
|
||||
/* 등록 목록 테이블 스크롤 영역 (카드 높이에서 헤더 제외하고 채움) */
|
||||
.cpg-reg-scroll {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
||||
}
|
||||
.cpg-src-item {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
||||
padding: 8px 12px; cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
.cpg-src-item:last-child { border-bottom: 0; }
|
||||
.cpg-src-item:hover { background: var(--color-ghost-gray); }
|
||||
.cpg-src-name { font-size: 14px; font-weight: 500; }
|
||||
.cpg-src-code { font-size: 12px; color: var(--color-midtone-gray); white-space: nowrap; }
|
||||
/* 선택됨: 검정 테두리 강조 */
|
||||
.cpg-src-item.is-selected { box-shadow: inset 0 0 0 2px var(--color-deep-black); }
|
||||
/* 이미 등록됨: 진한 회색 배경 + 흰 글씨 */
|
||||
.cpg-src-item.cpg-registered { background: #4b4b4b; }
|
||||
.cpg-src-item.cpg-registered .cpg-src-name { color: #fff; }
|
||||
.cpg-src-item.cpg-registered .cpg-src-code { color: #d4d4d4; }
|
||||
.cpg-src-item.cpg-registered:hover { background: #3a3a3a; }
|
||||
|
||||
/* ── 박스 입수량 (좌: 추가/수정 480 / 우: 목록 900) ── */
|
||||
.cpg-brule-layout { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
|
||||
.cpg-brule-add { flex: 0 0 480px; width: 480px; min-width: 0; margin-bottom: 0; }
|
||||
.cpg-brule-list { flex: 0 0 900px; width: 900px; min-width: 0; margin-bottom: 0; }
|
||||
@media (max-width: 1420px) { .cpg-brule-list { flex-basis: auto; width: 100%; } }
|
||||
@media (max-width: 540px) { .cpg-brule-add { flex-basis: 100%; width: 100%; } }
|
||||
|
||||
.cpg-brule-fields { display: flex; flex-direction: column; gap: 12px; }
|
||||
.cpg-brule-row { display: flex; gap: 16px; align-items: flex-end; }
|
||||
.cpg-brule-memo-field { flex: 1 1 auto; min-width: 0; }
|
||||
/* 필드별 고정 폭 (erp.css min-width:240 오버라이드) */
|
||||
.cpg .cpg-brule-fields .cpg-brule-name { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
|
||||
.cpg .cpg-brule-fields .cpg-brule-code { width: 100px; min-width: 100px; max-width: 100px; box-sizing: border-box; }
|
||||
/* 박스이름 폭을 제품명(180)과 동일하게 → 박스당 입수량이 제품코드와 세로 정렬 */
|
||||
.cpg .cpg-brule-fields .cpg-brule-box { width: 180px; min-width: 180px; max-width: 180px; box-sizing: border-box; }
|
||||
/* 박스당 입수량 60px + "개" */
|
||||
.cpg-upb-wrap { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.cpg .cpg-brule-fields .cpg-upb-wrap > .cpg-brule-upb {
|
||||
width: 60px; min-width: 60px; max-width: 60px; box-sizing: border-box;
|
||||
}
|
||||
.cpg-upb-unit { font-size: 13px; color: var(--color-midtone-gray); }
|
||||
/* 메모: 프레임 전체 너비 */
|
||||
.cpg .cpg-brule-fields .cpg-brule-memo { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; }
|
||||
@@ -0,0 +1,198 @@
|
||||
/* 쿠팡 밀크런 — 출고 폼 동적 라인.
|
||||
제품명 드롭다운(설정에서 등록한 카탈로그) 선택 → 제품코드 자동 입력.
|
||||
수량 입력 시 박스 수 미리보기(서버가 저장 시 store.compute_boxes 로 재계산). */
|
||||
(function () {
|
||||
"use strict";
|
||||
var body = document.getElementById("cpg-lines-body");
|
||||
var form = document.getElementById("cpg-form");
|
||||
if (!body || !form) return;
|
||||
|
||||
// 입수량 규칙: product_code -> units_per_box
|
||||
var ruleMap = {};
|
||||
try {
|
||||
var rules = JSON.parse(document.getElementById("cpg-box-rules").textContent || "[]");
|
||||
rules.forEach(function (r) { if (r.active !== false) ruleMap[r.product_code] = r.units_per_box; });
|
||||
} catch (e) {}
|
||||
|
||||
// 제품 카탈로그: [{product_code, product_name}]
|
||||
var products = [];
|
||||
try { products = JSON.parse(document.getElementById("cpg-products").textContent || "[]"); } catch (e) {}
|
||||
var nameByCode = {};
|
||||
products.forEach(function (p) { nameByCode[p.product_code] = p.product_name; });
|
||||
|
||||
var initLines = [];
|
||||
try { initLines = JSON.parse(document.getElementById("cpg-init-lines").textContent || "[]"); } catch (e) {}
|
||||
|
||||
function ceilDiv(a, b) { return Math.ceil(a / b); }
|
||||
|
||||
function recalc(row) {
|
||||
var qty = parseInt(row.querySelector(".cpg-qty").value, 10) || 0;
|
||||
var upb = parseInt(row.querySelector(".cpg-upb").value, 10);
|
||||
var cell = row.querySelector(".cpg-line-calc");
|
||||
if (!upb || upb <= 0) { cell.textContent = "미설정"; cell.classList.add("cpg-warn"); return; }
|
||||
cell.classList.remove("cpg-warn");
|
||||
if (qty <= 0) { cell.textContent = "—"; return; }
|
||||
var boxes = ceilDiv(qty, upb), rem = qty % upb;
|
||||
cell.textContent = boxes + "박스" + (rem ? " +" + rem : " (딱맞음)");
|
||||
}
|
||||
|
||||
function buildNameSelect(selectedCode) {
|
||||
var sel = document.createElement("select");
|
||||
sel.className = "erp-select cpg-name-sel";
|
||||
var opt0 = document.createElement("option");
|
||||
opt0.value = ""; opt0.textContent = "— 제품명 선택 —";
|
||||
sel.appendChild(opt0);
|
||||
var found = false;
|
||||
products.forEach(function (p) {
|
||||
var o = document.createElement("option");
|
||||
o.value = p.product_code;
|
||||
o.textContent = p.product_name;
|
||||
o.setAttribute("data-name", p.product_name);
|
||||
if (p.product_code === selectedCode) { o.selected = true; found = true; }
|
||||
sel.appendChild(o);
|
||||
});
|
||||
// 카탈로그에 없는 기존 라인 코드 → 임시 옵션으로 표시
|
||||
if (selectedCode && !found) {
|
||||
var o = document.createElement("option");
|
||||
o.value = selectedCode;
|
||||
o.textContent = (nameByCode[selectedCode] || selectedCode) + " (미등록)";
|
||||
o.setAttribute("data-name", nameByCode[selectedCode] || selectedCode);
|
||||
o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
function makeRow(data) {
|
||||
data = data || {};
|
||||
var tr = document.createElement("tr");
|
||||
// 컬럼: 체크 / 제품명(select) / 제품코드 / 수량 / 입수량 / 박스계산 / 라인메모
|
||||
tr.innerHTML =
|
||||
'<td class="cpg-check-col"><input type="checkbox" class="cpg-row-check" /></td>' +
|
||||
'<td class="cpg-cell-name"></td>' +
|
||||
'<td><input class="erp-input cpg-code" type="text" placeholder="제품코드" /></td>' +
|
||||
'<td><input class="erp-input cpg-qty" type="number" min="1" /></td>' +
|
||||
'<td><input class="erp-input cpg-upb" type="number" min="1" placeholder="입수량" /></td>' +
|
||||
'<td><span class="cpg-line-calc">—</span></td>' +
|
||||
'<td><input class="erp-input cpg-memo" type="text" /></td>';
|
||||
|
||||
var code = data.product_code || "";
|
||||
var nameSel = buildNameSelect(code);
|
||||
tr.querySelector(".cpg-cell-name").appendChild(nameSel);
|
||||
|
||||
var codeInput = tr.querySelector(".cpg-code");
|
||||
codeInput.value = code;
|
||||
tr.querySelector(".cpg-qty").value = data.quantity || "";
|
||||
var upb = data.units_per_box;
|
||||
if (upb == null && code && ruleMap[code] != null) upb = ruleMap[code];
|
||||
tr.querySelector(".cpg-upb").value = (upb != null ? upb : "");
|
||||
|
||||
// 제품명 선택 → 코드 자동 입력 + 입수량 자동
|
||||
nameSel.addEventListener("change", function () {
|
||||
var c = nameSel.value;
|
||||
codeInput.value = c;
|
||||
if (c && ruleMap[c] != null) tr.querySelector(".cpg-upb").value = ruleMap[c];
|
||||
recalc(tr);
|
||||
});
|
||||
// 코드 직접 입력 시 입수량 규칙 자동
|
||||
codeInput.addEventListener("input", function () {
|
||||
var c = codeInput.value.trim();
|
||||
if (c && ruleMap[c] != null && !tr.querySelector(".cpg-upb").value) {
|
||||
tr.querySelector(".cpg-upb").value = ruleMap[c];
|
||||
}
|
||||
});
|
||||
|
||||
tr.querySelector(".cpg-qty").addEventListener("input", function () { recalc(tr); });
|
||||
tr.querySelector(".cpg-upb").addEventListener("input", function () { recalc(tr); });
|
||||
|
||||
body.appendChild(tr);
|
||||
recalc(tr);
|
||||
syncCheckAll();
|
||||
return tr;
|
||||
}
|
||||
|
||||
// 전체 선택 체크박스 상태 동기화
|
||||
var checkAll = document.getElementById("cpg-check-all");
|
||||
function syncCheckAll() {
|
||||
if (!checkAll) return;
|
||||
var checks = body.querySelectorAll(".cpg-row-check");
|
||||
var total = checks.length;
|
||||
var on = 0;
|
||||
Array.prototype.forEach.call(checks, function (c) { if (c.checked) on++; });
|
||||
checkAll.checked = total > 0 && on === total;
|
||||
checkAll.indeterminate = on > 0 && on < total;
|
||||
}
|
||||
if (checkAll) {
|
||||
checkAll.addEventListener("change", function () {
|
||||
Array.prototype.forEach.call(body.querySelectorAll(".cpg-row-check"), function (c) {
|
||||
c.checked = checkAll.checked;
|
||||
});
|
||||
});
|
||||
}
|
||||
body.addEventListener("change", function (e) {
|
||||
if (e.target && e.target.classList.contains("cpg-row-check")) syncCheckAll();
|
||||
});
|
||||
|
||||
// 초기 라인
|
||||
if (initLines.length) { initLines.forEach(makeRow); } else { makeRow(); }
|
||||
document.getElementById("cpg-add-line").addEventListener("click", function () { makeRow(); });
|
||||
|
||||
// 선택 라인 삭제 (체크된 행 삭제, 최소 1줄 유지)
|
||||
var delBtn = document.getElementById("cpg-del-line");
|
||||
if (delBtn) {
|
||||
delBtn.addEventListener("click", function () {
|
||||
var checked = body.querySelectorAll(".cpg-row-check:checked");
|
||||
if (!checked.length) { alert("삭제할 라인을 선택하세요."); return; }
|
||||
Array.prototype.forEach.call(checked, function (c) {
|
||||
var tr = c.closest("tr");
|
||||
if (tr) tr.remove();
|
||||
});
|
||||
if (!body.querySelectorAll("tr").length) makeRow(); // 최소 1줄
|
||||
if (checkAll) { checkAll.checked = false; checkAll.indeterminate = false; }
|
||||
syncCheckAll();
|
||||
});
|
||||
}
|
||||
|
||||
// 제출: 라인 직렬화
|
||||
form.addEventListener("submit", function (e) {
|
||||
var lines = [];
|
||||
Array.prototype.forEach.call(body.querySelectorAll("tr"), function (tr) {
|
||||
var code = tr.querySelector(".cpg-code").value.trim();
|
||||
var qty = parseInt(tr.querySelector(".cpg-qty").value, 10) || 0;
|
||||
if (!code || qty <= 0) return;
|
||||
var upb = parseInt(tr.querySelector(".cpg-upb").value, 10);
|
||||
var sel = tr.querySelector(".cpg-name-sel");
|
||||
var name = "";
|
||||
if (sel && sel.selectedIndex >= 0) {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
name = opt ? (opt.getAttribute("data-name") || "") : "";
|
||||
}
|
||||
if (!name) name = nameByCode[code] || code;
|
||||
lines.push({
|
||||
product_code: code,
|
||||
product_name_snapshot: name,
|
||||
quantity: qty,
|
||||
units_per_box: (upb > 0 ? upb : null),
|
||||
memo: tr.querySelector(".cpg-memo").value.trim()
|
||||
});
|
||||
});
|
||||
if (!lines.length) {
|
||||
e.preventDefault();
|
||||
alert("품목 라인을 최소 1개 입력하세요 (제품명/코드 + 수량).");
|
||||
return;
|
||||
}
|
||||
document.getElementById("cpg-lines-json").value = JSON.stringify(lines);
|
||||
});
|
||||
|
||||
// 입고센터 select → 스냅샷 hidden 동기화
|
||||
var centerSel = document.getElementById("cpg-center-select");
|
||||
var centerName = document.getElementById("cpg-center-name");
|
||||
if (centerSel && centerName) {
|
||||
function syncCenter() {
|
||||
var opt = centerSel.options[centerSel.selectedIndex];
|
||||
if (opt && opt.value) centerName.value = opt.getAttribute("data-name") || opt.text;
|
||||
}
|
||||
centerSel.addEventListener("change", syncCenter);
|
||||
if (!centerName.value) syncCenter();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,129 @@
|
||||
/* ════════════════════════════════════════════════════
|
||||
첨부 뷰어 모달 — 이미지 패닝 + 엑셀 시트
|
||||
════════════════════════════════════════════════════ */
|
||||
|
||||
.eav-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
display: none;
|
||||
align-items: stretch; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.eav-overlay[data-open="true"] { display: flex; }
|
||||
|
||||
.eav-modal {
|
||||
background: var(--color-canvas-white, #fff);
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
||||
}
|
||||
|
||||
.eav-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
}
|
||||
.eav-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.eav-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.eav-btn {
|
||||
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
background: var(--color-canvas-white, #fff);
|
||||
color: var(--color-rich-black, #0a0a0a);
|
||||
padding: 5px 10px; font-size: 12px; border-radius: 8px; cursor: pointer;
|
||||
font-family: inherit; text-decoration: none; display: inline-block;
|
||||
}
|
||||
.eav-btn:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||
.eav-btn.is-active { background: var(--color-deep-black, #000); color: #fff; border-color: #000; }
|
||||
.eav-close {
|
||||
width: 28px; height: 28px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 0; background: transparent; cursor: pointer; font-size: 18px;
|
||||
}
|
||||
.eav-close:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||
|
||||
.eav-body {
|
||||
display: grid; grid-template-columns: 220px 1fr; min-height: 0; height: 100%;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.eav-list {
|
||||
background: #fff; border-right: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.eav-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px; border-radius: 8px; cursor: pointer;
|
||||
border: 1px solid transparent; background: transparent;
|
||||
text-align: left; font-family: inherit; font-size: 12px;
|
||||
}
|
||||
.eav-item:hover { background: var(--color-ghost-gray, #f2f2f2); }
|
||||
.eav-item.is-active { background: var(--color-ghost-gray, #f2f2f2); border-color: var(--color-subtle-ash, #e5e5e5); }
|
||||
.eav-item-thumb {
|
||||
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; color: var(--color-midtone-gray, #737373);
|
||||
overflow: hidden;
|
||||
}
|
||||
.eav-item-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.eav-item-meta { flex: 1; min-width: 0; }
|
||||
.eav-item-name { font-weight: 500; color: var(--color-rich-black, #0a0a0a);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
|
||||
.eav-item-sub { font-size: 11px; color: var(--color-midtone-gray, #737373); display: block; }
|
||||
|
||||
/* 이미지 패닝 스테이지 */
|
||||
.eav-canvas-wrap {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
.eav-canvas-wrap.eav-pan { cursor: grab; }
|
||||
.eav-canvas-wrap.eav-pan.is-panning { cursor: grabbing; }
|
||||
.eav-img { display: block; user-select: none; -webkit-user-drag: none; margin: 0 auto; }
|
||||
|
||||
/* 엑셀/시트 */
|
||||
.eav-sheet-wrap {
|
||||
background: #fff;
|
||||
width: 100%; height: 100%;
|
||||
display: grid; grid-template-rows: auto 1fr;
|
||||
}
|
||||
.eav-sheet-tabs {
|
||||
display: flex; gap: 4px; padding: 8px;
|
||||
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
background: var(--color-ghost-gray, #f2f2f2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.eav-sheet-body {
|
||||
overflow: auto; padding: 12px;
|
||||
}
|
||||
.eav-sheet-body table {
|
||||
border-collapse: collapse;
|
||||
font-family: 'Geist Mono', Menlo, monospace; font-size: 12px;
|
||||
}
|
||||
.eav-sheet-body td, .eav-sheet-body th {
|
||||
border: 1px solid var(--color-subtle-ash, #e5e5e5);
|
||||
padding: 4px 8px;
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.eav-sheet-body th { background: var(--color-ghost-gray, #f2f2f2); font-weight: 600; }
|
||||
.eav-sheet-loading { color: #fff; text-align: center; padding: 40px; }
|
||||
|
||||
.eav-fallback {
|
||||
color: #fff; text-align: center; padding: 40px;
|
||||
}
|
||||
.eav-fallback a {
|
||||
display: inline-block; margin-top: 12px;
|
||||
background: #fff; color: #000; padding: 8px 16px; border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.eav-body { grid-template-columns: 1fr; grid-template-rows: 100px 1fr; }
|
||||
.eav-list { flex-direction: row; overflow-x: auto; }
|
||||
.eav-item { flex-shrink: 0; min-width: 180px; }
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// ErpAttachViewer — 첨부 모달
|
||||
// - 이미지: 마우스 드래그로 패닝 (이미지 크면 스크롤). 확대/축소/맞춤.
|
||||
// - 엑셀 (xlsx/xls/csv): SheetJS 로 시트 표시 (CDN lazy load).
|
||||
// - 기타: 다운로드 링크 표시.
|
||||
// API: ErpAttachViewer.openFor(itemId, { title })
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
(function () {
|
||||
if (window.ErpAttachViewer) return;
|
||||
|
||||
const IMG_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
|
||||
const SHEET_EXTS = new Set(["xlsx", "xls", "xlsm", "csv", "ods"]);
|
||||
const XLSX_CDN = "https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js";
|
||||
|
||||
let overlay = null;
|
||||
let state = {
|
||||
items: [],
|
||||
currentIdx: -1,
|
||||
title: "",
|
||||
zoom: 1, // 이미지 줌 (1 = 100%)
|
||||
// 패닝
|
||||
panning: false,
|
||||
panStartX: 0, panStartY: 0,
|
||||
scrollStartLeft: 0, scrollStartTop: 0,
|
||||
};
|
||||
let xlsxLoading = null;
|
||||
|
||||
function ensureOverlay() {
|
||||
if (overlay) return overlay;
|
||||
overlay = document.createElement("div");
|
||||
overlay.className = "eav-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="eav-modal" role="dialog" aria-modal="true">
|
||||
<div class="eav-head">
|
||||
<div class="eav-title" data-role="title">첨부 보기</div>
|
||||
<div class="eav-toolbar" data-role="toolbar"></div>
|
||||
<button class="eav-close" data-role="close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="eav-body">
|
||||
<div class="eav-list" data-role="list"></div>
|
||||
<div class="eav-canvas-wrap" data-role="stage"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
overlay.querySelector('[data-role="close"]').addEventListener("click", close);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (overlay.dataset.open === "true" && e.key === "Escape") close();
|
||||
});
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function loadXlsxLib() {
|
||||
if (window.XLSX) return Promise.resolve(window.XLSX);
|
||||
if (xlsxLoading) return xlsxLoading;
|
||||
xlsxLoading = new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = XLSX_CDN;
|
||||
s.onload = () => resolve(window.XLSX);
|
||||
s.onerror = () => reject(new Error("XLSX 라이브러리 로드 실패"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return xlsxLoading;
|
||||
}
|
||||
|
||||
function buildToolbar() {
|
||||
const tb = overlay.querySelector('[data-role="toolbar"]');
|
||||
tb.innerHTML = "";
|
||||
const cur = state.items[state.currentIdx];
|
||||
if (!cur) return;
|
||||
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||
|
||||
if (IMG_EXTS.has(ext)) {
|
||||
[
|
||||
{ label: "-", title: "축소", fn: () => setZoom(state.zoom / 1.25) },
|
||||
{ label: "100%", title: "원본", fn: () => setZoom(1) },
|
||||
{ label: "+", title: "확대", fn: () => setZoom(state.zoom * 1.25) },
|
||||
{ label: "맞춤", title: "화면에 맞춤", fn: () => setZoom("fit") },
|
||||
].forEach((b) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "eav-btn";
|
||||
btn.textContent = b.label;
|
||||
btn.title = b.title;
|
||||
btn.onclick = b.fn;
|
||||
tb.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
const dl = document.createElement("a");
|
||||
dl.className = "eav-btn";
|
||||
dl.textContent = "다운로드";
|
||||
dl.href = `/expense/api/attachments/${cur.id}`;
|
||||
dl.setAttribute("download", cur.filename);
|
||||
tb.appendChild(dl);
|
||||
|
||||
const open = document.createElement("a");
|
||||
open.className = "eav-btn";
|
||||
open.textContent = "새 창";
|
||||
open.href = `/expense/api/attachments/${cur.id}`;
|
||||
open.target = "_blank";
|
||||
tb.appendChild(open);
|
||||
}
|
||||
|
||||
function buildList() {
|
||||
const list = overlay.querySelector('[data-role="list"]');
|
||||
list.innerHTML = "";
|
||||
state.items.forEach((a, i) => {
|
||||
const item = document.createElement("button");
|
||||
item.className = "eav-item" + (i === state.currentIdx ? " is-active" : "");
|
||||
const ext = (a.filename.split(".").pop() || "").toLowerCase();
|
||||
const isImg = IMG_EXTS.has(ext);
|
||||
const isSheet = SHEET_EXTS.has(ext);
|
||||
const icon = isImg ? `<img src="/expense/api/attachments/${a.id}" alt="" />`
|
||||
: isSheet ? "📊" : "📄";
|
||||
item.innerHTML = `
|
||||
<span class="eav-item-thumb">${icon}</span>
|
||||
<span class="eav-item-meta">
|
||||
<span class="eav-item-name">${escapeHtml(a.filename)}</span>
|
||||
<span class="eav-item-sub">${a.kind === "receipt" ? "영수증" : "기타파일"} · ${fmtSize(a.size_bytes)}</span>
|
||||
</span>
|
||||
`;
|
||||
item.onclick = () => { state.currentIdx = i; state.zoom = 1; renderCurrent(); buildList(); buildToolbar(); };
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const stage = overlay.querySelector('[data-role="stage"]');
|
||||
stage.innerHTML = "";
|
||||
stage.className = "eav-canvas-wrap";
|
||||
const cur = state.items[state.currentIdx];
|
||||
if (!cur) {
|
||||
stage.innerHTML = `<div class="eav-fallback">첨부가 없습니다.</div>`;
|
||||
return;
|
||||
}
|
||||
const ext = (cur.filename.split(".").pop() || "").toLowerCase();
|
||||
|
||||
if (IMG_EXTS.has(ext)) return renderImage(stage, cur);
|
||||
if (SHEET_EXTS.has(ext)) return renderSheet(stage, cur, ext);
|
||||
return renderFallback(stage, cur);
|
||||
}
|
||||
|
||||
function renderImage(stage, cur) {
|
||||
stage.classList.add("eav-pan");
|
||||
const img = document.createElement("img");
|
||||
img.className = "eav-img";
|
||||
img.alt = cur.filename;
|
||||
img.src = `/expense/api/attachments/${cur.id}`;
|
||||
img.draggable = false;
|
||||
img.onload = () => { applyZoom(); };
|
||||
img.onerror = () => { stage.innerHTML = `<div class="eav-fallback">이미지 로드 실패</div>`; };
|
||||
stage.appendChild(img);
|
||||
state.image = img;
|
||||
|
||||
// 마우스 드래그 패닝
|
||||
stage.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
state.panning = true;
|
||||
state.panStartX = e.clientX;
|
||||
state.panStartY = e.clientY;
|
||||
state.scrollStartLeft = stage.scrollLeft;
|
||||
state.scrollStartTop = stage.scrollTop;
|
||||
stage.setPointerCapture(e.pointerId);
|
||||
stage.classList.add("is-panning");
|
||||
});
|
||||
stage.addEventListener("pointermove", (e) => {
|
||||
if (!state.panning) return;
|
||||
stage.scrollLeft = state.scrollStartLeft - (e.clientX - state.panStartX);
|
||||
stage.scrollTop = state.scrollStartTop - (e.clientY - state.panStartY);
|
||||
});
|
||||
const endPan = (e) => {
|
||||
if (!state.panning) return;
|
||||
state.panning = false;
|
||||
try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
|
||||
stage.classList.remove("is-panning");
|
||||
};
|
||||
stage.addEventListener("pointerup", endPan);
|
||||
stage.addEventListener("pointercancel", endPan);
|
||||
stage.addEventListener("pointerleave", endPan);
|
||||
}
|
||||
|
||||
function applyZoom() {
|
||||
if (!state.image) return;
|
||||
const stage = overlay.querySelector('[data-role="stage"]');
|
||||
const img = state.image;
|
||||
if (state.zoom === "fit") {
|
||||
img.style.maxWidth = "100%";
|
||||
img.style.maxHeight = "calc(100vh - 200px)";
|
||||
img.style.width = "";
|
||||
img.style.height = "";
|
||||
} else {
|
||||
img.style.maxWidth = "none";
|
||||
img.style.maxHeight = "none";
|
||||
img.style.width = (img.naturalWidth * state.zoom) + "px";
|
||||
img.style.height = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
function setZoom(z) {
|
||||
state.zoom = z;
|
||||
applyZoom();
|
||||
}
|
||||
|
||||
async function renderSheet(stage, cur, ext) {
|
||||
stage.classList.remove("eav-pan");
|
||||
stage.innerHTML = `<div class="eav-sheet-loading">시트 로드 중…</div>`;
|
||||
try {
|
||||
const XLSX = await loadXlsxLib();
|
||||
const res = await fetch(`/expense/api/attachments/${cur.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
stage.innerHTML = "";
|
||||
|
||||
const sheetWrap = document.createElement("div");
|
||||
sheetWrap.className = "eav-sheet-wrap";
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "eav-sheet-tabs";
|
||||
const body = document.createElement("div");
|
||||
body.className = "eav-sheet-body";
|
||||
|
||||
function showSheet(name) {
|
||||
const ws = wb.Sheets[name];
|
||||
const html = XLSX.utils.sheet_to_html(ws, { editable: false });
|
||||
body.innerHTML = html;
|
||||
tabs.querySelectorAll("button").forEach((b) => {
|
||||
b.classList.toggle("is-active", b.dataset.sheet === name);
|
||||
});
|
||||
}
|
||||
wb.SheetNames.forEach((name) => {
|
||||
const b = document.createElement("button");
|
||||
b.className = "eav-btn";
|
||||
b.dataset.sheet = name;
|
||||
b.textContent = name;
|
||||
b.onclick = () => showSheet(name);
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
sheetWrap.appendChild(tabs);
|
||||
sheetWrap.appendChild(body);
|
||||
stage.appendChild(sheetWrap);
|
||||
showSheet(wb.SheetNames[0]);
|
||||
} catch (err) {
|
||||
stage.innerHTML = `<div class="eav-fallback">시트 로드 실패: ${escapeHtml(err.message || String(err))}
|
||||
<br/><a href="/expense/api/attachments/${cur.id}" target="_blank">원본 다운로드</a></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFallback(stage, cur) {
|
||||
stage.classList.remove("eav-pan");
|
||||
stage.innerHTML = `
|
||||
<div class="eav-fallback">
|
||||
미리보기를 지원하지 않는 파일입니다.<br/>
|
||||
<a href="/expense/api/attachments/${cur.id}" target="_blank">다운로드 / 새 창 열기</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function fmtSize(b) {
|
||||
b = Number(b || 0);
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
|
||||
);
|
||||
}
|
||||
|
||||
function open(items, opts) {
|
||||
ensureOverlay();
|
||||
state.items = items || [];
|
||||
state.currentIdx = state.items.length ? 0 : -1;
|
||||
state.zoom = 1;
|
||||
state.title = (opts && opts.title) || "첨부 보기";
|
||||
overlay.querySelector('[data-role="title"]').textContent = state.title;
|
||||
overlay.dataset.open = "true";
|
||||
buildList();
|
||||
buildToolbar();
|
||||
renderCurrent();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
overlay.dataset.open = "false";
|
||||
state.items = [];
|
||||
state.currentIdx = -1;
|
||||
state.image = null;
|
||||
}
|
||||
|
||||
async function openFor(itemId, opts) {
|
||||
try {
|
||||
const res = await fetch(`/expense/api/items/${itemId}/attachments`);
|
||||
if (!res.ok) throw new Error((await res.json()).detail || res.status);
|
||||
const { attachments } = await res.json();
|
||||
if (!attachments || !attachments.length) {
|
||||
alert("첨부 파일이 없습니다.");
|
||||
return;
|
||||
}
|
||||
open(attachments, opts);
|
||||
} catch (err) {
|
||||
alert(`첨부 로드 실패: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
window.ErpAttachViewer = { open, openFor, close };
|
||||
})();
|
||||
@@ -0,0 +1,305 @@
|
||||
/* ════════════════════════════════════════════════════
|
||||
ERP Shell — 좌측 사이드바 + 우측 콘텐츠 레이아웃
|
||||
erp.css 의 토큰을 그대로 사용한다. 같이 로드 필수.
|
||||
════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--erp-sidebar-w: 240px;
|
||||
--erp-sidebar-w-collapsed: 64px;
|
||||
--erp-topbar-h: 56px;
|
||||
}
|
||||
|
||||
/* ── 페이지 전체 컨테이너 ──
|
||||
뷰포트(예: 1883×938)에 고정 — 문서 전체 세로 스크롤 제거.
|
||||
넘치는 콘텐츠는 .erp-page 내부에서만 처리한다(CORM/Order 는 별도 창이라 무관). */
|
||||
body.erp-app-body { background: var(--color-canvas-white); height: 100vh; overflow: hidden; }
|
||||
|
||||
.erp-app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--erp-sidebar-w) 1fr;
|
||||
height: 100vh;
|
||||
transition: grid-template-columns .18s ease;
|
||||
}
|
||||
.erp-app:has(.erp-sidebar[data-collapsed="true"]) {
|
||||
grid-template-columns: var(--erp-sidebar-w-collapsed) 1fr;
|
||||
}
|
||||
|
||||
/* ── 사이드바 ── */
|
||||
.erp-sidebar {
|
||||
position: sticky; top: 0;
|
||||
height: 100vh;
|
||||
border-right: 1px solid var(--color-subtle-ash);
|
||||
background: var(--color-canvas-white);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.erp-sidebar-head {
|
||||
height: var(--erp-topbar-h);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--sp-12) 0 var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.erp-sidebar-brand {
|
||||
display: inline-flex; align-items: center; gap: var(--sp-8);
|
||||
color: var(--color-deep-black);
|
||||
}
|
||||
.erp-sidebar-logo { height: 22px; width: auto; display: block; }
|
||||
.erp-sidebar-system {
|
||||
font-weight: 600; font-size: 14px; letter-spacing: -0.2px;
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-system { display: none; }
|
||||
|
||||
.erp-sidebar-toggle {
|
||||
width: 28px; height: 28px; border-radius: 8px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid transparent; cursor: pointer;
|
||||
color: var(--color-midtone-gray);
|
||||
}
|
||||
.erp-sidebar-toggle:hover { background: var(--color-ghost-gray); color: var(--color-deep-black); }
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-toggle svg { transform: rotate(180deg); }
|
||||
|
||||
.erp-sidebar-nav {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: var(--sp-12) var(--sp-8);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
|
||||
.erp-sidebar-group {
|
||||
font-size: 12px; font-weight: 600;
|
||||
color: var(--color-midtone-gray);
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
padding: var(--sp-12) var(--sp-12) var(--sp-6);
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-group { opacity: 0; height: 0; padding: 0; pointer-events: none; }
|
||||
|
||||
.erp-sidebar-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px var(--sp-12);
|
||||
border-radius: 10px;
|
||||
color: var(--color-rich-black);
|
||||
font-size: 16px; font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 실행중(is-active) 메뉴는 hover 시 변화 없음. 그 외 메뉴만 진한 회색. */
|
||||
.erp-sidebar-item:hover:not(.is-disabled):not(.is-active) { background: #d9d9d9; }
|
||||
.erp-sidebar-item.is-active {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
}
|
||||
.erp-sidebar-item.is-disabled {
|
||||
color: var(--color-midtone-gray);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.erp-sidebar-icon {
|
||||
width: 22px; height: 22px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.erp-sidebar-icon svg { width: 21px; height: 21px; }
|
||||
.erp-sidebar-label { flex: 1; }
|
||||
.erp-sidebar-badge {
|
||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||
padding: 2px 8px; border-radius: var(--r-badge);
|
||||
font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.erp-sidebar-item.is-active .erp-sidebar-badge {
|
||||
background: rgba(255,255,255,0.18); color: var(--color-canvas-white);
|
||||
}
|
||||
.erp-sidebar-ext { color: var(--color-midtone-gray); display: inline-flex; }
|
||||
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-badge,
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-ext {
|
||||
display: none;
|
||||
}
|
||||
/* 접힌 상태: 아이콘 위 / 프로그램 이름 작은 글씨 아래 */
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-item {
|
||||
flex-direction: column; gap: 3px; padding: 8px 2px;
|
||||
justify-content: center; text-align: center;
|
||||
}
|
||||
.erp-sidebar[data-collapsed="true"] .erp-sidebar-label {
|
||||
flex: none; font-size: 10px; line-height: 1.1; font-weight: 500;
|
||||
white-space: normal; word-break: keep-all;
|
||||
}
|
||||
|
||||
.erp-sidebar-foot {
|
||||
padding: var(--sp-8);
|
||||
border-top: 1px solid var(--color-subtle-ash);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 콘텐츠 영역 ── */
|
||||
.erp-content { display: flex; flex-direction: column; min-width: 0; height: 100vh; min-height: 0; }
|
||||
|
||||
.erp-topbar {
|
||||
height: var(--erp-topbar-h); flex-shrink: 0;
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
background: var(--color-canvas-white);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 var(--sp-24);
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
.erp-topbar-left { display: flex; align-items: center; gap: var(--sp-12); min-width: 0; }
|
||||
.erp-topbar-title h1 {
|
||||
font-size: 16px; font-weight: 600; margin: 0; line-height: 1.2;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.erp-topbar-title p {
|
||||
margin: 2px 0 0; color: var(--color-midtone-gray); font-size: 12px;
|
||||
}
|
||||
.erp-topbar-right { display: flex; align-items: center; gap: var(--sp-12); }
|
||||
|
||||
.erp-sidebar-mobile-toggle {
|
||||
display: none;
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid var(--color-subtle-ash); cursor: pointer;
|
||||
color: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-page {
|
||||
padding: var(--sp-24);
|
||||
max-width: 1550px; width: 100%; margin: 0 auto;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
overflow-y: auto; /* 넘치면 페이지 영역만 스크롤(문서 전체 X) */
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
/* 달력 등 "한 화면 채움" 페이지: 첫 섹션이 남은 높이를 모두 차지 */
|
||||
.erp-page > .vac,
|
||||
.erp-page > .cpg { flex: 1 1 auto; min-height: 0; }
|
||||
|
||||
/* ── 홈 대시보드 타일 ── */
|
||||
.erp-hero { margin: var(--sp-8) 0 var(--sp-24); }
|
||||
.erp-hero-title { font-size: 28px; font-weight: 600; margin: 0; letter-spacing: -0.6px; }
|
||||
.erp-hero-sub { margin: 6px 0 0; color: var(--color-midtone-gray); font-size: 13px; }
|
||||
|
||||
.erp-home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
.erp-tile {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-card);
|
||||
padding: var(--sp-20);
|
||||
background: var(--color-canvas-white);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.erp-tile-head {
|
||||
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||
}
|
||||
.erp-tile-label {
|
||||
font-size: 11px; color: var(--color-midtone-gray); text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.erp-tile-title { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.3px; }
|
||||
.erp-tile-desc { margin: 0 0 8px; color: var(--color-midtone-gray); font-size: 13px; }
|
||||
.erp-tile-list { margin: 0; padding-left: 18px; color: var(--color-rich-black); font-size: 13px; }
|
||||
.erp-tile-list li { margin: 4px 0; }
|
||||
|
||||
/* ── 개인경비 페이지 ── */
|
||||
.erp-expense { display: flex; flex-direction: column; gap: var(--sp-20); }
|
||||
|
||||
.erp-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--sp-12);
|
||||
}
|
||||
.erp-summary-card {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-card);
|
||||
padding: var(--sp-16);
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
background: var(--color-canvas-white);
|
||||
}
|
||||
.erp-summary-card--mini { padding: var(--sp-12) var(--sp-16); }
|
||||
.erp-summary-label { font-size: 12px; color: var(--color-midtone-gray); }
|
||||
.erp-summary-value { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; font-variant-numeric: tabular-nums; }
|
||||
.erp-summary-value--sm { font-size: 16px; }
|
||||
|
||||
.erp-card-block {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-card);
|
||||
background: var(--color-canvas-white);
|
||||
overflow: hidden;
|
||||
}
|
||||
.erp-card-block-head {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
padding: var(--sp-16) var(--sp-20);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
.erp-card-block-head h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.2px; }
|
||||
.erp-muted { color: var(--color-midtone-gray); font-size: 12px; }
|
||||
|
||||
.erp-form-grid {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
|
||||
gap: var(--sp-12); padding: var(--sp-16) var(--sp-20);
|
||||
}
|
||||
.erp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--color-midtone-gray); }
|
||||
.erp-field-wide { grid-column: span 2; }
|
||||
.erp-field input, .erp-field select {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 6px 10px;
|
||||
font-family: inherit; font-size: 14px; color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
outline: none;
|
||||
}
|
||||
.erp-field input:focus, .erp-field select:focus {
|
||||
border-color: var(--color-rich-black);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.erp-form-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex; justify-content: flex-end; gap: var(--sp-8);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.erp-btn-sm { padding: 4px 10px !important; font-size: 12px !important; }
|
||||
|
||||
.erp-table-wrap { overflow-x: auto; }
|
||||
.erp-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.erp-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 500; color: var(--color-midtone-gray);
|
||||
padding: 10px var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
background: var(--color-canvas-white);
|
||||
}
|
||||
.erp-table tbody td {
|
||||
padding: 10px var(--sp-16);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.erp-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.erp-table tbody tr:hover { background: var(--color-ghost-gray); }
|
||||
.erp-row-sub { font-size: 12px; color: var(--color-midtone-gray); margin-top: 2px; }
|
||||
.erp-empty { text-align: center; color: var(--color-midtone-gray); padding: var(--sp-24); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
.erp-app { grid-template-columns: 0 1fr; }
|
||||
.erp-sidebar {
|
||||
position: fixed; left: 0; top: 0; height: 100vh;
|
||||
width: var(--erp-sidebar-w);
|
||||
transform: translateX(-100%);
|
||||
transition: transform .2s ease;
|
||||
box-shadow: 0 0 0 1px var(--color-subtle-ash);
|
||||
}
|
||||
.erp-sidebar.is-open { transform: translateX(0); }
|
||||
.erp-sidebar-mobile-toggle { display: inline-flex; }
|
||||
.erp-form-grid { grid-template-columns: 1fr 1fr; }
|
||||
.erp-field-wide { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.erp-form-grid { grid-template-columns: 1fr; }
|
||||
.erp-field-wide { grid-column: auto; }
|
||||
.erp-page { padding: var(--sp-16); }
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
/* ════════════════════════════════════════════════════
|
||||
ERP 대시보드 — DESIGN.md 기반 (Monochromatic / shadcn)
|
||||
════════════════════════════════════════════════════ */
|
||||
|
||||
/* Geist 폰트 — CDN. 실패시 Inter / 시스템 폰트 fallback */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono&family=Inter:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--color-canvas-white: #ffffff;
|
||||
--color-ghost-gray: #f2f2f2;
|
||||
--color-subtle-ash: #e5e5e5;
|
||||
--color-midtone-gray: #737373;
|
||||
--color-rich-black: #0a0a0a;
|
||||
--color-deep-black: #000000;
|
||||
--color-callout-red: #c22b10;
|
||||
--color-success-green:#10c22b;
|
||||
|
||||
/* Typography */
|
||||
--font-geist: 'Geist', 'Inter', ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-geist-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo,
|
||||
Monaco, Consolas, monospace;
|
||||
|
||||
--text-caption: 12px;
|
||||
--text-body: 14px;
|
||||
--text-heading: 18px;
|
||||
--text-display: 48px;
|
||||
--leading-body: 1.43;
|
||||
|
||||
--tracking-heading: -0.45px;
|
||||
--tracking-display: -2.4px;
|
||||
|
||||
/* Spacing */
|
||||
--sp-4: 4px; --sp-6: 6px; --sp-8: 8px; --sp-10: 10px;
|
||||
--sp-12: 12px; --sp-16: 16px; --sp-20: 20px; --sp-24: 24px;
|
||||
--sp-32: 32px; --sp-40: 40px; --sp-80: 80px;
|
||||
|
||||
/* Radii */
|
||||
--r-pill: 9999px;
|
||||
--r-badge: 26px;
|
||||
--r-card: 14px;
|
||||
--r-input: 10px;
|
||||
--r-btn: 10px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: oklab(0.145 -0.00000143796 0.00000340492 / 0.1) 0px 0px 0px 1px;
|
||||
--shadow-focus: lab(100 0 0) 0px 0px 0px 2px;
|
||||
}
|
||||
|
||||
/* ── 리셋 ── (auth pages 와 충돌 방지 위해 erp-shell 안에서만 적용) */
|
||||
.erp-shell, .erp-shell * { box-sizing: border-box; }
|
||||
.erp-shell { margin: 0; padding: 0; }
|
||||
|
||||
body.erp-body {
|
||||
margin: 0;
|
||||
font-family: var(--font-geist);
|
||||
font-size: var(--text-body);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.erp-body a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ── 상단 네비게이션 ── */
|
||||
.erp-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: var(--color-canvas-white);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-24);
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.erp-nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
|
||||
.erp-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
font-weight: 600;
|
||||
color: var(--color-deep-black);
|
||||
font-size: var(--text-body);
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.erp-brand-logo {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.erp-brand-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-subtle-ash);
|
||||
}
|
||||
|
||||
.erp-brand-system {
|
||||
font-size: var(--text-body);
|
||||
font-weight: 500;
|
||||
color: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
}
|
||||
|
||||
.erp-user-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-8);
|
||||
padding: 4px var(--sp-12) 4px 4px;
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--color-canvas-white);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-rich-black);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.erp-user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.erp-user-avatar-fallback {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.erp-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.erp-user-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-rich-black);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.erp-user-email {
|
||||
color: var(--color-midtone-gray);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── 버튼 ── */
|
||||
.erp-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-6);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-btn);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.erp-btn-primary {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.erp-btn-primary:hover {
|
||||
background: var(--color-rich-black);
|
||||
}
|
||||
|
||||
/* 검정 배경 요소는 글자 무조건 흰색.
|
||||
<a> 버튼/배지의 a:link/a:visited(특이도 0,1,1)가 클래스 색을 덮어쓰는 문제 차단. */
|
||||
a.erp-btn-primary, a.erp-btn-primary:link, a.erp-btn-primary:visited, a.erp-btn-primary:hover,
|
||||
.erp-badge-inverse, a.erp-badge-inverse:link, a.erp-badge-inverse:visited,
|
||||
.erp-sidebar-item.is-active, a.erp-sidebar-item.is-active:link, a.erp-sidebar-item.is-active:visited {
|
||||
color: var(--color-canvas-white) !important;
|
||||
}
|
||||
|
||||
.erp-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-rich-black);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
|
||||
.erp-btn-ghost:hover {
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
|
||||
.erp-btn-outline {
|
||||
background: var(--color-canvas-white);
|
||||
color: var(--color-rich-black);
|
||||
border-color: var(--color-subtle-ash);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.erp-btn-outline:hover {
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
|
||||
.erp-btn-danger {
|
||||
background: transparent;
|
||||
color: var(--color-callout-red);
|
||||
border-color: var(--color-subtle-ash);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.erp-btn-danger:hover {
|
||||
border-color: var(--color-callout-red);
|
||||
background: rgba(194, 43, 16, 0.04);
|
||||
}
|
||||
|
||||
/* ── 본문 컨테이너 ── */
|
||||
.erp-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-40) var(--sp-24);
|
||||
}
|
||||
|
||||
.erp-greeting {
|
||||
margin: 0 0 var(--sp-8);
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--color-deep-black);
|
||||
letter-spacing: -0.6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.erp-greeting-sub {
|
||||
margin: 0 0 var(--sp-32);
|
||||
color: var(--color-midtone-gray);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.erp-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-16);
|
||||
}
|
||||
|
||||
.erp-section-title {
|
||||
font-size: var(--text-heading);
|
||||
font-weight: 500;
|
||||
color: var(--color-deep-black);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
.erp-section-meta {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-midtone-gray);
|
||||
}
|
||||
|
||||
/* ── 카드 그리드 ── */
|
||||
.erp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--sp-16);
|
||||
}
|
||||
|
||||
.erp-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-12);
|
||||
background: var(--color-canvas-white);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: var(--sp-20);
|
||||
min-height: 180px;
|
||||
transition: box-shadow 120ms, transform 120ms;
|
||||
}
|
||||
|
||||
.erp-card[data-clickable="true"]:hover {
|
||||
box-shadow: oklab(0.145 -0.00000143796 0.00000340492 / 0.18) 0 0 0 1px,
|
||||
oklab(0.145 -0.00000143796 0.00000340492 / 0.06) 0 8px 24px;
|
||||
}
|
||||
|
||||
.erp-card[data-clickable="false"] {
|
||||
background: var(--color-ghost-gray);
|
||||
}
|
||||
|
||||
.erp-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-8);
|
||||
}
|
||||
|
||||
.erp-card-title {
|
||||
font-size: var(--text-heading);
|
||||
font-weight: 600;
|
||||
color: var(--color-deep-black);
|
||||
letter-spacing: var(--tracking-heading);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.erp-card-subtitle {
|
||||
font-family: var(--font-geist-mono);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-midtone-gray);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.erp-card-desc {
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-midtone-gray);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.erp-card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-8);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-12);
|
||||
border-top: 1px solid var(--color-subtle-ash);
|
||||
}
|
||||
|
||||
.erp-card-link {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
color: var(--color-deep-black);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.erp-card-link svg {
|
||||
transition: transform 120ms;
|
||||
}
|
||||
|
||||
.erp-card[data-clickable="true"]:hover .erp-card-link svg {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* ── 배지 ── */
|
||||
.erp-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-6);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--r-badge);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.erp-badge-inverse {
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
}
|
||||
|
||||
.erp-badge-neutral {
|
||||
background: var(--color-ghost-gray);
|
||||
color: var(--color-rich-black);
|
||||
}
|
||||
|
||||
.erp-badge-outline {
|
||||
background: transparent;
|
||||
color: var(--color-rich-black);
|
||||
border: 1px solid #a1a1a1;
|
||||
}
|
||||
|
||||
.erp-badge-success { background: #e6f8e9; color: var(--color-success-green); border: 1px solid #c2efc8; }
|
||||
.erp-badge-danger { background: #fbe9e6; color: var(--color-callout-red); border: 1px solid #efc6bf; }
|
||||
|
||||
.erp-badge-dot::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* ── 잠긴 카드 표시 ── */
|
||||
.erp-locked-overlay {
|
||||
position: absolute;
|
||||
top: var(--sp-12);
|
||||
right: var(--sp-12);
|
||||
font-size: 11px;
|
||||
color: var(--color-midtone-gray);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ── 관리자 페이지 ── */
|
||||
.erp-admin-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-12);
|
||||
margin-bottom: var(--sp-16);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.erp-input {
|
||||
font-family: inherit;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 6px var(--sp-10);
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.erp-input:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
border-color: var(--color-deep-black);
|
||||
}
|
||||
|
||||
.erp-table-wrap {
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--color-canvas-white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.erp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.erp-table thead th {
|
||||
text-align: left;
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 500;
|
||||
color: var(--color-midtone-gray);
|
||||
background: var(--color-ghost-gray);
|
||||
padding: var(--sp-10) var(--sp-12);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.erp-table tbody td {
|
||||
padding: var(--sp-12);
|
||||
border-bottom: 1px solid var(--color-subtle-ash);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.erp-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.erp-table tbody tr[data-dirty="true"] {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.erp-user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-10);
|
||||
}
|
||||
|
||||
.erp-user-cell .erp-user-avatar,
|
||||
.erp-user-cell .erp-user-avatar-fallback {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.erp-user-cell-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-rich-black);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.erp-user-cell-email {
|
||||
font-size: 12px;
|
||||
color: var(--color-midtone-gray);
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* shadcn-style 토글 (checkbox 기반) */
|
||||
.erp-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
.erp-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.erp-switch-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-subtle-ash);
|
||||
border-radius: var(--r-pill);
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.erp-switch-slider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 16px; height: 16px;
|
||||
left: 2px; top: 2px;
|
||||
background: var(--color-canvas-white);
|
||||
border-radius: 50%;
|
||||
transition: transform 120ms;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.2);
|
||||
}
|
||||
.erp-switch input:checked + .erp-switch-slider { background: var(--color-deep-black); }
|
||||
.erp-switch input:checked + .erp-switch-slider::before { transform: translateX(16px); }
|
||||
.erp-switch input:disabled + .erp-switch-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* shadcn-style select */
|
||||
.erp-select {
|
||||
font-family: inherit;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-rich-black);
|
||||
background: var(--color-canvas-white);
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 4px var(--sp-10);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.erp-select:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
border-color: var(--color-deep-black);
|
||||
}
|
||||
|
||||
.erp-select:disabled {
|
||||
background: var(--color-ghost-gray);
|
||||
cursor: not-allowed;
|
||||
color: var(--color-midtone-gray);
|
||||
}
|
||||
|
||||
/* 토스트 */
|
||||
.erp-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background: var(--color-deep-black);
|
||||
color: var(--color-canvas-white);
|
||||
padding: var(--sp-12) var(--sp-16);
|
||||
border-radius: var(--r-input);
|
||||
font-size: var(--text-body);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 200ms, transform 200ms;
|
||||
pointer-events: none;
|
||||
max-width: 360px;
|
||||
}
|
||||
.erp-toast[data-visible="true"] {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.erp-toast[data-type="error"] {
|
||||
background: var(--color-callout-red);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 640px) {
|
||||
.erp-nav { padding: 0 var(--sp-16); height: 52px; }
|
||||
.erp-brand-system { display: none; }
|
||||
.erp-user-info { display: none; }
|
||||
.erp-main { padding: var(--sp-24) var(--sp-16); }
|
||||
.erp-greeting { font-size: 22px; }
|
||||
.erp-table thead { display: none; }
|
||||
.erp-table tbody td { display: block; border: none; padding: 4px var(--sp-12); }
|
||||
.erp-table tbody tr { display: block; padding: var(--sp-12) 0; border-bottom: 1px solid var(--color-subtle-ash); }
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* 휴가 관리(vacation) 모듈 스타일.
|
||||
DESIGN.md 토큰 준수 — 모노크롬, 10/14px radius, 카드 padding 16px.
|
||||
캐시 버전: ?v=20260530a (변경 시 템플릿 head_extra 의 ?v= 도 함께 올린다) */
|
||||
|
||||
:root {
|
||||
--vac-ash: #e5e5e5;
|
||||
--vac-ghost: #f2f2f2;
|
||||
--vac-muted: #737373;
|
||||
--vac-black: #0a0a0a;
|
||||
--vac-red: #c22b10;
|
||||
--vac-blue: #1d4ed8;
|
||||
--vac-green: #10733a;
|
||||
}
|
||||
|
||||
.vac { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
/* 검정 배경 버튼 글자 흰색 강제 */
|
||||
.vac .erp-btn-primary { color: #fff !important; }
|
||||
|
||||
/* ── 페이지 액션 + 잔여 요약 ── */
|
||||
.vac-actions {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap; flex-shrink: 0;
|
||||
}
|
||||
.vac-actions-main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.vac-mini { margin-left: 4px; }
|
||||
.vac-balance {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
font-size: 14px; color: var(--vac-black);
|
||||
}
|
||||
.vac-balance strong { font-weight: 600; }
|
||||
.vac-bal-sep { color: var(--vac-ash); }
|
||||
.vac-bal-remain strong { color: var(--vac-green); }
|
||||
.vac-bal-year { margin-left: 6px; font-size: 12px; }
|
||||
|
||||
/* ── 레이아웃: 달력 좌 / 리스트 우 ── */
|
||||
.vac-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto; min-height: 0; /* 남은 높이 채움 → 문서 스크롤 방지 */
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.vac-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── 달력 카드 ── */
|
||||
.vac-cal-card {
|
||||
padding: 16px;
|
||||
display: flex; flex-direction: column; min-height: 0; overflow: hidden;
|
||||
}
|
||||
.vac-cal-head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vac-cal-title { font-size: 18pt; font-weight: 600; margin: 0; letter-spacing: -0.45px; }
|
||||
.vac-nav-btn { padding: 4px 12px; font-size: 18pt; line-height: 1; }
|
||||
.vac-today-btn { margin-left: auto; }
|
||||
|
||||
.vac-cal {
|
||||
border: 1px solid var(--vac-ash); border-radius: 10px; overflow: hidden;
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.vac-wd-row {
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
background: var(--vac-ghost); border-bottom: 1px solid var(--vac-ash);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vac-wd {
|
||||
text-align: center; padding: 8px 0; font-size: 15px; font-weight: 600;
|
||||
color: var(--vac-black);
|
||||
border-right: 1px solid var(--vac-ash);
|
||||
}
|
||||
.vac-wd:last-child { border-right: none; }
|
||||
|
||||
/* 주(week) — 날짜 셀 위에 bar 레이어를 오버레이. 6주가 높이를 균등 분할(유동). */
|
||||
.vac-week {
|
||||
position: relative; border-bottom: 1px solid var(--vac-ash);
|
||||
flex: 1 1 0; min-height: 88px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.vac-week:last-child { border-bottom: none; }
|
||||
|
||||
.vac-week-days {
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
}
|
||||
.vac-day {
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--vac-ash);
|
||||
padding: 6px 8px; text-decoration: none;
|
||||
display: block; box-sizing: border-box;
|
||||
}
|
||||
.vac-day:last-child { border-right: none; }
|
||||
.vac-day-num { font-size: 20px; font-weight: 600; color: var(--vac-black); }
|
||||
.vac-out { background: #fafafa; }
|
||||
.vac-out .vac-day-num { color: #bbb; }
|
||||
.vac-today { background: #f5f5f5; }
|
||||
.vac-today .vac-day-num {
|
||||
background: var(--vac-black);
|
||||
color: #fff !important; /* vac-red/vac-blue 글자색(!important) 이김 */
|
||||
border-radius: 9999px; padding: 2px 9px;
|
||||
}
|
||||
/* 오늘이 일/공휴일=빨강배경, 토요일=파랑배경, 평일=검정배경 (모두 흰글씨) */
|
||||
.vac-today .vac-day-num.vac-red { background: var(--vac-red); }
|
||||
.vac-today .vac-day-num.vac-blue { background: var(--vac-blue); }
|
||||
.vac-selected { box-shadow: inset 0 0 0 2px var(--vac-black); }
|
||||
.vac-red { color: var(--vac-red) !important; }
|
||||
.vac-blue { color: var(--vac-blue) !important; }
|
||||
|
||||
/* bar 오버레이 — 날짜 숫자 아래(top)부터 7열 그리드로 겹쳐 그림 */
|
||||
.vac-week-bars {
|
||||
position: absolute; left: 0; right: 0; top: 38px; bottom: 4px;
|
||||
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: 22px; row-gap: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 휴가 bar (구글 달력 스타일) ── */
|
||||
.vac-bar {
|
||||
margin: 0 3px; padding: 0 8px; height: 20px; line-height: 20px;
|
||||
border-radius: 6px; font-size: 13px; text-decoration: none;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
align-self: center; pointer-events: auto;
|
||||
}
|
||||
.vac-bar-label { pointer-events: none; }
|
||||
.vac-bar-l { border-top-left-radius: 0; border-bottom-left-radius: 0; margin-left: 0; }
|
||||
.vac-bar-r { border-top-right-radius: 0; border-bottom-right-radius: 0; margin-right: 0; }
|
||||
|
||||
.vac-bar-draft { background: #fff; border: 1px dashed var(--vac-muted); color: var(--vac-muted); }
|
||||
.vac-bar-submit { background: var(--vac-ghost); border: 1px solid var(--vac-muted); color: var(--vac-black); }
|
||||
.vac-bar-approve, a.vac-bar-approve:link, a.vac-bar-approve:visited { background: var(--vac-black); color: #fff; }
|
||||
.vac-bar-reject { background: #fff; border: 1px solid var(--vac-red); color: var(--vac-red); text-decoration: line-through; }
|
||||
.vac-bar-cancel { background: #fff; border: 1px dashed var(--vac-ash); color: #bbb; opacity: 0.7; }
|
||||
|
||||
/* 범례 */
|
||||
.vac-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.vac-leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--vac-muted); }
|
||||
.vac-dot { width: 14px; height: 12px; border-radius: 4px; display: inline-block; }
|
||||
|
||||
/* ── 선택일 리스트 ── */
|
||||
.vac-list-card {
|
||||
padding: 16px; min-height: 0; overflow-y: auto; /* 긴 목록은 패널 내부 스크롤 */
|
||||
}
|
||||
.vac-list-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 10px; }
|
||||
.vac-list-head h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.vac-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.vac-list-link {
|
||||
display: block; text-decoration: none; color: var(--vac-black);
|
||||
border: 1px solid var(--vac-ash); border-radius: 10px; padding: 10px 12px;
|
||||
}
|
||||
.vac-list-link:hover { background: var(--vac-ghost); }
|
||||
.vac-list-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vac-list-meta { font-size: 12px; margin-top: 4px; }
|
||||
.vac-list-reason { font-size: 12px; margin-top: 4px; }
|
||||
.vac-empty { padding: 12px 0; }
|
||||
|
||||
/* ── 신청 폼 ── */
|
||||
.vac-form-card { padding: 16px; max-width: 760px; }
|
||||
.vac-form-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px; margin-bottom: 12px;
|
||||
}
|
||||
.vac-full { grid-column: 1 / -1; }
|
||||
.vac-days-preview { margin: 12px 0; font-size: 14px; }
|
||||
.vac-days-preview strong { font-size: 16px; font-weight: 600; }
|
||||
.vac-form-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* ── 상세 ── */
|
||||
.vac-detail-card { padding: 16px; max-width: 760px; }
|
||||
.vac-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.vac-detail-head h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.vac-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 24px; margin: 16px 0;
|
||||
}
|
||||
.vac-detail-grid dt { font-size: 12px; color: var(--vac-muted); margin-bottom: 2px; }
|
||||
.vac-detail-grid dd { margin: 0; font-size: 14px; }
|
||||
.vac-detail-block { margin: 12px 0; }
|
||||
.vac-detail-label { font-size: 12px; color: var(--vac-muted); margin-bottom: 4px; }
|
||||
.vac-reject-block p { color: var(--vac-red); }
|
||||
.vac-approve-box {
|
||||
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
||||
background: var(--vac-ghost); border-radius: 10px; padding: 12px; margin: 12px 0;
|
||||
}
|
||||
.vac-reject-form { display: flex; gap: 6px; align-items: center; }
|
||||
.vac-inline-form { display: inline; }
|
||||
.vac-detail-actions { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
|
||||
.vac-push-right { margin-left: auto; }
|
||||
|
||||
/* ── 승인 대기 ── */
|
||||
.vac-pending-table .vac-act-col { width: 280px; }
|
||||
.vac-pending-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.vac-reason-cell { max-width: 260px; }
|
||||
.erp-btn-sm { padding: 2px 10px; font-size: 12px; }
|
||||
.erp-input-sm { padding: 2px 8px; font-size: 12px; min-width: 140px; }
|
||||
|
||||
/* ── 설정 ── */
|
||||
.vac-settings-head { margin-bottom: 4px; }
|
||||
.vac-year-form { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.vac-year-field { max-width: 140px; }
|
||||
.vac-settings-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) { .vac-settings-grid { grid-template-columns: 1fr; } }
|
||||
.vac-set-card { padding: 16px; }
|
||||
.vac-card-head h2 { font-size: 16px; font-weight: 600; margin: 0 0 12px; }
|
||||
.vac-holiday-form, .vac-balance-form {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px; margin-bottom: 12px; align-items: end;
|
||||
}
|
||||
.vac-holiday-form button, .vac-balance-form button { grid-column: 1 / -1; }
|
||||
.vac-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.vac-set-scroll { max-height: 420px; overflow: auto; }
|
||||
.vac-set-note { font-size: 12px; margin-top: 8px; }
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 휴가 신청 폼 — 종류에 따른 필드 토글 + 예상 일수 미리보기.
|
||||
서버(store.compute_days)가 공휴일 포함 정확히 재계산한다. 여기선 주말만 제외한 근사치. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var form = document.getElementById("vac-form");
|
||||
if (!form) return;
|
||||
|
||||
var type = document.getElementById("vac-type");
|
||||
var start = document.getElementById("vac-start");
|
||||
var startHalf = document.getElementById("vac-start-half");
|
||||
var endField = document.getElementById("vac-end-field");
|
||||
var end = document.getElementById("vac-end");
|
||||
var endHalfField = document.getElementById("vac-end-half-field");
|
||||
var endHalf = document.getElementById("vac-end-half");
|
||||
var startHalfField = document.getElementById("vac-start-half-field");
|
||||
var out = document.getElementById("vac-days-out");
|
||||
|
||||
function isHalfType(t) { return t === "오전반차" || t === "오후반차"; }
|
||||
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var p = s.split("-");
|
||||
if (p.length !== 3) return null;
|
||||
return new Date(+p[0], +p[1] - 1, +p[2]);
|
||||
}
|
||||
|
||||
function workingDays(sd, ed) {
|
||||
var n = 0;
|
||||
var d = new Date(sd.getTime());
|
||||
while (d <= ed) {
|
||||
var wd = d.getDay(); // 0=일, 6=토
|
||||
if (wd !== 0 && wd !== 6) n++;
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function isWorking(d) {
|
||||
var wd = d.getDay();
|
||||
return wd !== 0 && wd !== 6;
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
var t = type.value;
|
||||
var half = isHalfType(t);
|
||||
|
||||
// 반차 종류면 종료일/구분 숨김, 단일 0.5
|
||||
endField.style.display = half ? "none" : "";
|
||||
endHalfField.style.display = half ? "none" : "";
|
||||
startHalfField.style.display = half ? "none" : "";
|
||||
|
||||
if (half) {
|
||||
out.textContent = "0.5";
|
||||
return;
|
||||
}
|
||||
|
||||
var sd = parseDate(start.value);
|
||||
var ed = parseDate(end.value) || sd;
|
||||
if (!sd || !ed || ed < sd) { out.textContent = "—"; return; }
|
||||
|
||||
if (sd.getTime() === ed.getTime()) {
|
||||
out.textContent = (startHalf.value === "am" || startHalf.value === "pm") ? "0.5" : "1";
|
||||
return;
|
||||
}
|
||||
|
||||
var n = workingDays(sd, ed);
|
||||
var total = n;
|
||||
if ((startHalf.value === "am" || startHalf.value === "pm") && isWorking(sd)) total -= 0.5;
|
||||
if ((endHalf.value === "am" || endHalf.value === "pm") && isWorking(ed)) total -= 0.5;
|
||||
out.textContent = total > 0 ? String(total) : "0";
|
||||
}
|
||||
|
||||
// 시작일 변경 시 종료일이 비었거나 더 빠르면 맞춰줌
|
||||
start.addEventListener("change", function () {
|
||||
if (!end.value || parseDate(end.value) < parseDate(start.value)) {
|
||||
end.value = start.value;
|
||||
}
|
||||
recalc();
|
||||
});
|
||||
|
||||
[type, end, startHalf, endHalf].forEach(function (el) {
|
||||
if (el) el.addEventListener("change", recalc);
|
||||
});
|
||||
|
||||
recalc();
|
||||
})();
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
"""JSON 기반 사용자/권한 저장소.
|
||||
|
||||
- app/data/users.json 에 사용자 레코드 저장
|
||||
- 동시성: 파일락 대신 process-내 threading.Lock + 원자적 쓰기(temp → rename)
|
||||
- 운영시 컨테이너 한 대 가정. 다중 인스턴스 필요해지면 DB 로 교체.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .timezone import now_kst_iso
|
||||
|
||||
# 슈퍼 관리자 — 강등/삭제 불가
|
||||
SUPER_ADMIN_EMAIL = "king@dbxcorp.co.kr"
|
||||
|
||||
# 시스템에서 지원하는 권한 키.
|
||||
# - 접근권한: 모듈 페이지 진입 허용
|
||||
# - 승인자: 결재 워크플로에서 승인/반려 가능
|
||||
# 신규 추가 시 여기 + admin.html MODULE_LABELS + 라우터 검사 동시 갱신.
|
||||
MODULE_KEYS: tuple[str, ...] = (
|
||||
"corm",
|
||||
"order",
|
||||
"expense",
|
||||
"vacation",
|
||||
"cupang",
|
||||
"expense_approver",
|
||||
"vacation_approver",
|
||||
)
|
||||
|
||||
# 승인자 권한 키 — 결재 워크플로에서 별도 검사할 때 사용
|
||||
APPROVER_KEYS: tuple[str, ...] = ("expense_approver", "vacation_approver")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return now_kst_iso()
|
||||
|
||||
|
||||
class UserStore:
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._lock = threading.Lock()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self._path.exists():
|
||||
self._write_atomic({"users": {}})
|
||||
|
||||
def _read(self) -> dict[str, Any]:
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {"users": {}}
|
||||
if "users" not in data or not isinstance(data["users"], dict):
|
||||
data["users"] = {}
|
||||
return data
|
||||
|
||||
def _write_atomic(self, data: dict[str, Any]) -> None:
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=".users.", suffix=".json.tmp", dir=str(self._path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, self._path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _default_modules(is_admin: bool) -> dict[str, bool]:
|
||||
return {key: is_admin for key in MODULE_KEYS}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_modules(modules: dict[str, Any] | None, *, is_admin: bool) -> dict[str, bool]:
|
||||
base = UserStore._default_modules(is_admin)
|
||||
if isinstance(modules, dict):
|
||||
for key in MODULE_KEYS:
|
||||
if key in modules:
|
||||
base[key] = bool(modules[key])
|
||||
if is_admin:
|
||||
for key in MODULE_KEYS:
|
||||
base[key] = True
|
||||
return base
|
||||
|
||||
def get(self, email: str) -> dict[str, Any] | None:
|
||||
email = email.lower().strip()
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
user = data["users"].get(email)
|
||||
if user is None:
|
||||
return None
|
||||
return self._enrich(email, user)
|
||||
|
||||
def list_all(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
return [self._enrich(email, rec) for email, rec in data["users"].items()]
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
name: str = "",
|
||||
role: str = "user",
|
||||
modules: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""관리자가 이메일만으로 신규 사용자 등록. 로그인 전이라도 권한 부여 가능."""
|
||||
email = email.lower().strip()
|
||||
if not email or "@" not in email:
|
||||
raise ValueError("올바른 이메일 형식이 아닙니다.")
|
||||
if role not in ("admin", "user"):
|
||||
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||
is_super = email == SUPER_ADMIN_EMAIL
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if email in data["users"]:
|
||||
raise ValueError(f"이미 등록된 사용자입니다: {email}")
|
||||
now = _now_iso()
|
||||
final_role = "admin" if is_super else role
|
||||
rec = {
|
||||
"email": email,
|
||||
"name": name or email.split("@")[0],
|
||||
"picture": "",
|
||||
"role": final_role,
|
||||
"modules": self._normalize_modules(
|
||||
modules, is_admin=(final_role == "admin")
|
||||
),
|
||||
"created_at": now,
|
||||
"last_login": "",
|
||||
}
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def upsert_login(self, *, email: str, name: str, picture: str) -> dict[str, Any]:
|
||||
"""로그인 시 호출. 신규면 생성, 기존이면 last_login/name/picture 갱신."""
|
||||
email = email.lower().strip()
|
||||
is_super = email == SUPER_ADMIN_EMAIL
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
now = _now_iso()
|
||||
existing = data["users"].get(email)
|
||||
if existing is None:
|
||||
rec = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
"picture": picture,
|
||||
"role": "admin" if is_super else "user",
|
||||
"modules": self._default_modules(is_admin=is_super),
|
||||
"created_at": now,
|
||||
"last_login": now,
|
||||
}
|
||||
else:
|
||||
rec = dict(existing)
|
||||
rec["name"] = name or rec.get("name", email)
|
||||
rec["picture"] = picture or rec.get("picture", "")
|
||||
rec["last_login"] = now
|
||||
# 슈퍼 관리자는 항상 admin + 모든 모듈
|
||||
if is_super:
|
||||
rec["role"] = "admin"
|
||||
rec["modules"] = self._default_modules(is_admin=True)
|
||||
else:
|
||||
rec["modules"] = self._normalize_modules(
|
||||
rec.get("modules"), is_admin=(rec.get("role") == "admin")
|
||||
)
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def update_permissions(
|
||||
self, *, email: str, role: str | None, modules: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""관리자가 다른 사용자의 권한을 수정."""
|
||||
email = email.lower().strip()
|
||||
if email == SUPER_ADMIN_EMAIL:
|
||||
raise PermissionError("슈퍼 관리자 계정은 수정할 수 없습니다.")
|
||||
if role is not None and role not in ("admin", "user"):
|
||||
raise ValueError(f"role 값이 잘못되었습니다: {role}")
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
rec = data["users"].get(email)
|
||||
if rec is None:
|
||||
raise KeyError(f"사용자를 찾을 수 없습니다: {email}")
|
||||
if role is not None:
|
||||
rec["role"] = role
|
||||
new_is_admin = rec.get("role") == "admin"
|
||||
rec["modules"] = self._normalize_modules(
|
||||
modules if modules is not None else rec.get("modules"),
|
||||
is_admin=new_is_admin,
|
||||
)
|
||||
data["users"][email] = rec
|
||||
self._write_atomic(data)
|
||||
return self._enrich(email, rec)
|
||||
|
||||
def delete(self, email: str) -> None:
|
||||
email = email.lower().strip()
|
||||
if email == SUPER_ADMIN_EMAIL:
|
||||
raise PermissionError("슈퍼 관리자 계정은 삭제할 수 없습니다.")
|
||||
with self._lock:
|
||||
data = self._read()
|
||||
if email in data["users"]:
|
||||
del data["users"][email]
|
||||
self._write_atomic(data)
|
||||
|
||||
@staticmethod
|
||||
def _enrich(email: str, rec: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(rec)
|
||||
out["email"] = email
|
||||
out["is_super_admin"] = email == SUPER_ADMIN_EMAIL
|
||||
out["modules"] = UserStore._normalize_modules(
|
||||
out.get("modules"), is_admin=(out.get("role") == "admin")
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def is_admin(user_rec: dict[str, Any] | None) -> bool:
|
||||
return bool(user_rec and user_rec.get("role") == "admin")
|
||||
|
||||
|
||||
def has_module(user_rec: dict[str, Any] | None, module: str) -> bool:
|
||||
if not user_rec:
|
||||
return False
|
||||
if is_admin(user_rec):
|
||||
return True
|
||||
mods = user_rec.get("modules") or {}
|
||||
if mods.get(module):
|
||||
return True
|
||||
# 승인자(<module>_approver) 권한이 있으면 해당 모듈 접근도 허용 —
|
||||
# 본인 경비 등록/제출도 가능해야 하므로.
|
||||
if module in APPROVER_KEYS:
|
||||
return False
|
||||
if mods.get(f"{module}_approver"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_approver(user_rec: dict[str, Any] | None, kind: str) -> bool:
|
||||
"""결재 승인자 권한 검사. kind 예: 'expense', 'vacation'."""
|
||||
return has_module(user_rec, f"{kind}_approver")
|
||||
|
||||
|
||||
def allowed_modules(user_rec: dict[str, Any] | None) -> set[str]:
|
||||
return {m for m in MODULE_KEYS if has_module(user_rec, m)}
|
||||
@@ -0,0 +1,302 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>사용자/권한 관리 — DBX ERP</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
<style>
|
||||
.erp-admin-toolbar {
|
||||
display: flex; align-items: center; gap: var(--sp-12);
|
||||
margin: var(--sp-16) 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.erp-admin-toolbar .erp-input { flex: 0 1 280px; }
|
||||
|
||||
.erp-add-form {
|
||||
display: flex; gap: var(--sp-8); align-items: center;
|
||||
margin-left: auto; flex-wrap: wrap;
|
||||
}
|
||||
.erp-add-form input, .erp-add-form select {
|
||||
border: 1px solid var(--color-subtle-ash);
|
||||
border-radius: var(--r-input);
|
||||
padding: 6px 10px; font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.erp-mod-th { text-align: center; font-size: 11px; padding: 6px 4px; }
|
||||
.erp-mod-cell { text-align: center; padding: 4px; }
|
||||
.erp-mod-group { display: inline-block; padding: 2px 6px; border-radius: 6px;
|
||||
background: var(--color-ghost-gray); color: var(--color-midtone-gray);
|
||||
font-size: 10px; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="erp-body">
|
||||
|
||||
<div class="erp-shell">
|
||||
|
||||
<nav class="erp-nav">
|
||||
<div class="erp-nav-left">
|
||||
<a href="/" class="erp-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||
<span class="erp-brand-divider"></span>
|
||||
<span class="erp-brand-system">DBX ERP System</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="erp-nav-right">
|
||||
<a class="erp-btn erp-btn-ghost" href="/">← 대시보드</a>
|
||||
<div class="erp-user-chip">
|
||||
{% if user.picture %}
|
||||
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div class="erp-user-info">
|
||||
<span class="erp-user-name">{{ user.name }}</span>
|
||||
<span class="erp-user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="erp-main" style="max-width: 1280px;">
|
||||
|
||||
<h1 class="erp-greeting">사용자 / 권한 관리</h1>
|
||||
<p class="erp-greeting-sub">
|
||||
이메일만으로 사용자 등록 가능 (회사 도메인 자동 검사).
|
||||
토글을 변경한 뒤 <strong>저장</strong>을 누르세요.
|
||||
</p>
|
||||
|
||||
<!-- 이메일 등록 폼 + 검색 -->
|
||||
<div class="erp-admin-toolbar">
|
||||
<input type="search" id="user-search" class="erp-input"
|
||||
placeholder="이메일/이름 검색…" style="padding: 6px 10px;" />
|
||||
<span class="erp-section-meta" id="user-count">총 {{ users | length }}명</span>
|
||||
|
||||
<form id="add-user-form" class="erp-add-form">
|
||||
<input type="email" name="email" required placeholder="email@dbxcorp.co.kr" style="width: 220px;" />
|
||||
<input type="text" name="name" placeholder="이름(선택)" style="width: 120px;" />
|
||||
<select name="role">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button type="submit" class="erp-btn erp-btn-primary">사용자 추가</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="erp-table-wrap">
|
||||
<table class="erp-table" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 22%;">사용자</th>
|
||||
<th style="width: 80px;">역할</th>
|
||||
{% for key in module_keys %}
|
||||
<th class="erp-mod-th">
|
||||
{{ module_labels.get(key, key) }}
|
||||
{% if key in approver_keys %}<br><span class="erp-mod-group">승인</span>{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
<th style="width: 120px;">최근 로그인</th>
|
||||
<th style="width: 130px; text-align: right;">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr data-email="{{ u.email }}" data-dirty="false"
|
||||
data-super="{{ 'true' if u.is_super_admin else 'false' }}">
|
||||
<td>
|
||||
<div class="erp-user-cell">
|
||||
{% if u.picture %}
|
||||
<img class="erp-user-avatar" src="{{ u.picture }}" alt="{{ u.name }}" />
|
||||
{% else %}
|
||||
<span class="erp-user-avatar-fallback">{{ (u.name or u.email)[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="erp-user-cell-name">
|
||||
{{ u.name or u.email }}
|
||||
{% if u.is_super_admin %}
|
||||
<span class="erp-badge erp-badge-inverse" style="margin-left:6px;">슈퍼관리자</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="erp-user-cell-email">{{ u.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="erp-select" data-field="role"
|
||||
{% if u.is_super_admin %}disabled{% endif %}>
|
||||
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
|
||||
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
||||
</select>
|
||||
</td>
|
||||
{% for key in module_keys %}
|
||||
<td class="erp-mod-cell">
|
||||
<label class="erp-switch">
|
||||
<input type="checkbox"
|
||||
data-field="module"
|
||||
data-module="{{ key }}"
|
||||
{% if u.modules[key] %}checked{% endif %}
|
||||
{% if u.is_super_admin or u.role == 'admin' %}disabled{% endif %} />
|
||||
<span class="erp-switch-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td style="font-size:12px; color: var(--color-midtone-gray);">
|
||||
{{ (u.last_login or '미접속')[:16].replace('T', ' ') }}
|
||||
</td>
|
||||
<td style="text-align: right;">
|
||||
<button class="erp-btn erp-btn-primary" data-action="save"
|
||||
{% if u.is_super_admin %}disabled{% endif %}>저장</button>
|
||||
{% if not u.is_super_admin %}
|
||||
<button class="erp-btn erp-btn-outline" data-action="delete"
|
||||
style="padding: 4px 8px; font-size: 12px;">삭제</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="erp-section-meta" style="margin-top: var(--sp-16);">
|
||||
※ <strong>{{ super_admin_email }}</strong> 은(는) 슈퍼 관리자, 권한 변경/삭제 불가.
|
||||
admin 역할은 모든 모듈 권한 자동 부여.
|
||||
<code>expense_approver</code> / <code>vacation_approver</code> 는 결재 승인 권한.
|
||||
</p>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="erp-toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script>
|
||||
const tbody = document.querySelector("#users-table tbody");
|
||||
const searchInput = document.getElementById("user-search");
|
||||
const userCountEl = document.getElementById("user-count");
|
||||
const toastEl = document.getElementById("toast");
|
||||
const addForm = document.getElementById("add-user-form");
|
||||
|
||||
function markDirty(tr) { tr.setAttribute("data-dirty", "true"); }
|
||||
function clearDirty(tr) { tr.setAttribute("data-dirty", "false"); }
|
||||
|
||||
function showToast(msg, type) {
|
||||
toastEl.textContent = msg;
|
||||
toastEl.dataset.type = type || "info";
|
||||
toastEl.dataset.visible = "true";
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => { toastEl.dataset.visible = "false"; }, 2400);
|
||||
}
|
||||
|
||||
function syncModuleSwitches(tr) {
|
||||
const isSuper = tr.dataset.super === "true";
|
||||
const role = tr.querySelector("select[data-field='role']").value;
|
||||
const adminRole = role === "admin";
|
||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||
if (isSuper || adminRole) {
|
||||
cb.checked = true; cb.disabled = true;
|
||||
} else {
|
||||
cb.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tbody.addEventListener("change", (e) => {
|
||||
const tr = e.target.closest("tr");
|
||||
if (!tr) return;
|
||||
if (e.target.dataset.field === "role") syncModuleSwitches(tr);
|
||||
markDirty(tr);
|
||||
});
|
||||
|
||||
tbody.addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const tr = btn.closest("tr");
|
||||
const email = tr.dataset.email;
|
||||
|
||||
if (btn.dataset.action === "save") {
|
||||
const role = tr.querySelector("select[data-field='role']").value;
|
||||
const modules = {};
|
||||
tr.querySelectorAll("input[data-field='module']").forEach((cb) => {
|
||||
modules[cb.dataset.module] = cb.checked;
|
||||
});
|
||||
btn.disabled = true; btn.textContent = "저장 중…";
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ role, modules }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
clearDirty(tr);
|
||||
showToast(`${email} 저장 완료`, "info");
|
||||
} catch (err) {
|
||||
showToast(`저장 실패: ${err.message}`, "error");
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = "저장";
|
||||
}
|
||||
} else if (btn.dataset.action === "delete") {
|
||||
if (!confirm(`${email} 사용자를 삭제할까요? 복구 불가.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/users/${encodeURIComponent(email)}`, {
|
||||
method: "DELETE", credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
tr.remove();
|
||||
showToast(`${email} 삭제 완료`, "info");
|
||||
} catch (err) {
|
||||
showToast(`삭제 실패: ${err.message}`, "error");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 사용자 추가
|
||||
addForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(addForm);
|
||||
const payload = {
|
||||
email: fd.get("email").trim().toLowerCase(),
|
||||
name: fd.get("name").trim(),
|
||||
role: fd.get("role"),
|
||||
};
|
||||
try {
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
showToast(`${payload.email} 등록 완료. 새로고침합니다.`, "info");
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} catch (err) {
|
||||
showToast(`등록 실패: ${err.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
let visible = 0;
|
||||
tbody.querySelectorAll("tr").forEach((tr) => {
|
||||
const text = tr.textContent.toLowerCase();
|
||||
const match = !q || text.includes(q);
|
||||
tr.style.display = match ? "" : "none";
|
||||
if (match) visible++;
|
||||
});
|
||||
userCountEl.textContent = q
|
||||
? `검색 결과 ${visible}명`
|
||||
: `총 ${tbody.querySelectorAll("tr").length}명`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title or "ERP" }} — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/erp.css?v=20260530q" />
|
||||
<link rel="stylesheet" href="/static/erp-shell.css?v=20260530q" />
|
||||
<link rel="stylesheet" href="/static/erp-attach-viewer.css" />
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body class="erp-body erp-app-body">
|
||||
|
||||
<div class="erp-app">
|
||||
|
||||
<!-- ── 좌측 사이드바 ── -->
|
||||
<aside class="erp-sidebar" data-collapsed="false">
|
||||
|
||||
<div class="erp-sidebar-head">
|
||||
<a href="/" class="erp-sidebar-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-sidebar-logo" />
|
||||
<span class="erp-sidebar-system">DBX ERP System</span>
|
||||
</a>
|
||||
<button type="button" class="erp-sidebar-toggle" id="erp-sidebar-toggle" aria-label="사이드바 접기">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="erp-sidebar-nav">
|
||||
{% set last_group = namespace(value=None) %}
|
||||
{% for item in nav_items %}
|
||||
{% if item.group != last_group.value %}
|
||||
<div class="erp-sidebar-group">{{ item.group }}</div>
|
||||
{% set last_group.value = item.group %}
|
||||
{% endif %}
|
||||
|
||||
{% set classes = "erp-sidebar-item" %}
|
||||
{% if item.active %}{% set classes = classes + " is-active" %}{% endif %}
|
||||
{% if item.disabled %}{% set classes = classes + " is-disabled" %}{% endif %}
|
||||
|
||||
{% if item.disabled %}
|
||||
<div class="{{ classes }}" title="{{ item.disabled_reason or '준비중' }}">
|
||||
{% else %}
|
||||
<a class="{{ classes }}" href="{{ item.url }}"
|
||||
{% if item.target == '_blank' %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
{% endif %}
|
||||
<span class="erp-sidebar-icon">{{ item.icon | safe }}</span>
|
||||
<span class="erp-sidebar-label">{{ item.label }}</span>
|
||||
{% if item.badge %}<span class="erp-sidebar-badge">{{ item.badge }}</span>{% endif %}
|
||||
{% if item.target == '_blank' %}
|
||||
<span class="erp-sidebar-ext" aria-hidden="true">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if item.disabled %}
|
||||
</div>
|
||||
{% else %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="erp-sidebar-foot">
|
||||
{% if user.is_super_admin %}
|
||||
<a class="erp-sidebar-item" href="/modules" title="업무 모듈 선택 화면">
|
||||
<span class="erp-sidebar-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="erp-sidebar-label">업무 모듈</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<a class="erp-sidebar-item" href="/admin">
|
||||
<span class="erp-sidebar-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2 4 6v6c0 5 3.4 9.4 8 10 4.6-.6 8-5 8-10V6l-8-4Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="erp-sidebar-label">관리자</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── 우측 콘텐츠 ── -->
|
||||
<div class="erp-content">
|
||||
|
||||
<header class="erp-topbar">
|
||||
<div class="erp-topbar-left">
|
||||
<button type="button" class="erp-sidebar-mobile-toggle" id="erp-sidebar-mobile-toggle" aria-label="메뉴 열기">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="erp-topbar-title">
|
||||
<h1>{{ page_title or "" }}</h1>
|
||||
{% if page_subtitle %}<p>{{ page_subtitle }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="erp-topbar-right">
|
||||
<div class="erp-user-chip">
|
||||
{% if user.picture %}
|
||||
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div class="erp-user-info">
|
||||
<span class="erp-user-name">{{ user.name }}</span>
|
||||
<span class="erp-user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="erp-btn erp-btn-outline" href="/logout">로그아웃</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="erp-page">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const sidebar = document.querySelector(".erp-sidebar");
|
||||
const collapseBtn = document.getElementById("erp-sidebar-toggle");
|
||||
const mobileBtn = document.getElementById("erp-sidebar-mobile-toggle");
|
||||
if (collapseBtn) {
|
||||
collapseBtn.addEventListener("click", () => {
|
||||
const next = sidebar.dataset.collapsed === "true" ? "false" : "true";
|
||||
sidebar.dataset.collapsed = next;
|
||||
try { localStorage.setItem("erp_sidebar_collapsed", next); } catch (_) {}
|
||||
});
|
||||
try {
|
||||
const saved = localStorage.getItem("erp_sidebar_collapsed");
|
||||
if (saved === "true") sidebar.dataset.collapsed = "true";
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mobileBtn) {
|
||||
mobileBtn.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("is-open");
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/static/erp-attach-viewer.js" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "erp_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="erp-home">
|
||||
|
||||
<div class="erp-hero">
|
||||
<h2 class="erp-hero-title">안녕하세요, {{ user.name }}님</h2>
|
||||
<p class="erp-hero-sub" id="erp-hero-sub"></p>
|
||||
</div>
|
||||
|
||||
<div class="erp-home-grid">
|
||||
|
||||
<article class="erp-tile">
|
||||
<div class="erp-tile-head">
|
||||
<span class="erp-tile-label">바로가기</span>
|
||||
<span class="erp-badge erp-badge-neutral">개인업무</span>
|
||||
</div>
|
||||
<h3 class="erp-tile-title">개인경비</h3>
|
||||
<p class="erp-tile-desc">법인카드/개인지출 등록과 정산 신청.</p>
|
||||
<a class="erp-btn erp-btn-primary" href="/expense/">개인경비 열기</a>
|
||||
</article>
|
||||
|
||||
<article class="erp-tile">
|
||||
<div class="erp-tile-head">
|
||||
<span class="erp-tile-label">바로가기</span>
|
||||
<span class="erp-badge erp-badge-outline">준비중</span>
|
||||
</div>
|
||||
<h3 class="erp-tile-title">휴가</h3>
|
||||
<p class="erp-tile-desc">연차/반차 신청과 잔여일수 확인.</p>
|
||||
<button class="erp-btn erp-btn-outline" disabled>오픈 예정</button>
|
||||
</article>
|
||||
|
||||
<article class="erp-tile">
|
||||
<div class="erp-tile-head">
|
||||
<span class="erp-tile-label">알림</span>
|
||||
<span class="erp-badge erp-badge-outline">공지</span>
|
||||
</div>
|
||||
<h3 class="erp-tile-title">시스템 안내</h3>
|
||||
<ul class="erp-tile-list">
|
||||
<li>좌측 메뉴에서 사용 가능한 업무 모듈을 확인하세요.</li>
|
||||
<li>외부 모듈(주문/CORM)은 새 창에서 열립니다.</li>
|
||||
<li>권한이 필요한 모듈은 관리자에게 요청하세요.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const d = new Date();
|
||||
const h = d.getHours();
|
||||
let greet = "오늘도 좋은 하루 되세요.";
|
||||
if (h < 5) greet = "늦은 시간까지 수고 많으세요.";
|
||||
else if (h < 12) greet = "활기찬 아침입니다.";
|
||||
else if (h < 18) greet = "오후 업무 화이팅입니다.";
|
||||
else greet = "오늘도 고생 많으셨습니다.";
|
||||
const days = ["일","월","화","수","목","금","토"];
|
||||
const dateStr = `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} (${days[d.getDay()]})`;
|
||||
const el = document.getElementById("erp-hero-sub");
|
||||
if (el) el.textContent = `${dateStr} · ${greet}`;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+139
-123
@@ -3,153 +3,169 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>업무 포털 — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<title>ERP 시스템 — DBX Corporation</title>
|
||||
<link rel="stylesheet" href="/static/erp.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="erp-body">
|
||||
|
||||
<!-- ── 헤더 ── -->
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX Corporation" class="header-logo-img" />
|
||||
<span class="header-portal">업무 포털</span>
|
||||
</div>
|
||||
<div class="erp-shell">
|
||||
|
||||
<div class="header-user">
|
||||
<div class="header-user-info">
|
||||
<span class="header-user-name">{{ user.name }}</span>
|
||||
<span class="header-user-email">{{ user.email }}</span>
|
||||
<!-- ── 상단 네비게이션 ── -->
|
||||
<nav class="erp-nav">
|
||||
<div class="erp-nav-left">
|
||||
<a href="/" class="erp-brand">
|
||||
<img src="/static/dbx-logo.png" alt="DBX" class="erp-brand-logo" />
|
||||
<span class="erp-brand-divider"></span>
|
||||
<span class="erp-brand-system">DBX ERP System</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if user.picture %}
|
||||
<img class="header-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<div class="header-avatar-placeholder">
|
||||
{{ user.name[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="btn-logout" href="/logout">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span>로그아웃</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── 히어로 배너 ── -->
|
||||
<section class="hero">
|
||||
<p class="hero-greeting">안녕하세요, {{ user.name }}님 👋</p>
|
||||
<p class="hero-sub">오늘도 좋은 하루 되세요.</p>
|
||||
<p class="hero-date" id="today-date"></p>
|
||||
</section>
|
||||
<div class="erp-nav-right">
|
||||
{% if is_admin %}
|
||||
<a class="erp-btn erp-btn-ghost" href="/admin">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2 4 6v6c0 5 3.4 9.4 8 10 4.6-.6 8-5 8-10V6l-8-4Z"/>
|
||||
</svg>
|
||||
관리자
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── 메뉴 콘텐츠 ── -->
|
||||
<main class="content">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">업무 바로가기</h2>
|
||||
<span class="section-count">{{ menu_items | length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-grid">
|
||||
{% for item in menu_items %}
|
||||
<a class="menu-card" href="{{ item.url }}" target="_blank" rel="noopener noreferrer"
|
||||
{% if item.health_url %}data-health-url="{{ item.health_url }}"{% endif %}>
|
||||
<div class="card-icon">
|
||||
{% if loop.index == 1 %}
|
||||
<!-- 클립보드 / 발주 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
<line x1="8" y1="13" x2="16" y2="13"/>
|
||||
<line x1="8" y1="17" x2="14" y2="17"/>
|
||||
</svg>
|
||||
{% elif loop.index == 2 %}
|
||||
<!-- 리스트 / 주문 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
{% elif loop.index == 3 %}
|
||||
<!-- 차트 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<line x1="2" y1="20" x2="22" y2="20"/>
|
||||
</svg>
|
||||
<div class="erp-user-chip">
|
||||
{% if user.picture %}
|
||||
<img class="erp-user-avatar" src="{{ user.picture }}" alt="{{ user.name }}" />
|
||||
{% else %}
|
||||
<!-- 기본 링크 아이콘 -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
<span class="erp-user-avatar-fallback">{{ user.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
<div class="erp-user-info">
|
||||
<span class="erp-user-name">{{ user.name }}</span>
|
||||
<span class="erp-user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<p class="card-title">
|
||||
{{ item.title }}
|
||||
{% if item.health_url %}
|
||||
<span class="status-badge" data-status="checking" aria-live="polite">확인 중</span>
|
||||
<a class="erp-btn erp-btn-outline" href="/logout">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
로그아웃
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── 본문 ── -->
|
||||
<main class="erp-main">
|
||||
|
||||
<h1 class="erp-greeting">안녕하세요, {{ user.name }}님</h1>
|
||||
<p class="erp-greeting-sub" id="erp-greeting-sub"></p>
|
||||
|
||||
<div class="erp-section-head">
|
||||
<h2 class="erp-section-title">업무 모듈</h2>
|
||||
<span class="erp-section-meta">{{ menu_items | length }}개 모듈</span>
|
||||
</div>
|
||||
|
||||
<div class="erp-grid">
|
||||
{% for item in menu_items %}
|
||||
{% set clickable = item.allowed and item.status == 'ready' %}
|
||||
{% if clickable %}
|
||||
<a class="erp-card" href="{{ item.url }}"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
data-clickable="true"
|
||||
{% if item.health_url %}data-health-url="{{ item.health_url }}"{% endif %}>
|
||||
{% else %}
|
||||
<div class="erp-card" data-clickable="false">
|
||||
{% endif %}
|
||||
|
||||
<div class="erp-card-head">
|
||||
<div>
|
||||
<div class="erp-card-title">{{ item.title }}</div>
|
||||
<div class="erp-card-subtitle">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
{% if item.status == 'preparing' %}
|
||||
<span class="erp-badge erp-badge-neutral">준비중</span>
|
||||
{% elif not item.allowed %}
|
||||
<span class="erp-badge erp-badge-outline">권한 없음</span>
|
||||
{% elif item.health_url %}
|
||||
<span class="erp-badge erp-badge-neutral erp-badge-dot"
|
||||
data-health-url="{{ item.health_url }}" data-status="checking">확인 중</span>
|
||||
{% else %}
|
||||
<span class="erp-badge erp-badge-inverse">활성</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="card-desc">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="card-link">
|
||||
바로가기
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</span>
|
||||
<p class="erp-card-desc">{{ item.description }}</p>
|
||||
|
||||
<div class="erp-card-foot">
|
||||
<span class="erp-badge erp-badge-outline">{{ item.category }}</span>
|
||||
{% if clickable %}
|
||||
<span class="erp-card-link">
|
||||
바로가기
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% elif item.status == 'preparing' %}
|
||||
<span class="erp-card-link" style="color: var(--color-midtone-gray);">출시 예정</span>
|
||||
{% else %}
|
||||
<span class="erp-card-link" style="color: var(--color-midtone-gray);">접근 불가</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if clickable %}
|
||||
</a>
|
||||
{% else %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 오늘 날짜 표시
|
||||
const d = new Date();
|
||||
const days = ["일요일","월요일","화요일","수요일","목요일","금요일","토요일"];
|
||||
document.getElementById("today-date").textContent =
|
||||
d.getFullYear() + "년 " + (d.getMonth()+1) + "월 " + d.getDate() + "일 " + days[d.getDay()];
|
||||
// 인사말 — 시간대별
|
||||
(function() {
|
||||
const d = new Date();
|
||||
const h = d.getHours();
|
||||
let greet = "오늘도 좋은 하루 되세요.";
|
||||
if (h < 5) greet = "늦은 시간까지 수고 많으세요.";
|
||||
else if (h < 12) greet = "활기찬 아침입니다.";
|
||||
else if (h < 18) greet = "오후 업무 화이팅입니다.";
|
||||
else greet = "오늘도 고생 많으셨습니다.";
|
||||
const days = ["일","월","화","수","목","금","토"];
|
||||
const dateStr = `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,"0")}.${String(d.getDate()).padStart(2,"0")} (${days[d.getDay()]})`;
|
||||
document.getElementById("erp-greeting-sub").textContent = `${dateStr} · ${greet}`;
|
||||
})();
|
||||
|
||||
// 헬스 배지 — data-health-url 이 있는 카드만 갱신
|
||||
async function refreshHealthBadges() {
|
||||
const cards = document.querySelectorAll(".menu-card[data-health-url]");
|
||||
await Promise.all(Array.from(cards).map(async (card) => {
|
||||
const url = card.getAttribute("data-health-url");
|
||||
const badge = card.querySelector(".status-badge");
|
||||
if (!badge) return;
|
||||
// 헬스 배지 — data-health-url 이 있는 배지만 갱신
|
||||
async function refreshHealth() {
|
||||
const badges = document.querySelectorAll(".erp-badge[data-health-url]");
|
||||
await Promise.all(Array.from(badges).map(async (badge) => {
|
||||
const url = badge.getAttribute("data-health-url");
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store", credentials: "omit" });
|
||||
const ok = res.ok;
|
||||
badge.dataset.status = ok ? "online" : "offline";
|
||||
badge.textContent = ok ? "온라인" : "오프라인";
|
||||
if (res.ok) {
|
||||
badge.dataset.status = "online";
|
||||
badge.className = "erp-badge erp-badge-success erp-badge-dot";
|
||||
badge.textContent = "온라인";
|
||||
} else {
|
||||
badge.dataset.status = "offline";
|
||||
badge.className = "erp-badge erp-badge-danger erp-badge-dot";
|
||||
badge.textContent = "오프라인";
|
||||
}
|
||||
} catch {
|
||||
badge.dataset.status = "offline";
|
||||
badge.className = "erp-badge erp-badge-danger erp-badge-dot";
|
||||
badge.textContent = "오프라인";
|
||||
}
|
||||
}));
|
||||
}
|
||||
refreshHealthBadges();
|
||||
setInterval(refreshHealthBadges, 30000);
|
||||
refreshHealth();
|
||||
setInterval(refreshHealth, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""프로젝트 공통 시간대 — 모든 시간은 한국 시간(KST, UTC+9)으로 표시/계산한다.
|
||||
|
||||
대한민국은 서머타임(DST)이 없으므로 고정 오프셋 +9 로 충분하다.
|
||||
zoneinfo/tzdata 의존 없이 어디서나 동일하게 동작한다.
|
||||
|
||||
- DB 의 TIMESTAMPTZ 는 UTC 로 저장되고 psycopg 가 aware datetime(UTC)로 돌려준다.
|
||||
표시 직전에 `to_kst_iso()` 로 KST 문자열로 변환한다.
|
||||
- "오늘"/"지금" 판정은 `today_kst()` / `now_kst()` 를 쓴다(서버 로컬 TZ 무관).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
KST = timezone(timedelta(hours=9), name="KST")
|
||||
|
||||
|
||||
def now_kst() -> datetime:
|
||||
"""현재 시각(KST, tz-aware)."""
|
||||
return datetime.now(KST)
|
||||
|
||||
|
||||
def today_kst() -> date:
|
||||
"""오늘 날짜(KST 기준)."""
|
||||
return now_kst().date()
|
||||
|
||||
|
||||
def now_kst_iso() -> str:
|
||||
return now_kst().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def to_kst_iso(dt: datetime | None, *, timespec: str = "seconds") -> str | None:
|
||||
"""datetime → KST ISO 문자열. naive 는 UTC 로 간주 후 변환."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(KST).isoformat(timespec=timespec)
|
||||
@@ -8,3 +8,10 @@ services:
|
||||
- "8080:8000"
|
||||
env_file:
|
||||
- .env.local
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- dbx-main-data:/data
|
||||
|
||||
volumes:
|
||||
dbx-main-data:
|
||||
|
||||
@@ -9,3 +9,21 @@ services:
|
||||
- "8080:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# 사용자/권한 JSON 저장소 + 첨부 업로드 — 볼륨에 영구 보관
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- dbx-main-data:/data
|
||||
networks:
|
||||
- default
|
||||
- postgres_default # postgres-db 컨테이너와 통신 (DSN host=postgres-db)
|
||||
|
||||
volumes:
|
||||
dbx-main-data:
|
||||
|
||||
networks:
|
||||
# main_default — compose 자동 생성, 동일 프로젝트 내부 통신용
|
||||
default:
|
||||
# postgres_default — postgres-db 가 속한 외부 네트워크 (별도 compose 가 만듦)
|
||||
postgres_default:
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# Databases
|
||||
|
||||
## PostgreSQL DB 목록
|
||||
|
||||
| DB명 | 용도 |
|
||||
| --- | --- |
|
||||
| `itemcode_db` | 상품코드, 단품/세트 구성, 채널 ↔ 사내 코드 매칭 |
|
||||
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
|
||||
| `return_db` | 반품·교환·CS 데이터 |
|
||||
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
|
||||
| `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 |
|
||||
|
||||
---
|
||||
|
||||
## 명명 규칙
|
||||
|
||||
- 모든 DB 이름은 **소문자 + 언더스코어**, **`_db`로 끝낸다**.
|
||||
- ✅ `inventory_db`, `cs_db`
|
||||
- ❌ `inventoryApp`, `cs-database`
|
||||
- 신규 DB가 필요하면 **사용자 승인 후** 생성한다.
|
||||
- 신규 DB 생성 시 함께 정리할 항목:
|
||||
- 용도와 책임 모듈
|
||||
- 소유자(OWNER) 계정
|
||||
- 백업 주기와 위치
|
||||
- `.env`의 연결 정보 변수명
|
||||
|
||||
---
|
||||
|
||||
## 레거시 이름 매핑
|
||||
|
||||
| 예전 이름 | 현재 기준 |
|
||||
| --- | --- |
|
||||
| `orderlist_app` | `orderlist_db` |
|
||||
|
||||
코드/문서/설정에서 `orderlist_app`을 발견하면 `orderlist_db`로 수정한다 (수정 전 영향 범위 확인).
|
||||
|
||||
---
|
||||
|
||||
## DB 작업 원칙
|
||||
|
||||
1. **작업 전 백업 우선**. 백업 없는 변경은 진행하지 않는다.
|
||||
2. 테이블 owner, 권한, sequence 권한을 확인한다.
|
||||
3. 운영 DB의 `DROP`, `TRUNCATE`, 조건 없는 대량 `DELETE/UPDATE`는 **사용자 확인 없이 실행 금지**.
|
||||
4. 스키마 변경은 마이그레이션 스크립트(`scripts/` 또는 alembic 등)로 관리한다.
|
||||
5. 운영 DB와 개발 DB의 접속 정보를 혼동하지 않는다 (`.env`로 분리).
|
||||
|
||||
---
|
||||
|
||||
## 위험 명령 (사용자 승인 필수)
|
||||
|
||||
| 명령 | 비고 |
|
||||
| --- | --- |
|
||||
| `DROP DATABASE` | 복구 불가. 백업 없으면 절대 실행 금지 |
|
||||
| `DROP TABLE` / `DROP SCHEMA` | 의존 객체 확인 필수 |
|
||||
| `TRUNCATE` | FK CASCADE 시 광범위 삭제 위험 |
|
||||
| 조건 없는 `DELETE` / `UPDATE` | `WHERE` 없는 문 차단 |
|
||||
| `docker volume rm <postgres_volume>` | 운영 데이터 영구 손실 |
|
||||
| `docker compose down -v` | 볼륨까지 제거. 운영에서 금지 |
|
||||
|
||||
실행 전 반드시:
|
||||
|
||||
1. 백업 확인 (`pg_dump`, 컨테이너 외부 마운트)
|
||||
2. 영향 범위 설명
|
||||
3. 사용자 명시 승인
|
||||
|
||||
---
|
||||
|
||||
## 자주 쓰는 점검 명령
|
||||
|
||||
```bash
|
||||
# 컨테이너/네트워크
|
||||
docker ps
|
||||
docker network ls
|
||||
docker inspect <postgres_container_name>
|
||||
|
||||
# DB 목록 / 접속
|
||||
sudo -u postgres psql -l
|
||||
docker exec -it <postgres_container_name> psql -U postgres -l
|
||||
|
||||
# 특정 DB 접속
|
||||
docker exec -it <postgres_container_name> psql -U <user> -d itemcode_db
|
||||
|
||||
# 테이블/권한 확인
|
||||
\dt
|
||||
\dn+
|
||||
\du
|
||||
\z <table_name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## expense_db 스키마 / 초기화
|
||||
|
||||
DDL: `scripts/sql/expense_db_init.sql` (멱등). DB·역할·테이블·인덱스·트리거를 한 번에 생성.
|
||||
|
||||
### 테이블 `expense_items`
|
||||
|
||||
| 컬럼 | 타입 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | 12자 hex (uuid4 앞 12자) |
|
||||
| `owner` | TEXT | 소유자 email (소문자) |
|
||||
| `spent_at` | DATE | 사용일 |
|
||||
| `category` | TEXT | 식대/교통/숙박/비품/접대/통신/기타 |
|
||||
| `method` | TEXT | 법인카드/개인지출/현금 |
|
||||
| `merchant` | TEXT | 가맹점 |
|
||||
| `amount` | BIGINT | 원 단위, ≥ 0 |
|
||||
| `memo` | TEXT | 비고 |
|
||||
| `status` | TEXT | 작성중/제출/승인/반려/정산완료 |
|
||||
| `approver_email` | TEXT | 결재자 email (승인/반려/정산 시 기록) |
|
||||
| `decided_at` | TIMESTAMPTZ | 결재 시점 |
|
||||
| `reject_reason` | TEXT | 반려 사유 |
|
||||
| `created_at` / `updated_at` | TIMESTAMPTZ | 트리거로 자동 갱신 |
|
||||
|
||||
인덱스: `(owner, spent_at DESC)`, `(status)`, `(created_at DESC)`.
|
||||
|
||||
### 테이블 `expense_attachments`
|
||||
|
||||
영수증/기타파일 메타데이터. 실제 파일은 `DATA_DIR/uploads/expense/{item_id}/` 에 저장.
|
||||
|
||||
| 컬럼 | 타입 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | 12자 hex |
|
||||
| `item_id` | TEXT FK | `expense_items(id)` ON DELETE CASCADE |
|
||||
| `owner` | TEXT | 업로드한 사용자 email |
|
||||
| `kind` | TEXT | `receipt` 또는 `other` (CHECK) |
|
||||
| `filename` | TEXT | 원본 파일명 |
|
||||
| `stored_path` | TEXT | 디스크 경로 (절대) |
|
||||
| `content_type` | TEXT | MIME |
|
||||
| `size_bytes` | BIGINT | 바이트 |
|
||||
| `uploaded_at` | TIMESTAMPTZ | 업로드 시각 |
|
||||
|
||||
인덱스: `(item_id)`.
|
||||
|
||||
### 결재 워크플로
|
||||
|
||||
```
|
||||
작성중 ─submit──▶ 제출 ─approve──▶ 승인 ─settle──▶ 정산완료
|
||||
▲ │
|
||||
└─revert/reject──┴─reject──▶ 반려 ─revert──▶ 작성중
|
||||
```
|
||||
|
||||
- `submit`/`revert`: owner 본인
|
||||
- `approve`/`reject`/`settle`: `expense_approver` 또는 `admin`
|
||||
- 첨부 추가/항목 수정/삭제: `작성중` 또는 `반려` 상태에서만
|
||||
|
||||
### 마이그레이션
|
||||
|
||||
기존 운영 DB 에 신규 컬럼/테이블 적용:
|
||||
|
||||
```bash
|
||||
docker exec -i postgres-db psql -U postgres -d expense_db \
|
||||
< scripts/sql/expense_db_002_workflow_attachments.sql
|
||||
```
|
||||
|
||||
신규 설치는 `expense_db_init.sql` 하나로 충분 (둘 다 멱등).
|
||||
|
||||
### 운영 서버 초기화 (1회)
|
||||
|
||||
```bash
|
||||
# 1) 비밀번호 변수 준비 (셸 히스토리에 남지 않게 환경변수 사용)
|
||||
read -s -p "expense_app password: " APP_PWD; echo
|
||||
|
||||
# 2) PostgreSQL 컨테이너에 DDL 적용
|
||||
docker exec -i postgres-db psql -U postgres \
|
||||
-v app_password="$APP_PWD" \
|
||||
< scripts/sql/expense_db_init.sql
|
||||
|
||||
# 3) main-app .env 에 EXPENSE_DB_URL 추가
|
||||
# EXPENSE_DB_URL=postgresql://expense_app:<APP_PWD>@postgres-db:5432/expense_db
|
||||
|
||||
# 4) main-app 재기동
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### JSON → DB 마이그레이션
|
||||
|
||||
```bash
|
||||
docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \
|
||||
python scripts/migrate_expense_json_to_db.py \
|
||||
--json /data/expense.json --dry-run
|
||||
# 결과 확인 후
|
||||
docker exec -e EXPENSE_DB_URL="$EXPENSE_DB_URL" -it dbx-main \
|
||||
python scripts/migrate_expense_json_to_db.py --json /data/expense.json
|
||||
```
|
||||
|
||||
> 멱등 INSERT(`ON CONFLICT DO NOTHING`). 원본 JSON 은 건드리지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## cupang_db 스키마 / 초기화
|
||||
|
||||
DDL: `scripts/sql/cupang_db_init.sql` (멱등). DB·역할(`cupang_app`)·테이블·인덱스·트리거·센터 seed 를 한 번에 생성. **JSON 폴백 없음** — `CUPANG_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
|
||||
|
||||
테이블:
|
||||
|
||||
| 테이블 | 용도 |
|
||||
| --- | --- |
|
||||
| `cupang_centers` | 입고센터. `active=false` 로 비활성화(사용 중이면 hard delete 금지) |
|
||||
| `cupang_box_rules` | 제품코드별 박스당 입수량(`units_per_box`). `product_code` UNIQUE |
|
||||
| `cupang_shipments` | 출고 묶음 헤더 (작성일/출고일/센터입고일/센터/출고방식/상태/작업자/메모) |
|
||||
| `cupang_shipment_lines` | 출고 라인. `shipment_id` FK ON DELETE CASCADE. `UNIQUE(shipment_id, line_no)` |
|
||||
|
||||
`status` 허용값: `작성중`, `출고준비`, `출고완료`, `센터입고완료`, `취소`. 삭제는 기본 soft delete(`status='취소'`).
|
||||
|
||||
박스 계산은 서버(`store.compute_boxes`)에서 재계산: `required_boxes = ceil(quantity / units_per_box)`. 클라이언트 계산은 미리보기용.
|
||||
|
||||
상품은 `cupang_db` 에 복제 저장하지 않는다. 라인에는 `product_code` + `product_name_snapshot` 만 보존(과거 명칭 보존). 상품 검색은 `itemcode_db` **읽기 전용**(`ITEMCODE_DB_URL`, 미설정 시 수동 입력).
|
||||
|
||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||
|
||||
```bash
|
||||
read -s -p "cupang_app password: " APP_PWD; echo
|
||||
docker exec -i postgres-db psql -U postgres \
|
||||
-v app_password="$APP_PWD" \
|
||||
< scripts/sql/cupang_db_init.sql
|
||||
# main-app .env 에 추가:
|
||||
# CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
|
||||
cd /opt/www/main && docker compose up -d --build
|
||||
```
|
||||
|
||||
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
|
||||
|
||||
---
|
||||
|
||||
## vacation_db 스키마 / 초기화
|
||||
|
||||
DDL: `scripts/sql/vacation_db_init.sql` (멱등). DB·역할(`vacation_app`)·테이블·인덱스·트리거·2026 공휴일 seed 를 한 번에 생성. **JSON 폴백 없음** — `VACATION_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시.
|
||||
|
||||
테이블:
|
||||
|
||||
| 테이블 | 용도 |
|
||||
| --- | --- |
|
||||
| `vacation_requests` | 휴가 신청(헤더). 종류/기간/시작·종료 구분(full/am/pm)/일수/사유/상태/승인자/반려사유 |
|
||||
| `vacation_holidays` | 공휴일(`holiday_date` UNIQUE). `is_red=true` 면 달력 빨강 + 일수 계산 제외. 관리자가 settings 에서 추가/수정/삭제 |
|
||||
| `vacation_balances` | 사용자별 연차(`UNIQUE(user_email, year)`). `total_days` 설정, 사용일수는 승인 휴가 합계로 자동 계산 |
|
||||
|
||||
`status` 허용값: `작성중`, `제출`, `승인`, `반려`, `취소`. 워크플로: 작성중/반려 → 제출 → 승인|반려. 삭제는 기본 soft delete(`status='취소'`). 수정은 작성중/반려 상태에서 본인만.
|
||||
|
||||
휴가 일수는 서버(`store.compute_days`)에서 재계산: 주말 + `vacation_holidays(is_red)` 제외, 오전/오후 반차 0.5일, 시작/종료 반차는 각 0.5 차감. 클라이언트 계산은 미리보기(주말만 제외)용.
|
||||
|
||||
권한: `vacation`(접근) / `vacation_approver`(승인·반려). admin 은 항상 통과. 공휴일·연차 설정은 admin 전용.
|
||||
|
||||
### 운영 서버 초기화 (1회, 사용자 승인 후)
|
||||
|
||||
```bash
|
||||
read -s -p "vacation_app password: " APP_PWD; echo
|
||||
docker exec -i postgres-db psql -U postgres \
|
||||
-v app_password="$APP_PWD" \
|
||||
< scripts/sql/vacation_db_init.sql
|
||||
# main-app .env 에 추가:
|
||||
# VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
|
||||
cd /opt/www/main && docker compose up -d --build
|
||||
```
|
||||
|
||||
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. 공휴일은 연도별로 다르므로 settings 화면에서 추가/수정.
|
||||
|
||||
---
|
||||
|
||||
## 백업 / 복구 (안전 절차)
|
||||
|
||||
### 백업
|
||||
|
||||
```bash
|
||||
# 단일 DB 덤프 (운영 권장)
|
||||
docker exec -t <postgres_container_name> \
|
||||
pg_dump -U <user> -F c -d orderlist_db \
|
||||
> /var/backups/postgres/orderlist_db_$(date +%F).dump
|
||||
```
|
||||
|
||||
### 복구 (덮어쓰기 위험 → 사용자 승인 필수)
|
||||
|
||||
```bash
|
||||
# 1) 신규 DB로 먼저 복구해 검증
|
||||
docker exec -i <postgres_container_name> \
|
||||
pg_restore -U <user> -d <new_db_name> < backup.dump
|
||||
|
||||
# 2) 검증 완료 후 운영 DB 교체 (필요 시)
|
||||
```
|
||||
|
||||
> `pg_restore --clean`은 기존 객체를 삭제한다. **운영 대상 DB에서 절대 무단 실행 금지**.
|
||||
|
||||
---
|
||||
|
||||
## .env 관련
|
||||
|
||||
- DB 접속 정보(`*_HOST`, `*_PORT`, `*_USER`, `*_PASSWORD`, `*_NAME`)는 모두 `.env`로 관리한다.
|
||||
- `.env`는 **Git에 올리지 않는다**. `.env.example`만 커밋한다.
|
||||
- 비밀값 유출이 의심되면 즉시 회전(비밀번호/키 변경)을 진행한다.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Deployment
|
||||
|
||||
## 기본 배포 흐름
|
||||
|
||||
```text
|
||||
[Windows 개발 PC]
|
||||
│ git push (Gitea)
|
||||
▼
|
||||
[Gitea Repository]
|
||||
│ git pull (서버에서)
|
||||
▼
|
||||
[Ubuntu Server]
|
||||
│ .env 확인 → docker compose up --build -d
|
||||
▼
|
||||
[Docker]
|
||||
│ main-app, postgres 컨테이너 실행
|
||||
▼
|
||||
[NPM (Nginx Proxy Manager)]
|
||||
│ 외부 HTTPS 종단 → 내부 :80
|
||||
▼
|
||||
[서비스 정상 동작]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 운영 서버 배포 경로
|
||||
|
||||
운영 서버는 Ubuntu Server. 동일 호스트에 여러 서비스가 있으므로 경로를 분리한다.
|
||||
|
||||
| 경로 | 용도 |
|
||||
| --- | --- |
|
||||
| **`/opt/www/main`** | **main-app (본 프로젝트) 배포 경로 — 기본** |
|
||||
| `/opt/dbx-corm` | CORM (CS/발주/반품/코드관리) 서비스 경로 |
|
||||
| `/opt/dbx-orderlist` | 주문관리/orderlist 서비스 경로 |
|
||||
|
||||
> main-app 관련 모든 명령/문서 작성 시 `/opt/www/main` 사용.
|
||||
> 새로운 경로가 필요하면 **사용자 지시**를 받은 뒤 결정한다.
|
||||
|
||||
---
|
||||
|
||||
## 사전 준비
|
||||
|
||||
- 서버에 Docker, Docker Compose 설치 완료
|
||||
- Gitea 접근 권한 있는 SSH/HTTPS 인증 설정 완료
|
||||
- NPM에 호스트 매핑 등록: `dbx.no1king.freeddns.org → http://192.168.0.194:80`
|
||||
- `.env` 값 확보 (Google OAuth, 세션 키, DB 비밀번호 등)
|
||||
|
||||
---
|
||||
|
||||
## 최초 배포
|
||||
|
||||
```bash
|
||||
# 1) main-app 배포 경로
|
||||
sudo mkdir -p /opt/www/main
|
||||
sudo chown $USER:$USER /opt/www/main
|
||||
cd /opt/www/main
|
||||
|
||||
# 2) 저장소 클론
|
||||
git clone https://gitea.no1king.freeddns.org/king/dbx-main.git .
|
||||
|
||||
# 3) 환경 변수 파일 준비
|
||||
cp .env.example .env
|
||||
nano .env # 실제 값으로 수정 (절대 Git에 커밋 금지)
|
||||
|
||||
# 4) 빌드 및 백그라운드 실행
|
||||
docker compose up --build -d
|
||||
|
||||
# 5) 로그 확인
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 업데이트 배포
|
||||
|
||||
```bash
|
||||
cd /opt/www/main
|
||||
git pull
|
||||
docker compose up --build -d
|
||||
docker compose logs -f --tail=200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컨테이너 관리
|
||||
|
||||
```bash
|
||||
docker compose ps # 상태 확인
|
||||
docker compose restart web # 단일 서비스 재시작
|
||||
docker compose stop # 중지 (볼륨 유지)
|
||||
docker compose down # 컨테이너 제거 (볼륨 유지)
|
||||
# docker compose down -v # ⚠ 볼륨까지 삭제. 운영에서 금지
|
||||
```
|
||||
|
||||
> `docker compose down -v`, `docker volume rm`, `docker system prune -a --volumes` 는 **사용자 승인 없이 실행 금지**.
|
||||
|
||||
---
|
||||
|
||||
## .env / 비밀값 관리
|
||||
|
||||
- `.env`, `.env.local`, `.env.production` 은 **절대 Git에 올리지 않는다**.
|
||||
- 예시 파일(`.env.example`)만 커밋한다.
|
||||
- 신규 변수 추가 시 `.env.example`과 본 문서를 함께 갱신한다.
|
||||
- 비밀값 유출 의심 시 즉시 회전(rotate)한다 (특히 `GOOGLE_CLIENT_SECRET`, `SESSION_SECRET_KEY`, DB 비밀번호).
|
||||
|
||||
### 주요 환경 변수
|
||||
|
||||
| 변수 | 설명 |
|
||||
| --- | --- |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth 클라이언트 ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth 클라이언트 보안 비밀 |
|
||||
| `SESSION_SECRET_KEY` | 세션 쿠키 서명용 랜덤 문자열 |
|
||||
| `SESSION_COOKIE_SECURE` | HTTPS 환경 `true` / 로컬 HTTP `false` |
|
||||
| `PUBLIC_BASE_URL` | 외부 접속 주소 (예: `https://dbx.no1king.freeddns.org`) |
|
||||
| `CS_ORDER_URL` | CS 발주 업무 버튼 이동 주소 |
|
||||
| `CUSTOMER_ORDER_LIST_URL` | 고객 주문리스트 프로그램 버튼 이동 주소 |
|
||||
| `*_DB_HOST`, `*_DB_USER`, `*_DB_PASSWORD`, `*_DB_NAME` | 각 PostgreSQL DB 접속 정보 |
|
||||
| `EXPENSE_DB_URL` | 개인경비 DB DSN (예: `postgresql://expense_app:<pwd>@postgres-db:5432/expense_db`). 미설정 시 JSON 폴백 |
|
||||
| `CUPANG_DB_URL` | 쿠팡 밀크런 DB DSN (예: `postgresql://cupang_app:<pwd>@postgres-db:5432/cupang_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내) |
|
||||
| `VACATION_DB_URL` | 휴가 관리 DB DSN (예: `postgresql://vacation_app:<pwd>@postgres-db:5432/vacation_db`). **필수** — 미설정 시 모듈 비활성(설정 필요 안내). 권한키 `vacation`/`vacation_approver` |
|
||||
| `ITEMCODE_DB_URL` | 상품 검색용 itemcode_db 읽기 전용 DSN. 미설정 시 검색 비활성(수동 입력). 테이블/컬럼은 `ITEMCODE_TABLE`/`ITEMCODE_CODE_COL`/`ITEMCODE_NAME_COL`/`ITEMCODE_TYPE_COL` 또는 `ITEMCODE_SEARCH_SQL` 로 지정 |
|
||||
| `DATA_DIR` | 영구 데이터 경로 (Docker 볼륨 마운트). 첨부파일은 `$DATA_DIR/uploads/expense/{item_id}/` 에 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 로컬 Docker 테스트
|
||||
|
||||
```powershell
|
||||
# 1) 로컬 env 파일 준비
|
||||
copy .env.local.example .env.local
|
||||
# .env.local 을 열어 실제 값으로 수정
|
||||
|
||||
# 2) 이미지 빌드 및 실행
|
||||
docker compose -f docker-compose.local.yml up --build
|
||||
|
||||
# 3) 브라우저 확인
|
||||
# http://localhost:8080
|
||||
```
|
||||
|
||||
> 로컬 OAuth: Google Cloud Console 리디렉션 URI에 `http://localhost:8080/auth/google` 추가 필요.
|
||||
|
||||
---
|
||||
|
||||
## 복구 절차
|
||||
|
||||
### 컨테이너만 깨진 경우
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### DB 데이터가 깨진 경우 (사용자 승인 필수)
|
||||
|
||||
1. 즉시 트래픽 차단 (NPM 비활성화 또는 점검 페이지)
|
||||
2. 가장 최근 백업 확인
|
||||
3. **새 DB 이름으로 먼저 복구**해 검증
|
||||
4. 검증 완료 후 운영 DB 교체
|
||||
5. 복구 후 로그/주문 정합성 점검
|
||||
|
||||
> 운영 DB에 직접 `pg_restore --clean`을 실행하지 않는다. `DATABASES.md` 참고.
|
||||
|
||||
---
|
||||
|
||||
## 위험 명령 (사용자 승인 없이 실행 금지)
|
||||
|
||||
| 명령 | 위험 |
|
||||
| --- | --- |
|
||||
| `rm -rf` | 파일/디렉터리 영구 삭제 |
|
||||
| `docker compose down -v` | 볼륨 포함 삭제 → DB 손실 |
|
||||
| `docker volume rm`, `docker volume prune` | 볼륨 영구 삭제 |
|
||||
| `docker system prune -a --volumes` | 이미지·네트워크·볼륨 일괄 삭제 |
|
||||
| `DROP DATABASE`, `DROP TABLE`, `TRUNCATE` | DB 파괴 (`DATABASES.md` 참고) |
|
||||
| `git reset --hard`, `git push --force`, `git clean -fd` | 작업 내역 손실 |
|
||||
| 운영 `.env` 덮어쓰기 / 삭제 | 인증·세션 붕괴 |
|
||||
|
||||
원칙:
|
||||
|
||||
1. 실행 전 현재 상태 확인 명령을 먼저 보여준다.
|
||||
2. 백업 위치를 명시한다.
|
||||
3. 사용자 명시 승인 후에만 실행한다.
|
||||
|
||||
---
|
||||
|
||||
## 점검 체크리스트
|
||||
|
||||
- [ ] `docker compose ps` 모든 서비스 `running`
|
||||
- [ ] `docker compose logs --tail=200` 에러 없음
|
||||
- [ ] `https://dbx.no1king.freeddns.org` 200 응답
|
||||
- [ ] Google 로그인 정상 동작
|
||||
- [ ] PostgreSQL 컨테이너 볼륨 마운트 정상
|
||||
- [ ] 최근 DB 백업 존재 여부
|
||||
- [ ] `.env` 미커밋 상태 (`git status`로 확인)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Project Overview
|
||||
|
||||
## 한 줄 정의
|
||||
|
||||
`main-app`은 DBX ERP 시스템의 **메인 프로젝트(허브)** 이다.
|
||||
주문 수집부터 상품코드 매칭, 재고, CS, 반품, 외부 쇼핑몰 API 연동까지 ERP 운영 전체를 담당한다.
|
||||
|
||||
---
|
||||
|
||||
## 기능 범위
|
||||
|
||||
| 모듈 | 설명 |
|
||||
| --- | --- |
|
||||
| 주문관리 | 다채널 주문 수집·통합, 상태 추적, 분석 |
|
||||
| 상품코드 매칭 | 채널별 상품코드 ↔ 사내 표준 상품코드 매핑, 단품/세트 구성 |
|
||||
| 재고관리 | 입고/출고/재고 조정, 채널별 재고 동기화 |
|
||||
| CS관리 | 문의/응대 이력, 발주 업무 트리거 |
|
||||
| 반품관리 | 반품/교환 접수, 처리, 환불 연계 |
|
||||
| 외부 연동 | 쇼핑몰·통합관리·택배사·문자 API 연동 |
|
||||
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
||||
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
|
||||
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
||||
|
||||
### 권한 키 (`MODULE_KEYS`)
|
||||
|
||||
| 키 | 종류 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| `corm` / `order` | 접근 | 외부 모듈 진입 |
|
||||
| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 |
|
||||
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
|
||||
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
|
||||
|
||||
`admin` 역할은 모든 권한 자동 부여. 신규 사용자는 관리자 페이지(`/admin`)에서 이메일만으로 등록 가능.
|
||||
|
||||
---
|
||||
|
||||
## 모듈 디렉토리 규약
|
||||
|
||||
신규 업무 모듈은 **별도 디렉토리 한 곳**에 라우터·저장소·템플릿을 모은다.
|
||||
|
||||
```
|
||||
app/modules/<name>/
|
||||
├─ __init__.py # router export
|
||||
├─ router.py # FastAPI APIRouter (prefix=/<name>)
|
||||
├─ store.py # 데이터 저장소 (JSON → 향후 <name>_db)
|
||||
└─ templates/<name>/ # Jinja 템플릿 (ChoiceLoader 로 검색)
|
||||
```
|
||||
|
||||
신규 모듈 등록 시 `app/main.py` 의:
|
||||
|
||||
1. `_MODULE_TEMPLATE_DIRS` 에 템플릿 경로 추가
|
||||
2. `app.include_router(<name>_router)` 추가
|
||||
3. `app.state.<name>_store = ...` 등 상태 등록
|
||||
4. `MODULE_KEYS` (`app/store.py`) 와 `_menu_items_for()` 메뉴 항목 동기화
|
||||
|
||||
---
|
||||
|
||||
## 외부 연동 대상
|
||||
|
||||
- **쇼핑몰**: 카페24, 네이버 스마트스토어
|
||||
- **통합 관리**: 사방넷
|
||||
- **물류**: CJ대한통운(CJ Logistics) 외 택배사 API
|
||||
- **알림**: 문자 발송 API
|
||||
- **인증**: Google OAuth (Google Workspace 계정 기반)
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 영역 | 사용 기술 |
|
||||
| --- | --- |
|
||||
| Backend | Python, FastAPI |
|
||||
| DB | PostgreSQL (Docker 컨테이너) |
|
||||
| 컨테이너 | Docker, Docker Compose |
|
||||
| Reverse Proxy | Nginx Proxy Manager (NPM) |
|
||||
| OS | Ubuntu Server (Proxmox VM) |
|
||||
| 인증 | Google OAuth + 화이트리스트 |
|
||||
| 저장소 | Gitea (self-hosted) |
|
||||
|
||||
---
|
||||
|
||||
## 관련 DB
|
||||
|
||||
| DB명 | 용도 |
|
||||
| --- | --- |
|
||||
| `itemcode_db` | 상품코드, 단품/세트 구성, 매칭 정보 |
|
||||
| `orderlist_db` | 주문 수집·분석·관리 (구 `orderlist_app`) |
|
||||
| `return_db` | 반품·교환·CS 데이터 |
|
||||
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
|
||||
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
|
||||
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
|
||||
|
||||
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
|
||||
|
||||
---
|
||||
|
||||
## 접속/도메인
|
||||
|
||||
| 환경 | 주소 |
|
||||
| --- | --- |
|
||||
| 운영 | `https://dbx.no1king.freeddns.org` |
|
||||
| 로컬 | `http://localhost:8080` |
|
||||
| Git 저장소 | `https://gitea.no1king.freeddns.org/king/dbx-main.git` |
|
||||
|
||||
---
|
||||
|
||||
## 허용 사용자 (서버 측 화이트리스트)
|
||||
|
||||
`app/main.py`의 `ALLOWED_EMAILS`에서 검사한다.
|
||||
|
||||
- king@dbxcorp.co.kr
|
||||
- julie@dbxcorp.co.kr
|
||||
- ellen@dbxcorp.co.kr
|
||||
- bj@dbxcorp.co.kr
|
||||
|
||||
Google `hd=dbxcorp.co.kr` 힌트는 클라이언트 편의용이며, **실제 권한 검사는 항상 서버 측**에서 수행한다.
|
||||
|
||||
---
|
||||
|
||||
## 작업 우선순위 / 원칙
|
||||
|
||||
1. 운영 데이터 안전이 최우선. 위험 명령은 사용자 승인 후 실행.
|
||||
2. 신규 기능보다 기존 데이터 일관성 유지 우선.
|
||||
3. 외부 API 연동은 실패 재시도와 로깅을 기본으로 한다.
|
||||
4. 비밀값은 `.env`로만 관리하고 Git에 절대 올리지 않는다.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Server Architecture
|
||||
|
||||
## 전체 구성도
|
||||
|
||||
```text
|
||||
[Windows 개발 PC]
|
||||
│ Git push / 파일 수정
|
||||
▼
|
||||
[Gitea Repository] (https://gitea.no1king.freeddns.org/king/dbx-main.git)
|
||||
│ git pull
|
||||
▼
|
||||
[Ubuntu Server / Proxmox VM]
|
||||
│
|
||||
├─ Nginx Proxy Manager (NPM)
|
||||
│ dbx.no1king.freeddns.org → http://192.168.0.194:80
|
||||
│
|
||||
├─ Docker
|
||||
│ ├─ main-app 컨테이너 (FastAPI / Uvicorn)
|
||||
│ ├─ PostgreSQL 컨테이너
|
||||
│ └─ 기타 부속 컨테이너
|
||||
│
|
||||
└─ PostgreSQL Databases
|
||||
├─ itemcode_db
|
||||
├─ orderlist_db
|
||||
└─ return_db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 요청 흐름
|
||||
|
||||
1. 사용자 브라우저 → `https://dbx.no1king.freeddns.org`
|
||||
2. NPM(외부 HTTPS) → 내부 HTTP `192.168.0.194:80`
|
||||
3. 서버 내부 Nginx/Compose 포워딩 → `main-app` 컨테이너 (FastAPI)
|
||||
4. FastAPI → PostgreSQL 컨테이너(같은 Docker 네트워크) 또는 외부 쇼핑몰/택배 API
|
||||
|
||||
---
|
||||
|
||||
## 구성요소
|
||||
|
||||
| 구성요소 | 설명 |
|
||||
| --- | --- |
|
||||
| **Ubuntu Server** | 호스트 OS. 도커 호스트. systemd로 docker.service 관리 |
|
||||
| **Docker** | 모든 앱·DB는 컨테이너로 운영. Compose 파일 단위 배포 |
|
||||
| **PostgreSQL** | Docker 컨테이너에서 운영. 데이터는 named volume |
|
||||
| **Nginx Proxy Manager** | 외부 HTTPS 종단, 인증서 자동 갱신, 호스트명 라우팅 |
|
||||
| **FastAPI (main-app)** | 메인 ERP 백엔드. 인증·라우팅·외부 API 연동 |
|
||||
| **Gitea** | 소스 저장소(사내 호스팅) |
|
||||
|
||||
---
|
||||
|
||||
## 네트워크 / 포트
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| 외부 도메인 | `dbx.no1king.freeddns.org` (HTTPS, NPM 종단) |
|
||||
| 내부 호스트 | `192.168.0.194` |
|
||||
| 내부 노출 포트 | `80` (NPM → 컨테이너) |
|
||||
| 로컬 개발 포트 | `8080` |
|
||||
| PostgreSQL | Docker 내부 네트워크에서만 접근 (호스트 노출 금지 권장) |
|
||||
|
||||
---
|
||||
|
||||
## 배포 경로
|
||||
|
||||
운영 서버 기준, 아래 중 하나에 클론한다. 상세는 `DEPLOYMENT.md`.
|
||||
|
||||
- `/opt/dbx-corm`
|
||||
- `/opt/dbx-orderlist`
|
||||
- `/opt/www/main`
|
||||
|
||||
> 같은 호스트에 여러 서비스를 운영하므로, 경로는 서비스별로 분리한다.
|
||||
|
||||
---
|
||||
|
||||
## 인증 흐름
|
||||
|
||||
1. 사용자가 `/auth/google` 진입 → Google OAuth 동의 화면
|
||||
2. 콜백에서 `id_token` 검증 (`hd=dbxcorp.co.kr` 힌트는 참고용)
|
||||
3. 서버에서 이메일 도메인 + `ALLOWED_EMAILS` 화이트리스트 검사
|
||||
4. 통과 시 세션 쿠키 발급(`SESSION_SECRET_KEY` 서명)
|
||||
|
||||
---
|
||||
|
||||
## 운영 시 점검 포인트
|
||||
|
||||
- NPM 호스트 매핑이 살아있는가 (`dbx.no1king.freeddns.org → 192.168.0.194:80`)
|
||||
- `docker compose ps`로 `main-app`, `postgres` 컨테이너 상태
|
||||
- PostgreSQL volume 마운트 경로와 백업 위치
|
||||
- `.env` 값 유실 여부 (특히 `GOOGLE_CLIENT_*`, `SESSION_SECRET_KEY`)
|
||||
@@ -5,3 +5,7 @@ httpx>=0.28
|
||||
jinja2>=3.1
|
||||
itsdangerous>=2.2
|
||||
python-dotenv>=1.0
|
||||
psycopg[binary,pool]>=3.2
|
||||
python-multipart>=0.0.20
|
||||
openpyxl>=3.1
|
||||
pillow>=10.0
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JSON 저장소 → expense_db 마이그레이션.
|
||||
|
||||
사용법:
|
||||
EXPENSE_DB_URL=postgresql://expense_app:<pwd>@<host>:5432/expense_db \
|
||||
python scripts/migrate_expense_json_to_db.py \
|
||||
--json /data/expense.json \
|
||||
[--dry-run]
|
||||
|
||||
원칙:
|
||||
- 멱등(idempotent): id 가 같으면 INSERT ... ON CONFLICT DO NOTHING.
|
||||
- JSON 원본은 건드리지 않는다. 검증 완료 후 사용자가 직접 보존/이관.
|
||||
- 실패 시 트랜잭션 롤백 후 종료.
|
||||
|
||||
위험 경고:
|
||||
- 본 스크립트는 운영 데이터에 쓰기 작업을 한다. 백업/덤프 후 실행할 것.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_KEYS = {
|
||||
"id",
|
||||
"owner",
|
||||
"spent_at",
|
||||
"category",
|
||||
"method",
|
||||
"merchant",
|
||||
"amount",
|
||||
"memo",
|
||||
"status",
|
||||
}
|
||||
|
||||
|
||||
def load_items(path: Path) -> list[dict]:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
raise SystemExit(f"형식 오류: {path} 의 'items' 가 리스트가 아닙니다.")
|
||||
return items
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--json", required=True, help="원본 expense.json 경로")
|
||||
parser.add_argument("--dry-run", action="store_true", help="DB 에 쓰지 않고 검증만")
|
||||
args = parser.parse_args()
|
||||
|
||||
dsn = os.environ.get("EXPENSE_DB_URL", "").strip()
|
||||
if not dsn:
|
||||
raise SystemExit("EXPENSE_DB_URL 환경변수가 설정되지 않았습니다.")
|
||||
|
||||
json_path = Path(args.json)
|
||||
if not json_path.exists():
|
||||
raise SystemExit(f"파일을 찾을 수 없습니다: {json_path}")
|
||||
|
||||
items = load_items(json_path)
|
||||
print(f"읽음: {len(items)} 건 ({json_path})")
|
||||
|
||||
bad = [i for i in items if not REQUIRED_KEYS.issubset(i.keys())]
|
||||
if bad:
|
||||
print(f"경고: 필드 누락 {len(bad)}건 — 누락 키는 기본값 채움", file=sys.stderr)
|
||||
|
||||
if args.dry_run:
|
||||
print("dry-run 완료. DB 에 쓰지 않았습니다.")
|
||||
return 0
|
||||
|
||||
import psycopg # type: ignore
|
||||
|
||||
inserted = skipped = 0
|
||||
with psycopg.connect(dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
for it in items:
|
||||
row = (
|
||||
it["id"],
|
||||
str(it.get("owner", "")).lower().strip(),
|
||||
it.get("spent_at") or None,
|
||||
it.get("category") or "기타",
|
||||
it.get("method") or "법인카드",
|
||||
it.get("merchant") or "",
|
||||
int(it.get("amount") or 0),
|
||||
it.get("memo") or "",
|
||||
it.get("status") or "작성중",
|
||||
it.get("created_at"),
|
||||
it.get("updated_at"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO expense_items
|
||||
(id, owner, spent_at, category, method, merchant,
|
||||
amount, memo, status, created_at, updated_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,
|
||||
COALESCE(%s::timestamptz, now()),
|
||||
COALESCE(%s::timestamptz, now()))
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
row,
|
||||
)
|
||||
if cur.rowcount == 1:
|
||||
inserted += 1
|
||||
else:
|
||||
skipped += 1
|
||||
conn.commit()
|
||||
|
||||
print(f"완료: insert={inserted}, skip(중복)={skipped}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =====================================================================
|
||||
-- cupang_db 마이그레이션 002 — cupang_shipments.document_no 컬럼 제거
|
||||
-- =====================================================================
|
||||
-- 사유: 출고 폼에서 "문서번호" 항목 제거(미사용).
|
||||
-- 멱등: IF EXISTS. 운영 적용 전 백업 권장(DROP COLUMN 은 되돌릴 수 없음).
|
||||
--
|
||||
-- 실행:
|
||||
-- docker exec -i postgres-db psql -U postgres -d cupang_db \
|
||||
-- < scripts/sql/cupang_db_002_drop_document_no.sql
|
||||
--
|
||||
-- 주의: 이 컬럼에 보관된 값이 있으면 함께 삭제된다. 현재 폼에서 입력받지
|
||||
-- 않으므로 값이 없거나 NULL 일 가능성이 높다. 확인 후 실행:
|
||||
-- SELECT count(*) FROM cupang_shipments WHERE document_no IS NOT NULL AND document_no <> '';
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
\connect cupang_db
|
||||
|
||||
ALTER TABLE cupang_shipments DROP COLUMN IF EXISTS document_no;
|
||||
|
||||
SELECT 'cupang_db 002 done' AS status;
|
||||
@@ -0,0 +1,199 @@
|
||||
-- =====================================================================
|
||||
-- cupang_db 초기화 스크립트 (PostgreSQL) — 쿠팡 밀크런 모듈
|
||||
-- =====================================================================
|
||||
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
|
||||
--
|
||||
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
|
||||
--
|
||||
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||
--
|
||||
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
|
||||
-- read -s -p "cupang_app password: " APP_PWD; echo
|
||||
-- docker exec -i postgres-db psql -U postgres \
|
||||
-- -v app_password="$APP_PWD" \
|
||||
-- < scripts/sql/cupang_db_init.sql
|
||||
--
|
||||
-- 2) main-app .env 에 연결 정보 등록
|
||||
-- CUPANG_DB_URL=postgresql://cupang_app:<APP_PWD>@postgres-db:5432/cupang_db
|
||||
--
|
||||
-- 3) main-app 재기동
|
||||
-- cd /opt/www/main && docker compose up -d --build
|
||||
--
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||
-- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품은 읽기 전용 참조).
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- DB 가 없을 때만 생성
|
||||
SELECT 'CREATE DATABASE cupang_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'cupang_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할 (expense_app 패턴과 동일)
|
||||
SELECT 'CREATE ROLE cupang_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'cupang_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE cupang_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE cupang_db TO cupang_app;
|
||||
|
||||
-- cupang_db 컨텍스트로 전환
|
||||
\connect cupang_db
|
||||
|
||||
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
|
||||
CREATE OR REPLACE FUNCTION cupang_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 1) 입고센터
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_centers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_centers_updated ON cupang_centers;
|
||||
CREATE TRIGGER trg_cupang_centers_updated
|
||||
BEFORE UPDATE ON cupang_centers
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2) 박스 입수량 규칙
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_box_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_code TEXT NOT NULL UNIQUE,
|
||||
product_name_snapshot TEXT,
|
||||
box_name TEXT NOT NULL DEFAULT '쿠팡박스',
|
||||
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_box_rules_code ON cupang_box_rules (product_code);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_box_rules_updated ON cupang_box_rules;
|
||||
CREATE TRIGGER trg_cupang_box_rules_updated
|
||||
BEFORE UPDATE ON cupang_box_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2-b) 제품명 카탈로그 (itemcode_db 에서 가져와 등록 → 폼 드롭다운 소스)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_products (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
product_code TEXT NOT NULL UNIQUE,
|
||||
product_name TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_products_name ON cupang_products (product_name);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_products_updated ON cupang_products;
|
||||
CREATE TRIGGER trg_cupang_products_updated
|
||||
BEFORE UPDATE ON cupang_products
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 3) 출고 묶음 (헤더)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_shipments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_by TEXT NOT NULL,
|
||||
document_date DATE NOT NULL,
|
||||
ship_date DATE NOT NULL,
|
||||
center_arrival_date DATE NOT NULL,
|
||||
center_id BIGINT REFERENCES cupang_centers(id),
|
||||
center_name_snapshot TEXT NOT NULL DEFAULT '',
|
||||
ship_method TEXT NOT NULL DEFAULT '택배',
|
||||
outbound_summary TEXT NOT NULL DEFAULT '',
|
||||
worker TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_doc_date ON cupang_shipments (document_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_ship_date ON cupang_shipments (ship_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_arr_date ON cupang_shipments (center_arrival_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_status ON cupang_shipments (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_ship_center ON cupang_shipments (center_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_shipments_updated ON cupang_shipments;
|
||||
CREATE TRIGGER trg_cupang_shipments_updated
|
||||
BEFORE UPDATE ON cupang_shipments
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 4) 출고 라인
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS cupang_shipment_lines (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
shipment_id BIGINT NOT NULL REFERENCES cupang_shipments(id) ON DELETE CASCADE,
|
||||
line_no INTEGER NOT NULL,
|
||||
product_code TEXT NOT NULL,
|
||||
product_name_snapshot TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
box_rule_id BIGINT REFERENCES cupang_box_rules(id),
|
||||
units_per_box INTEGER,
|
||||
calculated_boxes INTEGER,
|
||||
remainder_units INTEGER,
|
||||
manual_box_text TEXT NOT NULL DEFAULT '',
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (shipment_id, line_no)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_lines_shipment ON cupang_shipment_lines (shipment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cupang_lines_code ON cupang_shipment_lines (product_code);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_cupang_lines_updated ON cupang_shipment_lines;
|
||||
CREATE TRIGGER trg_cupang_lines_updated
|
||||
BEFORE UPDATE ON cupang_shipment_lines
|
||||
FOR EACH ROW EXECUTE FUNCTION cupang_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 5) 초기 입고센터 seed (멱등: ON CONFLICT DO NOTHING)
|
||||
-- sort_order 는 목록 순서대로 부여.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
INSERT INTO cupang_centers (name, sort_order) VALUES
|
||||
('대구3', 1), ('인천32', 2), ('이천1', 3), ('인천42', 4), ('인천26', 5),
|
||||
('인천16', 6), ('인천28', 7), ('안성8', 8), ('천안8(RC)', 9), ('시흥2', 10),
|
||||
('인천36', 11), ('MGMH5', 12), ('XRC10(RC)', 13), ('인천14', 14), ('경기광주5', 15),
|
||||
('경기광주3', 16), ('XRC06(RC)', 17), ('용인1', 18), ('인천30', 19), ('마장1', 20),
|
||||
('안성4', 21), ('대구6', 22), ('전라광주2', 23), ('창원1', 24), ('고양1', 25),
|
||||
('동탄1', 26), ('이천4', 27), ('XRC09(RC)', 28)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 6) 권한 (cupang_app: CRUD only)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
GRANT USAGE ON SCHEMA public TO cupang_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||
cupang_centers, cupang_box_rules, cupang_products,
|
||||
cupang_shipments, cupang_shipment_lines
|
||||
TO cupang_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO cupang_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cupang_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO cupang_app;
|
||||
|
||||
SELECT 'cupang_db ready' AS status;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- =====================================================================
|
||||
-- expense_db 마이그레이션 #002 — 결재 워크플로 + 첨부파일
|
||||
-- =====================================================================
|
||||
-- 실행:
|
||||
-- docker exec -i postgres-db psql -U postgres -d expense_db \
|
||||
-- < scripts/sql/expense_db_002_workflow_attachments.sql
|
||||
--
|
||||
-- 멱등. init.sql 도 동일 변경을 포함하므로 신규 설치는 init 하나로 충분.
|
||||
-- 이미 운영 중인 DB 에만 별도로 적용한다.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- expense_items 컬럼 추가
|
||||
ALTER TABLE expense_items
|
||||
ADD COLUMN IF NOT EXISTS approver_email TEXT,
|
||||
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
|
||||
|
||||
-- 첨부파일 테이블
|
||||
CREATE TABLE IF NOT EXISTS expense_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
|
||||
owner TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
|
||||
|
||||
SELECT 'migration 002 applied' AS status;
|
||||
@@ -0,0 +1,107 @@
|
||||
-- =====================================================================
|
||||
-- expense_db 초기화 스크립트 (PostgreSQL)
|
||||
-- =====================================================================
|
||||
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||
--
|
||||
-- 1) DB / 역할 생성 (superuser 로 1회)
|
||||
-- docker exec -i postgres-db psql -U postgres -v app_password='<강한비밀번호>' \
|
||||
-- < scripts/sql/expense_db_init.sql
|
||||
--
|
||||
-- 2) main-app .env 에 연결 정보 등록
|
||||
-- EXPENSE_DB_URL=postgresql://expense_app:<강한비밀번호>@postgres-db:5432/expense_db
|
||||
--
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다. CREATE DATABASE 만 IF NOT EXISTS 대체.
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 또는 \set 으로만 전달.
|
||||
-- - DROP/TRUNCATE 가 필요한 경우 본 스크립트를 수정하지 말고 별도 작업으로 진행.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- DB 가 없을 때만 생성
|
||||
SELECT 'CREATE DATABASE expense_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'expense_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할
|
||||
-- psql 변수(:'app_password')는 dollar-quoted 블록 안에서 치환되지 않으므로
|
||||
-- DO 블록을 쓰지 않고 \gexec 로 동적 SQL 을 생성·실행한다.
|
||||
|
||||
SELECT 'CREATE ROLE expense_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'expense_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE expense_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE expense_db TO expense_app;
|
||||
|
||||
-- 이제 expense_db 컨텍스트로 전환
|
||||
\connect expense_db
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expense_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
spent_at DATE NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
merchant TEXT NOT NULL DEFAULT '',
|
||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
approver_email TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
reject_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- 기존 DB 호환: 컬럼이 없으면 추가
|
||||
ALTER TABLE expense_items
|
||||
ADD COLUMN IF NOT EXISTS approver_email TEXT,
|
||||
ADD COLUMN IF NOT EXISTS decided_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS reject_reason TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_owner_spent ON expense_items (owner, spent_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_status ON expense_items (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_created_at ON expense_items (created_at DESC);
|
||||
|
||||
-- 첨부파일 (영수증/기타). 실제 파일은 파일시스템 저장, 메타만 DB.
|
||||
CREATE TABLE IF NOT EXISTS expense_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES expense_items(id) ON DELETE CASCADE,
|
||||
owner TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('receipt', 'other')),
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_attach_item ON expense_attachments (item_id);
|
||||
|
||||
-- updated_at 자동 갱신 트리거
|
||||
CREATE OR REPLACE FUNCTION expense_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_expense_set_updated_at ON expense_items;
|
||||
CREATE TRIGGER trg_expense_set_updated_at
|
||||
BEFORE UPDATE ON expense_items
|
||||
FOR EACH ROW EXECUTE FUNCTION expense_set_updated_at();
|
||||
|
||||
-- 권한
|
||||
GRANT USAGE ON SCHEMA public TO expense_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_items TO expense_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON expense_attachments TO expense_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO expense_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO expense_app;
|
||||
|
||||
-- 확인용 출력
|
||||
SELECT 'expense_db ready' AS status;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =====================================================================
|
||||
-- vacation_db 초기화 스크립트 (PostgreSQL) — 휴가 관리 모듈
|
||||
-- =====================================================================
|
||||
-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다.
|
||||
--
|
||||
-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음.
|
||||
--
|
||||
-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db):
|
||||
--
|
||||
-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회)
|
||||
-- read -s -p "vacation_app password: " APP_PWD; echo
|
||||
-- docker exec -i postgres-db psql -U postgres \
|
||||
-- -v app_password="$APP_PWD" \
|
||||
-- < scripts/sql/vacation_db_init.sql
|
||||
--
|
||||
-- 2) main-app .env 에 연결 정보 등록
|
||||
-- VACATION_DB_URL=postgresql://vacation_app:<APP_PWD>@postgres-db:5432/vacation_db
|
||||
--
|
||||
-- 3) main-app 재기동
|
||||
-- cd /opt/www/main && docker compose up -d --build
|
||||
--
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- DB 가 없을 때만 생성
|
||||
SELECT 'CREATE DATABASE vacation_db ENCODING ''UTF8'' TEMPLATE template0'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'vacation_db')
|
||||
\gexec
|
||||
|
||||
-- 앱 전용 로그인 역할 (expense_app / cupang_app 패턴과 동일)
|
||||
SELECT 'CREATE ROLE vacation_app LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vacation_app')
|
||||
\gexec
|
||||
|
||||
-- 항상 최신 비밀번호로 동기화
|
||||
SELECT 'ALTER ROLE vacation_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password')
|
||||
\gexec
|
||||
|
||||
GRANT CONNECT ON DATABASE vacation_db TO vacation_app;
|
||||
|
||||
-- vacation_db 컨텍스트로 전환
|
||||
\connect vacation_db
|
||||
|
||||
-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ──
|
||||
CREATE OR REPLACE FUNCTION vacation_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 1) 휴가 신청 (헤더)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
owner_name TEXT NOT NULL DEFAULT '',
|
||||
vacation_type TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
start_half TEXT NOT NULL DEFAULT 'full',
|
||||
end_half TEXT NOT NULL DEFAULT 'full',
|
||||
days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '작성중',
|
||||
approver_email TEXT,
|
||||
decided_at TIMESTAMPTZ,
|
||||
reject_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_owner_start ON vacation_requests (owner, start_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_status ON vacation_requests (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_req_range ON vacation_requests (start_date, end_date);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_requests_updated ON vacation_requests;
|
||||
CREATE TRIGGER trg_vacation_requests_updated
|
||||
BEFORE UPDATE ON vacation_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2) 공휴일 (달력 빨강 표시 + 휴가일수 계산 제외 기준)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_holidays (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
holiday_date DATE NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'public',
|
||||
is_red BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_holidays_date ON vacation_holidays (holiday_date);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_holidays_updated ON vacation_holidays;
|
||||
CREATE TRIGGER trg_vacation_holidays_updated
|
||||
BEFORE UPDATE ON vacation_holidays
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 3) 사용자별 연차 잔여 (연도 단위)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS vacation_balances (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
total_days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
used_days NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_email, year)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vacation_balances_user_year ON vacation_balances (user_email, year);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_vacation_balances_updated ON vacation_balances;
|
||||
CREATE TRIGGER trg_vacation_balances_updated
|
||||
BEFORE UPDATE ON vacation_balances
|
||||
FOR EACH ROW EXECUTE FUNCTION vacation_set_updated_at();
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 4) 공휴일 seed — 2026년 대한민국 공휴일 (멱등: ON CONFLICT DO NOTHING)
|
||||
-- 연도별로 달라지므로 settings 화면에서 추가/수정/삭제 가능.
|
||||
-- KASI(한국천문연구원) 발표 기준. 새 연도는 settings 또는 본 seed 추가.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
INSERT INTO vacation_holidays (holiday_date, name, kind) VALUES
|
||||
('2026-01-01', '신정', 'public'),
|
||||
('2026-02-16', '설날 연휴', 'lunar'),
|
||||
('2026-02-17', '설날', 'lunar'),
|
||||
('2026-02-18', '설날 연휴', 'lunar'),
|
||||
('2026-03-01', '삼일절', 'public'),
|
||||
('2026-03-02', '삼일절 대체', 'substitute'),
|
||||
('2026-05-05', '어린이날', 'public'),
|
||||
('2026-05-24', '부처님오신날', 'lunar'),
|
||||
('2026-05-25', '부처님오신날 대체', 'substitute'),
|
||||
('2026-06-06', '현충일', 'public'),
|
||||
('2026-08-15', '광복절', 'public'),
|
||||
('2026-08-17', '광복절 대체', 'substitute'),
|
||||
('2026-09-24', '추석 연휴', 'lunar'),
|
||||
('2026-09-25', '추석', 'lunar'),
|
||||
('2026-09-26', '추석 연휴', 'lunar'),
|
||||
('2026-09-28', '추석 대체', 'substitute'),
|
||||
('2026-10-03', '개천절', 'public'),
|
||||
('2026-10-05', '개천절 대체', 'substitute'),
|
||||
('2026-10-09', '한글날', 'public'),
|
||||
('2026-12-25', '성탄절', 'public')
|
||||
ON CONFLICT (holiday_date) DO NOTHING;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 5) 권한 (vacation_app: CRUD only)
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
GRANT USAGE ON SCHEMA public TO vacation_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||
vacation_requests, vacation_holidays, vacation_balances
|
||||
TO vacation_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vacation_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO vacation_app;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO vacation_app;
|
||||
|
||||
SELECT 'vacation_db ready' AS status;
|
||||
@@ -0,0 +1 @@
|
||||
초보자도 쉽게 이해 할 수 있게 코드마다 한글 주석을 쉬운 표현으로 달아줘
|
||||
Reference in New Issue
Block a user