5 Commits

Author SHA1 Message Date
유병철
7d369d1404 feat: 계정과목 공통화 및 회계 모듈 전반 개선
- 계정과목 관리를 accounting/common/으로 통합 (AccountSubjectSettingModal 이동)
- GeneralJournalEntry: 계정과목 actions/types 분리, 모달 import 경로 변경
- CardTransactionInquiry: JournalEntryModal/ManualInputModal 개선
- TaxInvoiceManagement: actions/types 리팩토링
- DepositManagement/WithdrawalManagement: 소폭 개선
- ExpectedExpenseManagement: UI 개선
- GiftCertificateManagement: 상세/목록 개선
- BillManagement: BillDetail/Client/index 소폭 추가
- PurchaseManagement/SalesManagement: 상세뷰 개선
- CEO 대시보드: dashboard-invalidation 유틸 추가, useCEODashboard 확장
- OrderRegistration/OrderSalesDetailView 소폭 수정
- claudedocs: 계정과목 통합 계획/분석/체크리스트, 대시보드 검증 문서 추가
2026-03-08 12:44:36 +09:00
74e0e2bf44 fix: InspectionManagement 타입 에러 일괄 수정
- ProductInspectionApi order_items에 document_id, inspection_data 속성 추가
- saveLocationInspection 파라미터를 ProductInspectionData 타입으로 변경
- inspection_data API→Frontend 변환 시 타입 캐스팅 수정
- 로컬 빌드 성공 확인
2026-03-07 02:05:17 +09:00
c94236e15c fix: ProductInspectionApi order_items에 누락된 속성 추가
- document_id, inspection_data 속성 추가
- 빌드 타입 에러 해결
2026-03-07 01:56:30 +09:00
3bade70c5f fix: ProductInspectionData 타입 에러 수정
- saveLocationInspection 파라미터를 Record<string, unknown>에서 ProductInspectionData로 변경
- interface는 index signature가 없어 Record<string, unknown>에 할당 불가
2026-03-07 01:49:38 +09:00
b7c2b99c68 fix: ApiBomItem에 없는 specification 속성 참조 제거
- item.specification fallback 제거 (ApiBomItem에 spec만 존재)
- 빌드 타입 에러 해결
2026-03-07 01:38:23 +09:00
55 changed files with 3696 additions and 499 deletions

View File

@@ -0,0 +1,123 @@
# 계정과목 통합 프로젝트 체크리스트
> 시작: 2026-03-06
> 목표: 계정과목 마스터 통합 → 분개 흐름 통합 → 대시보드 연동
---
## Phase 1: 계정과목 마스터 강화 (백엔드)
### 1-1. account_codes 테이블 확장
- [x] 마이그레이션: sub_category(중분류), depth(계층), parent_code(상위계정), department_type(부문) 추가
- [x] AccountCode 모델 업데이트 (fillable, casts, 관계)
- [x] AccountCodeService 확장 (계층 조회, 부문 필터 지원)
- [x] AccountSubjectController 확장 (새 필드 지원 API)
- [x] UpdateAccountSubjectRequest 생성
- [x] 라우트 추가 (PUT /{id}, POST /seed-defaults)
### 1-2. 표준 계정과목표 시드 데이터 (더존 Smart A 기준)
- [x] 시드 데이터 정의 (대분류 5개 + 중분류 12개 + 소분류 111개 = 128건)
- [x] seedDefaults() API 엔드포인트 (별도 Seeder 대신 API로 제공)
- [x] 기존 데이터와 충돌 방지 로직 (tenant_id+code 중복 시 skip)
---
## Phase 2: 프론트 공용 컴포넌트
### 2-1. 공용 계정과목 설정 모달 (리스트 페이지용 - CRUD)
- [x] AccountSubjectSettingModal 공용 컴포넌트 생성 (src/components/accounting/common/)
- [x] 기존 GeneralJournalEntry/AccountSubjectSettingModal 코드 이관 + 확장
- [x] 계층 표시 (depth별 들여쓰기: 대→중→소)
- [x] 부문 컬럼 추가
- [x] "기본 계정과목 생성" 버튼 (seedDefaults API 연동)
### 2-2. 공용 계정과목 Select (세부 페이지/모달용 - 조회/선택)
- [x] AccountSubjectSelect 공용 컴포넌트 생성
- [x] DB 마스터 API 호출로 옵션 로드 (selectable=true, isActive=true)
- [x] 활성 계정과목만 표시
- [x] "[코드] 계정과목명" 형태 표시 (예: [51100] 복리후생비(제조))
- [x] 분류별 필터 지원 (props: category, subCategory, departmentType)
### 2-3. 공용 타입/API 함수
- [x] 공용 타입 정의 (src/components/accounting/common/types.ts)
- [x] 공용 actions.ts (계정과목 CRUD + seedDefaults + update API)
- [x] index.ts 배럴 파일 생성
---
## Phase 3: 7개 모듈 전환 (프론트)
### 3-1. 일반전표입력
- [x] 전용 AccountSubjectSettingModal → 공용 컴포넌트로 교체
- [x] 전용 타입/API → 공용으로 교체 (actions.ts, types.ts 정리)
- [x] ManualJournalEntryModal: getAccountSubjects → 공용 actions
- [x] JournalEditModal: getAccountSubjects → 공용 actions
- [x] 전용 AccountSubjectSettingModal.tsx 삭제
### 3-2. 세금계산서관리
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
### 3-3. 카드사용내역
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
- [x] ManualInputModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
- [x] index.tsx 인라인 Select → AccountSubjectSelect
- 참고: ACCOUNT_SUBJECT_OPTIONS 상수는 엑셀 변환에서 기존 데이터 호환용으로 유지
### 3-4. 입금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
### 3-5. 출금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
### 3-6. 미지급비용
- [x] ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect (category="expense" 필터)
### 3-7. 매출관리 — 보류 (매출유형 분류이며 계정과목 코드가 아님)
---
## Phase 4: 분개 흐름 통합 (백엔드)
### 4-1. source_type 확장
- [x] JournalEntry 모델에 SOURCE_CARD_TRANSACTION, SOURCE_TAX_INVOICE 상수 추가
- [x] source_type은 string(30)이므로 enum 마이그레이션 불필요 (상수 추가만으로 완료)
### 4-2. 세금계산서 분개 통합
- [x] JournalSyncService 생성 (공용 분개 CRUD + expense 동기화)
- [x] TaxInvoiceController에 journal CRUD 메서드 추가 (get/store/delete)
- [x] 라우트 추가: GET/POST/PUT/DELETE /api/v1/tax-invoices/{id}/journal-entries
- [x] source_type = 'tax_invoice', source_key = 'tax_invoice_{id}'
### 4-3. 카드사용내역 분개 통합
- [x] CardTransactionController에 journal CRUD 메서드 추가 (get/store)
- [x] 라우트 추가: GET/POST /api/v1/card-transactions/{id}/journal-entries
- [x] 카드 items → 차변(비용계정) + 대변(미지급금) 자동 변환
- [x] source_type = 'card_transaction', source_key = 'card_{id}'
---
## Phase 5: 대시보드 연동
### 5-1. expense_accounts 동기화 확장
- [x] SyncsExpenseAccounts 트레이트 생성 (app/Traits/)
- [x] GeneralJournalEntryService → 트레이트 사용으로 전환
- [x] JournalSyncService에서 트레이트 사용 (세금계산서/카드 분개 저장 시 자동 동기화)
- [x] source_type별 payment_method 자동 결정 (card_transaction → PAYMENT_CARD)
- [x] 모든 source_type에서 복리후생비/접대비 감지
### 5-2. 대시보드 집계 검증
- [x] expense_accounts에 journal_entry_id/journal_entry_line_id 연결 (기존 마이그레이션 활용)
- [x] CEO 대시보드는 expense_accounts 테이블 기준 집계 → 모든 source_type 반영됨
---
## 작업 순서 및 의존성
```
Phase 1 (백엔드 마스터 강화)
Phase 2 (프론트 공용 컴포넌트)
Phase 3 (7개 모듈 전환) — 모듈별 독립, 병렬 가능
Phase 4 (분개 흐름 통합) — Phase 3과 병렬 가능
Phase 5 (대시보드 연동)
```

View File

@@ -0,0 +1,498 @@
# 계정과목 통합 기획서
> 작성일: 2026-03-06
> 상태: 진행중
> 관련: `claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md`
---
## 1. 배경 및 목표
### 문제점
현재 계정과목이 **7개 모듈에서 각자 하드코딩**으로 관리되고 있음.
- 일반전표만 DB 마스터(account_codes) 사용, 나머지는 프론트 상수 배열
- 계정과목 등록은 일반전표 설정에서만 가능
- 분개 데이터가 3개 테이블에 분산 (journal_entries, hometax_invoice_journals, barobill_card_transactions)
- CEO 대시보드 비용 집계가 일반전표 분개에서만 작동
### 목표
1. **계정과목 마스터 통합**: 하나의 DB 테이블, 전 모듈 공유
2. **공용 컴포넌트**: 설정 모달(CRUD) + Select(조회) 2개로 전 모듈 대응
3. **분개 흐름 통합**: 모든 분개 → journal_entries 한 곳에 저장
4. **대시보드 정확도**: 어디서 분개하든 비용 집계 정상 작동
### 회계담당자 요구사항
- 계정과목을 번호 + 명칭으로 구분 (예: 5201 급여)
- 제조/회계 동일 명칭이지만 번호로 구분 가능해야 함
- 등록하면 전체 공유, 개별 등록도 가능
---
## 2. 현재 상태 (AS-IS)
### 2.1 모듈별 계정과목 관리
| 모듈 | 소스 | 옵션 수 | 필드명 | API 필드 |
|------|------|---------|--------|----------|
| 일반전표입력 | DB 마스터 | 동적 | accountSubjectId | account_subject_id |
| 세금계산서관리 | 프론트 상수 | 11개 | accountSubject | account_subject |
| 카드사용내역 | 프론트 상수 | 16개 | accountSubject | account_code |
| 입금관리 | 프론트 상수 | ~11개 | depositType | account_code |
| 출금관리 | 프론트 상수 | ~11개 | withdrawalType | account_code |
| 미지급비용 | 프론트 상수 | 9개 | accountSubject | account_code |
| 매출관리 | 프론트 상수 | 8개 | accountSubject | account_code |
### 2.2 분개 저장 위치
| 소스 | 저장 테이블 | expense_accounts 동기화 |
|------|-----------|----------------------|
| 일반전표 (수기) | journal_entries + journal_entry_lines | O |
| 일반전표 (입출금 연동) | journal_entries + journal_entry_lines | O |
| 세금계산서 분개 | hometax_invoice_journals (별도) | X |
| 카드 계정과목 태그 | barobill_card_transactions.account_code | X |
### 2.3 백엔드 현재 테이블
```sql
-- account_codes (계정과목 마스터 - 일반전표만 사용)
id, tenant_id, code(10), name(100), category(enum), sort_order, is_active
-- journal_entries (분개 헤더)
id, tenant_id, entry_no, entry_date, entry_type, description,
total_debit, total_credit, status, source_type, source_key
-- journal_entry_lines (분개 상세)
id, journal_entry_id, tenant_id, line_no, account_code, account_name,
side(debit/credit), amount, trading_partner_id, trading_partner_name, description
-- hometax_invoice_journals (세금계산서 분개 - 별도)
id, tenant_id, hometax_invoice_id, nts_confirm_num,
dc_type, account_code, account_name, debit_amount, credit_amount, ...
-- barobill_card_transactions (카드 거래)
..., account_code, ...
```
---
## 3. 목표 상태 (TO-BE)
### 3.1 통합 구조
```
[계정과목 마스터]
account_codes 테이블 (확장)
├── code: "5201"
├── name: "급여"
├── category: "expense"
├── sub_category: "selling_admin" (판관비)
├── parent_code: "52" (상위 그룹)
├── depth: 3 (대=1, 중=2, 소=3)
└── department_type: "common" (공통/제조/관리)
[분개 통합]
journal_entries (source_type으로 출처 구분)
├── source_type: 'manual' ← 수기 전표
├── source_type: 'bank_transaction' ← 입출금 연동
├── source_type: 'tax_invoice' ← 세금계산서 (신규)
└── source_type: 'card_transaction' ← 카드사용내역 (신규)
[프론트 공용 컴포넌트]
AccountSubjectSettingModal → 리스트 페이지에서 CRUD
AccountSubjectSelect → 세부 페이지/모달에서 선택
```
### 3.2 데이터 흐름 (TO-BE)
```
계정과목 등록 (어느 페이지에서든)
→ account_codes 테이블에 저장
→ 전 모듈에서 즉시 사용 가능
분개 입력 (어느 모듈에서든)
→ journal_entries + journal_entry_lines에 저장
→ account_code는 account_codes 마스터 참조
→ expense_accounts 자동 동기화 (복리후생비/접대비)
→ CEO 대시보드에 자동 반영
```
---
## 4. Phase별 세부 구현 계획
### Phase 1: 백엔드 마스터 강화
#### 1-1. account_codes 테이블 확장 마이그레이션
```php
// database/migrations/2026_03_06_100000_enhance_account_codes_table.php
Schema::table('account_codes', function (Blueprint $table) {
$table->string('sub_category', 50)->nullable()->after('category')
->comment('중분류 (current_asset, fixed_asset, selling_admin, cogs 등)');
$table->string('parent_code', 10)->nullable()->after('sub_category')
->comment('상위 계정과목 코드 (계층 구조)');
$table->tinyInteger('depth')->default(3)->after('parent_code')
->comment('계층 깊이 (1=대분류, 2=중분류, 3=소분류)');
$table->string('department_type', 20)->default('common')->after('depth')
->comment('부문 (common=공통, manufacturing=제조, admin=관리)');
$table->string('description', 500)->nullable()->after('department_type')
->comment('계정과목 설명');
});
```
**sub_category 값 목록:**
| category | sub_category | 한글 |
|----------|-------------|------|
| asset | current_asset | 유동자산 |
| asset | fixed_asset | 비유동자산 |
| liability | current_liability | 유동부채 |
| liability | long_term_liability | 비유동부채 |
| capital | - | 자본 |
| revenue | sales_revenue | 매출 |
| revenue | other_revenue | 영업외수익 |
| expense | cogs | 매출원가 |
| expense | selling_admin | 판매비와관리비 |
| expense | other_expense | 영업외비용 |
**department_type 값:**
- `common`: 공통 (모든 부문에서 사용)
- `manufacturing`: 제조 (매출원가 계정)
- `admin`: 관리 (판관비 계정)
#### 1-2. AccountCode 모델 업데이트
```php
// app/Models/Tenants/AccountCode.php
protected $fillable = [
'tenant_id', 'code', 'name', 'category',
'sub_category', 'parent_code', 'depth', 'department_type',
'description', 'sort_order', 'is_active',
];
// 상수
const DEPT_COMMON = 'common';
const DEPT_MANUFACTURING = 'manufacturing';
const DEPT_ADMIN = 'admin';
const DEPTH_MAJOR = 1; // 대분류
const DEPTH_MIDDLE = 2; // 중분류
const DEPTH_MINOR = 3; // 소분류
```
#### 1-3. AccountCodeService 확장
기존 CRUD에 추가:
- `getHierarchical()`: 계층 구조 조회 (대-중-소 트리)
- `getByCategory(category, sub_category?)`: 분류별 조회
- `getByDepartment(department_type)`: 부문별 조회
- 필터: category, sub_category, department_type, depth, search, is_active
#### 1-4. AccountSubjectController 확장
기존 엔드포인트 유지 + 확장:
```
GET /api/v1/account-subjects ← 기존 (필터 파라미터 확장)
?category=expense
&sub_category=selling_admin
&department_type=common
&depth=3
&search=급여
&is_active=true
&hierarchical=true ← 계층 구조 응답 옵션
POST /api/v1/account-subjects ← 기존 (새 필드 추가)
PATCH /api/v1/account-subjects/{id} ← 신규 (수정)
PATCH /api/v1/account-subjects/{id}/status ← 기존
DELETE /api/v1/account-subjects/{id} ← 기존
POST /api/v1/account-subjects/seed-defaults ← 신규 (기본 계정과목표 일괄 생성)
```
#### 1-5. 표준 계정과목표 시드 데이터
```
1xxx 자산
11xx 유동자산
1101 현금
1102 보통예금
1103 당좌예금
1110 매출채권(외상매출금)
1120 선급금
1130 미수금
1140 가지급금
12xx 비유동자산
1201 토지
1202 건물
1210 기계장치
1220 차량운반구
1230 비품
1240 보증금
2xxx 부채
21xx 유동부채
2101 매입채무(외상매입금)
2102 미지급금
2103 선수금
2104 예수금
2110 부가세예수금
2120 부가세대급금
22xx 비유동부채
2201 장기차입금
3xxx 자본
31xx 자본금
3101 자본금
32xx 잉여금
3201 이익잉여금
4xxx 수익
41xx 매출
4101 제품매출
4102 상품매출
4103 부품매출
4104 용역매출
4105 공사매출
4106 임대수익
42xx 영업외수익
4201 이자수익
4202 외환차익
5xxx 비용
51xx 매출원가 (제조)
5101 재료비 ← department: manufacturing
5102 노무비 ← department: manufacturing
5103 외주가공비 ← department: manufacturing
52xx 판매비와관리비 (관리)
5201 급여 ← department: admin
5202 복리후생비 ← department: admin
5203 접대비 ← department: admin
5204 세금과공과 ← department: admin
5205 감가상각비 ← department: admin
5206 임차료 ← department: admin
5207 보험료(4대보험) ← department: admin
5208 통신비 ← department: admin
5209 수도광열비 ← department: admin
5210 소모품비 ← department: admin
5211 여비교통비 ← department: admin
5212 차량유지비 ← department: admin
5213 운반비 ← department: admin
5214 재료비 ← department: admin (관리부문)
5220 경비 ← department: admin
53xx 영업외비용
5301 이자비용
5302 외환차손
5310 배당금지급
```
기존 하드코딩 옵션과의 매핑:
| 기존 하드코딩 (영문 키워드) | 매핑될 계정코드 |
|---------------------------|---------------|
| purchasePayment (매입대금) | 2101 매입채무 |
| advance (선급금) | 1120 선급금 |
| suspense (가지급금) | 1140 가지급금 |
| rent (임차료) | 5206 임차료 |
| salary (급여) | 5201 급여 |
| insurance (4대보험) | 5207 보험료 |
| tax (세금) | 5204 세금과공과 |
| utilities (공과금) | 5209 수도광열비 |
| expenses (경비) | 5220 경비 |
| salesRevenue (매출수금) | 4101~4106 매출 |
| accountsReceivable (외상매출금) | 1110 매출채권 |
| accountsPayable (외상매입금) | 2101 매입채무 |
| salesVat (부가세예수금) | 2110 부가세예수금 |
| purchaseVat (부가세대급금) | 2120 부가세대급금 |
| cashAndDeposits (현금및예금) | 1101~1103 현금/예금 |
| advanceReceived (선수금) | 2103 선수금 |
---
### Phase 2: 프론트 공용 컴포넌트
#### 2-1. 파일 구조
```
src/components/accounting/common/
├── types.ts # 공용 타입 정의
├── actions.ts # 공용 계정과목 API 함수
├── AccountSubjectSettingModal.tsx # 설정 모달 (CRUD)
└── AccountSubjectSelect.tsx # Select 컴포넌트 (조회/선택)
```
#### 2-2. 공용 타입 (types.ts)
```typescript
export interface AccountSubject {
id: string;
code: string; // "5201"
name: string; // "급여"
category: AccountCategory; // 'asset' | 'liability' | 'capital' | 'revenue' | 'expense'
subCategory: string | null;
parentCode: string | null;
depth: number; // 1=대, 2=중, 3=소
departmentType: string; // 'common' | 'manufacturing' | 'admin'
description: string | null;
isActive: boolean;
}
// Select에서 표시할 때: `[${code}] ${name}` → "[5201] 급여"
```
#### 2-3. 공용 actions.ts
```typescript
'use server';
// 계정과목 조회 (Select용 - 활성만)
export async function getAccountSubjects(params?)
// 계정과목 CRUD (설정 모달용)
export async function createAccountSubject(data)
export async function updateAccountSubject(id, data)
export async function updateAccountSubjectStatus(id, isActive)
export async function deleteAccountSubject(id)
// 기본 계정과목표 일괄 생성
export async function seedDefaultAccountSubjects()
```
#### 2-4. AccountSubjectSettingModal (설정 모달)
기존 GeneralJournalEntry/AccountSubjectSettingModal 기반 확장:
- 계층 구조 표시 (번호대별 그룹핑 또는 들여쓰기)
- 대분류/중분류/부문 필터
- 등록: 코드 + 명칭 + 분류 + 중분류 + 부문
- 수정: 명칭, 분류, 상태
- 삭제: 미사용 계정만
- "기본 계정과목표 불러오기" 버튼 (초기 세팅용)
#### 2-5. AccountSubjectSelect (Select 컴포넌트)
```typescript
interface AccountSubjectSelectProps {
value: string; // 선택된 계정과목 code
onValueChange: (code: string) => void;
category?: AccountCategory; // 특정 분류만 표시
subCategory?: string; // 특정 중분류만 표시
departmentType?: string; // 특정 부문만 표시
placeholder?: string;
disabled?: boolean;
className?: string;
size?: 'default' | 'sm';
}
```
사용 예시:
```tsx
// 세금계산서 분개 - 전체 계정과목
<AccountSubjectSelect value={row.accountCode} onValueChange={...} />
// 카드내역 - 비용 계정만
<AccountSubjectSelect value={...} onValueChange={...} category="expense" />
// 입금관리 - 수익 + 자산 계정
<AccountSubjectSelect value={...} onValueChange={...} />
```
---
### Phase 3: 7개 모듈 전환
각 모듈에서:
1. 하드코딩 ACCOUNT_SUBJECT_OPTIONS 상수 **제거**
2. Radix Select → **AccountSubjectSelect** 교체
3. 리스트 페이지에 **설정 모달 버튼** 추가 (필요한 곳만)
4. API 저장 시 영문 키워드 → **계정코드(숫자)** 로 변경
#### 데이터 마이그레이션 고려
기존 데이터의 영문 키워드를 숫자 코드로 변환하는 마이그레이션 필요:
```php
// 예: barobill_card_transactions.account_code
// 'salary' → '5201'
// 'rent' → '5206'
```
---
### Phase 4: 분개 흐름 통합
#### 4-1. JournalEntry source_type 확장
```php
// JournalEntry 모델
const SOURCE_MANUAL = 'manual';
const SOURCE_BANK_TRANSACTION = 'bank_transaction';
const SOURCE_TAX_INVOICE = 'tax_invoice'; // 신규
const SOURCE_CARD_TRANSACTION = 'card_transaction'; // 신규
```
#### 4-2. 세금계산서 분개 통합
현재: `/api/v1/tax-invoices/{id}/journal-entries` → hometax_invoice_journals 저장
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
- source_type = 'tax_invoice'
- source_key = 'tax_invoice_{id}'
- hometax_invoice_journals는 레거시 호환으로 유지 (향후 제거)
#### 4-3. 카드사용내역 분개 통합
현재: `/api/v1/card-transactions/{id}/journal-entries` → barobill_card_transaction_splits
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
- source_type = 'card_transaction'
- source_key = 'card_{id}'
---
### Phase 5: 대시보드 연동
#### 5-1. expense_accounts 동기화 공용화
현재 GeneralJournalEntryService에만 있는 syncExpenseAccounts를:
- **JournalEntryService (공용)** 로 분리
- 모든 분개 저장/수정/삭제 시 자동 호출
- account_name에 '복리후생비' 또는 '접대비' 포함 → expense_accounts 동기화
#### 5-2. 검증
- 일반전표에서 복리후생비 분개 → 대시보드 반영 확인
- 세금계산서에서 복리후생비 분개 → 대시보드 반영 확인
- 카드내역에서 복리후생비 분개 → 대시보드 반영 확인
---
## 5. 작업 순서 및 의존성
```
Phase 1: 백엔드 마스터 강화
├── 1-1. 마이그레이션 + 모델
├── 1-2. 서비스 + 컨트롤러
└── 1-3. 시드 데이터
Phase 2: 프론트 공용 컴포넌트
├── 2-1. 공용 타입 + actions
├── 2-2. AccountSubjectSettingModal
└── 2-3. AccountSubjectSelect
Phase 3: 7개 모듈 전환 ──────────── Phase 4: 분개 흐름 통합
├── 3-1. 일반전표 ├── 4-1. source_type 확장
├── 3-2. 세금계산서 ├── 4-2. 세금계산서 분개
├── 3-3. 카드사용내역 └── 4-3. 카드 분개
├── 3-4. 입금관리 ↓
├── 3-5. 출금관리 Phase 5: 대시보드 연동
├── 3-6. 미지급비용 ├── 5-1. 동기화 공용화
└── 3-7. 매출관리 └── 5-2. 검증
```
---
## 6. 리스크 및 주의사항
| 리스크 | 대응 |
|--------|------|
| 기존 데이터 마이그레이션 | 영문 키워드 → 숫자 코드 변환 마이그레이션 작성 |
| 하드코딩 의존 코드 | 엑셀 다운로드 등에서 label 변환 로직 확인 |
| API 하위호환 | 기존 엔드포인트 유지, 새 필드는 optional |
| 시드 데이터 중복 | tenant별 기존 데이터 확인 후 없는 것만 추가 |

View File

@@ -0,0 +1,281 @@
# 계정과목(Chart of Accounts) 현황 분석 및 일반 ERP 비교
> 작성일: 2026-03-06
> 목적: 회계담당자 피드백 기반, 현재 시스템 vs 일반 ERP 계정과목 체계 비교
---
## 1. 회계담당자 요구사항 요약
| # | 요구사항 | 핵심 |
|---|---------|------|
| 1 | 계정과목을 통일해서 관리 | 하나의 마스터에서 전사적 관리 |
| 2 | 번호와 명칭으로 구분 | 코드 체계 필수 (예: 401-매출, 501-급여) |
| 3 | 제조/회계 동일 명칭이지만 번호가 다른 경우 존재 | 부문별 세분화 필요 |
| 4 | 등록하면 전체가 공유 + 개별등록도 가능 | 공통 + 부문별 계정 |
---
## 2. 현재 시스템 계정과목 사용 현황
### 2.1 모듈별 계정과목 관리 방식
| 모듈 | 계정과목 소스 | 옵션 수 | 관리 방식 | API 필드명 |
|------|-------------|---------|----------|-----------|
| **일반전표입력** | DB 마스터 (account_codes) | 동적 | API CRUD | `account_subject_id` |
| **카드사용내역** | 프론트 하드코딩 | 16개 | 상수 배열 | `account_code` |
| **미지급비용** | 프론트 하드코딩 | 9개 | 상수 배열 | `account_code` |
| **매출관리** | 프론트 하드코딩 | 8개 | 상수 배열 | `account_code` |
| **입금관리** | 프론트 하드코딩 | ~11개 | depositType 상수 | `account_code` |
| **출금관리** | 프론트 하드코딩 | ~11개 | withdrawalType 상수 | `account_code` |
| **세금계산서관리** | 프론트 하드코딩 | 11개 | 상수 배열 (분개 모달) | `account_subject` |
| **CEO 대시보드** | 표시만 | - | account_title 표시 | `account_title` |
### 2.2 핵심 문제점
```
[문제 1] 계정과목 이원화
일반전표: DB 마스터 (code + name + category) ← 유일하게 정상
나머지: 프론트엔드 하드코딩 상수 배열 ← 각자 따로 관리
[문제 2] 코드 체계 불일치
일반전표: { code: "101", name: "현금", category: "asset" }
카드내역: { value: "purchasePayment", label: "매입대금" } ← 영문 키워드
입금관리: { value: "salesRevenue", label: "매출수금" } ← 또 다른 영문 키워드
[문제 3] 옵션 중복 + 불일치
"급여"가 카드내역(salary), 미지급비용(salary), 입출금(salary)에 각각 존재
세금계산서(분개)는 또 다른 옵션 세트 (매출, 부가세예수금 등)
하지만 서로 독립적이라 추가/수정 시 각 파일 개별 수정 필요
[문제 4] 번호 체계 없음
카드내역의 "매입대금" = 코드 없이 "purchasePayment"라는 문자열만 존재
제조에서 쓰는 "재료비"와 회계에서 쓰는 "재료비"를 구분할 방법 없음
```
### 2.3 백엔드 DB 구조 (현재)
```
account_codes 테이블 (일반전표 전용 마스터)
├── id (PK)
├── tenant_id (테넌트 격리)
├── code (varchar 10) ← 계정번호
├── name (varchar 100) ← 계정명
├── category (enum: asset/liability/capital/revenue/expense)
├── sort_order
├── is_active
├── created_at / updated_at
└── unique(tenant_id, code)
journal_entry_lines (분개 상세)
├── account_code (varchar) ← 코드 저장
├── account_name (varchar) ← 명칭 스냅샷 저장
└── ... (side, amount 등)
barobill_card_transactions (카드거래)
├── account_code (varchar) ← 문자열 직접 저장 ("purchasePayment" 등)
└── ...
barobill_card_transaction_splits (카드 분개)
├── account_code (varchar) ← 문자열 직접 저장
└── ...
```
---
## 3. 일반적인 ERP의 계정과목(Chart of Accounts) 체계
### 3.1 표준 구조
```
[계정과목표 = Chart of Accounts]
계정분류(대분류)
├── 1xxx: 자산 (Assets)
│ ├── 11xx: 유동자산
│ │ ├── 1101: 현금
│ │ ├── 1102: 보통예금
│ │ ├── 1103: 당좌예금
│ │ ├── 1110: 매출채권
│ │ └── 1120: 선급금
│ └── 12xx: 비유동자산
│ ├── 1201: 토지
│ ├── 1202: 건물
│ └── 1210: 기계장치
├── 2xxx: 부채 (Liabilities)
│ ├── 21xx: 유동부채
│ │ ├── 2101: 매입채무
│ │ ├── 2102: 미지급금
│ │ └── 2110: 예수금
│ └── 22xx: 비유동부채
├── 3xxx: 자본 (Equity)
│ ├── 3101: 자본금
│ └── 3201: 이익잉여금
├── 4xxx: 수익 (Revenue)
│ ├── 4101: 제품매출
│ ├── 4102: 상품매출
│ └── 4201: 임대수익
└── 5xxx: 비용 (Expenses)
├── 51xx: 매출원가
│ ├── 5101: 재료비 (제조) ← 코드로 구분!
│ └── 5102: 노무비
├── 52xx: 판매비와관리비
│ ├── 5201: 급여
│ ├── 5202: 복리후생비
│ ├── 5203: 접대비
│ ├── 5210: 재료비 (관리) ← 같은 명칭, 다른 코드!
│ └── 5220: 임차료
└── 53xx: 영업외비용
├── 5301: 이자비용
└── 5302: 외환차손
```
### 3.2 일반 ERP 계정과목 마스터 구조
```
account_subjects (계정과목 마스터)
├── id (PK)
├── code (varchar 10) ← "5101" 같은 번호 (4~6자리)
├── name (varchar 100) ← "재료비"
├── category (대분류) ← 자산/부채/자본/수익/비용
├── sub_category (중분류) ← 유동자산/비유동자산/매출원가/판관비 등
├── parent_code (상위 계정) ← 계층 구조용
├── depth (계층 깊이) ← 1=대, 2=중, 3=소
├── department_type (부문) ← 제조/관리/공통 등
├── is_control (통제계정) ← 하위 세부계정 존재 여부
├── is_active (사용여부)
├── sort_order
├── description (설명)
└── tenant_id
```
### 3.3 일반 ERP vs 현재 SAM ERP 비교
| 항목 | 일반 ERP | SAM ERP (현재) | 차이 |
|------|---------|---------------|------|
| **마스터 테이블** | 1개 (전사 공유) | 1개 있지만 일반전표만 사용 | 다른 모듈 미연동 |
| **코드 체계** | 4~6자리 숫자 (1101, 5201) | 일반전표만 code 있음, 나머지 영문 키워드 | 번호 체계 불통일 |
| **계층 구조** | 대-중-소 분류 (parent_code) | 대분류(5개)만 존재 | 중/소분류 없음 |
| **부문 구분** | department_type으로 제조/관리 분리 | 없음 | 제조vs회계 구분 불가 |
| **공유 범위** | 전 모듈이 같은 마스터 참조 | 각 모듈 독자 관리 | 핵심 문제 |
| **등록 방식** | 계정과목 설정 화면 1곳 | 일반전표 설정에서만 등록 | 접근성 제한 |
| **사용처 추적** | 어떤 전표에서 사용되는지 추적 | 없음 | 감사 추적 불가 |
| **잠금/보호** | 사용 중인 계정 삭제 방지 | 없음 | 데이터 무결성 위험 |
---
## 4. 담당자 요구사항 vs 현재 시스템 GAP 분석
### 요구 1: "계정과목을 통일해서 관리"
```
현재 상태:
일반전표 → account_codes 테이블 (DB)
세금계산서 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 11개) - 분개 모달
카드내역 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 16개)
미지급비용 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 9개)
매출관리 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 8개)
입금관리 → depositType 상수
출금관리 → withdrawalType 상수
필요한 것:
모든 모듈 → account_codes 테이블 (DB) 하나만 참조
GAP: 크다 (프론트 하드코딩 → DB 마스터 참조로 전환 필요)
```
### 요구 2: "번호와 명칭으로 구분"
```
현재 상태:
일반전표: code="101", name="현금" ← 있음
카드내역: value="salary", label="급여" ← 영문 키워드, 번호 없음
필요한 것:
모든 곳에서: code="5201", name="급여" 형태로 표시
UI에서: "5201 - 급여" 또는 "[5201] 급여" 식으로 코드+명칭 동시 표시
GAP: 중간 (코드 체계는 DB에 이미 있으나, 다른 모듈이 참조하지 않음)
```
### 요구 3: "제조/회계 동일 명칭, 번호로 구분"
```
현재 상태:
구분 불가. "재료비"가 제조인지 관리인지 알 방법 없음
필요한 것:
5101: 재료비 (제조 - 매출원가)
5210: 재료비 (판관비 - 관리비용)
→ 코드가 다르므로 자동 구분
GAP: 크다 (중분류 + 부문 구분 필드 추가 필요)
```
### 요구 4: "전체 공유 + 개별 등록 가능"
```
현재 상태:
일반전표 설정에서만 등록 가능. 다른 모듈은 하드코딩이라 등록 개념 없음.
필요한 것:
- 기본 계정과목표 (회사 설정 시 일괄 생성)
- 추가 등록 (필요에 따라 개별 계정과목 추가)
- 전 모듈 공유 (등록 즉시 카드, 입출금, 세금계산서 등에서 사용 가능)
GAP: 중간 (DB 마스터는 있으니, 다른 모듈이 참조하도록 연결만 하면 됨)
```
---
## 5. 결론 및 권장사항
### 5.1 담당자 말씀이 맞는가?
**맞습니다.** 일반적인 ERP에서 계정과목은 반드시:
- 하나의 마스터(Chart of Accounts)로 전사 통합 관리
- 숫자 코드 + 명칭으로 식별 (코드가 PK 역할)
- 코드 번호로 계정 분류/부문 구분 (제조 5101 vs 관리 5210)
- 한 번 등록하면 모든 회계 모듈에서 공유
현재 SAM ERP는 일반전표에만 정상적인 마스터가 있고, 나머지는 각자 하드코딩이므로
**회계적으로 올바르지 않은 상태**입니다.
### 5.2 개선 방향 (단계별)
```
[Phase 1] 계정과목 마스터 강화 (백엔드)
- account_codes 테이블에 sub_category, parent_code, depth, department_type 추가
- 표준 계정과목표 시드 데이터 준비 (대/중/소 분류)
- 코드 체계 확정 (4자리 vs 6자리)
[Phase 2] 계정과목 설정 화면 독립 (프론트)
- 일반전표 내부 모달 → 독립 메뉴로 분리 (회계 > 계정과목 설정)
- 계층 구조 표시 (트리뷰 또는 들여쓰기 목록)
- 대량 등록 (Excel import), 기본 계정과목표 초기 세팅
[Phase 3] 전 모듈 통합 (프론트 + 백엔드)
- 세금계산서관리: ACCOUNT_SUBJECT_OPTIONS 상수 (11개) → DB 마스터 API 호출로 전환
- 카드사용내역: ACCOUNT_SUBJECT_OPTIONS 상수 (16개) → DB 마스터 API 호출로 전환
- 입금/출금관리: depositType/withdrawalType → DB 마스터 참조로 전환
- 미지급비용, 매출관리: 동일하게 전환
- Select UI에 "코드 - 명칭" 형태로 표시 (예: "[5201] 급여")
[Phase 4] 고급 기능
- 사용중 계정 삭제 방지 (참조 무결성)
- 계정과목별 거래 내역 조회
- 기간별 잔액 집계
```
### 5.3 작업 규모 예상
| Phase | 범위 | 핵심 변경 |
|-------|------|----------|
| 1 | 백엔드 마이그레이션 + 시드 | account_codes 테이블 확장, 시드 데이터 |
| 2 | 프론트 1개 페이지 신규 | 계정과목 설정 독립 페이지 |
| 3 | 프론트 6~7개 모듈 수정, 백엔드 API 조정 | 하드코딩 → API 참조 전환 |
| 4 | 양쪽 추가 개발 | 무결성, 집계, 조회 |

View File

@@ -0,0 +1,38 @@
# 2026-03-02 (월) 백엔드 구현 내역
## 1. `🆕 신규` [roadmap] 중장기 계획 테이블 마이그레이션 추가
**커밋**: `3ca161e` | **유형**: feat
### 배경
관리자 패널에서 프로젝트 로드맵을 관리할 수 있도록 데이터베이스 테이블이 필요했음.
### 구현 내용
- `admin_roadmap_plans` 테이블 생성 — 계획 마스터 (제목, 카테고리, 상태, Phase, 진행률)
- `admin_roadmap_milestones` 테이블 생성 — 마일스톤 관리 (plan_id FK, 상태, 예정일)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php` | 신규 생성 |
---
## 2. `🆕 신규` [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
**커밋**: `abe0460` | **유형**: feat
### 배경
AI 기반 자동 견적 시스템을 위한 데이터 저장 구조 및 초기 모듈 카탈로그 데이터가 필요했음.
### 구현 내용
- `ai_quotation_modules` 테이블 — SAM 모듈 카탈로그 (18개 모듈 정의)
- `ai_quotations` 테이블 — AI 견적 요청/결과 저장
- `ai_quotation_items` 테이블 — AI 추천 모듈 목록
- `AiQuotationModuleSeeder` — customer-pricing 기반 초기 데이터 시딩
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_02_100000_create_ai_quotation_tables.php` | 신규 생성 |
| `database/seeders/AiQuotationModuleSeeder.php` | 신규 생성 |

View File

@@ -0,0 +1,197 @@
# 2026-03-03 (화) 백엔드 구현 내역
## 1. `⚙️ 설정` [ai] Gemini 모델 버전 업그레이드
**커밋**: `f79d008` | **유형**: chore
### 배경
Google Gemini 모델의 새 버전(2.5-flash)이 출시되어 기존 2.0-flash에서 업그레이드 필요.
### 구현 내용
- `config/services.php` — fallback 기본 모델명 `gemini-2.5-flash`로 변경
- `AiReportService.php` — fallback 기본값 동일 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `config/services.php` | 수정 |
| `app/Services/AiReportService.php` | 수정 |
---
## 2. `🔧 수정` [deploy] 배포 시 .env 권한 640 보장 추가
**커밋**: `7e309e4` | **유형**: fix
### 배경
2026-03-03 장애 발생 — vi 편집으로 `.env` 파일 권한이 600으로 변경되어 PHP-FPM이 읽기 실패 → 500 에러. 재발 방지를 위해 배포 파이프라인에 권한 보장 로직 추가.
### 구현 내용
- Stage/Production Jenkinsfile 배포 스크립트에 `chmod 640 .env` 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `Jenkinsfile` | 수정 |
---
## 3. `🔧 수정` [hr] 사업소득자 임금대장 컬럼 추가
**커밋**: `b3c7d08` | **유형**: feat (기존 테이블 확장)
### 배경
사업소득자(프리랜서)를 시스템 회원이 아닌 직접 입력 대상자로 지원하기 위해 추가 컬럼 필요.
### 구현 내용
- `user_id` nullable 변경 (직접 입력 대상자 지원)
- `display_name`, `business_reg_number` 컬럼 추가
- 기존 데이터는 earner 프로필에서 자동 채움 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_display_name_to_business_income_payments.php` | 신규 생성 |
---
## 4. `🔧 수정` [ai-quotation] 제조 견적서 마이그레이션 추가
**커밋**: `da1142a` | **유형**: feat (기존 테이블 확장)
### 배경
AI 견적 시스템에서 제조업 견적서를 지원하기 위해 기존 테이블 확장 및 가격표 테이블 신규 생성 필요.
### 구현 내용
- `ai_quotations` 테이블에 `quote_mode`, `quote_number`, `product_category` 컬럼 추가
- `ai_quotation_items` 테이블에 `specification`, `unit`, `quantity`, `unit_price`, `total_price`, `item_category`, `floor_code` 컬럼 추가
- `ai_quote_price_tables` 테이블 신규 생성
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_manufacture_fields_to_ai_quotations.php` | 신규 생성 |
---
## 5. `🔧 수정` [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
**커밋**: `83a7745` | **유형**: feat (기존 기능 확장)
### 배경
오늘의 이슈를 특정 날짜 기준으로 과거 데이터도 조회할 수 있어야 함. 이전에는 현재 날짜 기준만 지원했음.
### 구현 내용
- `TodayIssueController``date` 파라미터(YYYY-MM-DD) 추가
- `TodayIssueService.summary()`에 날짜 기반 필터링 로직 구현
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/TodayIssueController.php` | 수정 |
| `app/Services/TodayIssueService.php` | 수정 |
---
## 6. `🔧 수정` [approval] 결재 수신함 날짜 범위 필터 추가
**커밋**: `b7465be` | **유형**: feat (기존 기능 확장)
### 배경
결재 수신함에서 특정 기간의 결재 건만 조회할 수 있도록 날짜 필터 필요.
### 구현 내용
- `InboxIndexRequest``start_date`/`end_date` 검증 룰 추가
- `ApprovalService.inbox()``created_at` 날짜 범위 필터 구현
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/Approval/InboxIndexRequest.php` | 수정 |
| `app/Services/ApprovalService.php` | 수정 |
---
## 7. `🔧 수정` [daily-report] 자금현황 카드용 필드 추가
**커밋**: `ad27090` | **유형**: feat (기존 API 확장)
### 배경
일일보고서 대시보드에 자금현황 카드를 표시하기 위해 미수금/미지급금/당월 예상 지출 데이터 필요.
### 구현 내용
- 미수금 잔액(`receivable_balance`) 계산 로직 구현
- 미지급금 잔액(`payable_balance`) 계산 로직 구현
- 당월 예상 지출(`monthly_expense_total`) 계산 로직 구현
- summary API 응답에 자금현황 3개 필드 포함
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/DailyReportService.php` | 수정 |
---
## 8. `🔧 수정` [stock,client,status-board] 날짜 필터 및 조건 보완
**커밋**: `4244334` | **유형**: feat (기존 기능 확장)
### 배경
재고/거래처/현황판 화면에서 날짜 범위 필터가 미지원이었고, 부실채권 현황에 비활성 데이터가 포함되는 이슈.
### 구현 내용
- `StockController/StockService` — 입출고 이력 기반 날짜 범위 필터 추가
- `ClientService` — 등록일 기간 필터(`start_date`/`end_date`) 추가
- `StatusBoardService` — 부실채권 현황에 `is_active` 조건 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/StockController.php` | 수정 |
| `app/Services/StockService.php` | 수정 |
| `app/Services/ClientService.php` | 수정 |
| `app/Services/StatusBoardService.php` | 수정 |
---
## 9. `🔧 수정` [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
**커밋**: `23c6cf6` | **유형**: feat (기존 모델 확장)
### 배경
기존 연차/반차만 지원하던 휴가 시스템에 출장, 재택근무, 외근, 조퇴, 지각, 결근 등 근태 유형 확장 필요. 결재 양식(근태신청, 사유서)도 추가.
### 구현 내용
- Leave 타입 6개 추가: `business_trip`, `remote`, `field_work`, `early_leave`, `late_reason`, `absent_reason`
- 그룹 상수: `VACATION_TYPES`, `ATTENDANCE_REQUEST_TYPES`, `REASON_REPORT_TYPES`
- `FORM_CODE_MAP` — 유형 → 결재양식코드 매핑
- `ATTENDANCE_STATUS_MAP` — 유형 → 근태상태 매핑
- 결재양식 2개 추가: `attendance_request`(근태신청), `reason_report`(사유서)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Leave.php` | 수정 |
| `database/migrations/..._insert_attendance_approval_forms.php` | 신규 생성 |
---
## 10. `🔧 수정` [production] 자재투입 모달 개선
**커밋**: `fc53789` | **유형**: fix (기존 기능 버그 수정 + 개선)
### 배경
자재투입 시 lot 미관리 품목(L-Bar, 보강평철)이 목록에 표시되는 이슈, BOM 그룹키 부재로 동일 자재 구분 불가, 셔터박스 순서가 작업일지와 불일치.
### 구현 내용
- `getMaterialsForItem``lot_managed===false` 품목을 자재투입 목록에서 제외
- `getMaterialsForItem``bom_group_key` 필드 추가 (category+partType 기반 고유키)
- `BendingInfoBuilder``shutterPartTypes`에서 `top_cover`/`fin_cover` 제거 (중복 방지)
- `BendingInfoBuilder` — 셔터박스 루프 순서 파트→길이로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
| `app/Services/WorkOrderService.php` | 수정 |

View File

@@ -0,0 +1,336 @@
# 2026-03-04 (수) 백엔드 구현 내역
## 1. `🔧 수정` [inspection] 캘린더 스케줄 조회 API 추가
**커밋**: `e9fd75f` | **유형**: feat (기존 검사 모듈에 캘린더 API 추가)
### 배경
검사 일정을 캘린더 형태로 표시하기 위한 API 필요.
### 구현 내용
- `GET /api/v1/inspections/calendar` 엔드포인트 추가
- `year`, `month`, `inspector`, `status` 파라미터 지원
- React 프론트엔드 `CalendarItemApi` 형식에 맞춰 응답
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/InspectionController.php` | 수정 |
| `app/Services/InspectionService.php` | 수정 |
| `routes/api/v1/production.php` | 수정 |
---
## 2. `🆕 신규` [barobill] 바로빌 연동 API 엔드포인트 추가
**커밋**: `4f3467c` | **유형**: feat
### 배경
바로빌(전자세금계산서/은행/카드 연동 서비스) API 연동을 위한 백엔드 엔드포인트 필요.
### 구현 내용
- `GET /api/v1/barobill/status` — 연동 현황 조회
- `POST /api/v1/barobill/login` — 로그인 정보 등록
- `POST /api/v1/barobill/signup` — 회원가입 정보 등록
- `GET /api/v1/barobill/bank-service-url` — 은행 서비스 URL
- `GET /api/v1/barobill/account-link-url` — 계좌 연동 URL
- `GET /api/v1/barobill/card-link-url` — 카드 연동 URL
- `GET /api/v1/barobill/certificate-url` — 공인인증서 URL
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/BarobillController.php` | 신규 생성 |
| `routes/api/v1/finance.php` | 수정 |
---
## 3. `🔧 수정` [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
**커밋**: `1deeafc` | **유형**: feat (기존 대시보드 확장)
### 배경
경비/가지급금 대시보드에서 날짜 범위 필터와 검색 기능이 없었고, 가지급금에 카테고리(카드/경조사/상품권/접대비) 분류 필요.
### 구현 내용
- `ExpectedExpenseController/Service` — dashboardDetail에 `start_date`/`end_date`/`search` 파라미터 추가
- `Loan` 모델 — category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
- `LoanService` — dashboard에 `category_breakdown` 집계 추가
- 마이그레이션 — loans 테이블 `category` 컬럼 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/ExpectedExpenseController.php` | 수정 |
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Services/ExpectedExpenseService.php` | 수정 |
| `app/Services/LoanService.php` | 수정 |
| `database/migrations/2026_03_04_100000_add_category_to_loans_table.php` | 신규 생성 |
---
## 4. `🔧 수정` [models] User 모델 import 누락/오류 수정
**커밋**: `da04b84` | **유형**: fix (버그 수정)
### 배경
Tenants 네임스페이스에서 `User::class``App\Models\Tenants\User`로 잘못 해석되는 문제. Loan, TodayIssue 모델에서 User import 경로 오류.
### 구현 내용
- `Loan.php``App\Models\Members\User` import 추가
- `TodayIssue.php``App\Models\Users\User``App\Models\Members\User` 수정
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Models/Tenants/TodayIssue.php` | 수정 |
---
## 5. `🔧 수정` [cards] 리다이렉트 추가
**커밋**: `76192fc` | **유형**: fix (하위호환)
### 배경
프론트엔드에서 기존 `cards/stats` 경로로 호출하는 코드가 있어 새 경로로 리다이렉트 필요.
### 구현 내용
- `cards/stats``card-transactions/dashboard` 리다이렉트 라우트 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `routes/api/v1/finance.php` | 수정 |
---
## 6. `🔧 수정` [address] 주소 필드 255자 → 500자 확장
**커밋**: `7cf70db` | **유형**: fix (제한 완화)
### 배경
실제 주소 데이터가 255자를 초과하는 경우 발생. DB와 FormRequest 검증 모두 확장 필요.
### 구현 내용
- DB 마이그레이션 — `clients`, `tenants`, `site_briefings`, `sites` 테이블 address 컬럼 `varchar(500)`
- FormRequest 8개 파일 — `max:255``max:500` 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/Client/ClientStoreRequest.php` | 수정 |
| `app/Http/Requests/Client/ClientUpdateRequest.php` | 수정 |
| `app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php` | 수정 |
| `app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php` | 수정 |
| `app/Http/Requests/Tenant/TenantStoreRequest.php` | 수정 |
| `app/Http/Requests/Tenant/TenantUpdateRequest.php` | 수정 |
| `app/Http/Requests/V1/Site/StoreSiteRequest.php` | 수정 |
| `app/Http/Requests/V1/Site/UpdateSiteRequest.php` | 수정 |
| `database/migrations/..._extend_address_columns_to_500.php` | 신규 생성 |
---
## 7. `🔧 수정` [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
**커밋**: `e637e3d` | **유형**: feat (기존 대시보드 대규모 리팩토링)
### 배경
D1.7 기획서 요구사항에 따라 접대비/복리후생비/매출채권 대시보드를 단순 집계에서 리스크 감지형으로 전환.
### 구현 내용
- `EntertainmentService` — 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
- `WelfareService` — 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
- `ReceivablesService` — summary를 `cards` + `check_points` 구조로 개선 (누적/당월 미수금, Top3 거래처)
- `LoanService` — getCategoryBreakdown 전체 대상으로 집계 조건 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
| `app/Services/WelfareService.php` | 수정 (대규모) |
| `app/Services/ReceivablesService.php` | 수정 (대규모) |
| `app/Services/LoanService.php` | 수정 |
---
## 8. `🔧 수정` [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
**커밋**: `f665d3a` | **유형**: fix (버그 수정)
### 배경
바로빌 카드거래 테이블 조인 시 컬럼명 불일치 및 심야 판별 함수 오류.
### 구현 내용
- `approval_no``approval_num` 컬럼명 수정
- `use_time` 심야 판별: `HOUR()``SUBSTRING` 문자열 파싱으로 변경
- `whereNotNull('bct.use_time')` 조건 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/EntertainmentService.php` | 수정 |
| `app/Services/WelfareService.php` | 수정 |
---
## 9. `🆕 신규` [approval] 지출결의서 양식 등록 및 고도화
**커밋**: `b86af29`, `282bf26` | **유형**: feat
### 배경
전자결재에 지출결의서 양식을 등록하고, HTML body_template 필드로 정형화된 양식 제공.
### 구현 내용
- `approval_forms` 테이블에 `body_template` TEXT 컬럼 추가 (마이그레이션)
- 지출결의서(expense) 양식 데이터 등록
- 참조 문서 기반으로 정형 양식 HTML 리디자인 — 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_body_template_to_approval_forms.php` | 신규 생성 |
| `database/migrations/..._insert_expense_approval_form.php` | 신규 생성 |
| `database/migrations/..._update_expense_approval_form_body_template.php` | 신규 생성 |
---
## 10. `🆕 신규` [entertainment] 접대비 상세 조회 API + `🔧 수정` 가지급금 날짜 필터
**커밋**: `66da297`, `a173a5a`, `94b96e2`, `2f3ec13` | **유형**: feat + fix
### 배경
접대비 상세 대시보드(손금한도, 월별추이, 거래내역)가 필요하고, 가지급금 대시보드에도 날짜 필터 지원 필요.
### 구현 내용
- `EntertainmentController/Service``getDetail()` 상세 조회 API 신규 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
- 수입금액별 추가한도 계산 (세법 기준), 거래건별 리스크 감지
- `LoanController/Service` — dashboard에 `start_date`/`end_date` 파라미터 지원 (기존 수정)
- `getCategoryBreakdown` SQL alias 충돌 수정
- 분기 사용액 조회에 날짜 필터 적용
- 라우트: `GET /entertainment/detail` 엔드포인트 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/EntertainmentController.php` | 수정 |
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
| `app/Services/LoanService.php` | 수정 |
| `routes/api/v1/finance.php` | 수정 |
---
## 11. `🆕 신규` [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API
**커밋**: `74a60e0` | **유형**: feat
### 배경
일정 관리를 위한 캘린더 CRUD API와 부가세 상세 조회 대시보드 API 필요.
### 구현 내용
- `CalendarController/Service` — 일정 등록/수정/삭제 API 신규
- `VatController/Service``getDetail()` 상세 조회 신규 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
- 라우트: `POST/PUT/DELETE /calendar/schedules`, `GET /vat/detail`
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/CalendarController.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/VatController.php` | 신규 생성 |
| `app/Services/CalendarService.php` | 신규 생성 |
| `app/Services/VatService.php` | 신규 생성 |
| `routes/api/v1/finance.php` | 수정 |
---
## 12. `🆕 신규` [shipment] 배차정보 다중 행 시스템
**커밋**: `851862` | **유형**: feat
### 배경
기존 출하 건에 단일 배차정보만 저장 가능했으나, 다중 차량 배차를 지원해야 함.
### 구현 내용
- `shipment_vehicle_dispatches` 테이블 신규 생성 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
- `ShipmentVehicleDispatch` 모델 신규
- `Shipment` 모델에 `vehicleDispatches()` HasMany 관계 추가
- `ShipmentService``syncDispatches()` 추가, store/update/delete/show/index에서 연동
- FormRequest — Store/Update에 `vehicle_dispatches` 배열 검증 규칙 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 신규 생성 |
| `app/Models/Tenants/Shipment.php` | 수정 |
| `app/Services/ShipmentService.php` | 수정 |
| `app/Http/Requests/Shipment/ShipmentStoreRequest.php` | 수정 |
| `app/Http/Requests/Shipment/ShipmentUpdateRequest.php` | 수정 |
| `database/migrations/..._create_shipment_vehicle_dispatches_table.php` | 신규 생성 |
---
## 13. `🔧 수정` [production] 자재투입 bom_group_key 개별 저장
**커밋**: `5ee97c2` | **유형**: fix (기존 기능 보완)
### 배경
동일 자재가 다른 BOM 그룹에 속할 때 구분이 안 되는 문제. bom_group_key로 개별 식별 필요.
### 구현 내용
- `work_order_material_inputs` 테이블에 `bom_group_key` 컬럼 추가
- 기투입 조회를 `stock_lot_id` + `bom_group_key` 복합키로 변경
- `replace` 모드 지원 (기존 삭제 → 재등록)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php` | 수정 |
| `app/Models/Production/WorkOrderMaterialInput.php` | 수정 |
| `app/Services/WorkOrderService.php` | 수정 |
| `database/migrations/..._bom_group_key_to_work_order_material_inputs.php` | 신규 생성 |
---
## 14. `🔧 수정` [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
**커밋**: `897511c` | **유형**: fix (기존 검사 로직 개선)
### 배경
절곡 검사 시 동일 작업지시의 모든 item에 검사 데이터가 복제 저장되어야 하며, products 배열을 bending EAV 레코드로 변환 필요.
### 구현 내용
- `storeItemInspection` — bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
- `transformBendingProductsToRecords` — products 배열 → bending EAV 레코드 변환
- `getMaterialInputLots` — 품목코드별 그룹핑으로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/WorkOrderService.php` | 수정 (대규모) |
---
## 15. `🆕 신규` [outbound] 배차차량 관리 API
**커밋**: `1a8bb46` | **유형**: feat
### 배경
출고 관련 배차차량을 독립적으로 관리(조회/수정/통계)하는 API 필요.
### 구현 내용
- `VehicleDispatchService` — index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
- `VehicleDispatchController` + `VehicleDispatchUpdateRequest`
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
- inventory.php에 `vehicle-dispatches` 라우트 4개 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/VehicleDispatchController.php` | 신규 생성 |
| `app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php` | 신규 생성 |
| `app/Services/VehicleDispatchService.php` | 신규 생성 |
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 수정 |
| `app/Services/ShipmentService.php` | 수정 |
| `database/migrations/..._options_to_shipment_vehicle_dispatches_table.php` | 신규 생성 |
| `routes/api/v1/inventory.php` | 수정 |

View File

@@ -0,0 +1,386 @@
# 2026-03-05 (목) 백엔드 구현 내역
## 1. `🔧 수정` [storage] RecordStorageUsage 명령어 수정
**커밋**: `e0bb19a` | **유형**: fix (버그 수정)
### 배경
`Tenant::where('status', 'active')` 하드코딩 사용 중이나 tenants 테이블에 `status` 컬럼이 없고 `tenant_st_code`를 사용함. 모델 스코프 사용으로 수정.
### 구현 내용
- `Tenant::where('status', 'active')``Tenant::active()` 스코프 사용
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
---
## 2. `🆕 신규` [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀
**커밋**: `e8da2ea`, `f1a3e0f` | **유형**: feat + fix
### 배경
CEO 전용 대시보드에 매출/매입/생산/미출고/시공/근태 등 6개 섹션 데이터를 제공하는 API 및 엑셀 다운로드 기능 필요.
### 구현 내용
- `DashboardCeoController/Service` — 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
- `DailyReportController/Service` — 엑셀 다운로드 API (`GET /daily-report/export`)
- 라우트: dashboard 하위 6개 + `daily-report/export` 엔드포인트
- 공정명 컬럼 수정 (`p.name``p.process_name`)
- 근태 부서 조인 수정 (`users.department_id``tenant_user_profiles` 경유)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/DashboardCeoController.php` | 신규 생성 |
| `app/Services/DashboardCeoService.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/DailyReportController.php` | 수정 |
| `app/Services/DailyReportService.php` | 수정 |
| `routes/api/v1/common.php` | 수정 |
| `routes/api/v1/finance.php` | 수정 |
---
## 3. `🔧 수정` [daily-report] 엑셀 내보내기 어음/외상매출채권 현황 및 리팩토링
**커밋**: `1b2363d`, `fefd129` | **유형**: feat + refactor (기존 엑셀 기능 확장/개선)
### 배경
일일보고서 엑셀에 어음/외상매출채권 현황 섹션이 빠져있었고, 엑셀과 화면 데이터가 불일치하는 문제.
### 구현 내용
- `DailyReportExport` — 어음 현황 테이블 + 합계 + 스타일링 추가
- `DailyReportService` — exportData를 `dailyAccounts()` 재사용 구조로 리팩토링
- 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Exports/DailyReportExport.php` | 수정 |
| `app/Services/DailyReportService.php` | 수정 (리팩토링) |
---
## 4. `🔧 수정` [production] 절곡 검사 FormRequest 검증 누락 수정
**커밋**: `ef7d9fa` | **유형**: fix (버그 수정)
### 배경
`StoreItemInspectionRequest``inspection_data.products` 검증 규칙이 누락되어 `validated()`에서 products 데이터가 제거되는 버그.
### 구현 내용
- `products.*.id`, `bendingStatus`, `lengthMeasured`, `widthMeasured`, `gapPoints` 검증 규칙 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php` | 수정 |
---
## 5. `🆕 신규` [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
**커밋**: `cd847e0` | **유형**: feat
### 배경
문서(Document) 시스템과 결재(Approval) 시스템을 연동하여, 문서 상신 시 결재가 자동 생성되고 결재 처리 시 문서 상태가 동기화되어야 함.
### 구현 내용
- `Approval` 모델에 `linkable` morphTo 관계 추가
- `DocumentService` — 상신 시 Approval 자동 생성 + approval_steps 변환
- `ApprovalService` — 승인/반려/회수 시 Document 상태 동기화
- `approvals` 테이블에 `linkable_type`, `linkable_id` 컬럼 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Approval.php` | 수정 |
| `app/Services/ApprovalService.php` | 수정 |
| `app/Services/DocumentService.php` | 수정 |
| `database/migrations/..._add_linkable_to_approvals_table.php` | 신규 생성 |
---
## 6. `🔧 수정` [process] 공정단계 options 컬럼 추가
**커밋**: `1f7f45e` | **유형**: feat (기존 테이블 확장)
### 배경
공정단계별 검사 설정/범위 등 확장 속성을 저장할 JSON 컬럼 필요.
### 구현 내용
- `ProcessStep` 모델에 `options` JSON 컬럼 추가 (fillable, cast)
- Store/UpdateProcessStepRequest에 `inspection_setting`, `inspection_scope` 검증 규칙
- `process_steps` 테이블 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php` | 수정 |
| `app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php` | 수정 |
| `app/Models/ProcessStep.php` | 수정 |
| `database/migrations/..._add_options_to_process_steps_table.php` | 신규 생성 |
---
## 7. `🔄 리팩토링` [production] 셔터박스 prefix isStandard 파라미터 제거
**커밋**: `d4f21f0` | **유형**: refactor
### 배경
CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize와 무관하게 적용됨. isStandard 분기가 불필요.
### 구현 내용
- `resolveShutterBoxPrefix()`에서 `isStandard` 파라미터 및 분기 로직 제거
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
| `app/Services/Production/PrefixResolver.php` | 수정 |
---
## 8. `🔧 수정` [production] 자재투입 replace 모드 지원
**커밋**: `7432fb1` | **유형**: feat (기존 기능 확장)
### 배경
자재투입 시 기존 투입 데이터를 교체하는 방식 선택 가능하도록 지원.
### 구현 내용
- `registerMaterialInputForItem``replace` 파라미터 추가
- Controller에서 request body의 `replace` 값 전달
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/WorkOrderController.php` | 수정 |
---
## 9. `🔄 리팩토링` [core] 모델 스코프 적용 규칙 추가
**커밋**: `9b8cdfa` | **유형**: refactor
### 배경
`where` 하드코딩 대신 모델에 정의된 스코프를 우선 사용하도록 코드 규칙 명시.
### 구현 내용
- `RecordStorageUsage` — where 하드코딩 → `Tenant::active()` 스코프
- `CLAUDE.md` — 쿼리 수정 시 모델 스코프 우선 규칙 명시
### 변경 파일
| 파일 | 작업 |
|------|------|
| `CLAUDE.md` | 수정 |
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
---
## 10. `⚙️ 설정` [infra] Slack 알림 채널 분리
**커밋**: `3d4dd9f` | **유형**: chore
### 배경
배포 알림 채널을 product_infra에서 deploy_api로 분리하여 알림 관리 개선.
### 구현 내용
- Jenkinsfile Slack 알림 채널 `product_infra``deploy_api` 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `Jenkinsfile` | 수정 |
---
## 11. `🔧 수정` [approval] 결재 테이블 확장 (3건)
**커밋**: `ac72487`, `558a393`, `ce1f910` | **유형**: feat (기존 테이블 확장)
### 배경
결재 시스템에 기안자 읽음 확인, 재상신 횟수, 반려 이력 추적 기능 필요.
### 구현 내용
- `drafter_read_at` 컬럼 — 기안자 완료 결과 확인 타임스탬프 (미읽음 뱃지 지원)
- `resubmit_count` 컬럼 — 재상신 횟수 추적
- `rejection_history` JSON 컬럼 — 반려 이력 저장
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_drafter_read_at_to_approvals_table.php` | 신규 생성 |
| `database/migrations/..._add_resubmit_count_to_approvals_table.php` | 신규 생성 |
| `database/migrations/..._add_rejection_history_to_approvals_table.php` | 신규 생성 |
---
## 12. `🆕 신규` [rd] CM송 저장 테이블 마이그레이션
**커밋**: `66d1004` | **유형**: feat
### 배경
AI 생성 CM송(광고 음악) 데이터 저장을 위한 테이블 필요.
### 구현 내용
- `cm_songs` 테이블 생성 — tenant_id, user_id, company_name, industry, lyrics, audio_path, options
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_05_170000_create_cm_songs_table.php` | 신규 생성 |
---
## 13. `🆕 신규` [approval] 결재양식 마이그레이션 (3건)
**커밋**: `f41605c`, `0f25a5d`, `846ced3` | **유형**: feat
### 배경
전자결재에 재직증명서, 경력증명서, 위촉증명서 양식 추가 필요.
### 구현 내용
- `employment_cert` — 재직증명서 양식 등록
- `career_cert` — 경력증명서 양식 등록
- `appointment_cert` — 위촉증명서 양식 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_05_184507_add_employment_cert_form.php` | 신규 생성 |
| `database/migrations/2026_03_05_230000_add_career_cert_form.php` | 신규 생성 |
| `database/migrations/2026_03_05_234000_add_appointment_cert_form.php` | 신규 생성 |
---
## 14. `🔧 수정` [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리
**커밋**: `8c9f2fc` | **유형**: feat (기존 모델 대규모 확장)
### 배경
어음 관리에 V8 규격(증권종류, 할인, 배서, 추심, 개서, 부도 등) 54개 필드 지원 필요. 가지급금에 상품권 카테고리 및 상태(보유/사용/폐기) 관리 필요.
### 구현 내용
- `Bill` 모델 — V8 확장 필드 54개 추가, 수취/발행 어음·수표별 세분화된 상태 체계
- `BillService``assignV8Fields`/`syncInstallments` 헬퍼, instrument_type/medium 필터
- `BillInstallment` — type/counterparty 필드 추가
- `Loan` 모델 — holding/used/disposed 상태 + metadata(JSON) 필드
- `LoanService` — 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
- FormRequest — V8 확장 필드 검증 규칙
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Bill.php` | 수정 (대규모) |
| `app/Models/Tenants/BillInstallment.php` | 수정 |
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Services/BillService.php` | 수정 |
| `app/Services/LoanService.php` | 수정 |
| `app/Http/Requests/V1/Bill/StoreBillRequest.php` | 수정 |
| `app/Http/Requests/V1/Bill/UpdateBillRequest.php` | 수정 |
| `app/Http/Requests/Loan/LoanStoreRequest.php` | 수정 |
| `app/Http/Requests/Loan/LoanUpdateRequest.php` | 수정 |
| `app/Http/Requests/Loan/LoanIndexRequest.php` | 수정 |
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
| `database/migrations/..._add_v8_fields_to_bills_table.php` | 신규 생성 |
| `database/migrations/..._add_metadata_to_loans_table.php` | 신규 생성 |
---
## 15. `🆕 신규` [loan] 상품권 접대비 자동 연동 + `🔧 수정` 후속 수정 (5건)
**커밋**: `31d2f08`, `03f86f3`, `652ac3d`, `7fe856f`, `c57e768` | **유형**: feat + fix
### 배경
상품권이 사용+접대비해당일 경우 expense_accounts에 자동으로 접대비 레코드를 생성/삭제해야 함. 관련 집계 및 수정/삭제 정책도 정비.
### 구현 내용
- `ExpenseAccount``loan_id` 필드 + `SUB_TYPE_GIFT_CERTIFICATE` 상수 추가
- `LoanService` — 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제 (🆕)
- store()에서도 접대비 자동 연동 호출 (🔧)
- `getCategoryBreakdown` — used/disposed 상품권은 가지급금 집계에서 제외 (🔧)
- dashboard summary/목록에서도 used/disposed 상품권 제외 (🔧)
- `isEditable()`/`isDeletable()` — 상품권이면 상태 무관하게 허용 (🔧)
- 접대비 연동 시 `receipt_no`에 시리얼번호 매핑 (🔧)
- `expense_accounts``loan_id` 컬럼 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Services/LoanService.php` | 수정 (다회) |
| `database/migrations/..._add_loan_id_to_expense_accounts_table.php` | 신규 생성 |
---
## 16. `🆕 신규` [생산지시] 전용 API 엔드포인트 신규 생성 + `🔧 수정` 후속 수정 (4건)
**커밋**: `2df8ecf`, `59d13ee`, `38c2402`, `0aa0a85` | **유형**: feat + fix
### 배경
수주 기반 생산지시 전용 API가 없어 프론트엔드에서 여러 API를 조합해야 했음. 전용 엔드포인트로 통합.
### 구현 내용
- `ProductionOrderService` — 목록(index), 통계(stats), 상세(show) 구현 (🆕)
- Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
- `workOrderProgress` 가공 필드, `production_ordered_at` 첫 WO 기반
- BOM 공정 분류 추출 (order_nodes.options.bom_result)
- `ProductionOrderController` + `ProductionOrderIndexRequest` + Swagger 문서 (🆕)
- 날짜 포맷 Y-m-d 변환, `withCount('nodes')` 개소수 추가 (🔧)
- 자재투입 시 WO 자동 상태 전환 (`autoStartWorkOrderOnMaterialInput`) (🆕)
- `process_id=null`인 구매품/서비스 WO 제외 (🔧)
- `extractBomProcessGroups` BOM 파싱 수정 (🔧)
- 재고생산 보조 공정을 일반 워크플로우에서 분리 (`is_auxiliary` 플래그) (🆕)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/ProductionOrderController.php` | 신규 생성 |
| `app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php` | 신규 생성 |
| `app/Services/ProductionOrderService.php` | 신규 생성 + 수정 |
| `app/Swagger/v1/ProductionOrderApi.php` | 신규 생성 |
| `app/Services/WorkOrderService.php` | 수정 |
| `app/Services/OrderService.php` | 수정 |
| `routes/api/v1/production.php` | 수정 |
---
## 17. `🆕 신규` [품질관리] 백엔드 API 구현 + `🔧 수정` 후속 수정 (3건)
**커밋**: `a6e29bc`, `3600c7b`, `0f26ea5` | **유형**: feat + fix
### 배경
품질관리서(제품검사 요청서) 및 실적신고 관리를 위한 백엔드 API 전체 구현.
### 구현 내용
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트 (🆕)
- 실적신고(performance_reports) 관리 API 6개 엔드포인트 (🆕)
- DB 마이그레이션 4개 테이블 (🆕)
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 (🆕)
- 납품일 Y-m-d 포맷 변환, 개소 수 order_nodes 루트 노드 기준 변경 (🔧)
- 수주선택 API에 `client_name` 필드 추가 (🔧)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/QualityDocumentController.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/PerformanceReportController.php` | 신규 생성 |
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 신규 생성 |
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 신규 생성 |
| `app/Http/Requests/Quality/PerformanceReportConfirmRequest.php` | 신규 생성 |
| `app/Http/Requests/Quality/PerformanceReportMemoRequest.php` | 신규 생성 |
| `app/Models/Qualitys/QualityDocument.php` | 신규 생성 |
| `app/Models/Qualitys/QualityDocumentOrder.php` | 신규 생성 |
| `app/Models/Qualitys/QualityDocumentLocation.php` | 신규 생성 |
| `app/Models/Qualitys/PerformanceReport.php` | 신규 생성 |
| `app/Services/QualityDocumentService.php` | 신규 생성 + 수정 |
| `app/Services/PerformanceReportService.php` | 신규 생성 |
| `database/migrations/..._create_quality_documents_table.php` | 신규 생성 |
| `database/migrations/..._create_quality_document_orders_table.php` | 신규 생성 |
| `database/migrations/..._create_quality_document_locations_table.php` | 신규 생성 |
| `database/migrations/..._create_performance_reports_table.php` | 신규 생성 |
| `routes/api/v1/quality.php` | 신규 생성 |

View File

@@ -0,0 +1,287 @@
# 2026-03-06 (금) 백엔드 구현 내역
## 1. `🔧 수정` [생산지시] 보조 공정 WO 카운트 제외
**커밋**: `a845f52` | **유형**: fix (기존 기능 보완)
### 배경
목록 조회 시 `work_orders_count`에 보조 공정(재고생산) WO가 포함되어 공정 진행률이 부정확.
### 구현 내용
- `withCount`에서 `is_auxiliary` WO 제외 조건 추가
- `whereNotNull(process_id)` + `options->is_auxiliary` 조건 적용
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/ProductionOrderService.php` | 수정 |
---
## 2. `🔧 수정` [loan] 상품권 summary에 접대비 집계 추가
**커밋**: `a7973bb` | **유형**: feat (기존 API 확장)
### 배경
상품권 대시보드에서 접대비로 전환된 건수/금액을 별도로 표시해야 함.
### 구현 내용
- `expense_accounts` 테이블에서 접대비(상품권) 건수/금액 조회
- `entertainment_count`, `entertainment_amount` 응답 필드 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/LoanService.php` | 수정 |
---
## 3. `🔧 수정` [receivables] 상위 거래처 집계 soft delete 제외
**커밋**: `be9c1ba` | **유형**: fix (버그 수정)
### 배경
매출채권 상위 거래처 집계 쿼리에서 soft delete된 레코드가 포함되어 금액이 부풀려지는 이슈.
### 구현 내용
- orders, deposits, bills 서브쿼리에 `whereNull('deleted_at')` 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/ReceivablesService.php` | 수정 |
---
## 4. `🆕 신규` [finance] 계정과목 및 일반전표 API 추가
**커밋**: `12d172e` | **유형**: feat
### 배경
회계 시스템의 핵심인 계정과목 관리 및 일반전표(입금/출금/수동전표 통합 목록) API 신규 구현.
### 구현 내용
- `AccountCode` 모델/서비스/컨트롤러 — 계정과목 CRUD
- `JournalEntry`, `JournalEntryLine` 모델 — 전표/전표 분개 모델
- `GeneralJournalEntryService` — 입금/출금/수동전표 UNION 통합 목록, 수동전표 CRUD
- `GeneralJournalEntryController` + FormRequest 검증 클래스
- finance 라우트 등록, i18n 메시지 키 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/GeneralJournalEntryController.php` | 신규 생성 |
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 신규 생성 |
| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | 신규 생성 |
| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | 신규 생성 |
| `app/Models/Tenants/AccountCode.php` | 신규 생성 |
| `app/Models/Tenants/JournalEntry.php` | 신규 생성 |
| `app/Models/Tenants/JournalEntryLine.php` | 신규 생성 |
| `app/Services/AccountCodeService.php` | 신규 생성 |
| `app/Services/GeneralJournalEntryService.php` | 신규 생성 |
| `lang/ko/error.php` | 수정 |
| `lang/ko/message.php` | 수정 |
| `routes/api/v1/finance.php` | 수정 |
---
## 5. `🔧 수정` [finance] 일반전표 source 필드 및 페이지네이션 수정
**커밋**: `816c25a` | **유형**: fix (신규 기능 후속 수정)
### 배경
입금/출금 조회 시 source가 CASE WHEN으로 불필요하게 분기되었고, 페이지네이션 응답 구조가 프론트엔드 기대와 불일치.
### 구현 내용
- deposits/withdrawals 조회 시 source를 항상 `'linked'`로 고정
- 페이지네이션 meta 래핑 제거 → 플랫 구조로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/GeneralJournalEntryService.php` | 수정 |
---
## 6. `🆕 신규` [menu] 즐겨찾기 테이블 마이그레이션
**커밋**: `a67c5d9` | **유형**: feat
### 배경
사용자별 메뉴 즐겨찾기 기능을 위한 데이터 테이블 필요.
### 구현 내용
- `menu_favorites` 테이블 — tenant_id, user_id, menu_id, sort_order
- unique 제약: (tenant_id, user_id, menu_id)
- FK cascade delete: users, menus
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_06_143037_create_menu_favorites_table.php` | 신규 생성 |
---
## 7. `🔧 수정` [departments] options JSON 컬럼 추가
**커밋**: `56e7164` | **유형**: feat (기존 테이블 확장)
### 배경
조직도 숨기기 등 부서별 확장 속성을 저장할 JSON 컬럼 필요.
### 구현 내용
- `departments` 테이블에 `options` JSON 컬럼 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_options_to_departments_table.php` | 신규 생성 |
---
## 8. `🆕 신규` [approval] 결재양식 마이그레이션 (6건)
**커밋**: `58fedb0`, `eb28b57`, `c5a0115`, `9d4143a`, `449fce1`, `96def0d` | **유형**: feat
### 배경
전자결재 양식 확대 — 사용인감계, 사직서, 위임장, 이사회의사록, 견적서, 공문서 양식 추가.
### 구현 내용
- `seal_usage` — 사용인감계 양식
- `resignation` — 사직서 양식
- `delegation` — 위임장 양식
- `board_minutes` — 이사회의사록 양식
- `quotation` — 견적서 양식
- `official_letter` — 공문서 양식
- 전체 테넌트에 자동 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_06_100000_add_resignation_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_210000_add_seal_usage_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_230000_add_delegation_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_233000_add_board_minutes_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_235000_add_quotation_form.php` | 신규 생성 |
| `database/migrations/2026_03_07_000000_add_official_letter_form.php` | 신규 생성 |
---
## 9. `🆕 신규` [database] 경조사비 관리 테이블 + 메뉴 추가
**커밋**: `0ea5fa5`, `22160e5` | **유형**: feat
### 배경
거래처 경조사비 관리대장 기능 신규 도입. 데이터 테이블 및 사이드바 메뉴 추가 필요.
### 구현 내용
- `condolence_expenses` 테이블 — 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조), 부조금, 선물, 총금액
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가 (중복 방지)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._create_condolence_expenses_table.php` | 신규 생성 |
| `database/migrations/..._add_condolence_expenses_menu.php` | 신규 생성 |
---
## 10. `🆕 신규` [문서스냅샷] rendered_html 저장 지원 + Lazy Snapshot API
**커밋**: `293330c`, `5ebf940`, `c5d5b5d` | **유형**: feat + fix
### 배경
문서의 렌더링된 HTML을 스냅샷으로 저장하여 PDF 변환/인쇄 등에 활용. 편집 권한 없이도 스냅샷 갱신 가능한 Lazy Snapshot API 필요.
### 구현 내용
- `Document` 모델 $fillable에 `rendered_html` 추가 (🔧)
- `DocumentService` create/update에서 rendered_html 저장 (🔧)
- Store/Update/UpsertRequest에 `rendered_html` 검증 추가 (🔧)
- `WorkOrderService` 검사문서/작업일지 생성 시 rendered_html 전달 (🔧)
- `PATCH /documents/{id}/snapshot` — canEdit 체크 없이 rendered_html만 업데이트 (🆕)
- `resolveInspectionDocument()``snapshot_document_id` 반환 (🆕)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Documents/Document.php` | 수정 |
| `app/Services/DocumentService.php` | 수정 |
| `app/Services/WorkOrderService.php` | 수정 |
| `app/Http/Requests/Document/StoreRequest.php` | 수정 |
| `app/Http/Requests/Document/UpdateRequest.php` | 수정 |
| `app/Http/Requests/Document/UpsertRequest.php` | 수정 |
| `app/Http/Controllers/Api/V1/Documents/DocumentController.php` | 수정 |
| `routes/api/v1/documents.php` | 수정 |
---
## 11. `🔧 수정` [품질관리] order_ids 영속성 + location 데이터 저장
**커밋**: `f2eede6` | **유형**: feat (기존 API 확장)
### 배경
품질관리서에 수주 연결 및 개소별 검사 데이터(시공규격, 변경사유, 검사결과)를 저장해야 함.
### 구현 내용
- StoreRequest/UpdateRequest에 `order_ids`, `locations` 검증 추가
- `QualityDocumentLocation``inspection_data`(JSON) fillable/cast 추가
- store()에 `syncOrders` 연동, update()에 `syncOrders` + `updateLocations` 연동
- `inspection_data` 컬럼 추가 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 수정 |
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 수정 |
| `app/Models/Qualitys/QualityDocumentLocation.php` | 수정 |
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
| `database/migrations/..._inspection_data_to_quality_document_locations.php` | 신규 생성 |
---
## 12. `🆕 신규` 제품검사 요청서 Document(EAV) 자동생성 및 동기화
**커밋**: `2231c9a` | **유형**: feat
### 배경
품질관리서 생성/수정/수주연결 시 제품검사 요청서 Document를 EAV 방식으로 자동 생성하고 동기화해야 함.
### 구현 내용
- `document_template_sections``description` 컬럼 추가
- `QualityDocumentService``syncRequestDocument()` 메서드 추가
- 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
- `rendered_html` 초기화 (데이터 변경 시 재캡처 트리거)
- `transformToFrontend``request_document_id` 포함
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Documents/DocumentTemplateSection.php` | 수정 |
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
| `database/migrations/..._add_description_to_document_template_sections.php` | 신규 생성 |
---
## 13. `⚙️ 설정` [API] logging, docs, seeder 등 부수 정리
**커밋**: `ff85530` | **유형**: chore
### 배경
여러 파일의 경로, 설정, 문서 등 소소한 정리 작업.
### 구현 내용
- `LOGICAL_RELATIONSHIPS.md` 보완 (최신 모델 관계 반영)
- `Legacy5130Calculator` 수정
- `logging.php` 설정 추가
- `KyungdongItemSeeder` 수정
- docs 문서 경로 수정
### 변경 파일
| 파일 | 작업 |
|------|------|
| `LOGICAL_RELATIONSHIPS.md` | 수정 |
| `app/Helpers/Legacy5130Calculator.php` | 수정 |
| `config/logging.php` | 수정 |
| `database/seeders/Kyungdong/KyungdongItemSeeder.php` | 수정 |
| `docs/INDEX.md` | 수정 |

View File

@@ -0,0 +1,40 @@
# 2026-03-07 (토) 백엔드 구현 내역
## 1. `🆕 신규` [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션
**커밋**: `ad93743` | **유형**: feat
### 배경
근로기준법에 따른 연차사용촉진 통지서(1차/2차) 양식을 전자결재 시스템에 등록 필요.
### 구현 내용
- `leave_promotion_1st` — 연차사용촉진 통지서 (1차) 양식, hr 카테고리
- `leave_promotion_2nd` — 연차사용촉진 통지서 (2차) 양식, hr 카테고리
- 전체 테넌트에 자동 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_07_100000_add_leave_promotion_forms.php` | 신규 생성 |
---
## 2. `🔧 수정` [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
**커밋**: `3ac64d5` | **유형**: feat (기존 API 확장)
### 배경
품질관리서 작성 시 수주 선택 API에 거래처/품목 필터가 없고, 개소별 상세 데이터 부족. 검사 상태 판별 로직도 개선 필요.
### 구현 내용
- `availableOrders``client_id`/`item_id` 필터 파라미터 지원
- 응답에 `client_id`, `client_name`, `item_id`, `item_name`, `locations`(개소 상세) 추가
- `show` — 개소별 데이터에 거래처/모델 정보 포함
- `DocumentService``fqcStatus`를 rootNodes 기반으로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/QualityDocumentService.php` | 수정 |
| `app/Services/DocumentService.php` | 수정 |
| `LOGICAL_RELATIONSHIPS.md` | 수정 |

View File

@@ -0,0 +1,47 @@
# 2026-03-08 (일) 백엔드 구현 내역
## 1. `🔧 수정` [finance] 계정과목 확장 및 전표 연동 시스템 구현
**커밋**: `0044779` | **유형**: feat (3/6 신규 기능 대규모 확장)
### 배경
3/6에 추가한 계정과목/일반전표 기본 API를 확장하여 기본 계정과목 시딩, 전표 자동 연동(카드거래/세금계산서), 계정과목 업데이트 기능 구현.
### 구현 내용
#### 계정과목 확장 (🔧 기존 확장)
- `AccountCode` 모델 확장 — 관계, 스코프, 헬퍼 추가
- `AccountCodeService` 확장 — 업데이트, 트리 조회, 기본 계정과목 시딩 로직
- `UpdateAccountSubjectRequest` 신규 — 업데이트 검증 규칙
- `StoreAccountSubjectRequest` — 추가 검증 규칙 보강
#### 전표 자동 연동 (🆕 신규)
- `JournalSyncService` 신규 — 카드거래/세금계산서 → 전표 자동 생성 서비스
- `SyncsExpenseAccounts` 트레이트 — 경비계정 동기화 공통 로직
- `CardTransactionController` 확장 — 전표 연동 엔드포인트 추가
- `TaxInvoiceController` 확장 — 전표 연동 엔드포인트 추가
#### 데이터베이스 (🆕 신규)
- `expense_accounts` 테이블에 전표 연결 컬럼 마이그레이션 (journal_entry_id 등)
- `account_codes` 테이블 확장 마이그레이션 (추가 속성 컬럼)
- 전체 테넌트 기본 계정과목 시딩 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 수정 |
| `app/Http/Controllers/Api/V1/CardTransactionController.php` | 수정 (대규모) |
| `app/Http/Controllers/Api/V1/TaxInvoiceController.php` | 수정 (대규모) |
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 수정 |
| `app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php` | 신규 생성 |
| `app/Models/Tenants/AccountCode.php` | 수정 |
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
| `app/Models/Tenants/JournalEntry.php` | 수정 |
| `app/Services/AccountCodeService.php` | 수정 (대규모) |
| `app/Services/GeneralJournalEntryService.php` | 수정 |
| `app/Services/JournalSyncService.php` | 신규 생성 |
| `app/Traits/SyncsExpenseAccounts.php` | 신규 생성 |
| `database/migrations/..._add_journal_link_to_expense_accounts_table.php` | 신규 생성 |
| `database/migrations/..._enhance_account_codes_table.php` | 신규 생성 |
| `database/migrations/..._seed_default_account_codes_for_all_tenants.php` | 신규 생성 |
| `routes/api/v1/finance.php` | 수정 |

View File

@@ -0,0 +1,72 @@
# SAM API 백엔드 구현 내역서
## 2026년 3월 1주차 (3/2 ~ 3/8)
**83개 커밋**, 7일간 구현 내역
### 태그 범례
| 태그 | 의미 |
|------|------|
| `🆕 신규` | 새로운 기능/API/테이블 생성 |
| `🔧 수정` | 기존 기능 버그 수정, 확장, 보완 |
| `🔄 리팩토링` | 기능 변경 없이 코드 구조 개선 |
| `⚙️ 설정` | 환경 설정, 인프라, 문서 정리 |
### 날짜별 문서
| 날짜 | 파일 | 주요 작업 | 🆕 | 🔧 | 🔄 | ⚙️ |
|------|------|-----------|-----|-----|-----|-----|
| 3/2 (월) | [2026-03-02_구현내역.md](./2026-03-02_구현내역.md) | 로드맵 테이블, AI 견적 엔진 | 2 | - | - | - |
| 3/3 (화) | [2026-03-03_구현내역.md](./2026-03-03_구현내역.md) | Gemini 업그레이드, 배포 수정, HR 확장, 자재투입 개선 | - | 7 | - | 1 |
| 3/4 (수) | [2026-03-04_구현내역.md](./2026-03-04_구현내역.md) | 바로빌 연동, 리스크 대시보드, 지출결의서, 배차 시스템 | 6 | 9 | - | - |
| 3/5 (목) | [2026-03-05_구현내역.md](./2026-03-05_구현내역.md) | CEO 대시보드, 어음 V8, 상품권 접대비, 생산지시, 품질관리 | 7 | 7 | 2 | 1 |
| 3/6 (금) | [2026-03-06_구현내역.md](./2026-03-06_구현내역.md) | 계정과목/일반전표, 문서 스냅샷, 결재양식 6종, 경조사비 | 7 | 5 | - | 1 |
| 3/7 (토) | [2026-03-07_구현내역.md](./2026-03-07_구현내역.md) | 연차촉진 통지서, 품질검사 필터링 | 1 | 1 | - | - |
| 3/8 (일) | [2026-03-08_구현내역.md](./2026-03-08_구현내역.md) | 계정과목 확장, 전표 연동 시스템 | - | 1 | - | - |
| **합계** | | | **23** | **30** | **2** | **3** |
### 도메인별 주요 기능
#### 재무/회계
- 🆕 계정과목 및 일반전표 API 신규 구축
- 🆕 전표 자동 연동 (카드거래/세금계산서)
- 🆕 접대비 상세 조회 API + 리스크 감지
- 🆕 부가세 상세 조회 API
- 🆕 경조사비 관리 테이블
- 🆕 바로빌 연동 API
- 🔧 접대비/복리후생비 리스크 감지형 대시보드 전환
- 🔧 매출채권 상세 대시보드 개선
- 🔧 가지급금 카테고리 분류 (카드/경조사/상품권/접대비)
- 🔧 상품권 접대비 자동 연동
- 🔧 어음 V8 확장 필드 (54개)
#### 생산/품질
- 🆕 생산지시 전용 API (목록/통계/상세)
- 🆕 품질관리서 CRUD API (14개 엔드포인트)
- 🆕 실적신고 관리 API (6개 엔드포인트)
- 🆕 제품검사 요청서 EAV 자동생성
- 🆕 보조 공정(재고생산) 분리
- 🔧 절곡 검사 데이터 복제/EAV 변환
- 🔧 자재투입 bom_group_key/replace 모드
#### 전자결재
- 🆕 Document ↔ Approval 브릿지 연동
- 🆕 결재양식 11종 추가 (지출결의서, 근태신청, 사유서, 재직증명서 등)
- 🔧 drafter_read_at, resubmit_count, rejection_history 컬럼
#### 대시보드/리포트
- 🆕 CEO 대시보드 6개 섹션 API
- 🆕 일일보고서 엑셀 내보내기
- 🔧 자금현황 카드 필드
#### 출고/배차
- 🆕 배차정보 다중 행 시스템
- 🆕 배차차량 관리 API
#### 인프라/기타
- ⚙️ Gemini 2.5-flash 업그레이드
- 🔧 .env 권한 640 보장 (배포)
- ⚙️ Slack 알림 채널 분리
- 🆕 문서 rendered_html 스냅샷 API
- 🆕 메뉴 즐겨찾기 테이블
- 🔧 주소 필드 500자 확장

View File

@@ -0,0 +1,432 @@
# CEO 대시보드 데이터 흐름 검증 보고서
> **작성일**: 2026-03-06
> **목적**: 대시보드 ↔ 개별 페이지 간 데이터 연동 완전성 검증
> **🔴 이 문서에 정리된 데이터 레이어는 "확정된 인프라"로 고정. 디자인 변경 시 UI만 교체할 것.**
---
## 🔒 변경 금지 영역 (데이터 인프라)
디자인 변경 시 아래 파일들은 **절대 수정하지 않음**:
| 레이어 | 파일 | 역할 |
|--------|------|------|
| **Hooks** | `src/hooks/useCEODashboard.ts` | 23개 Hook, API 호출 |
| **Transformers** | `src/lib/api/dashboard/transformers/*.ts` | API→Frontend 변환 |
| **Types (API)** | `src/lib/api/dashboard/types.ts` | API 응답 타입 |
| **Types (UI)** | `src/components/business/CEODashboard/types.ts` | UI 컴포넌트 타입 |
| **Modal Configs** | `src/components/business/CEODashboard/modalConfigs/*.ts` | 모달 설정 |
디자인 변경 시 수정 가능한 파일:
- `sections/*.tsx` (JSX/CSS만)
- `CEODashboard.tsx` (레이아웃만)
- `components.tsx` (공통 UI 컴포넌트)
- `SummaryNavBar.tsx` (네비게이션)
- `skeletons/*.ts` (로딩 UI)
---
## 📊 전체 20개 섹션 데이터 흐름 매핑
### 1. 상품권 → 가지급금 → 접대비 (핵심 연관관계)
```
상품권 관리 (/accounting/gift-certificate)
├─ 등록: status='holding' → cm3(상품권) 카운트 증가, 접대비 미반영
├─ 수정: status='used' + entertainmentExpense='applicable'
│ → Backend: syncGiftCertificateExpense() 자동 실행
│ → expense_accounts INSERT (account_type='entertainment')
│ → 접대비 섹션 반영됨
├─ 조건별 접대비 분류:
│ ├─ 일련번호 없음 → et_no_receipt (증빙미비) ✅
│ ├─ 금액 > 50만원 → et_high_amount (고액결제) ✅
│ └─ 주말/심야 사용 → et_weekend (주말/심야) ✅
└─ 삭제: expense_accounts도 함께 삭제
```
**검증 시나리오:**
| # | 작업 | 기대 결과 (카드관리) | 기대 결과 (접대비) |
|---|------|-------------------|------------------|
| 1 | 상품권 100만원 등록 (holding) | cm3 금액 +100만원 | 미반영 |
| 2 | status → used, 접대비=해당 | cm3 유지 | 접대비 총액 +100만원, 고액결제 +1건 |
| 3 | 일련번호 삭제 | cm3 미증빙 +1건 | 증빙미비 +1건 |
| 4 | status → holding 복귀 | cm3 유지 | 접대비에서 제거 |
| 5 | 상품권 삭제 | cm3 금액 -100만원 | 접대비에서 제거 |
---
### 2. 미수금 (ReceivableSection)
```
매출관리 (/accounting/sales) → Sale 생성 → receivable_balance 증가
미수금현황 (/accounting/receivables-status) → 입금처리/연체설정
API: GET /api/v1/receivables/summary
useReceivable() → transformReceivableResponse() → ReceivableSection
```
**데이터 소스 → 대시보드 매핑:**
| 소스 페이지 | 작업 | 대시보드 반영 |
|-----------|------|------------|
| 매출관리 | 매출 등록 | 누적미수금 증가 |
| 미수금현황 | 입금 처리 | 누적미수금 감소 |
| 어음관리 | 어음 발행 | 미수금 일부 이월 |
| 미수금현황 | 연체 설정 | 체크포인트 메시지 변경 |
---
### 3. 채권추심 (DebtCollectionSection)
```
악성채권관리 (/accounting/bad-debt-collection) → BadDebt CRUD
API: GET /api/v1/bad-debts/summary
useDebtCollection() → transformDebtCollectionResponse() → DebtCollectionSection
```
**상태 전환:**
| 상태 | 카드 | 설명 |
|------|------|------|
| collecting | 추심중 | 채권 추심 진행 |
| legalAction | 법적조치 | 법적 절차 진행 |
| recovered | 회수완료 | 채권 회수 완료 |
---
### 4. 매출현황 (SalesStatusSection)
```
매출관리 (/accounting/sales) → Sale CRUD
API: GET /api/v1/dashboard/sales/summary
useSalesStatus() → transformSalesStatusResponse() → SalesStatusSection
```
**대시보드 표시:** 누적매출, 달성률, 전년동기대비, 당월매출, 월별추이차트, 거래처별차트, 일별내역
---
### 5. 구매현황 (PurchaseStatusSection)
```
매입관리 (/accounting/purchases) → Purchase CRUD
API: GET /api/v1/dashboard/purchases/summary
usePurchaseStatus() → transformPurchaseStatusResponse() → PurchaseStatusSection
```
**결제 상태 매핑:**
| DB 상태 | 표시 | 조건 |
|--------|------|------|
| paid | 결제완료 | withdrawal_id 있음 |
| unpaid | 미결제 | withdrawal_id 없음 |
| partial | 부분결제 | 일부만 결제 |
---
### 6. 카드/가지급금 (CardManagementSection)
```
카드거래 + 가지급금(Loan) 데이터
API: GET /api/proxy/card-transactions/summary + /loans/dashboard + /loans/tax-simulation
useCardManagement() → transformCardManagementResponse() → CardManagementSection
```
**5개 카드:** cm1(카드), cm2(경조사), cm3(상품권), cm4(접대비), cm_total(합계)
---
### 7. 접대비 (EntertainmentSection)
```
expense_accounts 테이블 (상품권/카드 접대비 전환 시 자동 INSERT)
API: GET /api/v1/entertainment/summary
useEntertainment() → transformEntertainmentResponse() → EntertainmentSection
```
**4개 리스크 카드:**
| 카드 | 조건 |
|------|------|
| 주말/심야 | expense_date가 토/일/심야 |
| 기피업종 | merchant_biz_type MCC 매칭 |
| 고액결제 | amount > 500,000원 |
| 증빙미비 | receipt_no IS NULL |
---
### 8. 복리후생비 (WelfareSection)
```
지출 결재 승인 → 복리후생 관련 지출 집계
API: GET /api/v1/welfare/summary
useWelfare() → transformWelfareResponse() → WelfareSection
```
**4개 리스크 카드:** 비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과
---
### 9. 부가세 (VatSection)
```
매출/매입 거래 → 부가세 자동 계산
API: GET /api/v1/vat/summary
useVat() → transformVatResponse() → VatSection
```
**신고 기한 색상:** D-15+(녹색), D-1~15(주황), D-0(빨강), D-(음수)(진빨강경고)
---
### 10. 당월 예상 지출 (MonthlyExpenseSection)
```
구매발주 + 카드결제 + 어음 → 유형별 집계
API: GET /api/v1/expected-expenses/summary
useMonthlyExpense() → transformMonthlyExpenseResponse() → MonthlyExpenseSection
```
**4개 카드:** 구매금액, 카드결제, 어음/외상, 전체합계
---
### 11. 일일일보 (DailyReportSection)
```
배송완료(매출) + 입금기록 + 결재완료(지출) → 오늘 기준 집계
API: GET /api/v1/daily-report/summary
useDailyReport() → transformDailyReportResponse() → DailyReportSection
```
**4개 카드:** 당일매출액, 당일입금액, 당일지출액, 당일순현금
---
### 12. 현황판 (StatusBoardSection)
```
각 도메인 페이지 → 미처리 건수 집계
API: GET /api/v1/status-board/summary
useStatusBoard() → transformStatusBoardResponse() → StatusBoardSection
```
**항목:** 수주, 채권추심, 안전재고, 세금신고, 신규업체, 연차, 차량, 장비, 결재요청
---
### 13. 오늘의 이슈 (TodayIssueSection)
```
각 도메인 이벤트 발생 → TodayIssue 자동 생성
API: GET /api/v1/today-issues/summary
useTodayIssue() → transformTodayIssueResponse() → TodayIssueSection
```
**이슈 타입:** sales_order, bad_debt, safety_stock, expected_expense, vat_report, approval_request, new_vendor, deposit, withdrawal
---
### 14. 일정/캘린더 (CalendarSection)
```
일정관리 + 발주일정 + 시공일정 + 공휴일/세무일정(상수)
API: GET /api/v1/calendar/schedules
useCalendar() → transformCalendarResponse() → CalendarSection
```
**일정 타입:** schedule(파랑), order(초록), construction(보라), holiday(빨강), tax(주황)
---
### 15. 일일생산 (DailyProductionSection)
```
작업지시 상태변경 → 공정별 집계 (오늘만)
API: GET /api/v1/dashboard/production/summary
useDailyProduction() → transformDailyProductionResponse() → DailyProductionSection
```
**공정별 탭:** 각 공정(스크린 등)의 전체/대기/진행/완료/긴급 카운트 + 작업자 진행률
---
### 16. 출하현황 (DailyProduction 내 ShipmentSection)
```
shipments 테이블 → 당월 예상/실제 출고 집계
production/summary API 내 shipment 필드
DailyProductionSection 내 출하현황 카드
```
---
### 17. 미출하 (UnshippedSection)
```
출하관리 → shipments status='scheduled'|'ready'
API: GET /api/v1/dashboard/unshipped/summary
useUnshipped() → transformUnshippedResponse() → UnshippedSection
```
**납기 색상:** ≤3일(빨강), ≤7일(주황), 이상(회색)
---
### 18. 공사현황 (ConstructionSection)
```
계약관리 → contracts 당월 포함 건
API: GET /api/v1/dashboard/construction/summary
useConstruction() → transformConstructionResponse() → ConstructionSection
```
**진행률:** (경과일/총일수) × 100, 완료=100%, 미시작=0%
---
### 19. 일일근태 (DailyAttendanceSection)
```
출퇴근기록 + 휴가신청 → 오늘 기준 분류
API: GET /api/v1/dashboard/attendance/summary
useDailyAttendance() → transformDailyAttendanceResponse() → DailyAttendanceSection
```
**상태 분류:** checkin ≤ 기준=출근, checkin > 기준=지각, leave=휴가, 없음=결근
---
### 20. Enhanced 섹션 (EnhancedSections.tsx)
일별 매출/매입 상세 내역 — SalesStatus/PurchaseStatus API의 daily_items 활용
---
## ⚡ 공통 갱신 메커니즘
- **자동 갱신 없음**: 대시보드는 수동 refetch() 또는 페이지 새로고침 시에만 갱신
- **sam_stat 5분 캐시**: 백엔드 통계 테이블 캐싱 (일부 섹션)
- **대시보드 진입 시**: useCEODashboard()가 모든 섹션 병렬 로드 (Promise.all)
---
## 📋 화면 검수 시나리오 (2단계용)
### 시나리오 A: 상품권 → 가지급금 → 접대비
1. 상품권 100만원 등록 (holding) → 카드관리 cm3 확인
2. status=used, 접대비=해당으로 수정 → 접대비 고액결제 확인
3. 일련번호 제거 → 접대비 증빙미비 확인
4. 상태 복귀 → 접대비에서 제거 확인
### 시나리오 B: 매출 → 미수금
1. 매출 등록 → 매출현황 + 미수금 증가 확인
2. 입금 처리 → 미수금 감소 확인
### 시나리오 C: 작업지시 → 생산현황
1. 작업지시 등록 (오늘) → 생산현황 대기 +1 확인
2. 상태 → 진행중 → 진행 +1, 대기 -1 확인
3. 상태 → 완료 → 완료 +1, 진행 -1 확인
### 시나리오 D: 근태
1. 출근 기록 → 출근 인원 +1 확인
2. 휴가 신청 승인 → 휴가 +1 확인
### 시나리오 E: 구매 → 지출
1. 구매 등록 → 구매현황 + 당월예상지출 증가 확인
2. 결제 처리 → 구매현황 미결제→결제완료 변경 확인
### 시나리오 F: 일일일보
1. 배송 완료 → 당일매출액 증가 확인
2. 입금 기록 → 당일입금액 증가 확인
---
## ✅ 화면 검수 결과 (2026-03-06 실행)
### 시나리오 A: 상품권 → 가지급금 → 접대비 (CRUD 전체 사이클 검증)
| Step | 작업 | 가지급금 상품권 | 접대비 | 결과 |
|------|------|----------------|--------|------|
| 1 | 100만원 등록 (holding) | 0→100만 | 미반영 | ✅ PASS |
| 2 | status→사용, 접대비=해당 | 100만→0원 | 고액결제 +100만 1건 | ✅ PASS |
| 3 | 일련번호 삭제 | 0원 유지 | 증빙미비 10만1건→110만2건 | ✅ PASS |
| 4 | status→보유 복귀 | 0→100만 복귀 | 접대비에서 전부 제거 | ✅ PASS |
| 5 | 상품권 삭제 | 100만→0원 | 변화 없음 | ✅ PASS |
**검증 결론**: 상품권↔가지급금↔접대비 양방향 연동 완벽 작동
### 전체 20개 섹션 데이터 일관성 검증 (대시보드 vs 소스 페이지)
| # | 섹션 | NavBar 값 | 상세 섹션 값 | API 연동 | 결과 |
|---|------|----------|------------|---------|------|
| 1 | 오늘의 이슈 | 2건 | 신규거래처 2건 표시 | ✅ | ✅ PASS |
| 2 | 자금현황 | 0원 | 일일일보 0원, 미수금 9.4억, 미지급금 1.6억 | ✅ | ✅ PASS |
| 3 | 현황판 | 7항목 | 수주0, 채권추심7, 안전재고833, 연차0 | ✅ | ✅ PASS |
| 4 | 당월예상지출 | 1억 | 매입0, 카드0, 발행어음1억 | ✅ | ✅ PASS |
| 5 | 가지급금 | 1,150만 | 카드1,150만, 경조사0, 상품권0, 접대비0 | ✅ | ✅ PASS |
| 6 | 접대비 | 10만 | 주말심야0, 기피업종0, 고액결제0, 증빙미비10만1건 | ✅ | ✅ PASS |
| 7 | 복리후생비 | 0원 | 4개 리스크 카드 모두 0원 0건 | ✅ | ✅ PASS |
| 8 | 미수금 | 9.4억 | 누적9.4억, 당월-533만, 거래처69건, Top3 표시 | ✅ | ✅ PASS |
| 9 | 채권추심 | 1.2억 | 추심중4,782만, 법적조치4,463만, 회수2,058만 | ✅ | ✅ PASS |
| 10 | 부가세 | 0원 | 매출세액0, 매입세액0, 미발행0건 | ✅ | ✅ PASS |
| 11 | 캘린더 | 26일정 | 3월 캘린더 정상, 공휴일/일정/신규업체 표시 | ✅ | ✅ PASS |
| 12 | 매출현황 | 1억 | 누적1억343만, 당월715만, 달성률4%, 월별차트/거래처차트 | ✅ | ✅ PASS |
| 13 | 당월매출내역 | - | 10건, 합계220만, 거래처별 필터 | ✅ | ✅ PASS |
| 14 | 매입현황 | 165만 | 누적165만, 미결제165만, 월별차트/유형별차트 | ✅ | ✅ PASS |
| 15 | 당월매입내역 | - | 1건, 165만, 미결제 | ✅ | ✅ PASS |
| 16 | 생산현황 | 0공정 | "오늘 등록된 작업 지시가 없습니다" | ✅ | ✅ PASS |
| 17 | 출고현황 | 0건 | 7일 이내 0건, 30일 이내 0건 | ✅ | ✅ PASS |
| 18 | 미출고내역 | 6건 | 6건 목록, 포트번호/현장명/납기일/남은일 표시 | ✅ | ✅ PASS |
| 19 | 시공현황 | 0건 | 시공진행0, 시공완료0 | ✅ | ✅ PASS |
| 20 | 근태현황 | 0명 | 출근0, 휴가0, 지각0, 결근0 | ✅ | ✅ PASS |
### 매출관리 ↔ 대시보드 교차검증
| 소스 페이지 | 소스 값 | 대시보드 값 | 일치 |
|-----------|---------|-----------|------|
| 매출관리 > 당월 매출 | 7,150,000원 | 당월 매출 715만 | ✅ |
| 매출관리 > 총 매출 | 17,050,000원 | 누적 매출 1억 343만 | ✅ (누적=해당년도) |
| 미수금 > 자금현황 | 9억 4,145만 | 미수금 섹션 9억 4,145만 | ✅ |
### 최종 검수 결론
- **전체 20개 섹션**: API 연동 확인, 데이터 정상 표시 ✅
- **CRUD 검증 (시나리오A)**: 등록→수정→상태변경→삭제 전 사이클 완벽 ✅
- **교차 섹션 연동**: 상품권↔가지급금↔접대비 양방향 완벽 ✅
- **NavBar ↔ 섹션 일관성**: 모든 NavBar 요약값과 상세 섹션값 일치 ✅
- **소스 페이지 ↔ 대시보드 일관성**: 매출관리 등 소스 데이터와 일치 ✅
**🟢 CEO 대시보드 백엔드 연동 검수 완료. 데이터 인프라 확정.**

View File

@@ -17,7 +17,7 @@ export default function VendorsPage() {
useEffect(() => {
if (mode !== 'new') {
getClients({ size: 100 })
getClients({ size: 1000 })
.then(result => {
setData(result.data);
setTotal(result.total);

View File

@@ -9,6 +9,7 @@ import { apiDataToFormData, transformFormDataToApi } from './types';
import type { BillApiData } from './types';
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
import { useBillForm } from './hooks/useBillForm';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useBillConditions } from './hooks/useBillConditions';
import {
BasicInfoSection,
@@ -130,6 +131,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
if (isNewMode) {
const result = await createBillRaw(apiPayload);
if (result.success) {
invalidateDashboard('bill');
toast.success('등록되었습니다.');
router.push('/ko/accounting/bills');
return { success: false, error: '' };
@@ -137,6 +139,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
return result;
} else {
const result = await updateBillRaw(String(billId), apiPayload);
if (result.success) {
invalidateDashboard('bill');
}
return result;
}
} finally {

View File

@@ -24,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -94,6 +95,7 @@ export function BillManagementClient({
onDelete: async (id) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await loadData(currentPage);
setSelectedItems(prev => {
@@ -304,6 +306,7 @@ export function BillManagementClient({
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건이 저장되었습니다.`);
loadData(currentPage);
setSelectedItems(new Set());

View File

@@ -158,7 +158,7 @@ export async function updateBillRaw(id: string, data: Record<string, unknown>):
// ===== 거래처 목록 조회 =====
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
return executeServerAction({
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];

View File

@@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { formatNumber } from '@/lib/utils/amount';
import { getBills, deleteBill, updateBillStatus } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import { extractUniqueOptions } from '../shared';
import {
@@ -209,6 +210,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
await loadBills();
}
@@ -247,6 +249,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
// 서버에서 재조회 (pagination 메타데이터 포함)
await loadBills();
}

View File

@@ -23,7 +23,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { CardTransaction, JournalEntryItem } from './types';
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
import { DEDUCTION_OPTIONS } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { saveJournalEntries } from './actions';
interface JournalEntryModalProps {
@@ -194,23 +195,16 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
<div className="grid grid-cols-3 gap-3">
{/* Select - FormField 예외 */}
<div>
<Label className="text-xs"></Label>
<Select
value={item.accountSubject || 'none'}
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="mt-1 h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<AccountSubjectSelect
value={item.accountSubject}
onValueChange={(v) => updateItem(index, 'accountSubject', v)}
placeholder="선택"
size="sm"
/>
</div>
</div>
{/* Select - FormField 예외 */}
<div>

View File

@@ -25,7 +25,8 @@ import {
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import type { ManualInputFormData } from './types';
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
import { DEDUCTION_OPTIONS } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { getCardList, createCardTransaction } from './actions';
import { getTodayString } from '@/lib/utils/date';
@@ -254,20 +255,13 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
</div>
<div>
<Label className="text-sm font-medium"></Label>
<Select
value={formData.accountSubject || 'none'}
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<AccountSubjectSelect
value={formData.accountSubject}
onValueChange={(v) => handleChange('accountSubject', v)}
placeholder="선택"
/>
</div>
</div>
</div>

View File

@@ -42,6 +42,7 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
import {
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
getCardTransactionList,
getCardTransactionSummary,
@@ -599,20 +600,13 @@ export function CardTransactionInquiry() {
</TableCell>
{/* 계정과목 (인라인 Select) */}
<TableCell onClick={(e) => e.stopPropagation()}>
<Select
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<AccountSubjectSelect
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
placeholder="선택"
size="sm"
className="min-w-[90px] w-auto"
/>
</TableCell>
{/* 분개 버튼 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>

View File

@@ -16,6 +16,7 @@ import {
getBankAccounts,
} from './actions';
import { useDevFill, generateDepositData } from '@/components/dev';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== Props =====
interface DepositDetailClientV2Props {
@@ -81,14 +82,17 @@ export default function DepositDetailClientV2({
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
if (result.success && mode === 'create') {
invalidateDashboard('deposit');
toast.success('등록되었습니다.');
router.push('/ko/accounting/deposits');
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
}
return result.success
? { success: true }
: { success: false, error: result.error };
if (result.success) {
invalidateDashboard('deposit');
return { success: true };
}
return { success: false, error: result.error };
},
[mode, depositId, router]
);
@@ -98,9 +102,11 @@ export default function DepositDetailClientV2({
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
const result = await deleteDeposit(depositId);
return result.success
? { success: true }
: { success: false, error: result.error };
if (result.success) {
invalidateDashboard('deposit');
return { success: true };
}
return { success: false, error: result.error };
}, [depositId]);
// ===== 모드 변경 핸들러 =====

View File

@@ -73,6 +73,7 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
extractUniqueOptions,
@@ -225,6 +226,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
deleteItem: async (id: string) => {
const result = await deleteDeposit(id);
if (result.success) {
invalidateDashboard('deposit');
toast.success('입금 내역이 삭제되었습니다.');
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await handleRefresh();

View File

@@ -184,7 +184,7 @@ export async function getClients(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];

View File

@@ -15,6 +15,7 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
Receipt,
Calendar as CalendarIcon,
@@ -88,8 +89,8 @@ import { CurrencyInput } from '@/components/ui/currency-input';
import {
TRANSACTION_TYPE_FILTER_OPTIONS,
PAYMENT_STATUS_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { extractUniqueOptions } from '../shared';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
@@ -247,6 +248,7 @@ export function ExpectedExpenseManagement({
// 수정
const result = await updateExpectedExpense(editingItem.id, formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
toast.success('미지급비용이 수정되었습니다.');
setShowFormDialog(false);
@@ -258,6 +260,7 @@ export function ExpectedExpenseManagement({
// 등록
const result = await createExpectedExpense(formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => [result.data!, ...prev]);
toast.success('미지급비용이 등록되었습니다.');
setShowFormDialog(false);
@@ -278,6 +281,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpenses(selectedIds);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
@@ -492,6 +496,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpense(deleteTargetId);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
@@ -522,6 +527,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item =>
selectedItems.has(item.id)
? { ...item, expectedPaymentDate: newExpectedDate }
@@ -1185,21 +1191,12 @@ export function ExpectedExpenseManagement({
<div className="space-y-2">
<Label></Label>
<Select
value={formData.accountSubject}
<AccountSubjectSelect
value={formData.accountSubject || ''}
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
>
<SelectTrigger>
<SelectValue placeholder="계정과목 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="계정과목 선택"
category="expense"
/>
</div>
</div>

View File

@@ -33,6 +33,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
Table,
TableBody,
@@ -56,14 +57,12 @@ import {
getJournalDetail,
updateJournalDetail,
deleteJournalDetail,
getAccountSubjects,
getVendorList,
} from './actions';
import type {
GeneralJournalRecord,
JournalEntryRow,
JournalSide,
AccountSubject,
VendorOption,
} from './types';
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
@@ -109,7 +108,6 @@ export function JournalEditModal({
const [accountNumber, setAccountNumber] = useState('');
// 옵션 데이터
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
const [vendors, setVendors] = useState<VendorOption[]>([]);
// 데이터 로드
@@ -119,15 +117,11 @@ export function JournalEditModal({
const loadData = async () => {
setIsLoading(true);
try {
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
const [detailRes, vendorsRes] = await Promise.all([
getJournalDetail(record.id),
getAccountSubjects({ category: 'all' }),
getVendorList(),
]);
if (subjectsRes.success && subjectsRes.data) {
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
}
if (vendorsRes.success && vendorsRes.data) {
setVendors(vendorsRes.data);
}
@@ -361,24 +355,14 @@ export function JournalEditModal({
</div>
</TableCell>
<TableCell className="p-1">
<Select
value={row.accountSubjectId || 'none'}
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
handleRowChange(row.id, 'accountSubjectId', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accountSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select

View File

@@ -42,8 +42,9 @@ import {
TableRow,
TableFooter,
} from '@/components/ui/table';
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { createManualJournal, getVendorList } from './actions';
import type { JournalEntryRow, JournalSide, VendorOption } from './types';
import { JOURNAL_SIDE_OPTIONS } from './types';
import { getTodayString } from '@/lib/utils/date';
@@ -81,7 +82,6 @@ export function ManualJournalEntryModal({
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
// 옵션 데이터
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
const [vendors, setVendors] = useState<VendorOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -94,13 +94,7 @@ export function ManualJournalEntryModal({
setDescription('');
setRows([createEmptyRow()]);
Promise.all([
getAccountSubjects({ category: 'all' }),
getVendorList(),
]).then(([subjectsRes, vendorsRes]) => {
if (subjectsRes.success && subjectsRes.data) {
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
}
getVendorList().then((vendorsRes) => {
if (vendorsRes.success && vendorsRes.data) {
setVendors(vendorsRes.data);
}
@@ -272,24 +266,14 @@ export function ManualJournalEntryModal({
</div>
</TableCell>
<TableCell className="p-1">
<Select
value={row.accountSubjectId || 'none'}
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
handleRowChange(row.id, 'accountSubjectId', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accountSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select

View File

@@ -8,69 +8,14 @@ import type {
GeneralJournalApiData,
GeneralJournalSummary,
GeneralJournalSummaryApiData,
AccountSubject,
AccountSubjectApiData,
JournalEntryRow,
VendorOption,
} from './types';
import {
transformApiToFrontend,
transformSummaryApi,
transformAccountSubjectApi,
} from './types';
// ===== Mock 데이터 (개발용) =====
function generateMockJournalData(): GeneralJournalRecord[] {
const descriptions = ['사무용품 구매', '직원 급여', '임대료 지급', '매출 입금', '교통비'];
const journalDescs = ['복리후생비', '급여', '임차료', '매출', '여비교통비'];
const divisions: Array<'deposit' | 'withdrawal' | 'transfer'> = ['deposit', 'withdrawal', 'transfer'];
const sources: Array<'manual' | 'linked'> = ['manual', 'linked'];
return Array.from({ length: 10 }, (_, i) => {
const division = divisions[i % 3];
const depositAmount = division === 'deposit' ? 100000 * (i + 1) : 0;
const withdrawalAmount = division === 'withdrawal' ? 80000 * (i + 1) : 0;
return {
id: String(5000 + i),
date: '2025-12-12',
division,
amount: depositAmount || withdrawalAmount || 50000,
description: descriptions[i % 5],
journalDescription: journalDescs[i % 5],
depositAmount,
withdrawalAmount,
balance: 1000000 - (i * 50000),
debitAmount: [6000, 100000, 50000, 0, 30000][i % 5],
creditAmount: [0, 0, 50000, 100000, 0][i % 5],
source: sources[i % 4 === 0 ? 0 : 1],
};
});
}
function generateMockSummary(): GeneralJournalSummary {
return { totalCount: 10, depositCount: 4, depositAmount: 400000, withdrawalCount: 3, withdrawalAmount: 300000, journalCompleteCount: 7, journalIncompleteCount: 3 };
}
function generateMockAccountSubjects(): AccountSubject[] {
return [
{ id: '101', code: '1010', name: '현금', category: 'asset', isActive: true },
{ id: '102', code: '1020', name: '보통예금', category: 'asset', isActive: true },
{ id: '201', code: '2010', name: '미지급금', category: 'liability', isActive: true },
{ id: '401', code: '4010', name: '매출', category: 'revenue', isActive: true },
{ id: '501', code: '5010', name: '복리후생비', category: 'expense', isActive: true },
];
}
function generateMockVendors(): VendorOption[] {
return [
{ id: '1', name: '삼성전자' },
{ id: '2', name: '(주)한국물류' },
{ id: '3', name: 'LG전자' },
{ id: '4', name: '현대모비스' },
{ id: '5', name: '(주)대한상사' },
];
}
// ===== 전표 목록 조회 =====
export async function getJournalEntries(params: {
startDate?: string;
@@ -91,15 +36,6 @@ export async function getJournalEntries(params: {
errorMessage: '전표 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || result.data.length === 0) {
const mockData = generateMockJournalData();
return {
success: true as const,
data: mockData,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
};
}
return result;
}
@@ -119,10 +55,6 @@ export async function getJournalSummary(params: {
errorMessage: '전표 요약 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data) {
return { success: true, data: generateMockSummary() };
}
return result;
}
@@ -151,67 +83,6 @@ export async function createManualJournal(data: {
});
}
// ===== 계정과목 목록 조회 =====
export async function getAccountSubjects(params?: {
search?: string;
category?: string;
}): Promise<ActionResult<AccountSubject[]>> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/account-subjects', {
search: params?.search || undefined,
category: params?.category && params.category !== 'all' ? params.category : undefined,
}),
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
errorMessage: '계정과목 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data || result.data.length === 0) {
return { success: true, data: generateMockAccountSubjects() };
}
return result;
}
// ===== 계정과목 추가 =====
export async function createAccountSubject(data: {
code: string;
name: string;
category: string;
}): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects'),
method: 'POST',
body: {
code: data.code,
name: data.name,
category: data.category,
},
errorMessage: '계정과목 추가에 실패했습니다.',
});
}
// ===== 계정과목 상태 토글 =====
export async function updateAccountSubjectStatus(
id: string,
isActive: boolean
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
method: 'PATCH',
body: { is_active: isActive },
errorMessage: '계정과목 상태 변경에 실패했습니다.',
});
}
// ===== 계정과목 삭제 =====
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'DELETE',
errorMessage: '계정과목 삭제에 실패했습니다.',
});
}
// ===== 분개 상세 조회 =====
type JournalDetailData = {
id: number;
@@ -241,26 +112,6 @@ export async function getJournalDetail(id: string): Promise<ActionResult<Journal
errorMessage: '분개 상세 조회에 실패했습니다.',
});
// API 실패 시 mock fallback (개발용)
if (!result.success || !result.data) {
return {
success: true,
data: {
id: Number(id),
date: '2025-12-12',
division: 'deposit',
amount: 100000,
description: '사무용품 구매',
bank_name: '신한은행',
account_number: '110-123-456789',
journal_memo: '',
rows: [
{ id: 1, side: 'debit', account_subject_id: 501, account_subject_name: '복리후생비', vendor_id: 1, vendor_name: '삼성전자', debit_amount: 100000, credit_amount: 0, memo: '' },
{ id: 2, side: 'credit', account_subject_id: 101, account_subject_name: '현금', vendor_id: null, vendor_name: '', debit_amount: 0, credit_amount: 100000, memo: '' },
],
},
};
}
return result;
}
@@ -308,9 +159,5 @@ export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
errorMessage: '거래처 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data || result.data.length === 0) {
return { success: true, data: generateMockVendors() };
}
return result;
}

View File

@@ -28,7 +28,7 @@ import {
import { MobileCard } from '@/components/organisms/MobileCard';
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
import { getJournalEntries, getJournalSummary } from './actions';
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
import { AccountSubjectSettingModal } from '@/components/accounting/common';
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
import { JournalEditModal } from './JournalEditModal';
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
@@ -38,6 +38,7 @@ import {
getPeriodDates,
} from './types';
import { formatNumber } from '@/lib/utils/amount';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== 테이블 컬럼 (기획서 기준 10개) =====
const tableColumns = [
@@ -151,12 +152,14 @@ export function GeneralJournalEntry() {
const handleManualEntrySuccess = useCallback(() => {
setShowManualEntry(false);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 분개 수정 완료 =====
const handleJournalEditSuccess = useCallback(() => {
setJournalEditTarget(null);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 합계 계산 =====

View File

@@ -34,30 +34,6 @@ export const PERIOD_BUTTONS = [
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
// ===== 계정과목 분류 =====
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
{ value: 'asset', label: '자산' },
{ value: 'liability', label: '부채' },
{ value: 'capital', label: '자본' },
{ value: 'revenue', label: '수익' },
{ value: 'expense', label: '비용' },
];
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
...ACCOUNT_CATEGORY_OPTIONS,
];
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
asset: '자산',
liability: '부채',
capital: '자본',
revenue: '수익',
expense: '비용',
};
// ===== 분개 구분 (차변/대변) =====
export type JournalSide = 'debit' | 'credit';
@@ -121,25 +97,6 @@ export interface GeneralJournalSummaryApiData {
journal_incomplete_count?: number;
}
// ===== 계정과목 =====
export interface AccountSubject {
id: string;
code: string;
name: string;
category: AccountSubjectCategory;
isActive: boolean;
}
export interface AccountSubjectApiData {
id: number;
code: string;
name: string;
category: string;
is_active: boolean | number;
created_at: string;
updated_at: string;
}
// ===== 분개 행 =====
export interface JournalEntryRow {
id: string;
@@ -216,17 +173,6 @@ export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): Gene
};
}
// ===== 계정과목 API → Frontend 변환 =====
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
return {
id: String(apiData.id),
code: apiData.code,
name: apiData.name,
category: apiData.category as AccountSubjectCategory,
isActive: Boolean(apiData.is_active),
};
}
// ===== 기간 버튼 → 날짜 변환 =====
export function getPeriodDates(period: PeriodButtonValue): { start: string; end: string } {
const today = new Date();

View File

@@ -13,6 +13,7 @@ import {
updateGiftCertificate,
deleteGiftCertificate,
} from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
PURCHASE_PURPOSE_OPTIONS,
ENTERTAINMENT_EXPENSE_OPTIONS,
@@ -80,6 +81,7 @@ export function GiftCertificateDetail({
: await updateGiftCertificate(id!, formData);
if (result.success) {
invalidateDashboard('giftCertificate');
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
router.push('/ko/accounting/gift-certificates');
} else {
@@ -96,6 +98,7 @@ export function GiftCertificateDetail({
try {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
toast.success('상품권이 삭제되었습니다.');
router.push('/ko/accounting/gift-certificates');
} else {
@@ -134,8 +137,8 @@ export function GiftCertificateDetail({
label="일련번호"
value={formData.serialNumber}
onChange={(v) => handleChange('serialNumber', v)}
placeholder="자동 생성"
disabled={!isNew}
placeholder="일련번호를 입력하세요"
disabled={!isEditable}
/>
<FormField
label="상품권명"

View File

@@ -126,6 +126,8 @@ export async function getGiftCertificateSummary(params?: {
holding_amount?: number;
used_count?: number;
used_amount?: number;
entertainment_count?: number;
entertainment_amount?: number;
}) => ({
totalCount: data.total_count ?? 0,
totalAmount: data.total_amount ?? 0,
@@ -133,8 +135,8 @@ export async function getGiftCertificateSummary(params?: {
holdingAmount: data.holding_amount ?? 0,
usedCount: data.used_count ?? 0,
usedAmount: data.used_amount ?? 0,
entertainmentCount: 0,
entertainmentAmount: 0,
entertainmentCount: data.entertainment_count ?? 0,
entertainmentAmount: data.entertainment_amount ?? 0,
}),
errorMessage: '상품권 요약 조회에 실패했습니다.',
});

View File

@@ -44,8 +44,10 @@ import type {
import {
getGiftCertificates,
getGiftCertificateSummary,
deleteGiftCertificate,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { applyFilters, enumFilter } from '@/lib/utils/search';
import { useDateRange } from '@/hooks';
@@ -123,7 +125,7 @@ export function GiftCertificateManagement() {
// ===== 핸들러 =====
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
router.push(`/accounting/gift-certificates?mode=view&id=${item.id}`);
}, [router]);
const handleCreate = useCallback(() => {
@@ -145,6 +147,14 @@ export function GiftCertificateManagement() {
data,
totalCount: data.length,
}),
deleteItem: async (id: string) => {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
await loadData();
}
return { success: result.success, error: result.error };
},
},
columns: tableColumns,
@@ -359,7 +369,7 @@ export function GiftCertificateManagement() {
);
},
}),
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate, loadData]
);
return (

View File

@@ -32,6 +32,7 @@ import {
deletePurchase,
} from './actions';
import { getClients } from '../VendorManagement/actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
@@ -260,6 +261,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
}
if (result?.success) {
invalidateDashboard('purchase');
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
return { success: true };
} else {
@@ -282,6 +284,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const result = await deletePurchase(purchaseId);
if (result.success) {
invalidateDashboard('purchase');
toast.success('매입이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -60,6 +60,7 @@ import {
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber } from '@/lib/utils/amount';
// ===== 테이블 컬럼 정의 =====
@@ -253,6 +254,7 @@ export function PurchaseManagement() {
deleteItem: async (id: string) => {
const result = await deletePurchase(id);
if (result.success) {
invalidateDashboard('purchase');
setPurchaseData(prev => prev.filter(item => item.id !== id));
toast.success('매입이 삭제되었습니다.');
}

View File

@@ -34,6 +34,7 @@ import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTa
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions';
@@ -173,6 +174,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
}
if (result?.success) {
invalidateDashboard('sales');
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
return { success: true };
} else {
@@ -195,6 +197,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const result = await deleteSale(salesId);
if (result.success) {
invalidateDashboard('sales');
toast.success('매출이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -303,7 +303,7 @@ export async function searchVendorsForTaxInvoice(
url: buildApiUrl('/api/v1/clients', {
q: query || undefined,
only_active: true,
size: 100,
size: 1000,
}),
transform: (data: { data: ClientApiData[] }) =>
data.data.map((item) => ({

View File

@@ -53,11 +53,11 @@ import {
updateJournalEntry,
deleteJournalEntry,
} from './actions';
import { AccountSubjectSelect } from '@/components/accounting/common';
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
import {
TAB_OPTIONS,
JOURNAL_SIDE_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
interface JournalEntryModalProps {
@@ -288,25 +288,14 @@ export function JournalEntryModal({
</Select>
</TableCell>
<TableCell className="p-1">
<Select
<AccountSubjectSelect
value={row.accountSubject}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubject', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
(opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
placeholder="선택"
size="sm"
/>
</TableCell>
<TableCell className="p-1">
<Input

View File

@@ -8,8 +8,8 @@ import type {
TaxInvoiceMgmtApiData,
TaxInvoiceSummary,
TaxInvoiceSummaryApiData,
CardHistoryRecord,
CardHistoryApiData,
CardHistoryRecord,
ManualEntryFormData,
JournalEntryRow,
} from './types';
@@ -20,17 +20,6 @@ import {
transformSummaryApi,
} from './types';
// ===== 세금계산서 목록 Mock =====
// TODO: 실제 API 연동 시 Mock 제거
const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [
{ id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' },
{ id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' },
{ id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' },
{ id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' },
{ id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' },
{ id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' },
];
// ===== 세금계산서 목록 조회 =====
export async function getTaxInvoices(params: {
division?: string;
@@ -41,45 +30,39 @@ export async function getTaxInvoices(params: {
page?: number;
perPage?: number;
}) {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
// url: buildApiUrl('/api/v1/tax-invoices', { ... }),
// transform: transformApiToFrontend,
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
// });
const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales'));
return {
success: true as const,
data: filtered,
error: undefined as string | undefined,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length },
};
// frontend 'purchase' → backend 'purchases'
const direction = params.division === 'purchase' ? 'purchases' : params.division;
return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
url: buildApiUrl('/api/v1/tax-invoices', {
direction,
issue_date_from: params.startDate,
issue_date_to: params.endDate,
corp_name: params.vendorSearch || undefined,
page: params.page,
per_page: params.perPage,
}),
transform: transformApiToFrontend,
errorMessage: '세금계산서 목록 조회에 실패했습니다.',
});
}
// ===== 세금계산서 요약 조회 =====
export async function getTaxInvoiceSummary(_params: {
export async function getTaxInvoiceSummary(params: {
dateType?: string;
startDate?: string;
endDate?: string;
vendorSearch?: string;
}): Promise<ActionResult<TaxInvoiceSummary>> {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executeServerAction({ ... });
const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales');
const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase');
return {
success: true,
data: {
salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0),
salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0),
salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0),
salesCount: sales.length,
purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0),
purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0),
purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0),
purchaseCount: purchase.length,
},
};
return executeServerAction<TaxInvoiceSummaryApiData, TaxInvoiceSummary>({
url: buildApiUrl('/api/v1/tax-invoices/summary', {
issue_date_from: params.startDate,
issue_date_to: params.endDate,
corp_name: params.vendorSearch || undefined,
}),
transform: transformSummaryApi,
errorMessage: '세금계산서 요약 조회에 실패했습니다.',
});
}
// ===== 세금계산서 수기 등록 =====
@@ -96,35 +79,24 @@ export async function createTaxInvoice(
}
// ===== 카드 내역 조회 =====
// TODO: 실제 API 연동 시 Mock 제거
const MOCK_CARD_HISTORY: CardHistoryRecord[] = [
{ id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' },
{ id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' },
{ id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' },
{ id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' },
{ id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' },
];
export async function getCardHistory(_params: {
export async function getCardHistory(params: {
startDate?: string;
endDate?: string;
search?: string;
page?: number;
perPage?: number;
}): Promise<ActionResult<CardHistoryRecord[]>> {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
// url: buildApiUrl('/api/v1/card-transactions/history', {
// start_date: _params.startDate,
// end_date: _params.endDate,
// search: _params.search || undefined,
// page: _params.page,
// per_page: _params.perPage,
// }),
// transform: transformCardHistoryApi,
// errorMessage: '카드 내역 조회에 실패했습니다.',
// });
return { success: true, data: MOCK_CARD_HISTORY };
}) {
return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
url: buildApiUrl('/api/v1/card-transactions', {
start_date: params.startDate,
end_date: params.endDate,
search: params.search || undefined,
page: params.page,
per_page: params.perPage,
}),
transform: transformCardHistoryApi,
errorMessage: '카드 내역 조회에 실패했습니다.',
});
}
// ===== 분개 내역 조회 =====

View File

@@ -45,12 +45,14 @@ export const RECEIPT_TYPE_LABELS: Record<ReceiptType, string> = {
};
// ===== 세금계산서 상태 =====
export type InvoiceStatus = 'pending' | 'journalized' | 'error';
export type InvoiceStatus = 'draft' | 'issued' | 'sent' | 'cancelled' | 'failed';
export const INVOICE_STATUS_MAP: Record<InvoiceStatus, { label: string; color: string }> = {
pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' },
journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' },
error: { label: '오류', color: 'bg-red-100 text-red-700' },
draft: { label: '임시저장', color: 'bg-gray-100 text-gray-700' },
issued: { label: '발급완료', color: 'bg-blue-100 text-blue-700' },
sent: { label: '전송완료', color: 'bg-green-100 text-green-700' },
cancelled: { label: '취소', color: 'bg-red-100 text-red-700' },
failed: { label: '실패', color: 'bg-orange-100 text-orange-700' },
};
// ===== 소스 구분 (수기/홈택스) =====
@@ -87,24 +89,25 @@ export interface TaxInvoiceMgmtRecord {
memo: string;
}
// ===== API 응답 타입 (snake_case) =====
// ===== API 응답 타입 (백엔드 TaxInvoice 모델 기준) =====
export interface TaxInvoiceMgmtApiData {
id: number;
division: string;
write_date: string;
direction: string;
supplier_corp_num: string | null;
supplier_corp_name: string | null;
buyer_corp_num: string | null;
buyer_corp_name: string | null;
issue_date: string | null;
vendor_name: string;
vendor_business_number: string;
tax_type: string;
item_name: string;
supply_amount: string | number;
tax_amount: string | number;
total_amount: string | number;
receipt_type: string;
document_number: string;
status: string;
source: string;
memo: string | null;
invoice_type: string | null;
issue_type: string | null;
nts_confirm_num: string | null;
description: string | null;
barobill_invoice_id: string | null;
items: Array<{ name?: string; [key: string]: unknown }> | null;
created_at: string;
updated_at: string;
}
@@ -121,15 +124,20 @@ export interface TaxInvoiceSummary {
purchaseCount: number;
}
// 백엔드 summary API는 by_direction 중첩 구조로 응답
interface DirectionSummary {
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}
export interface TaxInvoiceSummaryApiData {
sales_supply_amount: number;
sales_tax_amount: number;
sales_total_amount: number;
sales_count: number;
purchase_supply_amount: number;
purchase_tax_amount: number;
purchase_total_amount: number;
purchase_count: number;
by_direction: {
sales: DirectionSummary;
purchases: DirectionSummary;
};
by_status: Record<string, number>;
}
// ===== 분개 항목 =====
@@ -165,11 +173,12 @@ export interface CardHistoryRecord {
export interface CardHistoryApiData {
id: number;
transaction_date: string;
used_at: string;
merchant_name: string;
amount: string | number;
approval_number: string;
business_number: string;
approval_number?: string;
business_number?: string;
description?: string | null;
}
// ===== 수기 입력 폼 데이터 =====
@@ -202,40 +211,62 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
];
// ===== API → Frontend 변환 =====
const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed'];
const INVOICE_TYPE_TO_TAX_TYPE: Record<string, TaxType> = {
tax_invoice: 'taxable',
modified: 'taxable',
invoice: 'tax_free',
};
const ISSUE_TYPE_TO_RECEIPT_TYPE: Record<string, ReceiptType> = {
receipt: 'receipt',
claim: 'claim',
};
export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord {
const isSales = apiData.direction === 'sales';
return {
id: String(apiData.id),
division: apiData.division as InvoiceTab,
writeDate: apiData.write_date,
division: isSales ? 'sales' : 'purchase',
writeDate: apiData.issue_date || apiData.created_at?.split('T')[0] || '',
issueDate: apiData.issue_date,
vendorName: apiData.vendor_name,
vendorBusinessNumber: apiData.vendor_business_number,
taxType: apiData.tax_type as TaxType,
itemName: apiData.item_name,
supplyAmount: Number(apiData.supply_amount),
taxAmount: Number(apiData.tax_amount),
totalAmount: Number(apiData.total_amount),
receiptType: apiData.receipt_type as ReceiptType,
documentNumber: apiData.document_number,
status: apiData.status as InvoiceStatus,
source: apiData.source as InvoiceSource,
memo: apiData.memo || '',
vendorName: isSales
? (apiData.buyer_corp_name || '')
: (apiData.supplier_corp_name || ''),
vendorBusinessNumber: isSales
? (apiData.buyer_corp_num || '')
: (apiData.supplier_corp_num || ''),
taxType: INVOICE_TYPE_TO_TAX_TYPE[apiData.invoice_type || ''] || 'taxable',
itemName: apiData.items?.[0]?.name || apiData.description || '',
supplyAmount: Number(apiData.supply_amount) || 0,
taxAmount: Number(apiData.tax_amount) || 0,
totalAmount: Number(apiData.total_amount) || 0,
receiptType: ISSUE_TYPE_TO_RECEIPT_TYPE[apiData.issue_type || ''] || 'receipt',
documentNumber: apiData.nts_confirm_num || '',
status: VALID_STATUSES.includes(apiData.status as InvoiceStatus)
? (apiData.status as InvoiceStatus)
: 'draft',
source: apiData.barobill_invoice_id ? 'hometax' : 'manual',
memo: apiData.description || '',
};
}
// ===== Frontend → API 변환 =====
export function transformFrontendToApi(data: ManualEntryFormData): Record<string, unknown> {
const isSales = data.division === 'sales';
return {
division: data.division,
write_date: data.writeDate,
vendor_name: data.vendorName,
vendor_business_number: data.vendorBusinessNumber,
direction: isSales ? 'sales' : 'purchases',
issue_date: data.writeDate,
...(isSales
? { buyer_corp_name: data.vendorName, buyer_corp_num: data.vendorBusinessNumber }
: { supplier_corp_name: data.vendorName, supplier_corp_num: data.vendorBusinessNumber }),
supply_amount: data.supplyAmount,
tax_amount: data.taxAmount,
total_amount: data.totalAmount,
item_name: data.itemName,
tax_type: data.taxType,
memo: data.memo || null,
invoice_type: data.taxType === 'tax_free' ? 'invoice' : 'tax_invoice',
description: data.memo || null,
items: data.itemName ? [{ name: data.itemName, amount: data.supplyAmount }] : [],
};
}
@@ -243,24 +274,28 @@ export function transformFrontendToApi(data: ManualEntryFormData): Record<string
export function transformCardHistoryApi(apiData: CardHistoryApiData): CardHistoryRecord {
return {
id: String(apiData.id),
transactionDate: apiData.transaction_date,
transactionDate: apiData.used_at,
merchantName: apiData.merchant_name,
amount: Number(apiData.amount),
approvalNumber: apiData.approval_number,
businessNumber: apiData.business_number,
approvalNumber: apiData.approval_number || '',
businessNumber: apiData.business_number || '',
};
}
// ===== 요약 API → Frontend 변환 =====
const EMPTY_DIRECTION: DirectionSummary = { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 };
export function transformSummaryApi(apiData: TaxInvoiceSummaryApiData): TaxInvoiceSummary {
const sales = apiData.by_direction?.sales || EMPTY_DIRECTION;
const purchases = apiData.by_direction?.purchases || EMPTY_DIRECTION;
return {
salesSupplyAmount: apiData.sales_supply_amount,
salesTaxAmount: apiData.sales_tax_amount,
salesTotalAmount: apiData.sales_total_amount,
salesCount: apiData.sales_count,
purchaseSupplyAmount: apiData.purchase_supply_amount,
purchaseTaxAmount: apiData.purchase_tax_amount,
purchaseTotalAmount: apiData.purchase_total_amount,
purchaseCount: apiData.purchase_count,
salesSupplyAmount: sales.supply_amount,
salesTaxAmount: sales.tax_amount,
salesTotalAmount: sales.total_amount,
salesCount: sales.count,
purchaseSupplyAmount: purchases.supply_amount,
purchaseTaxAmount: purchases.tax_amount,
purchaseTotalAmount: purchases.total_amount,
purchaseCount: purchases.count,
};
}

View File

@@ -16,6 +16,7 @@ import {
getBankAccounts,
} from './actions';
import { useDevFill, generateWithdrawalData } from '@/components/dev';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== Props =====
interface WithdrawalDetailClientV2Props {
@@ -82,6 +83,7 @@ export default function WithdrawalDetailClientV2({
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
if (result.success) {
invalidateDashboard('withdrawal');
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };
@@ -99,6 +101,7 @@ export default function WithdrawalDetailClientV2({
const result = await deleteWithdrawal(withdrawalId);
if (result.success) {
invalidateDashboard('withdrawal');
toast.success('출금 내역이 삭제되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };

View File

@@ -72,9 +72,9 @@ import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actio
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
createDeleteItemHandler,
extractUniqueOptions,
createDateAmountSortFn,
computeMonthlyTotal,
@@ -237,7 +237,15 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
totalCount: initialData.length,
};
},
deleteItem: createDeleteItemHandler(deleteWithdrawal, setWithdrawalData, '출금 내역이 삭제되었습니다.'),
deleteItem: async (id: string) => {
const result = await deleteWithdrawal(id);
if (result.success) {
setWithdrawalData(prev => prev.filter(item => item.id !== id));
invalidateDashboard('withdrawal');
toast.success('출금 내역이 삭제되었습니다.');
}
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼

View File

@@ -0,0 +1,215 @@
'use client';
/**
* 계정과목 Select 공용 컴포넌트
*
* DB 마스터에서 활성 계정과목(소분류, depth=3)을 로드하여 검색 가능한 Select로 표시.
* "[코드] 계정과목명" 형태로 표시. 코드/이름으로 검색 가능.
* Popover + Command 패턴 (SearchableSelect 기반).
* props로 category 제한 가능.
*/
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/components/ui/utils';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { getAccountSubjects } from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import { formatAccountLabel } from './types';
interface AccountSubjectSelectProps {
value: string;
onValueChange: (value: string) => void;
/** 특정 대분류만 표시 */
category?: AccountSubjectCategory;
/** 특정 중분류만 표시 */
subCategory?: string;
/** 특정 부문만 표시 */
departmentType?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
/** 빈 값(전체) 옵션 표시 여부 */
showAllOption?: boolean;
allOptionLabel?: string;
/** 트리거 크기 */
size?: 'default' | 'sm';
/** value/onValueChange에 사용할 필드 (기본: code) */
valueField?: 'code' | 'id';
}
export function AccountSubjectSelect({
value,
onValueChange,
category,
subCategory,
departmentType,
placeholder = '계정과목 선택',
disabled = false,
className,
showAllOption = false,
allOptionLabel = '전체',
size = 'default',
valueField = 'code',
}: AccountSubjectSelectProps) {
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const triggerRef = useRef<HTMLButtonElement>(null);
const loadSubjects = useCallback(async () => {
setIsLoading(true);
try {
const result = await getAccountSubjects({
selectable: true,
isActive: true,
category: category || undefined,
subCategory: subCategory || undefined,
departmentType: departmentType || undefined,
});
if (result.success && result.data) {
setSubjects(result.data);
}
} catch {
// 조회 실패 시 빈 목록 유지
} finally {
setIsLoading(false);
}
}, [category, subCategory, departmentType]);
useEffect(() => {
loadSubjects();
}, [loadSubjects]);
// subject에서 value로 사용할 필드 추출
const getSubjectValue = useCallback(
(s: AccountSubject) => (valueField === 'id' ? s.id : s.code),
[valueField]
);
// 선택된 계정과목 찾기
const selectedSubject = useMemo(
() => subjects.find((s) => getSubjectValue(s) === value),
[subjects, value, getSubjectValue]
);
// 트리거에 표시할 텍스트
const displayLabel = useMemo(() => {
if (isLoading) return '로딩 중...';
if (value === 'all' && showAllOption) return allOptionLabel;
if (selectedSubject) return formatAccountLabel(selectedSubject);
return '';
}, [isLoading, value, showAllOption, allOptionLabel, selectedSubject]);
const handleSelect = (subjectValue: string) => {
onValueChange(subjectValue);
setOpen(false);
setSearchQuery('');
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setSearchQuery('');
}
};
const triggerClassName = size === 'sm' ? 'h-8 text-sm' : 'h-9 text-sm';
return (
<Popover open={open} onOpenChange={handleOpenChange} modal>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoading}
className={cn(
'w-full justify-between font-normal',
triggerClassName,
!displayLabel && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{displayLabel || placeholder}
</span>
{isLoading ? (
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin" />
) : (
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] min-w-[280px] p-0"
align="start"
>
<Command shouldFilter>
<CommandInput
placeholder="코드 또는 계정과목명 검색..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{showAllOption && (
<CommandItem
value={allOptionLabel}
onSelect={() => handleSelect('all')}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === 'all' ? 'opacity-100' : 'opacity-0'
)}
/>
{allOptionLabel}
</CommandItem>
)}
{subjects.map((subject) => {
const subjectVal = getSubjectValue(subject);
return (
<CommandItem
key={subject.id}
value={`${subject.code} ${subject.name}`}
onSelect={() => handleSelect(subjectVal)}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === subjectVal ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-muted-foreground mr-1.5 font-mono text-xs">
{subject.code}
</span>
<span>{subject.name}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,17 +1,18 @@
'use client';
/**
*
* ()
*
* - 추가: 코드, , Select,
* - 검색: 검색 Input, Select,
* - 테이블: 코드 | | | (/ ) | ()
* - 테이블: 코드 | | | | (/ ) | ()
* -
* - 버튼: 닫기
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { Plus, Trash2, Loader2, Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -54,13 +55,16 @@ import {
createAccountSubject,
updateAccountSubjectStatus,
deleteAccountSubject,
seedDefaultAccountSubjects,
} from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
} from './types';
import type { DepartmentType } from './types';
interface AccountSubjectSettingModalProps {
open: boolean;
@@ -84,6 +88,7 @@ export function AccountSubjectSettingModal({
// 데이터
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSeeding, setIsSeeding] = useState(false);
// 삭제 확인
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
@@ -195,10 +200,40 @@ export function AccountSubjectSettingModal({
}
}, [deleteTarget, loadSubjects]);
// 기본 계정과목표 생성
const handleSeedDefaults = useCallback(async () => {
setIsSeeding(true);
try {
const result = await seedDefaultAccountSubjects();
if (result.success) {
const count = result.data?.inserted_count ?? 0;
if (count > 0) {
toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`);
} else {
toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.');
}
loadSubjects();
} else {
toast.error(result.error || '기본 계정과목 생성에 실패했습니다.');
}
} catch {
toast.error('기본 계정과목 생성 중 오류가 발생했습니다.');
} finally {
setIsSeeding(false);
}
}, [loadSubjects]);
// depth에 따른 들여쓰기
const getIndentClass = (depth: number) => {
if (depth === 1) return 'font-bold';
if (depth === 2) return 'pl-4 font-medium';
return 'pl-8';
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> , , , </DialogDescription>
@@ -211,7 +246,7 @@ export function AccountSubjectSettingModal({
label="코드"
value={newCode}
onChange={setNewCode}
placeholder="코드"
placeholder="예: 10100"
/>
<FormField
label="계정과목명"
@@ -273,9 +308,23 @@ export function AccountSubjectSettingModal({
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground ml-auto">
{filteredSubjects.length}
<span className="text-sm text-muted-foreground">
{filteredSubjects.length}
</span>
<Button
variant="outline"
size="sm"
className="h-9 ml-auto"
onClick={handleSeedDefaults}
disabled={isSeeding}
>
{isSeeding ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<Database className="h-4 w-4 mr-1" />
)}
</Button>
</div>
</div>
@@ -289,30 +338,36 @@ export function AccountSubjectSettingModal({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-center w-[80px]"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[70px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[90px]"></TableHead>
<TableHead className="text-center w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubjects.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
.
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground h-[100px]">
. &quot; &quot; .
</TableCell>
</TableRow>
) : (
filteredSubjects.map((subject) => (
<TableRow key={subject.id}>
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
<TableCell className="text-sm">{subject.name}</TableCell>
<TableCell className={`text-sm ${getIndentClass(subject.depth)}`}>
{subject.name}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{ACCOUNT_CATEGORY_LABELS[subject.category]}
</Badge>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'}
</TableCell>
<TableCell className="text-center">
<Button
variant={subject.isActive ? 'default' : 'outline'}

View File

@@ -0,0 +1,123 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { AccountSubject, AccountSubjectApiData } from './types';
import { transformAccountSubjectApi } from './types';
// ===== 계정과목 목록 조회 =====
export async function getAccountSubjects(params?: {
search?: string;
category?: string;
subCategory?: string;
departmentType?: string;
depth?: number;
isActive?: boolean;
selectable?: boolean;
}): Promise<ActionResult<AccountSubject[]>> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects', {
search: params?.search || undefined,
category: params?.category && params.category !== 'all' ? params.category : undefined,
sub_category: params?.subCategory || undefined,
department_type: params?.departmentType || undefined,
depth: params?.depth,
is_active: params?.isActive,
selectable: params?.selectable,
}),
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
errorMessage: '계정과목 목록 조회에 실패했습니다.',
});
}
// ===== 계정과목 추가 =====
export async function createAccountSubject(data: {
code: string;
name: string;
category: string;
subCategory?: string;
parentCode?: string;
depth?: number;
departmentType?: string;
description?: string;
sortOrder?: number;
}): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects'),
method: 'POST',
body: {
code: data.code,
name: data.name,
category: data.category,
sub_category: data.subCategory || undefined,
parent_code: data.parentCode || undefined,
depth: data.depth ?? 3,
department_type: data.departmentType || 'common',
description: data.description || undefined,
sort_order: data.sortOrder,
},
errorMessage: '계정과목 추가에 실패했습니다.',
});
}
// ===== 계정과목 수정 =====
export async function updateAccountSubject(
id: string,
data: {
name?: string;
category?: string;
subCategory?: string;
parentCode?: string;
depth?: number;
departmentType?: string;
description?: string;
sortOrder?: number;
}
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'PUT',
body: {
name: data.name,
category: data.category,
sub_category: data.subCategory,
parent_code: data.parentCode,
depth: data.depth,
department_type: data.departmentType,
description: data.description,
sort_order: data.sortOrder,
},
errorMessage: '계정과목 수정에 실패했습니다.',
});
}
// ===== 계정과목 상태 토글 =====
export async function updateAccountSubjectStatus(
id: string,
isActive: boolean
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
method: 'PATCH',
body: { is_active: isActive },
errorMessage: '계정과목 상태 변경에 실패했습니다.',
});
}
// ===== 계정과목 삭제 =====
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'DELETE',
errorMessage: '계정과목 삭제에 실패했습니다.',
});
}
// ===== 기본 계정과목표 일괄 생성 =====
export async function seedDefaultAccountSubjects(): Promise<ActionResult<{ inserted_count: number }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects/seed-defaults'),
method: 'POST',
errorMessage: '기본 계정과목 생성에 실패했습니다.',
});
}

View File

@@ -0,0 +1,18 @@
export { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
export { AccountSubjectSelect } from './AccountSubjectSelect';
export type {
AccountSubject,
AccountSubjectApiData,
AccountSubjectCategory,
AccountSubCategory,
DepartmentType,
} from './types';
export {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
SUB_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
transformAccountSubjectApi,
formatAccountLabel,
} from './types';

View File

@@ -0,0 +1,118 @@
/**
* 계정과목 공용 타입 및 상수
*
* 모든 회계 모듈에서 공유하는 계정과목 관련 타입/상수 정의.
* 기존 각 모듈별 ACCOUNT_SUBJECT_OPTIONS, AccountSubjectCategory 등을 대체.
*/
// ===== 계정과목 분류 (대분류) =====
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
{ value: 'asset', label: '자산' },
{ value: 'liability', label: '부채' },
{ value: 'capital', label: '자본' },
{ value: 'revenue', label: '수익' },
{ value: 'expense', label: '비용' },
];
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
...ACCOUNT_CATEGORY_OPTIONS,
];
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
asset: '자산',
liability: '부채',
capital: '자본',
revenue: '수익',
expense: '비용',
};
// ===== 중분류 =====
export type AccountSubCategory =
| 'current_asset'
| 'fixed_asset'
| 'current_liability'
| 'long_term_liability'
| 'capital'
| 'sales_revenue'
| 'other_revenue'
| 'cogs'
| 'selling_admin'
| 'other_expense';
export const SUB_CATEGORY_LABELS: Record<AccountSubCategory, string> = {
current_asset: '유동자산',
fixed_asset: '비유동자산',
current_liability: '유동부채',
long_term_liability: '비유동부채',
capital: '자본',
sales_revenue: '매출',
other_revenue: '영업외수익',
cogs: '매출원가',
selling_admin: '판매비와관리비',
other_expense: '영업외비용',
};
// ===== 부문 =====
export type DepartmentType = 'common' | 'manufacturing' | 'admin';
export const DEPARTMENT_TYPE_LABELS: Record<DepartmentType, string> = {
common: '공통',
manufacturing: '제조',
admin: '관리',
};
// ===== 계정과목 인터페이스 =====
export interface AccountSubject {
id: string;
code: string;
name: string;
category: AccountSubjectCategory;
subCategory: string | null;
parentCode: string | null;
depth: number;
departmentType: DepartmentType;
description: string | null;
sortOrder: number;
isActive: boolean;
}
export interface AccountSubjectApiData {
id: number;
code: string;
name: string;
category: string;
sub_category: string | null;
parent_code: string | null;
depth: number;
department_type: string;
description: string | null;
sort_order: number;
is_active: boolean | number;
created_at: string;
updated_at: string;
}
// ===== API → Frontend 변환 =====
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
return {
id: String(apiData.id),
code: apiData.code,
name: apiData.name,
category: apiData.category as AccountSubjectCategory,
subCategory: apiData.sub_category,
parentCode: apiData.parent_code,
depth: apiData.depth ?? 3,
departmentType: (apiData.department_type || 'common') as DepartmentType,
description: apiData.description,
sortOrder: apiData.sort_order ?? 0,
isActive: Boolean(apiData.is_active),
};
}
// ===== 표시용 포맷 =====
export function formatAccountLabel(subject: AccountSubject): string {
return `[${subject.code}] ${subject.name}`;
}

View File

@@ -41,6 +41,7 @@ import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import { getCardManagementModalConfigWithData } from './modalConfigs';
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
import { toast } from 'sonner';
import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation';
export function CEODashboard() {
const router = useRouter();
@@ -70,6 +71,27 @@ export function CEODashboard() {
// Welfare API Hook (Phase 2)
const welfareData = useWelfare();
// 대시보드 targeted refetch: CUD 후 stale 섹션만 갱신
useEffect(() => {
const refetchSection = (key: string) => {
if (key === 'entertainment') entertainmentData.refetch();
else if (key === 'welfare') welfareData.refetch();
else apiData.refetchMap[key as DashboardSectionKey]?.();
};
const stale = consumeStaleSections();
if (stale.length > 0) {
for (const key of stale) refetchSection(key);
}
const handler = (e: Event) => {
const sections = (e as CustomEvent).detail?.sections as string[] | undefined;
if (sections) {
for (const key of sections) refetchSection(key);
}
};
window.addEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
return () => window.removeEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
}, [apiData.refetchMap, entertainmentData.refetch, welfareData.refetch]);
// Card Management Modal API Hook (Phase 3)
const cardManagementModals = useCardManagementModals();

View File

@@ -197,7 +197,7 @@ export function OrderRegistration({
// 컴포넌트 마운트 시 거래처 목록 불러오기
useEffect(() => {
fetchClients({ onlyActive: true, size: 100 });
fetchClients({ onlyActive: true, size: 1000 });
}, [fetchClients]);
// Daum 우편번호 서비스

View File

@@ -65,6 +65,7 @@ import {
type OrderStatus,
} from "@/components/orders";
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
import { invalidateDashboard } from "@/lib/dashboard-invalidation";
// 상태 뱃지 헬퍼
@@ -293,6 +294,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
try {
const result = await updateOrderStatus(order.id, "cancelled");
if (result.success) {
invalidateDashboard('sales');
setOrder({ ...order, status: "cancelled" });
toast.success("수주가 취소되었습니다.");
setIsCancelDialogOpen(false);
@@ -321,6 +323,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
try {
const result = await updateOrderStatus(order.id, "order_confirmed");
if (result.success && result.data) {
invalidateDashboard('sales');
setOrder(result.data);
toast.success("수주가 확정되었습니다.");
setIsConfirmDialogOpen(false);

View File

@@ -68,7 +68,7 @@ function transformDetailApiToFrontend(data: ApiProductionOrderDetail): Productio
id: item.id,
itemCode: item.item_code,
itemName: item.item_name,
spec: item.spec || item.specification || '',
spec: item.spec || '',
unit: item.unit || '',
quantity: item.quantity ?? 0,
unitPrice: item.unit_price ?? 0,

View File

@@ -19,6 +19,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
ProductInspection,
ProductInspectionData,
InspectionStats,
InspectionStatus,
InspectionCalendarItem,
@@ -100,6 +101,8 @@ interface ProductInspectionApi {
construction_width: number;
construction_height: number;
change_reason: string;
document_id?: number | null;
inspection_data?: Record<string, unknown>;
}>;
request_document_id: number | null;
created_at: string;
@@ -252,7 +255,7 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
constructionHeight: item.construction_height,
changeReason: item.change_reason,
documentId: item.document_id ?? null,
inspectionData: item.inspection_data || undefined,
inspectionData: item.inspection_data ? item.inspection_data as unknown as ProductInspectionData : undefined,
})),
requestDocumentId: api.request_document_id ?? null,
};
@@ -616,7 +619,7 @@ export async function updateInspection(
export async function saveLocationInspection(
docId: string,
locationId: string,
inspectionData: Record<string, unknown>,
inspectionData: ProductInspectionData,
constructionInfo?: {
width: number | null;
height: number | null;

View File

@@ -5,7 +5,7 @@
* - 왼쪽: 목록으로/취소 (뒤로가기 성격)
* - 오른쪽: [추가액션] 삭제 | 수정/저장/등록 (액션 성격)
*
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
* View 모드: 목록으로 | [추가액션] 수정
* Edit 모드: 취소 | [추가액션] 삭제 | 저장
* Create 모드: 취소 | [추가액션] 등록
*/
@@ -156,8 +156,8 @@ export function DetailActions({
);
})}
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제 대상 없음) */}
{!isCreateMode && canDelete && showDelete && onDelete && (
{/* 삭제 버튼: edit 모드에서 표시 (view는 읽기 전용, create는 삭제 대상 없음) */}
{isEditMode && canDelete && showDelete && onDelete && (
<Button
variant="outline"
onClick={onDelete}

View File

@@ -59,6 +59,8 @@ import {
transformDailyAttendanceResponse,
} from '@/lib/api/dashboard/transformers';
import type { DashboardSectionKey } from '@/lib/dashboard-invalidation';
import type {
DailyReportData,
ReceivableData,
@@ -664,6 +666,7 @@ export interface CEODashboardState {
construction: SectionState<ConstructionData>;
dailyAttendance: SectionState<DailyAttendanceData>;
refetchAll: () => void;
refetchMap: Partial<Record<DashboardSectionKey, () => void>>;
}
export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashboardState {
@@ -782,6 +785,22 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
// 섹션별 refetch 함수 맵 (targeted invalidation용)
const refetchMap = useMemo<Partial<Record<DashboardSectionKey, () => void>>>(() => ({
dailyReport: dr.refetch,
receivable: rv.refetch,
debtCollection: dc.refetch,
monthlyExpense: me.refetch,
cardManagement: fetchCM,
statusBoard: sb.refetch,
salesStatus: ss.refetch,
purchaseStatus: ps.refetch,
dailyProduction: dp.refetch,
unshipped: us.refetch,
construction: cs.refetch,
dailyAttendance: da.refetch,
}), [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
return {
dailyReport: { data: dr.data, loading: dr.loading, error: dr.error },
receivable: { data: rv.data, loading: rv.loading, error: rv.error },
@@ -796,5 +815,6 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
construction: { data: cs.data, loading: cs.loading, error: cs.error },
dailyAttendance: { data: da.data, loading: da.loading, error: da.error },
refetchAll,
refetchMap,
};
}

View File

@@ -42,7 +42,7 @@ function extractArray<T>(data: PaginatedOrArray<T>): T[] {
export async function fetchVendorOptions(): Promise<ActionResult<SelectOption[]>> {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
url: `${API_URL}/api/v1/clients?size=1000`,
transform: (data: PaginatedOrArray<ClientApiItem>) => {
const clients = extractArray(data);
return clients.map(c => ({ id: String(c.id), name: c.name }));

View File

@@ -0,0 +1,91 @@
/**
* CEO 대시보드 targeted refetch 시스템
*
* CUD 발생 시 sessionStorage + CustomEvent로 대시보드 섹션별 갱신 트리거
*/
// 대시보드 섹션 키 (useCEODashboard의 refetchMap과 1:1 매핑)
export type DashboardSectionKey =
| 'dailyReport'
| 'receivable'
| 'debtCollection'
| 'monthlyExpense'
| 'cardManagement'
| 'statusBoard'
| 'salesStatus'
| 'purchaseStatus'
| 'dailyProduction'
| 'unshipped'
| 'construction'
| 'dailyAttendance'
| 'entertainment'
| 'welfare';
// CUD 도메인 → 영향받는 대시보드 섹션 매핑
type DomainKey =
| 'deposit'
| 'withdrawal'
| 'sales'
| 'purchase'
| 'badDebt'
| 'expectedExpense'
| 'bill'
| 'giftCertificate'
| 'journalEntry';
const DOMAIN_SECTION_MAP: Record<DomainKey, DashboardSectionKey[]> = {
deposit: ['dailyReport', 'receivable'],
withdrawal: ['dailyReport', 'monthlyExpense'],
sales: ['dailyReport', 'salesStatus', 'receivable'],
purchase: ['dailyReport', 'purchaseStatus', 'monthlyExpense'],
badDebt: ['debtCollection', 'receivable'],
expectedExpense: ['monthlyExpense'],
bill: ['dailyReport', 'receivable'],
giftCertificate: ['entertainment', 'cardManagement'],
journalEntry: ['entertainment', 'welfare', 'monthlyExpense'],
};
const STORAGE_KEY = 'dashboard:stale-sections';
const EVENT_NAME = 'dashboard:invalidate';
/**
* CUD 성공 후 호출 — 해당 도메인이 영향 주는 대시보드 섹션을 stale 처리
*/
export function invalidateDashboard(domain: DomainKey): void {
const sections = DOMAIN_SECTION_MAP[domain];
if (!sections || sections.length === 0) return;
// 1. sessionStorage에 stale 섹션 저장 (navigation 사이 유지)
try {
const existing = sessionStorage.getItem(STORAGE_KEY);
const current: string[] = existing ? JSON.parse(existing) : [];
const merged = Array.from(new Set([...current, ...sections]));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch {
// sessionStorage 접근 불가 시 무시
}
// 2. CustomEvent 발행 (대시보드가 마운트 중이면 즉시 처리)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(EVENT_NAME, { detail: { sections } }),
);
}
}
/**
* 대시보드 마운트 시 호출 — stale 섹션 읽고 클리어
*/
export function consumeStaleSections(): DashboardSectionKey[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
sessionStorage.removeItem(STORAGE_KEY);
return JSON.parse(raw) as DashboardSectionKey[];
} catch {
return [];
}
}
/** CustomEvent 이름 (리스너 등록용) */
export const DASHBOARD_INVALIDATE_EVENT = EVENT_NAME;