17 Commits

Author SHA1 Message Date
fdb1230c69 fix: middleware publicRoutes 타입 에러 수정 2026-03-11 02:15:19 +09:00
e871f6232f deploy: 2026-03-11 배포
- feat: MNG→SAM 자동 로그인 (auto-login 페이지, token-login API 프록시, auth-config)
- feat: QMS 품질감사 API 연동 (actions, hooks, Day1/Day2 컴포넌트 개선)
- feat: 공지 팝업 모달 (NoticePopupContainer, PopupManagement 설정 개선)
- feat: CEO 대시보드 캘린더 섹션 개선
- fix: 게시판 폼, 로그인 페이지, 작업자 화면, 청구서 관리 수정
- chore: AuthenticatedLayout, logout, userStorage 정리
2026-03-11 02:06:51 +09:00
2f00eac0f0 feat: CEO 대시보드·결재·레이아웃·HR 개선
- CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 API 연동
- dashboard-invalidation 유틸 추가
- 결재 문서작성/결재함 검사성적서 렌더링 개선
- HeaderFavoritesBar/Sidebar 레이아웃 수정
- 근태관리/휴가관리 뷰 보강
- LoginPage 개선
- 대시보드 transformer 수정 (receivable, status-issue)
2026-03-10 11:35:57 +09:00
dcaca59685 feat: 품질관리·생산·출하 개선 — 검사관리·생산지시·배차·문서스냅샷
- 검사관리 수주처 선택 UI + client_id 연동 + 타입 에러 수정
- 제품검사 요청서/성적서 동적 렌더링 + Lazy Snapshot
- 품질관리 Mock→API 전환 + 수주선택 모달 발주처 연동
- 생산지시 Create/Detail/Edit 제품코드 표시 추가
- 배차 상세/수정 그리드 레이아웃 개선
- 자재/수주 상세 뷰 보강
2026-03-10 11:35:45 +09:00
81373281ea feat: 회계 모듈 전면 개선 — 계정과목 공통화·전표·세금계산서·어음·상품권
- AccountSubjectSelect 공통 컴포넌트 신규 (계정과목 선택 통합)
- 일반전표 수동입력/수정 모달 계정과목 연동
- 세금계산서 관리 타입 시스템 재정의 + 전표 연동 모달
- 어음관리 리팩토링 + 상품권 접대비 연동
- 카드거래 조회 전표 연동 모달 개선
- 악성채권/입출금/매입매출/거래처 상세 뷰 보강
2026-03-10 11:35:26 +09:00
72cf5d86a2 feat: [결재/공통] 결재함 + 레이아웃 + 캘린더 + 모바일 반응형
- 결재함 검사성적서 템플릿 기반 렌더링 + 결재 상신
- Sidebar/HeaderFavoritesBar 개선
- AuthenticatedLayout 모바일 반응형
- SearchableSelectionModal HTML 유효성 수정
- VacationManagement, 사원관리 정렬 옵션
2026-03-07 03:04:01 +09:00
a4f99ae339 feat: [출하/배차/회계] 배차 다중행 + 어음 리팩토링 + 출고관리
- 배차차량관리 목업→API 연동, 배차정보 다중 행
- ShipmentManagement 출고관리 API 매핑
- BillManagement 리팩토링 (섹션 분리, hooks, constants)
- 상품권 actions/types 확장
- 출하관리 캘린더 기본 뷰 week-time
2026-03-07 03:03:27 +09:00
9ad4c8ee9f feat: [CEO 대시보드] API 연동 + 섹션 확장 + SummaryNavBar
- 접대비/복리후생비/매출채권/캘린더 섹션 API 연동
- SummaryNavBar 추가 + mockData/modalConfigs 대규모 리팩토링
- Dashboard transformers 도메인별 분리
- 상세 모달 ScheduleDetailModal 추가
2026-03-07 03:03:07 +09:00
04f2a8a74c feat: [문서스냅샷] Lazy Snapshot + rendered_html 캡처
- capture-rendered-html 유틸 추가
- 검사성적서/작업일지 저장 시 HTML 스냅샷 캡처
- 중간검사/작업일지 조회 시 자동 스냅샷
- DocumentViewer 스냅샷 출력 지원
2026-03-07 03:02:59 +09:00
8b6da749a9 feat: [생산지시] 목록/상세 API 연동 + 작업자 화면 개선
- ProductionOrders 목록/상세 페이지 API 연동
- 절곡 중간검사 입력 모달 (7개 제품 항목 통합)
- 자재투입 다중 BOM 그룹 LOT 독립 관리
- 작업자 화면 제품명 productCode만 표시
- BOM 공정 분류 접이식 카드 UI
- 검사성적서 TemplateInspectionContent API 연동
2026-03-07 03:02:52 +09:00
c150d80725 feat: [품질관리] Mock→API 전환 + 검사 모달/문서 개선
- InspectionManagement 전체 API 연동 (Mock 제거)
- 제품검사 성적서 8컬럼 동적 렌더링 + FQC 모드
- 제품검사 요청서 양식 기반 렌더링 + Lazy Snapshot
- 수주선택 모달 발주처 필터링/비활성화 제약
- 실적신고 snake_case→camelCase 변환
- 공정 단계 검사범위(InspectionScope) 설정 추가
- 빌드 타입 에러 수정 (specification, ProductInspectionData 등)
2026-03-07 03:02:30 +09:00
f9eea0c950 chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_react 2026-03-05 11:33:07 +09:00
유병철
2a2a356f58 feat: [생산] 작업지시/작업자화면/대시보드 개선
- 검사문서 모달 및 템플릿 기능 확장
- WorkOrders actions 추가
- 작업자화면 WorkOrderListPanel 개선
- 생산대시보드 actions/타입 보강

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:20:41 +09:00
유병철
181352d7a9 feat: [전자결재] 결재함 기능 확장 및 연결문서 기능 추가
- ApprovalBox actions/타입 확장
- DocumentDetailModalV2 개선
- LinkedDocumentContent 신규 추가
- 결재 문서 타입 보강

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:20:33 +09:00
유병철
1691337f7d feat: [회계] 매출/매입/부실채권/일일보고 UI 개선
- 부실채권 상세/목록/타입 개선
- 매출관리 SalesDetail 개선
- 매입관리 PurchaseDetail 개선
- 일일보고 UI 리팩토링

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:20:21 +09:00
유병철
4e179d2eca refactor: [CEO대시보드] 컴포넌트 분리 및 모달/섹션 리팩토링
- DashboardSettingsSections, DetailModalSections 분리
- 모달 설정(카드/접대비/복리후생/부가세/월비용) 개선
- 섹션 컴포넌트 최적화 (매출/매입/카드/미출고 등)
- mockData, types 정리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:20:05 +09:00
유병철
db84d6796b feat: [공통] Sidebar, 대시보드 훅, 유틸 개선
- Sidebar 레이아웃 개선
- useCEODashboard 최적화, useDashboardFetch 훅 신규
- amount, status-config 유틸 개선
- dashboard transformers 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:19:53 +09:00
233 changed files with 17066 additions and 8659 deletions

View File

@@ -326,16 +326,19 @@ const [data, setData] = useState(() => {
---
## Backend API Analysis Policy
## Backend API Policy
**Priority**: 🟡
- Backend API 코드는 **분석만**, 직접 수정 안 함
- 수정 필요 시 백엔드 요청 문서로 정리:
- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
- 신규 API가 필요한 경우 요청 문서로 정리:
```markdown
## 백엔드 API 수정 요청
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX)
### 현재 문제: [설명]
### 수정 요청: [내용]
## 백엔드 API 신규 요청
### 엔드포인트: [HTTP METHOD /api/v1/path]
### 목적: [설명]
### 요청/응답 구조: [내용]
```
---

6
Jenkinsfile vendored
View File

@@ -17,7 +17,7 @@ pipeline {
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_react', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
@@ -128,11 +128,11 @@ pipeline {
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
"start": "next start -H 0.0.0.0",

View File

@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
return (
<BadDebtCollection
initialData={data}
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; recovered_amount: number; bad_debt_amount: number; } | null}
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; collection_end_amount: number; } | null}
/>
);
}

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);

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
// UI - 추가
import { VisuallyHidden } from '@/components/ui/visually-hidden';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import { DateTimePicker } from '@/components/ui/date-time-picker';
// Molecules - 추가
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog';
import { ReorderButtons } from '@/components/molecules/ReorderButtons';
// Organisms - 추가
import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable';
// Lucide icons for demos
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
@@ -339,6 +347,89 @@ function SearchableSelectionDemo() {
);
}
// ── 추가 Demo Wrappers ──
function DateRangePickerDemo() {
const [start, setStart] = useState<string | undefined>();
const [end, setEnd] = useState<string | undefined>();
return (
<div className="max-w-sm">
<DateRangePicker startDate={start} endDate={end} onStartDateChange={setStart} onEndDateChange={setEnd} />
</div>
);
}
function DateTimePickerDemo() {
const [v, setV] = useState<string | undefined>();
return (
<div className="max-w-sm">
<DateTimePicker value={v} onChange={setV} />
</div>
);
}
function ColumnSettingsPopoverDemo() {
const [cols, setCols] = useState([
{ key: 'name', label: '품목명', visible: true, locked: true },
{ key: 'spec', label: '규격', visible: true, locked: false },
{ key: 'qty', label: '수량', visible: true, locked: false },
{ key: 'price', label: '단가', visible: false, locked: false },
{ key: 'note', label: '비고', visible: false, locked: false },
]);
return (
<ColumnSettingsPopover
columns={cols}
onToggle={(key) => setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))}
onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))}
hasHiddenColumns={cols.some((c) => !c.visible)}
/>
);
}
function GenericCRUDDialogDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>CRUD </Button>
<GenericCRUDDialog
isOpen={open}
onOpenChange={setOpen}
mode="add"
entityName="직급"
fields={[
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명 입력' },
{ key: 'status', label: '상태', type: 'select', options: [{ value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' }], defaultValue: 'active' },
]}
onSubmit={() => setOpen(false)}
/>
</>
);
}
function LineItemsTableDemo() {
const [items, setItems] = useState([
{ id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' },
{ id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' },
]);
return (
<div className="max-w-3xl overflow-x-auto">
<LineItemsTable
items={items}
getItemName={(i) => i.itemName}
getQuantity={(i) => i.quantity}
getUnitPrice={(i) => i.unitPrice}
getSupplyAmount={(i) => i.supplyAmount}
getVat={(i) => i.vat}
getNote={(i) => i.note}
onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))}
onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])}
onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))}
totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }}
/>
</div>
);
}
// ── Preview Registry ──
type PreviewEntry = {
@@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
},
],
'date-range-picker.tsx': [
{ label: 'DateRangePicker', render: () => <DateRangePickerDemo /> },
],
'date-time-picker.tsx': [
{ label: 'DateTimePicker', render: () => <DateTimePickerDemo /> },
],
// ─── Atoms ───
'BadgeSm.tsx': [
{
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
{ label: 'Filter', render: () => <MobileFilterDemo /> },
],
'ColumnSettingsPopover.tsx': [
{ label: 'Popover', render: () => <ColumnSettingsPopoverDemo /> },
],
'GenericCRUDDialog.tsx': [
{ label: 'CRUD Dialog', render: () => <GenericCRUDDialogDemo /> },
],
'ReorderButtons.tsx': [
{
label: 'Sizes',
render: () => (
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">sm:</span>
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" />
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">xs:</span>
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" />
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">disabled:</span>
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" />
</div>
</div>
),
},
],
// ─── Organisms ───
'EmptyState.tsx': [
{
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
'SearchableSelectionModal.tsx': [
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
],
'LineItemsTable.tsx': [
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
],
};

View File

@@ -0,0 +1,306 @@
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ===== API 원본 타입 (snake_case) =====
// ⚠️ 'use server' 파일에서 타입 re-export 금지 (Turbopack 제한)
interface QualityReportApi {
id: number;
code: string;
site_name: string;
item: string;
route_count: number;
total_routes: number;
quarter: string;
year: number;
quarter_num: number;
}
interface RouteItemApi {
id: number;
code: string;
date: string;
site: string;
location_count: number;
sub_items: {
id: number;
name: string;
location: string;
is_completed: boolean;
}[];
}
interface DocumentApi {
id: number;
type: string;
title: string;
date?: string;
count: number;
items?: {
id: number;
title: string;
date: string;
code?: string;
sub_type?: string;
}[];
}
interface ChecklistDetailApi {
id: number;
year: number;
quarter: number;
type: string;
status: string;
progress: { completed: number; total: number };
categories: {
id: number;
title: string;
sort_order: number;
sub_items: {
id: number;
name: string;
description?: string;
is_completed: boolean;
completed_at?: string;
sort_order: number;
standard_documents: {
id: number;
title: string;
version: string;
date: string;
file_name?: string;
file_url?: string;
}[];
}[];
}[];
}
// ===== Transform 함수 (snake_case → camelCase) =====
function transformReportApi(api: QualityReportApi) {
return {
id: String(api.id),
code: api.code,
siteName: api.site_name,
item: api.item,
routeCount: api.route_count,
totalRoutes: api.total_routes,
quarter: api.quarter,
year: api.year,
quarterNum: api.quarter_num,
};
}
function transformRouteApi(api: RouteItemApi) {
return {
id: String(api.id),
code: api.code,
date: api.date,
site: api.site,
locationCount: api.location_count,
subItems: api.sub_items.map((s) => ({
id: String(s.id),
name: s.name,
location: s.location,
isCompleted: s.is_completed,
})),
};
}
function transformDocumentApi(api: DocumentApi) {
return {
id: String(api.id),
type: api.type as 'import' | 'order' | 'log' | 'report' | 'confirmation' | 'shipping' | 'product' | 'quality',
title: api.title,
date: api.date,
count: api.count,
items: api.items?.map((i) => ({
id: String(i.id),
title: i.title,
date: i.date,
code: i.code,
subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined,
})),
};
}
function transformChecklistDetail(api: ChecklistDetailApi) {
return {
progress: api.progress,
categories: api.categories.map((cat) => ({
id: String(cat.id),
title: cat.title,
subItems: cat.sub_items.map((item) => ({
id: String(item.id),
name: item.name,
isCompleted: item.is_completed,
})),
})),
checkItems: api.categories.flatMap((cat) =>
cat.sub_items.map((item) => ({
id: `check-${item.id}`,
categoryId: String(cat.id),
subItemId: String(item.id),
title: item.name,
description: item.description || '',
buttonLabel: '기준/매뉴얼 확인',
standardDocuments: item.standard_documents.map((doc) => ({
id: String(doc.id),
title: doc.title,
version: doc.version,
date: doc.date,
fileName: doc.file_name,
fileUrl: doc.file_url,
})),
})),
),
};
}
// ===== 2일차: 로트 추적 심사 =====
export async function getQualityReports(params: {
year: number;
quarter?: number;
q?: string;
}) {
return executeServerAction({
url: buildApiUrl('/api/v1/qms/lot-audit/reports', {
year: params.year,
quarter: params.quarter,
q: params.q,
}),
transform: (data: { items: QualityReportApi[] }) =>
data.items.map(transformReportApi),
errorMessage: '품질관리서 목록 조회에 실패했습니다.',
});
}
export async function getReportRoutes(reportId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`),
transform: (data: RouteItemApi[]) => data.map(transformRouteApi),
errorMessage: '수주/개소 목록 조회에 실패했습니다.',
});
}
export async function getRouteDocuments(routeId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/routes/${routeId}/documents`),
transform: (data: DocumentApi[]) => data.map(transformDocumentApi),
errorMessage: '서류 목록 조회에 실패했습니다.',
});
}
export async function getDocumentDetail(type: string, id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/documents/${type}/${id}`),
errorMessage: '서류 상세 조회에 실패했습니다.',
});
}
export async function confirmUnitInspection(unitId: string, confirmed: boolean) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/units/${unitId}/confirm`),
method: 'PATCH',
body: { confirmed },
transform: (data: { id: number; name: string; location: string; is_completed: boolean }) => ({
id: String(data.id),
name: data.name,
location: data.location,
isCompleted: data.is_completed,
}),
errorMessage: '확인 상태 변경에 실패했습니다.',
});
}
// ===== 1일차: 기준/매뉴얼 심사 =====
export async function getChecklistDetail(params: {
year: number;
quarter?: number;
}) {
return executeServerAction({
url: buildApiUrl('/api/v1/qms/checklists', {
year: params.year,
quarter: params.quarter,
}),
transform: (data: { items: { id: number }[] }) => {
if (data.items.length === 0) return null;
return { checklistId: String(data.items[0].id) };
},
errorMessage: '점검표 목록 조회에 실패했습니다.',
});
}
export async function getChecklistById(id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/checklists/${id}`),
transform: (data: ChecklistDetailApi) => transformChecklistDetail(data),
errorMessage: '점검표 상세 조회에 실패했습니다.',
});
}
export async function toggleChecklistItem(itemId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/toggle`),
method: 'PATCH',
transform: (data: { id: number; name: string; is_completed: boolean; completed_at?: string }) => ({
id: String(data.id),
name: data.name,
isCompleted: data.is_completed,
}),
errorMessage: '항목 상태 변경에 실패했습니다.',
});
}
export async function getCheckItemDocuments(itemId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`),
transform: (data: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }[]) =>
data.map((d) => ({
id: String(d.id),
title: d.title,
version: d.version,
date: d.date,
fileName: d.file_name,
fileUrl: d.file_url,
})),
errorMessage: '기준 문서 조회에 실패했습니다.',
});
}
export async function attachStandardDocument(
itemId: string,
data: { title: string; version?: string; date?: string; documentId?: number },
) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`),
method: 'POST',
body: {
title: data.title,
version: data.version,
date: data.date,
document_id: data.documentId,
},
transform: (d: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }) => ({
id: String(d.id),
title: d.title,
version: d.version,
date: d.date,
fileName: d.file_name,
fileUrl: d.file_url,
}),
errorMessage: '기준 문서 연결에 실패했습니다.',
});
}
export async function detachStandardDocument(itemId: string, docId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents/${docId}`),
method: 'DELETE',
errorMessage: '기준 문서 연결 해제에 실패했습니다.',
});
}

View File

@@ -11,6 +11,7 @@ interface Day1ChecklistPanelProps {
searchTerm: string;
onSubItemSelect: (categoryId: string, subItemId: string) => void;
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
isMock?: boolean;
}
export function Day1ChecklistPanel({
@@ -19,6 +20,7 @@ export function Day1ChecklistPanel({
searchTerm,
onSubItemSelect,
onSubItemToggle,
isMock,
}: Day1ChecklistPanelProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
@@ -95,7 +97,14 @@ export function Day1ChecklistPanel({
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
{/* 검색 결과 카운트 */}
{searchTerm && (
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">

View File

@@ -12,6 +12,7 @@ interface Day1DocumentSectionProps {
onDocumentSelect: (documentId: string) => void;
onConfirmComplete: () => void;
isCompleted: boolean;
isMock?: boolean;
}
export function Day1DocumentSection({
@@ -20,6 +21,7 @@ export function Day1DocumentSection({
onDocumentSelect,
onConfirmComplete,
isCompleted,
isMock,
}: Day1DocumentSectionProps) {
if (!checkItem) {
return (
@@ -36,7 +38,14 @@ export function Day1DocumentSection({
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
</div>
{/* 콘텐츠 */}

View File

@@ -7,9 +7,10 @@ import type { StandardDocument } from '../types';
interface Day1DocumentViewerProps {
document: StandardDocument | null;
isMock?: boolean;
}
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
export function Day1DocumentViewer({ document, isMock }: Day1DocumentViewerProps) {
if (!document) {
return (
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
@@ -38,7 +39,14 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
<div>
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
<p className="text-[10px] sm:text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
{document.date}

View File

@@ -11,6 +11,7 @@ interface DocumentListProps {
documents: Document[];
routeCode: string | null;
onViewDocument: (doc: Document, item?: DocumentItem) => void;
isMock?: boolean;
}
const getIcon = (type: string) => {
@@ -27,7 +28,7 @@ const getIcon = (type: string) => {
}
};
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => {
export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: DocumentListProps) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
// 문서 카테고리 클릭 핸들러
@@ -52,12 +53,19 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
{' '}
{routeCode && (
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
{' '}
{routeCode && (
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
)}
</h2>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</h2>
</div>
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{!routeCode ? (

View File

@@ -8,13 +8,21 @@ interface ReportListProps {
reports: InspectionReport[];
selectedId: string | null;
onSelect: (report: InspectionReport) => void;
isMock?: boolean;
}
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportListProps) => {
return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
<div className="flex items-center justify-between mb-3 sm:mb-4">
<h2 className="font-bold text-sm sm:text-lg text-gray-800"> </h2>
<div className="flex items-center gap-2">
<h2 className="font-bold text-sm sm:text-lg text-gray-800"> </h2>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
{reports.length}
</span>

View File

@@ -11,9 +11,10 @@ interface RouteListProps {
onSelect: (route: RouteItem) => void;
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
reportCode: string | null;
isMock?: boolean;
}
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => {
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode, isMock }: RouteListProps) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
const handleClick = (route: RouteItem) => {
@@ -28,12 +29,19 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
{' '}
{reportCode && (
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
{' '}
{reportCode && (
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
)}
</h2>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</h2>
</div>
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{routes.length === 0 ? (

View File

@@ -0,0 +1,172 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import type { ChecklistCategory, Day1CheckItem, StandardDocument } from '../types';
import {
getChecklistById,
toggleChecklistItem,
getCheckItemDocuments,
} from '../actions';
import {
MOCK_DAY1_CATEGORIES,
MOCK_DAY1_CHECK_ITEMS,
MOCK_DAY1_STANDARD_DOCUMENTS,
} from '../mockData';
const USE_MOCK = true; // API 연동 완료 시 false로 변경
export function useDay1Audit() {
// 데이터 상태
const [categories, setCategories] = useState<ChecklistCategory[]>(USE_MOCK ? MOCK_DAY1_CATEGORIES : []);
const [checkItems] = useState<Day1CheckItem[]>(USE_MOCK ? MOCK_DAY1_CHECK_ITEMS : []);
const [standardDocuments] = useState<Record<string, StandardDocument[]>>(USE_MOCK ? MOCK_DAY1_STANDARD_DOCUMENTS : {});
// 선택 상태
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
// 로딩 상태
const [loadingChecklist, setLoadingChecklist] = useState(false);
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
// 진행률 계산
const day1Progress = useMemo(() => {
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
const completed = categories.reduce(
(sum, cat) => sum + cat.subItems.filter((item) => item.isCompleted).length,
0,
);
return { completed, total };
}, [categories]);
// 선택된 점검 항목
const selectedCheckItem = useMemo(() => {
if (!selectedSubItemId) return null;
return checkItems.find((item) => item.subItemId === selectedSubItemId) || null;
}, [selectedSubItemId, checkItems]);
// 선택된 표준 문서
const selectedStandardDoc = useMemo(() => {
if (!selectedStandardDocId || !selectedSubItemId) return null;
const docs = standardDocuments[selectedSubItemId] || [];
return docs.find((doc) => doc.id === selectedStandardDocId) || null;
}, [selectedStandardDocId, selectedSubItemId, standardDocuments]);
// 선택된 항목의 완료 여부
const isSelectedItemCompleted = useMemo(() => {
if (!selectedSubItemId) return false;
for (const cat of categories) {
const item = cat.subItems.find((sub) => sub.id === selectedSubItemId);
if (item) return item.isCompleted;
}
return false;
}, [categories, selectedSubItemId]);
// === 핸들러 ===
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
setSelectedCategoryId(categoryId);
setSelectedSubItemId(subItemId);
setSelectedStandardDocId(null);
}, []);
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string, isCompleted: boolean) => {
if (USE_MOCK) {
// Mock: 로컬 상태만 업데이트
setCategories((prev) =>
prev.map((cat) => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map((item) => {
if (item.id !== subItemId) return item;
return { ...item, isCompleted };
}),
};
}),
);
return;
}
// API: 비관적 업데이트
if (pendingToggleIds.has(subItemId)) return;
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
try {
const result = await toggleChecklistItem(subItemId);
if (result.success && result.data) {
setCategories((prev) =>
prev.map((cat) => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map((item) => {
if (item.id !== subItemId) return item;
return { ...item, isCompleted: result.data!.isCompleted };
}),
};
}),
);
} else {
toast.error(result.error || '항목 상태 변경에 실패했습니다.');
}
} finally {
setPendingToggleIds((prev) => {
const next = new Set(prev);
next.delete(subItemId);
return next;
});
}
}, [pendingToggleIds]);
const handleConfirmComplete = useCallback(() => {
if (selectedCategoryId && selectedSubItemId) {
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
}
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
const fetchChecklist = useCallback(async (checklistId: string) => {
if (USE_MOCK) return;
setLoadingChecklist(true);
try {
const result = await getChecklistById(checklistId);
if (result.success && result.data) {
setCategories(result.data.categories);
}
} finally {
setLoadingChecklist(false);
}
}, []);
return {
// 데이터
categories,
day1Progress,
selectedCheckItem,
selectedStandardDoc,
isSelectedItemCompleted,
// 선택
selectedSubItemId,
selectedCategoryId,
selectedStandardDocId,
setSelectedStandardDocId,
handleSubItemSelect,
// 토글
handleSubItemToggle,
handleConfirmComplete,
pendingToggleIds,
// 로딩
loadingChecklist,
// API
fetchChecklist,
// Mock 여부
isMock: USE_MOCK,
};
}

View File

@@ -0,0 +1,268 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import type { InspectionReport, RouteItem, Document, DocumentItem } from '../types';
import {
getQualityReports,
getReportRoutes,
getRouteDocuments,
confirmUnitInspection,
} from '../actions';
import {
MOCK_REPORTS,
MOCK_ROUTES_INITIAL,
MOCK_DOCUMENTS,
DEFAULT_DOCUMENTS,
} from '../mockData';
const USE_MOCK = true; // API 연동 완료 시 false로 변경
export function useDay2LotAudit() {
// 필터 상태
const [selectedYear, setSelectedYear] = useState(2025);
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 데이터 상태
const [reports, setReports] = useState<InspectionReport[]>(USE_MOCK ? MOCK_REPORTS : []);
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(USE_MOCK ? MOCK_ROUTES_INITIAL : {});
const [documents, setDocuments] = useState<Document[]>([]);
// 선택 상태
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
// 로딩 상태
const [loadingReports, setLoadingReports] = useState(false);
const [loadingRoutes, setLoadingRoutes] = useState(false);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set());
// 진행률 계산
const day2Progress = useMemo(() => {
let completed = 0;
let total = 0;
Object.values(routesData).forEach((routes) => {
routes.forEach((route) => {
route.subItems.forEach((item) => {
total++;
if (item.isCompleted) completed++;
});
});
});
return { completed, total };
}, [routesData]);
// 필터링된 보고서
const filteredReports = useMemo(() => {
return reports.filter((report) => {
if (report.year !== selectedYear) return false;
if (selectedQuarter !== '전체') {
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
if (report.quarterNum !== quarterNum) return false;
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
const matchesCode = report.code.toLowerCase().includes(term);
const matchesSite = report.siteName.toLowerCase().includes(term);
const matchesItem = report.item.toLowerCase().includes(term);
if (!matchesCode && !matchesSite && !matchesItem) return false;
}
return true;
});
}, [reports, selectedYear, selectedQuarter, searchTerm]);
// 현재 루트/문서
const currentRoutes = useMemo(() => {
if (!selectedReport) return [];
return routesData[selectedReport.id] || [];
}, [selectedReport, routesData]);
const currentDocuments = useMemo(() => {
if (USE_MOCK) {
if (!selectedRoute) return DEFAULT_DOCUMENTS;
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
}
return documents;
}, [selectedRoute, documents]);
// === API 호출 핸들러 ===
const fetchReports = useCallback(async (year: number, quarter?: number, q?: string) => {
if (USE_MOCK) return;
setLoadingReports(true);
try {
const result = await getQualityReports({ year, quarter, q });
if (result.success && result.data) {
setReports(result.data);
}
} finally {
setLoadingReports(false);
}
}, []);
const handleReportSelect = useCallback(async (report: InspectionReport) => {
setSelectedReport(report);
setSelectedRoute(null);
setDocuments([]);
if (USE_MOCK) return;
setLoadingRoutes(true);
try {
const result = await getReportRoutes(report.id);
if (result.success && result.data) {
setRoutesData((prev) => ({ ...prev, [report.id]: result.data! }));
}
} finally {
setLoadingRoutes(false);
}
}, []);
const handleRouteSelect = useCallback(async (route: RouteItem) => {
setSelectedRoute(route);
if (USE_MOCK) return;
setLoadingDocuments(true);
try {
const result = await getRouteDocuments(route.id);
if (result.success && result.data) {
setDocuments(result.data);
}
} finally {
setLoadingDocuments(false);
}
}, []);
const handleViewDocument = useCallback((doc: Document, item?: DocumentItem) => {
setSelectedDoc(doc);
setSelectedDocItem(item || null);
setModalOpen(true);
}, []);
const handleToggleItem = useCallback(async (routeId: string, itemId: string, isCompleted: boolean) => {
if (USE_MOCK) {
// Mock: 로컬 상태만 업데이트
setRoutesData((prev) => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map((route) => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map((item) => {
if (item.id !== itemId) return item;
return { ...item, isCompleted };
}),
};
});
}
return newData;
});
return;
}
// API: 비관적 업데이트
if (pendingConfirmIds.has(itemId)) return;
setPendingConfirmIds((prev) => new Set(prev).add(itemId));
try {
const result = await confirmUnitInspection(itemId, isCompleted);
if (result.success && result.data) {
setRoutesData((prev) => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map((route) => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map((item) => {
if (item.id !== itemId) return item;
return { ...item, isCompleted: result.data!.isCompleted };
}),
};
});
}
return newData;
});
} else {
toast.error(result.error || '확인 상태 변경에 실패했습니다.');
}
} finally {
setPendingConfirmIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}
}, [pendingConfirmIds]);
const handleYearChange = useCallback((year: number) => {
setSelectedYear(year);
setSelectedReport(null);
setSelectedRoute(null);
setDocuments([]);
}, []);
const handleQuarterChange = useCallback((quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
setSelectedQuarter(quarter);
setSelectedReport(null);
setSelectedRoute(null);
setDocuments([]);
}, []);
const handleSearchChange = useCallback((term: string) => {
setSearchTerm(term);
}, []);
return {
// 필터
selectedYear,
selectedQuarter,
searchTerm,
handleYearChange,
handleQuarterChange,
handleSearchChange,
// 데이터
filteredReports,
currentRoutes,
currentDocuments,
day2Progress,
// 선택
selectedReport,
selectedRoute,
handleReportSelect,
handleRouteSelect,
// 모달
modalOpen,
selectedDoc,
selectedDocItem,
handleViewDocument,
setModalOpen,
// 토글
handleToggleItem,
pendingConfirmIds,
// 로딩
loadingReports,
loadingRoutes,
loadingDocuments,
// API
fetchReports,
// Mock 여부
isMock: USE_MOCK,
};
}

View File

@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
export const MOCK_WORK_ORDER: WorkOrder = {
id: 'wo-1',
orderNo: 'KD-WO-240924-01',
productCode: 'WY-SC780',
productName: '스크린 셔터 (표준형)',
processCode: 'screen',
processName: 'screen',

View File

@@ -1,28 +1,19 @@
"use client";
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo } from 'react';
import { Header } from './components/Header';
import { Filters } from './components/Filters';
import { ReportList } from './components/ReportList';
import { RouteList } from './components/RouteList';
import { DocumentList } from './components/DocumentList';
// import { InspectionModal } from './components/InspectionModal';
import { InspectionModal } from './components/InspectionModal';
import { DayTabs } from './components/DayTabs';
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
import { Day1DocumentSection } from './components/Day1DocumentSection';
import { Day1DocumentViewer } from './components/Day1DocumentViewer';
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types';
import {
MOCK_REPORTS,
MOCK_ROUTES_INITIAL,
MOCK_DOCUMENTS,
DEFAULT_DOCUMENTS,
MOCK_DAY1_CATEGORIES,
MOCK_DAY1_CHECK_ITEMS,
MOCK_DAY1_STANDARD_DOCUMENTS,
} from './mockData';
import { useDay1Audit } from './hooks/useDay1Audit';
import { useDay2LotAudit } from './hooks/useDay2LotAudit';
// 기본 설정값
const DEFAULT_SETTINGS: AuditDisplaySettings = {
@@ -41,195 +32,59 @@ export default function QualityInspectionPage() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
// 1일차 상태
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
// 1일차 커스텀 훅
const {
categories,
day1Progress,
selectedCheckItem,
selectedStandardDoc,
isSelectedItemCompleted,
selectedSubItemId,
selectedStandardDocId,
setSelectedStandardDocId,
handleSubItemSelect,
handleSubItemToggle,
handleConfirmComplete,
isMock: day1IsMock,
} = useDay1Audit();
// 2일차(로트추적) 필터 상태
const [selectedYear, setSelectedYear] = useState(2025);
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 2일차 커스텀 훅
const {
selectedYear,
selectedQuarter,
searchTerm,
handleYearChange,
handleQuarterChange,
handleSearchChange,
filteredReports,
currentRoutes,
currentDocuments,
day2Progress,
selectedReport,
selectedRoute,
handleReportSelect,
handleRouteSelect,
modalOpen,
selectedDoc,
selectedDocItem,
handleViewDocument,
setModalOpen,
handleToggleItem,
isMock: day2IsMock,
} = useDay2LotAudit();
// 2일차 선택 상태
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
// 2일차 루트 데이터 상태 (완료 토글용)
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
// ===== 1일차 진행률 계산 =====
const day1Progress = useMemo(() => {
const total = day1Categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
const completed = day1Categories.reduce(
(sum, cat) => sum + cat.subItems.filter(item => item.isCompleted).length,
0
);
return { completed, total };
}, [day1Categories]);
// ===== 2일차 진행률 계산 (개소별 완료 기준) =====
const day2Progress = useMemo(() => {
let completed = 0;
let total = 0;
Object.values(routesData).forEach(routes => {
routes.forEach(route => {
route.subItems.forEach(item => {
total++;
if (item.isCompleted) completed++;
});
});
});
return { completed, total };
}, [routesData]);
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
const filteredDay1Categories = useMemo(() => {
if (displaySettings.showCompletedItems) return day1Categories;
if (displaySettings.showCompletedItems) return categories;
return day1Categories.map(category => ({
return categories.map(category => ({
...category,
subItems: category.subItems.filter(item => !item.isCompleted),
})).filter(category => category.subItems.length > 0);
}, [day1Categories, displaySettings.showCompletedItems]);
// ===== 1일차 핸들러 =====
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
setSelectedCategoryId(categoryId);
setSelectedSubItemId(subItemId);
setSelectedStandardDocId(null);
}, []);
const handleSubItemToggle = useCallback((categoryId: string, subItemId: string, isCompleted: boolean) => {
setDay1Categories(prev => prev.map(cat => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map(item => {
if (item.id !== subItemId) return item;
return { ...item, isCompleted };
}),
};
}));
}, []);
const handleConfirmComplete = useCallback(() => {
if (selectedCategoryId && selectedSubItemId) {
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
}
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
// 선택된 1일차 점검 항목
const selectedCheckItem = useMemo(() => {
if (!selectedSubItemId) return null;
return MOCK_DAY1_CHECK_ITEMS.find(item => item.subItemId === selectedSubItemId) || null;
}, [selectedSubItemId]);
// 선택된 표준 문서
const selectedStandardDoc = useMemo(() => {
if (!selectedStandardDocId || !selectedSubItemId) return null;
const docs = MOCK_DAY1_STANDARD_DOCUMENTS[selectedSubItemId] || [];
return docs.find(doc => doc.id === selectedStandardDocId) || null;
}, [selectedStandardDocId, selectedSubItemId]);
// 선택된 항목의 완료 여부
const isSelectedItemCompleted = useMemo(() => {
if (!selectedSubItemId) return false;
for (const cat of day1Categories) {
const item = cat.subItems.find(item => item.id === selectedSubItemId);
if (item) return item.isCompleted;
}
return false;
}, [day1Categories, selectedSubItemId]);
// ===== 2일차(로트추적) 로직 =====
const filteredReports = useMemo(() => {
return MOCK_REPORTS.filter((report) => {
if (report.year !== selectedYear) return false;
if (selectedQuarter !== '전체') {
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
if (report.quarterNum !== quarterNum) return false;
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
const matchesCode = report.code.toLowerCase().includes(term);
const matchesSite = report.siteName.toLowerCase().includes(term);
const matchesItem = report.item.toLowerCase().includes(term);
if (!matchesCode && !matchesSite && !matchesItem) return false;
}
return true;
});
}, [selectedYear, selectedQuarter, searchTerm]);
const currentRoutes = useMemo(() => {
if (!selectedReport) return [];
return routesData[selectedReport.id] || [];
}, [selectedReport, routesData]);
const currentDocuments = useMemo(() => {
if (!selectedRoute) return DEFAULT_DOCUMENTS;
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
}, [selectedRoute]);
const handleReportSelect = (report: InspectionReport) => {
setSelectedReport(report);
setSelectedRoute(null);
};
const handleRouteSelect = (route: RouteItem) => {
setSelectedRoute(route);
};
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
setSelectedDoc(doc);
setSelectedDocItem(item || null);
setModalOpen(true);
};
const handleYearChange = (year: number) => {
setSelectedYear(year);
setSelectedReport(null);
setSelectedRoute(null);
};
const handleQuarterChange = (quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
setSelectedQuarter(quarter);
setSelectedReport(null);
setSelectedRoute(null);
};
const handleSearchChange = (term: string) => {
setSearchTerm(term);
};
// ===== 2일차 개소별 완료 토글 =====
const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => {
setRoutesData(prev => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map(route => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map(item => {
if (item.id !== itemId) return item;
return { ...item, isCompleted };
}),
};
});
}
return newData;
});
}, []);
}, [categories, displaySettings.showCompletedItems]);
return (
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
{/* 헤더 (설정 버튼 포함) */}
<Header
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
@@ -283,9 +138,9 @@ export default function QualityInspectionPage() {
{activeDay === 1 ? (
// ===== 기준/매뉴얼 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
{/* 좌측: 점검표 항목 */}
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
? 'lg:col-span-3'
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
@@ -298,12 +153,13 @@ export default function QualityInspectionPage() {
searchTerm={searchTerm}
onSubItemSelect={handleSubItemSelect}
onSubItemToggle={handleSubItemToggle}
isMock={day1IsMock}
/>
</div>
{/* 중앙: 기준 문서화 */}
{displaySettings.showDocumentSection && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
}`}>
<Day1DocumentSection
@@ -312,45 +168,49 @@ export default function QualityInspectionPage() {
onDocumentSelect={setSelectedStandardDocId}
onConfirmComplete={handleConfirmComplete}
isCompleted={isSelectedItemCompleted}
isMock={day1IsMock}
/>
</div>
)}
{/* 우측: 문서 뷰어 */}
{displaySettings.showDocumentViewer && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
}`}>
<Day1DocumentViewer document={selectedStandardDoc} />
<Day1DocumentViewer document={selectedStandardDoc} isMock={day1IsMock} />
</div>
)}
</div>
) : (
// ===== 로트 추적 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
<ReportList
reports={filteredReports}
selectedId={selectedReport?.id || null}
onSelect={handleReportSelect}
isMock={day2IsMock}
/>
</div>
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<RouteList
routes={currentRoutes}
selectedId={selectedRoute?.id || null}
onSelect={handleRouteSelect}
onToggleItem={handleToggleItem}
reportCode={selectedReport?.code || null}
isMock={day2IsMock}
/>
</div>
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<DocumentList
documents={currentDocuments}
routeCode={selectedRoute?.code || null}
onViewDocument={handleViewDocument}
isMock={day2IsMock}
/>
</div>
</div>

View File

@@ -30,6 +30,7 @@ import {
Circle,
Activity,
Play,
ChevronDown,
} from "lucide-react";
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
@@ -47,143 +48,17 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { toast } from "sonner";
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
import { formatNumber } from '@/lib/utils/amount';
// 생산지시 상태 타입
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
// 작업지시 상태 타입
type WorkOrderStatus = "pending" | "in_progress" | "completed";
// 작업지시 데이터 타입
interface WorkOrder {
id: string;
workOrderNumber: string; // KD-WO-XXXXXX-XX
process: string; // 공정명
quantity: number;
status: WorkOrderStatus;
assignee: string;
}
// 생산지시 상세 데이터 타입
interface ProductionOrderDetail {
id: string;
productionOrderNumber: string;
orderNumber: string;
productionOrderDate: string;
dueDate: string;
quantity: number;
status: ProductionOrderStatus;
client: string;
siteName: string;
productType: string;
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
workOrders: WorkOrder[];
}
// 샘플 생산지시 상세 데이터
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
"PO-001": {
id: "PO-001",
productionOrderNumber: "PO-KD-TS-251217-07",
orderNumber: "KD-TS-251217-07",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-15",
quantity: 2,
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
client: "호반건설(주)",
siteName: "씨밋 광교 센트럴시티",
productType: "",
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
workOrders: [
{
id: "WO-001",
workOrderNumber: "KD-WO-251217-07",
process: "재단",
quantity: 2,
status: "completed",
assignee: "-",
},
{
id: "WO-002",
workOrderNumber: "KD-WO-251217-08",
process: "조립",
quantity: 2,
status: "completed",
assignee: "-",
},
{
id: "WO-003",
workOrderNumber: "KD-WO-251217-09",
process: "검수",
quantity: 2,
status: "completed",
assignee: "-",
},
],
},
"PO-002": {
id: "PO-002",
productionOrderNumber: "PO-KD-TS-251217-09",
orderNumber: "KD-TS-251217-09",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-10",
quantity: 10,
status: "waiting",
client: "태영건설(주)",
siteName: "데시앙 동탄 파크뷰",
productType: "",
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
workOrders: [],
},
"PO-003": {
id: "PO-003",
productionOrderNumber: "PO-KD-TS-251217-06",
orderNumber: "KD-TS-251217-06",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-10",
quantity: 1,
status: "waiting",
client: "롯데건설(주)",
siteName: "예술 검실 푸르지오",
productType: "",
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
workOrders: [],
},
"PO-004": {
id: "PO-004",
productionOrderNumber: "PO-KD-BD-251220-35",
orderNumber: "KD-BD-251220-35",
productionOrderDate: "2025-12-20",
dueDate: "2026-02-03",
quantity: 3,
status: "in_progress",
client: "현대건설(주)",
siteName: "[코레타스프] 판교 물류센터 철거현장",
productType: "",
pendingWorkOrderCount: 0,
workOrders: [
{
id: "WO-004",
workOrderNumber: "KD-WO-251220-01",
process: "재단",
quantity: 3,
status: "completed",
assignee: "-",
},
{
id: "WO-005",
workOrderNumber: "KD-WO-251220-02",
process: "조립",
quantity: 3,
status: "in_progress",
assignee: "-",
},
],
},
};
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
import { createProductionOrder } from "@/components/orders/actions";
import type {
ProductionOrderDetail,
ProductionStatus,
ProductionWorkOrder,
BomProcessGroup,
} from "@/components/production/ProductionOrders/types";
// 공정 진행 현황 컴포넌트
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
if (workOrders.length === 0) {
return (
<Card>
@@ -202,7 +77,9 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
);
}
const completedCount = workOrders.filter((w) => w.status === "completed").length;
const completedCount = workOrders.filter(
(w) => w.status === "completed" || w.status === "shipped"
).length;
const totalCount = workOrders.length;
const progressPercent = Math.round((completedCount / totalCount) * 100);
@@ -237,25 +114,27 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
<div className="flex flex-col items-center gap-1">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full ${
wo.status === "completed"
wo.status === "completed" || wo.status === "shipped"
? "bg-green-500 text-white"
: wo.status === "in_progress"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-400"
}`}
>
{wo.status === "completed" ? (
{wo.status === "completed" || wo.status === "shipped" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
</div>
<span className="text-xs text-muted-foreground">{wo.process}</span>
<span className="text-xs text-muted-foreground">{wo.processName}</span>
</div>
{index < workOrders.length - 1 && (
<div
className={`w-12 h-0.5 mx-1 ${
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
wo.status === "completed" || wo.status === "shipped"
? "bg-green-500"
: "bg-gray-200"
}`}
/>
)}
@@ -269,13 +148,13 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
}
// 상태 배지 헬퍼
function getStatusBadge(status: ProductionOrderStatus) {
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
function getStatusBadge(status: ProductionStatus) {
const config: Record<ProductionStatus, { label: string; className: string }> = {
waiting: {
label: "생산대기",
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
in_progress: {
in_production: {
label: "생산중",
className: "bg-green-100 text-green-700 border-green-200",
},
@@ -289,22 +168,16 @@ function getStatusBadge(status: ProductionOrderStatus) {
}
// 작업지시 상태 배지 헬퍼
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
pending: {
label: "대기",
className: "bg-gray-100 text-gray-700 border-gray-200",
},
in_progress: {
label: "작업중",
className: "bg-blue-100 text-blue-700 border-blue-200",
},
completed: {
label: "완료",
className: "bg-green-100 text-green-700 border-green-200",
},
function getWorkOrderStatusBadge(status: string) {
const config: Record<string, { label: string; className: string }> = {
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
};
const c = config[status];
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
}
@@ -318,99 +191,33 @@ function InfoItem({ label, value }: { label: string; value: string }) {
);
}
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
const SAMPLE_PROCESSES = [
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
{ id: "P3", name: "3.1 케이스", quantity: 10 },
{ id: "P4", name: "4. 연기단자", quantity: 10 },
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
];
// BOM 품목 타입
interface BomItem {
id: string;
itemCode: string;
itemName: string;
spec: string;
lotNo: string;
requiredQty: number;
qty: number;
}
// BOM 공정 분류 타입
interface BomProcessGroup {
processName: string;
sizeSpec?: string;
items: BomItem[];
}
// BOM 품목별 공정 분류 목데이터
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
{
processName: "1.1 백판필름",
sizeSpec: "[20-70]",
items: [
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
],
},
{
processName: "2. 하안마감재",
sizeSpec: "[60-40]",
items: [
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
],
},
{
processName: "3.1 케이스",
sizeSpec: "[500*330]",
items: [
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
],
},
{
processName: "4. 연기단자",
sizeSpec: "",
items: [
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
],
},
];
export default function ProductionOrderDetailPage() {
const router = useRouter();
const params = useParams();
const productionOrderId = params.id as string;
const orderId = params.id as string;
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
const [loading, setLoading] = useState(true);
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [bomOpen, setBomOpen] = useState(false);
// 데이터 로드
const loadDetail = async () => {
setLoading(true);
const result = await getProductionOrderDetail(orderId);
if (result.success && result.data) {
setDetail(result.data);
} else {
setDetail(null);
}
setLoading(false);
};
useEffect(() => {
setTimeout(() => {
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
setProductionOrder(found || null);
setLoading(false);
}, 300);
}, [productionOrderId]);
loadDetail();
}, [orderId]);
const handleBack = () => {
router.push("/sales/order-management-sales/production-orders");
@@ -423,19 +230,13 @@ export default function ProductionOrderDetailPage() {
const handleConfirmCreateWorkOrder = async () => {
setIsCreating(true);
try {
// API 호출 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 500));
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
const created = Array.from({ length: workOrderCount }, (_, i) =>
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
);
setCreatedWorkOrders(created);
// 확인 팝업 닫고 성공 팝업 열기
setIsCreateWorkOrderDialogOpen(false);
setIsSuccessDialogOpen(true);
const result = await createProductionOrder(orderId);
if (result.success) {
setIsCreateWorkOrderDialogOpen(false);
setIsSuccessDialogOpen(true);
} else {
toast.error(result.error || "작업지시 생성에 실패했습니다.");
}
} finally {
setIsCreating(false);
}
@@ -457,7 +258,7 @@ export default function ProductionOrderDetailPage() {
);
}
if (!productionOrder) {
if (!detail) {
return (
<ServerErrorPage
title="생산지시 정보를 불러올 수 없습니다"
@@ -468,6 +269,9 @@ export default function ProductionOrderDetailPage() {
);
}
const hasWorkOrders = detail.workOrders.length > 0;
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
return (
<PageLayout>
{/* 헤더 */}
@@ -476,9 +280,9 @@ export default function ProductionOrderDetailPage() {
<div className="flex items-center gap-3">
<span> </span>
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
{productionOrder.productionOrderNumber}
{detail.orderNumber}
</code>
{getStatusBadge(productionOrder.status)}
{getStatusBadge(detail.productionStatus)}
</div>
}
icon={Factory}
@@ -488,10 +292,7 @@ export default function ProductionOrderDetailPage() {
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
{productionOrder.status !== "completed" &&
productionOrder.workOrders.length === 0 &&
productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<Button onClick={handleCreateWorkOrder}>
<ClipboardList className="h-4 w-4 mr-2" />
@@ -503,7 +304,7 @@ export default function ProductionOrderDetailPage() {
<div className="space-y-6">
{/* 공정 진행 현황 */}
<ProcessProgress workOrders={productionOrder.workOrders} />
<ProcessProgress workOrders={detail.workOrders} />
{/* 기본 정보 & 거래처/현장 정보 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -514,11 +315,10 @@ export default function ProductionOrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
<InfoItem label="납기일" value={productionOrder.dueDate} />
<InfoItem label="수량" value={`${productionOrder.quantity}`} />
<InfoItem label="수주번호" value={detail.orderNumber} />
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
<InfoItem label="납기일" value={detail.deliveryDate} />
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
</div>
</CardContent>
</Card>
@@ -530,112 +330,108 @@ export default function ProductionOrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="거래처" value={productionOrder.client} />
<InfoItem label="현장명" value={productionOrder.siteName} />
<InfoItem label="제품유형" value={productionOrder.productType} />
<InfoItem label="거래처" value={detail.clientName} />
<InfoItem label="현장명" value={detail.siteName} />
</div>
</CardContent>
</Card>
</div>
{/* BOM 품목별 공정 분류 */}
<Card>
<CardHeader>
<CardTitle className="text-base">BOM </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 절곡 부품 전개도 정보 헤더 */}
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
</p>
{/* 공정별 테이블 */}
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
<div key={group.processName} className="space-y-2">
{/* 공정명 헤더 */}
<h4 className="text-sm font-semibold">
{group.processName}
{group.sizeSpec && (
<span className="ml-2 text-muted-foreground font-normal">
{group.sizeSpec}
</span>
)}
</h4>
{/* BOM 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>LOT NO</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center font-medium">
{item.itemCode}
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.spec || "-"}
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.lotNo}
</code>
</TableCell>
<TableCell className="text-right">
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
</TableCell>
<TableCell className="text-center">{item.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* BOM 품목별 공정 분류 (접이식) */}
{detail.bomProcessGroups.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setBomOpen((prev) => !prev)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
BOM
<span className="ml-2 text-sm font-normal text-muted-foreground">
({detail.bomProcessGroups.length} )
</span>
</CardTitle>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${
bomOpen ? "rotate-180" : ""
}`}
/>
</div>
))}
</CardHeader>
{bomOpen && (
<CardContent className="space-y-6 pt-0">
{detail.bomProcessGroups.map((group) => (
<div key={group.processName} className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
<Badge variant="outline">{group.processName}</Badge>
<span className="text-muted-foreground font-normal text-xs">
{group.items.length}
</span>
</h4>
{/* 합계 정보 */}
<div className="flex justify-between items-center pt-4 border-t text-sm">
<span className="text-muted-foreground"> 종류: 18개</span>
<span className="text-muted-foreground"> 중량: 25.8 kg</span>
<span className="text-muted-foreground">비고: VT칼 </span>
</div>
</CardContent>
</Card>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, idx) => (
<TableRow key={`${item.id}-${idx}`}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.spec || "-"}
</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
))}
</CardContent>
)}
</Card>
)}
{/* 작업지시서 목록 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
{productionOrder.status !== "completed" &&
productionOrder.workOrders.length === 0 &&
productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<Button onClick={handleCreateWorkOrder}>
<Play className="h-4 w-4 mr-2" />
{productionOrder.pendingWorkOrderCount > 1
? "작업지시 일괄생성"
: "작업지시 생성"}
</Button>
)}
</CardHeader>
<CardContent>
{productionOrder.workOrders.length === 0 ? (
{!hasWorkOrders ? (
<div className="text-center py-8">
<div className="flex flex-col items-center gap-2">
<ClipboardList className="h-12 w-12 text-gray-300" />
<p className="text-muted-foreground text-sm">
.
</p>
{productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<p className="text-sm text-muted-foreground">
BOM .
</p>
@@ -649,23 +445,23 @@ export default function ProductionOrderDetailPage() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productionOrder.workOrders.map((wo) => (
{detail.workOrders.map((wo) => (
<TableRow key={wo.id}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{wo.workOrderNumber}
{wo.workOrderNo}
</code>
</TableCell>
<TableCell>{wo.process}</TableCell>
<TableCell className="text-center">{wo.quantity}</TableCell>
<TableCell>{wo.processName}</TableCell>
<TableCell className="text-center">{wo.quantity}</TableCell>
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
<TableCell>{wo.assignee}</TableCell>
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -676,7 +472,7 @@ export default function ProductionOrderDetailPage() {
</Card>
</div>
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
{/* 작업지시 생성 확인 다이얼로그 */}
<ConfirmDialog
open={isCreateWorkOrderDialogOpen}
onOpenChange={setIsCreateWorkOrderDialogOpen}
@@ -685,19 +481,10 @@ export default function ProductionOrderDetailPage() {
description={
<div className="space-y-4 pt-2">
<p className="font-medium text-foreground">
:
.
</p>
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
<li key={process.id} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
{process.name} ({process.quantity})
</li>
))}
</ul>
)}
<p className="text-muted-foreground">
BOM .
.
</p>
</div>
@@ -706,7 +493,7 @@ export default function ProductionOrderDetailPage() {
loading={isCreating}
/>
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
{/* 작업지시 생성 성공 다이얼로그 */}
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
@@ -716,24 +503,9 @@ export default function ProductionOrderDetailPage() {
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-medium text-foreground">
{createdWorkOrders.length} .
.
</span>
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2"> :</p>
{createdWorkOrders.length > 0 ? (
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
{createdWorkOrders.map((wo, idx) => (
<li key={wo} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
{wo}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</div>
<p className="text-muted-foreground">
.
</p>
@@ -749,4 +521,4 @@ export default function ProductionOrderDetailPage() {
</AlertDialog>
</PageLayout>
);
}
}

View File

@@ -4,24 +4,20 @@
* 생산지시 목록 페이지
*
* - 수주관리 > 생산지시 보기에서 접근
* - 진행 단계 바
* - 진행 단계 바 (Order 상태 기반 동적)
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
* - IntegratedListTemplateV2 템플릿 적용
* - 서버사이드 페이지네이션
*/
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -29,7 +25,6 @@ import {
ArrowLeft,
CheckCircle2,
Eye,
Trash2,
} from "lucide-react";
import {
UniversalListPage,
@@ -39,136 +34,63 @@ import {
} from "@/components/templates/UniversalListPage";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
// 생산지시 상태 타입
type ProductionOrderStatus =
| "waiting" // 생산대기
| "in_progress" // 생산중
| "completed"; // 생산완료
// 생산지시 데이터 타입
interface ProductionOrder {
id: string;
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
orderNumber: string; // KD-TS-XXXXXX-XX
siteName: string;
client: string;
quantity: number;
dueDate: string;
productionOrderDate: string;
status: ProductionOrderStatus;
workOrderCount: number;
}
// 샘플 생산지시 데이터
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
{
id: "PO-001",
productionOrderNumber: "PO-KD-TS-251217-07",
orderNumber: "KD-TS-251217-07",
siteName: "씨밋 광교 센트럴시티",
client: "호반건설(주)",
quantity: 2,
dueDate: "2026-02-15",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 3,
},
{
id: "PO-002",
productionOrderNumber: "PO-KD-TS-251217-09",
orderNumber: "KD-TS-251217-09",
siteName: "데시앙 동탄 파크뷰",
client: "태영건설(주)",
quantity: 10,
dueDate: "2026-02-10",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 0,
},
{
id: "PO-003",
productionOrderNumber: "PO-KD-TS-251217-06",
orderNumber: "KD-TS-251217-06",
siteName: "예술 검실 푸르지오",
client: "롯데건설(주)",
quantity: 1,
dueDate: "2026-02-10",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 0,
},
{
id: "PO-004",
productionOrderNumber: "PO-KD-BD-251220-35",
orderNumber: "KD-BD-251220-35",
siteName: "[코레타스프] 판교 물류센터 철거현장",
client: "현대건설(주)",
quantity: 3,
dueDate: "2026-02-03",
productionOrderDate: "2025-12-20",
status: "in_progress",
workOrderCount: 2,
},
{
id: "PO-005",
productionOrderNumber: "PO-KD-BD-251219-34",
orderNumber: "KD-BD-251219-34",
siteName: "[코레타스프1] 김포 6차 필라테스장",
client: "신성플랜(주)",
quantity: 2,
dueDate: "2026-01-15",
productionOrderDate: "2025-12-19",
status: "in_progress",
workOrderCount: 3,
},
{
id: "PO-006",
productionOrderNumber: "PO-KD-TS-250401-29",
orderNumber: "KD-TS-250401-29",
siteName: "포레나 전주",
client: "한화건설(주)",
quantity: 2,
dueDate: "2025-05-16",
productionOrderDate: "2025-04-01",
status: "completed",
workOrderCount: 3,
},
{
id: "PO-007",
productionOrderNumber: "PO-KD-BD-250331-28",
orderNumber: "KD-BD-250331-28",
siteName: "포레나 수원",
client: "포레나건설(주)",
quantity: 4,
dueDate: "2025-05-15",
productionOrderDate: "2025-03-31",
status: "completed",
workOrderCount: 3,
},
{
id: "PO-008",
productionOrderNumber: "PO-KD-TS-250314-23",
orderNumber: "KD-TS-250314-23",
siteName: "자이 흑산파크",
client: "GS건설(주)",
quantity: 3,
dueDate: "2025-04-28",
productionOrderDate: "2025-03-14",
status: "completed",
workOrderCount: 3,
},
];
import {
getProductionOrders,
getProductionOrderStats,
} from "@/components/production/ProductionOrders/actions";
import type {
ProductionOrder,
ProductionStatus,
ProductionOrderStats,
} from "@/components/production/ProductionOrders/types";
import { formatNumber } from '@/lib/utils/amount';
// 진행 단계 컴포넌트
function ProgressSteps() {
const steps = [
{ label: "수주확정", active: true, completed: true },
{ label: "생산지시", active: true, completed: false },
{ label: "작업지시", active: false, completed: false },
{ label: "생산", active: false, completed: false },
{ label: "검사출하", active: false, completed: false },
];
function ProgressSteps({ statusCode }: { statusCode?: string }) {
const getSteps = () => {
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
const steps = [
{ label: "수주확정", completed: true, active: false },
{ label: "생산지시", completed: true, active: false },
{ label: "작업지시", completed: false, active: false },
{ label: "생산", completed: false, active: false },
{ label: "검사출하", completed: false, active: false },
];
if (!statusCode) return steps;
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
if (statusCode === "IN_PROGRESS") {
steps[2].active = true;
}
// IN_PRODUCTION = 생산중
if (statusCode === "IN_PRODUCTION") {
steps[2].completed = true;
steps[3].active = true;
}
// PRODUCED = 생산완료
if (statusCode === "PRODUCED") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].active = true;
}
// SHIPPING = 출하중
if (statusCode === "SHIPPING") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].active = true;
}
// SHIPPED = 출하완료
if (statusCode === "SHIPPED") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].completed = true;
}
return steps;
};
const steps = getSteps();
return (
<div className="flex items-center justify-center gap-2 py-4">
@@ -214,16 +136,16 @@ function ProgressSteps() {
}
// 상태 배지 헬퍼
function getStatusBadge(status: ProductionOrderStatus) {
function getStatusBadge(status: ProductionStatus) {
const config: Record<
ProductionOrderStatus,
ProductionStatus,
{ label: string; className: string }
> = {
waiting: {
label: "생산대기",
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
in_progress: {
in_production: {
label: "생산중",
className: "bg-green-100 text-green-700 border-green-200",
},
@@ -239,13 +161,12 @@ function getStatusBadge(status: ProductionOrderStatus) {
// 테이블 컬럼 정의
const TABLE_COLUMNS: TableColumn[] = [
{ key: "no", label: "번호", className: "w-[60px] text-center" },
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
{ key: "orderNumber", label: "수주번호", className: "min-w-[150px]" },
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
{ key: "client", label: "거래처", className: "min-w-[120px]" },
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
{ key: "dueDate", label: "납기", className: "w-[110px]" },
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
{ key: "clientName", label: "거래처", className: "min-w-[120px]" },
{ key: "nodeCount", label: "개소", className: "w-[80px] text-center" },
{ key: "deliveryDate", label: "납기", className: "w-[110px]" },
{ key: "productionOrderedAt", label: "생산지시일", className: "w-[110px]" },
{ key: "status", label: "상태", className: "w-[100px]" },
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
@@ -253,65 +174,21 @@ const TABLE_COLUMNS: TableColumn[] = [
export default function ProductionOrdersListPage() {
const router = useRouter();
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 삭제 확인 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
// 필터링된 데이터
const filteredData = orders.filter((item) => {
// 탭 필터
if (activeTab !== "all") {
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
if (item.status !== statusMap[activeTab]) return false;
}
// 검색 필터
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
}
return true;
const [stats, setStats] = useState<ProductionOrderStats>({
total: 0,
waiting: 0,
in_production: 0,
completed: 0,
});
// 페이지네이션
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
const paginatedData = filteredData.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// 탭별 건수
const tabCounts = {
all: orders.length,
waiting: orders.filter((i) => i.status === "waiting").length,
in_progress: orders.filter((i) => i.status === "in_progress").length,
completed: orders.filter((i) => i.status === "completed").length,
};
// 탭 옵션
const tabs: TabOption[] = [
{ value: "all", label: "전체", count: tabCounts.all },
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
];
// 통계 로드
useEffect(() => {
getProductionOrderStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}, []);
const handleBack = () => {
router.push("/sales/order-management-sales");
@@ -325,57 +202,13 @@ export default function ProductionOrdersListPage() {
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
};
// 개별 삭제 다이얼로그 열기
const handleDelete = (item: ProductionOrder) => {
setDeleteTargetId(item.id);
setShowDeleteDialog(true);
};
// 체크박스 선택
const toggleSelection = (id: string) => {
const newSelection = new Set(selectedItems);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
setSelectedItems(newSelection);
};
const toggleSelectAll = () => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
};
// 일괄 삭제 다이얼로그 열기
const handleBulkDelete = () => {
if (selectedItems.size > 0) {
setDeleteTargetId(null); // 일괄 삭제
setShowDeleteDialog(true);
}
};
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
// 실제 삭제 실행
const handleConfirmDelete = () => {
if (deleteTargetId) {
// 개별 삭제
setOrders(orders.filter((o) => o.id !== deleteTargetId));
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
} else {
// 일괄 삭제
const selectedIds = Array.from(selectedItems);
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
setSelectedItems(new Set());
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
};
// 탭 옵션 (통계 기반 동적 카운트)
const tabs: TabOption[] = [
{ value: "all", label: "전체", count: stats.total },
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
];
// 테이블 행 렌더링
const renderTableRow = (
@@ -402,22 +235,17 @@ export default function ProductionOrdersListPage() {
</TableCell>
<TableCell>
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
{item.productionOrderNumber}
</code>
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.orderNumber}
</code>
</TableCell>
<TableCell className="max-w-[200px] truncate">
{item.siteName}
</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell>{item.dueDate}</TableCell>
<TableCell>{item.productionOrderDate}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell>{item.clientName}</TableCell>
<TableCell className="text-center">{formatNumber(item.nodeCount)}</TableCell>
<TableCell>{item.deliveryDate}</TableCell>
<TableCell>{item.productionOrderedAt}</TableCell>
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
<TableCell className="text-center">
{item.workOrderCount > 0 ? (
<Badge variant="outline">{item.workOrderCount}</Badge>
@@ -431,9 +259,6 @@ export default function ProductionOrdersListPage() {
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
@@ -463,19 +288,19 @@ export default function ProductionOrdersListPage() {
variant="outline"
className="bg-blue-50 text-blue-700 font-mono text-xs"
>
{item.productionOrderNumber}
{item.orderNumber}
</Badge>
{getStatusBadge(item.status)}
{getStatusBadge(item.productionStatus)}
</>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="수주번호" value={item.orderNumber} />
<InfoField label="현장명" value={item.siteName} />
<InfoField label="거래처" value={item.client} />
<InfoField label="수량" value={`${item.quantity}`} />
<InfoField label="납기" value={item.dueDate} />
<InfoField label="생산지시일" value={item.productionOrderDate} />
<InfoField label="거래처" value={item.clientName} />
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}`} />
<InfoField label="납기" value={item.deliveryDate} />
<InfoField label="생산지시일" value={item.productionOrderedAt} />
<InfoField
label="작업지시"
value={item.workOrderCount > 0 ? `${item.workOrderCount}` : "-"}
@@ -497,18 +322,6 @@ export default function ProductionOrdersListPage() {
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
@@ -516,6 +329,43 @@ export default function ProductionOrdersListPage() {
);
};
// getList API 호출
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
const productionStatus = params?.tab && params.tab !== "all"
? (params.tab as ProductionStatus)
: undefined;
const result = await getProductionOrders({
search: params?.search,
productionStatus,
page: params?.page,
perPage: params?.pageSize,
});
if (result.success) {
// 통계 새로고침
getProductionOrderStats().then((statsResult) => {
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
});
return {
success: true,
data: result.data,
totalCount: result.pagination?.total || 0,
totalPages: result.pagination?.lastPage || 1,
};
}
return {
success: false,
data: [],
totalCount: 0,
error: result.error,
};
}, []);
// ===== UniversalListPage 설정 =====
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
title: "생산지시 목록",
@@ -525,43 +375,19 @@ export default function ProductionOrdersListPage() {
idField: "id",
actions: {
getList: async () => ({
success: true,
data: orders,
totalCount: orders.length,
}),
getList,
},
columns: TABLE_COLUMNS,
tabs: tabs,
defaultTab: activeTab,
defaultTab: "all",
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
itemsPerPage,
itemsPerPage: 20,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const term = searchValue.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
},
tabFilter: (item, tabValue) => {
if (tabValue === "all") return true;
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
return item.status === statusMap[tabValue];
},
clientSideFiltering: false,
headerActions: () => (
<Button variant="outline" onClick={handleBack}>
@@ -580,50 +406,11 @@ export default function ProductionOrdersListPage() {
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="삭제 확인"
description={
<>
<strong>{deleteCount}</strong> ?
<br />
<span className="text-muted-foreground text-sm">
. .
</span>
</>
}
/>
),
};
return (
<UniversalListPage<ProductionOrder>
config={productionOrderConfig}
initialData={orders}
initialTotalCount={orders.length}
externalSelection={{
selectedItems,
onToggleSelection: toggleSelection,
onToggleSelectAll: toggleSelectAll,
setSelectedItems,
getItemId: (item: ProductionOrder) => item.id,
}}
onTabChange={(value: string) => {
setActiveTab(value);
setCurrentPage(1);
}}
onSearchChange={setSearchTerm}
externalPagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
);
}
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { transformApiMenusToMenuItems } from '@/lib/utils/menuTransform';
import { performFullLogout } from '@/lib/auth/logout';
/**
* MNG 관리자 패널 → SAM 자동 로그인 페이지
*
* 흐름:
* 1. MNG에서 "SAM 접속" 클릭 → /auto-login?token=xxx 로 새 창 열림
* 2. 기존 세션 로그아웃 (쿠키 + localStorage + Zustand 초기화)
* 3. One-Time Token으로 API 호출 → 새 세션 생성
* 4. 사용자 정보 저장 후 /dashboard로 이동
*/
export default function AutoLoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<'processing' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setStatus('error');
setErrorMessage('로그인 토큰이 없습니다.');
return;
}
const performAutoLogin = async () => {
try {
// 1. 기존 세션 완전 로그아웃 (쿠키 삭제 + 스토어 초기화)
await performFullLogout({ skipServerLogout: false });
// 2. One-Time Token으로 로그인
const response = await fetch('/api/auth/token-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '자동 로그인에 실패했습니다.');
}
// 3. 사용자 정보 localStorage 저장 (LoginPage와 동일 패턴)
const transformedMenus = transformApiMenusToMenuItems(data.menus || []);
const userData = {
id: data.user?.id,
name: data.user?.name,
position: data.roles?.[0]?.description || '사용자',
userId: data.user?.user_id,
department: data.user?.department || null,
department_id: data.user?.department_id || null,
menu: transformedMenus,
roles: data.roles || [],
tenant: data.tenant || {},
};
localStorage.setItem('user', JSON.stringify(userData));
// 4. persist store rehydrate
const { useFavoritesStore } = await import('@/stores/favoritesStore');
const { useTableColumnStore } = await import('@/stores/useTableColumnStore');
useFavoritesStore.persist.rehydrate();
useTableColumnStore.persist.rehydrate();
// 5. 로그인 플래그 설정
sessionStorage.setItem('auth_just_logged_in', 'true');
// 6. 대시보드로 이동
router.push('/dashboard');
} catch (err) {
console.error('자동 로그인 실패:', err);
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : '자동 로그인에 실패했습니다.');
}
};
performAutoLogin();
}, [searchParams, router]);
if (status === 'error') {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">
<div className="text-destructive text-lg font-semibold"> </div>
<p className="text-muted-foreground">{errorMessage}</p>
<button
onClick={() => router.push('/login')}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition"
>
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* 🔵 Next.js 내부 API - 토큰 자동 로그인 프록시
*
* MNG 관리자 패널에서 "SAM 접속" 버튼 클릭 시 사용
* One-Time Token으로 사용자 인증 후 HttpOnly 쿠키 설정
*
* 🔄 동작 흐름:
* 1. 클라이언트 → Next.js /api/auth/token-login (token)
* 2. Next.js → PHP /api/v1/token-login (토큰 검증)
* 3. PHP → Next.js (access_token, refresh_token, 사용자 정보)
* 4. Next.js: 토큰을 HttpOnly 쿠키로 설정
* 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token } = body;
if (!token) {
return NextResponse.json(
{ error: '토큰이 필요합니다.' },
{ status: 400 }
);
}
// PHP 백엔드 API 호출
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/token-login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({ token }),
});
if (!backendResponse.ok) {
const errorData = await backendResponse.json().catch(() => ({}));
return NextResponse.json(
{ error: errorData.error || '토큰 인증에 실패했습니다.' },
{ status: backendResponse.status }
);
}
const data = await backendResponse.json();
// 클라이언트에 전달할 응답 (토큰 제외)
const responseData = {
message: data.message,
user: data.user,
tenant: data.tenant,
menus: data.menus,
roles: data.roles,
token_type: data.token_type,
expires_in: data.expires_in,
expires_at: data.expires_at,
};
// HttpOnly 쿠키 설정 (login/route.ts와 동일한 패턴)
const isProduction = process.env.NODE_ENV === 'production';
const accessTokenCookie = [
`access_token=${data.access_token}`,
'HttpOnly',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
const refreshTokenCookie = [
`refresh_token=${data.refresh_token}`,
'HttpOnly',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
'Max-Age=604800',
].join('; ');
const isAuthenticatedCookie = [
'is_authenticated=true',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
const response = NextResponse.json(responseData, { status: 200 });
response.headers.append('Set-Cookie', accessTokenCookie);
response.headers.append('Set-Cookie', refreshTokenCookie);
response.headers.append('Set-Cookie', isAuthenticatedCookie);
return response;
} catch (error) {
console.error('Token login proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
deviceScaleFactor: 2,
});
// HTML 설정
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
req.abort();
} else {
req.continue();
}
});
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
await page.setContent(fullHtml, {
waitUntil: 'networkidle0',
waitUntil: 'domcontentloaded',
});
// 헤더 템플릿 (문서번호, 생성일)

View File

@@ -6,6 +6,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
@@ -39,8 +40,10 @@ import type {
} from './types';
import {
STATUS_SELECT_OPTIONS,
COLLECTION_END_REASON_OPTIONS,
VENDOR_TYPE_LABELS,
} from './types';
import type { CollectionEndReason } from './types';
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -87,6 +90,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
assignedManagerId: null,
assignedManager: null,
settingToggle: true,
collectionEndReason: undefined,
badDebtCount: 0,
badDebts: [],
files: [],
@@ -134,12 +138,14 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
if (isNewMode) {
const result = await createBadDebt(formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '등록에 실패했습니다.' };
} else {
const result = await updateBadDebt(recordId!, formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '수정에 실패했습니다.' };
@@ -156,6 +162,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
try {
const result = await deleteBadDebt(String(id));
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };
@@ -778,22 +785,47 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
{/* 상태 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Select
value={formData.status}
onValueChange={(val) => handleChange('status', val as CollectionStatus)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{STATUS_SELECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Select
value={formData.status}
onValueChange={(val) => {
handleChange('status', val as CollectionStatus);
if (val !== 'collectionEnd') {
handleChange('collectionEndReason', null);
}
}}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{STATUS_SELECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.status === 'collectionEnd' && (
<Select
value={formData.collectionEndReason || ''}
onValueChange={(val) => handleChange('collectionEndReason', val as CollectionEndReason)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="종료사유 선택" />
</SelectTrigger>
<SelectContent>
{COLLECTION_END_REASON_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* 연체일수 */}
<div className="space-y-2">

View File

@@ -72,8 +72,9 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus {
switch (apiStatus) {
case 'collecting': return 'collecting';
case 'legal_action': return 'legalAction';
case 'recovered': return 'recovered';
case 'bad_debt': return 'badDebt';
case 'recovered':
case 'bad_debt':
case 'collection_end': return 'collectionEnd';
default: return 'collecting';
}
}
@@ -82,8 +83,7 @@ function mapFrontendStatusToApi(status: CollectionStatus): string {
switch (status) {
case 'collecting': return 'collecting';
case 'legalAction': return 'legal_action';
case 'recovered': return 'recovered';
case 'badDebt': return 'bad_debt';
case 'collectionEnd': return 'collection_end';
default: return 'collecting';
}
}

View File

@@ -14,8 +14,10 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { AlertTriangle } from 'lucide-react';
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -56,6 +58,7 @@ const tableColumns = [
{ key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
// ===== Props 타입 정의 =====
@@ -65,8 +68,7 @@ interface BadDebtCollectionProps {
total_amount: number;
collecting_amount: number;
legal_action_amount: number;
recovered_amount: number;
bad_debt_amount: number;
collection_end_amount: number;
} | null;
}
@@ -132,7 +134,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
totalAmount: initialSummary.total_amount,
collectingAmount: initialSummary.collecting_amount,
legalActionAmount: initialSummary.legal_action_amount,
recoveredAmount: initialSummary.recovered_amount,
collectionEndAmount: initialSummary.collection_end_amount,
};
}
@@ -144,11 +146,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
const legalActionAmount = data
.filter((d) => d.status === 'legalAction')
.reduce((sum, d) => sum + d.debtAmount, 0);
const recoveredAmount = data
.filter((d) => d.status === 'recovered')
const collectionEndAmount = data
.filter((d) => d.status === 'collectionEnd')
.reduce((sum, d) => sum + d.debtAmount, 0);
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount };
}, [data, initialSummary]);
// ===== UniversalListPage Config =====
@@ -175,6 +177,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
deleteItem: async (id: string) => {
const result = await deleteBadDebt(id);
if (result.success) {
invalidateDashboard('badDebt');
setData((prev) => prev.filter((item) => item.id !== id));
}
return { success: result.success, error: result.error };
@@ -335,7 +338,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
},
{
label: '회수완료',
value: `${formatNumber(statsData.recoveredAmount)}`,
value: `${formatNumber(statsData.collectionEndAmount)}`,
icon: AlertTriangle,
iconColor: 'text-green-500',
},
@@ -390,6 +393,27 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
disabled={isPending}
/>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-700"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
),

View File

@@ -1,7 +1,15 @@
// ===== 악성채권 추심관리 타입 정의 =====
// 추심 상태
export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt';
export type CollectionStatus = 'collecting' | 'legalAction' | 'collectionEnd';
// 추심종료 사유
export type CollectionEndReason = 'recovered' | 'badDebt';
export const COLLECTION_END_REASON_OPTIONS: { value: CollectionEndReason; label: string }[] = [
{ value: 'recovered', label: '회수완료' },
{ value: 'badDebt', label: '대손처리' },
];
// 정렬 옵션
export type SortOption = 'latest' | 'oldest';
@@ -70,6 +78,7 @@ export interface BadDebtRecord {
debtAmount: number; // 총 미수금액
badDebtCount: number; // 악성채권 건수
status: CollectionStatus; // 대표 상태 (가장 최근)
collectionEndReason?: CollectionEndReason; // 추심종료 사유 (status === 'collectionEnd'일 때)
overdueDays: number; // 최대 연체일수
overdueToggle: boolean;
occurrenceDate: string;

View File

@@ -51,12 +51,27 @@ import {
getBankAccountOptions,
getFinancialInstitutions,
batchSaveTransactions,
exportBankTransactionsExcel,
type BankTransactionSummaryData,
} from './actions';
import { TransactionFormModal } from './TransactionFormModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
{ header: '거래일시', key: 'transactionDate', width: 12 },
{ header: '구분', key: 'type', width: 8,
transform: (v) => v === 'deposit' ? '입금' : '출금' },
{ header: '은행명', key: 'bankName', width: 12 },
{ header: '계좌명', key: 'accountName', width: 15 },
{ header: '적요/내용', key: 'note', width: 20 },
{ header: '입금', key: 'depositAmount', width: 14 },
{ header: '출금', key: 'withdrawalAmount', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '취급점', key: 'branch', width: 12 },
{ header: '상대계좌예금주명', key: 'depositorName', width: 18 },
];
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
const tableColumns = [
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
}
}, [localChanges, loadData]);
// 엑셀 다운로드
// 엑셀 다운로드 (프론트 xlsx 생성)
const handleExcelDownload = useCallback(async () => {
try {
const result = await exportBankTransactionsExcel({
startDate,
endDate,
accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter,
});
if (result.success && result.data) {
window.open(result.data.downloadUrl, '_blank');
toast.info('엑셀 파일 생성 중...');
const allData: BankTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getBankTransactionList({
startDate,
endDate,
accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (BankTransaction & Record<string, unknown>)[],
columns: excelColumns,
filename: '계좌입출금내역',
sheetName: '입출금내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);

View File

@@ -1,99 +1,65 @@
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { billConfig } from './billConfig';
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
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 {
BILL_TYPE_OPTIONS,
getBillStatusOptions,
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
BasicInfoSection,
ElectronicBillSection,
ExchangeBillSection,
DiscountInfoSection,
EndorsementSection,
CollectionSection,
HistorySection,
RenewalSection,
RecourseSection,
BuybackSection,
DishonoredSection,
} from './sections';
import { useDetailData } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
mode: 'view' | 'edit' | 'new';
}
// ===== 거래처 타입 =====
interface ClientOption {
id: string;
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetail({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 거래처 목록 =====
// 거래처 목록
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// V8 폼 훅
const {
formData,
updateField,
handleInstrumentTypeChange,
handleDirectionChange,
addInstallment,
removeInstallment,
updateInstallment,
setFormDataFull,
} = useBillForm();
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 조건부 표시 플래그
const conditions = useBillConditions(formData);
// ===== 거래처 목록 로드 =====
// 거래처 목록 로드
useEffect(() => {
async function loadClients() {
const result = await getClients();
@@ -104,41 +70,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
loadClients();
}, []);
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
// API 데이터 로딩 (BillApiData 그대로)
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
(id: string | number) => getBillRaw(String(id)),
[]
);
const {
data: billData,
data: billApiData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
} = useDetailData<BillApiData>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
// ===== 데이터 로드 시 폼에 반영 =====
// API 데이터 → V8 폼 데이터로 변환
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
if (billApiData) {
setFormDataFull(apiDataToFormData(billApiData));
}
}, [billData]);
}, [billApiData, setFormDataFull]);
// ===== 로드 에러 처리 =====
// 로드 에러
useEffect(() => {
if (loadError) {
toast.error(loadError);
@@ -146,43 +101,21 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
}
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
// 유효성 검사
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
if (!formData.billNumber.trim()) return { valid: false, error: '어음번호를 입력해주세요.' };
const vendorId = conditions.isReceived ? formData.vendor : formData.payee;
if (!vendorId) return { valid: false, error: '거래처를 선택해주세요.' };
if (formData.amount <= 0) return { valid: false, error: '금액을 입력해주세요.' };
if (!formData.issueDate) return { valid: false, error: '발행일을 입력해주세요.' };
if (conditions.isBill && !formData.maturityDate) return { valid: false, error: '만기일을 입력해주세요.' };
return { valid: true };
}, [formData]);
}, [formData, conditions.isReceived, conditions.isBill]);
// ===== 제출 상태 =====
// 제출
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const validation = validateForm();
if (!validation.valid) {
@@ -192,28 +125,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
setIsSubmitting(true);
try {
const billData: Partial<BillRecord> = {
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
const vendorName = clients.find(c => c.id === (conditions.isReceived ? formData.vendor : formData.payee))?.name || '';
const apiPayload = transformFormDataToApi(formData, vendorName);
if (isNewMode) {
const result = await createBill(billData);
const result = await createBillRaw(apiPayload);
if (result.success) {
invalidateDashboard('bill');
toast.success('등록되었습니다.');
router.push('/ko/accounting/bills');
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
return { success: false, error: '' };
}
return result;
} else {
return await updateBill(String(billId), billData);
const result = await updateBillRaw(String(billId), apiPayload);
if (result.success) {
invalidateDashboard('bill');
}
return result;
}
} finally {
setIsSubmitting(false);
}
}, [formData, clients, isNewMode, billId, validateForm, router]);
}, [formData, clients, conditions.isReceived, isNewMode, billId, validateForm, router]);
// ===== 삭제 핸들러 (결과만 반환, 토스트/리다이렉트는 IntegratedDetailTemplate에 위임) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
setIsDeleting(true);
try {
@@ -223,284 +158,91 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
}
}, [billId]);
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
date: '',
amount: 0,
note: '',
};
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
const handleRemoveInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
// 폼 콘텐츠 렌더링
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label htmlFor="billNumber">
<span className="text-red-500">*</span>
</Label>
<Input
id="billNumber"
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 1. 기본 정보 */}
<BasicInfoSection
formData={formData}
updateField={updateField}
isViewMode={isViewMode}
clients={clients}
conditions={conditions}
onInstrumentTypeChange={handleInstrumentTypeChange}
onDirectionChange={handleDirectionChange}
/>
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{BILL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2. 전자어음 정보 */}
{conditions.showElectronic && (
<ElectronicBillSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 3. 환어음 정보 */}
{conditions.showExchangeBill && (
<ExchangeBillSection
formData={formData}
updateField={updateField}
isViewMode={isViewMode}
showAcceptanceRefusal={conditions.showAcceptanceRefusal}
/>
)}
{/* 금액 */}
<div className="space-y-2">
<Label htmlFor="amount">
<span className="text-red-500">*</span>
</Label>
<CurrencyInput
id="amount"
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 4. 할인 정보 */}
{conditions.showDiscount && (
<DiscountInfoSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.issueDate}
onChange={(date) => updateField('issueDate', date)}
disabled={isViewMode}
/>
</div>
{/* 5. 배서양도 정보 */}
{conditions.showEndorsement && (
<EndorsementSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.maturityDate}
onChange={(date) => updateField('maturityDate', date)}
disabled={isViewMode}
/>
</div>
{/* 6. 추심 정보 */}
{conditions.showCollection && (
<CollectionSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 7. 이력 관리 (받을어음만) */}
{conditions.isReceived && (
<HistorySection
formData={formData}
updateField={updateField}
isViewMode={isViewMode}
isElectronic={conditions.isElectronic}
maxSplitCount={conditions.maxSplitCount}
onAddInstallment={addInstallment}
onRemoveInstallment={removeInstallment}
onUpdateInstallment={updateInstallment}
/>
)}
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 8. 개서 정보 */}
{conditions.showRenewal && (
<RenewalSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 차수 관리 섹션 */}
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-red-500">*</span>
</CardTitle>
{!isViewMode && (
<Button
variant="outline"
size="sm"
onClick={handleAddInstallment}
className="text-orange-500 border-orange-300 hover:bg-orange-50"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<DatePicker
value={inst.date}
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={inst.amount}
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
value={inst.note}
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
{!isViewMode && (
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveInstallment(inst.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 9. 소구 정보 */}
{conditions.showRecourse && (
<RecourseSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 10. 환매 정보 */}
{conditions.showBuyback && (
<BuybackSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 11. 부도 정보 */}
{conditions.showDishonored && (
<DishonoredSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
</>
);
// ===== 템플릿 모드 및 동적 설정 =====
// 템플릿 설정
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
title: isViewMode ? '어음 상세' : '어음',
title: isViewMode ? '어음/수표 상세' : '어음/수표',
actions: {
...billConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',

View File

@@ -10,20 +10,19 @@
* - tableHeaderActions: 거래처, 구분, 상태 필터
*/
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
import { useDateRange } from '@/hooks';
import {
FileText,
Plus,
Save,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
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,
@@ -32,8 +31,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import {
UniversalListPage,
type UniversalListConfig,
@@ -54,6 +51,8 @@ import {
BILL_TYPE_FILTER_OPTIONS,
BILL_STATUS_COLORS,
BILL_STATUS_FILTER_OPTIONS,
RECEIVED_BILL_STATUS_OPTIONS,
ISSUED_BILL_STATUS_OPTIONS,
getBillStatusLabel,
} from './types';
import { getBills, deleteBill, updateBillStatus } from './actions';
@@ -87,28 +86,11 @@ export function BillManagementClient({
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [targetStatus, setTargetStatus] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
// 삭제 다이얼로그
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteBill(id);
if (result.success) {
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '어음',
});
// 날짜 범위 상태
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
@@ -148,6 +130,16 @@ export function BillManagementClient({
}
}, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]);
// ===== 필터 변경 시 자동 재조회 =====
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
loadData(1);
}, [loadData]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
@@ -273,15 +265,15 @@ export function BillManagementClient({
];
}, [data]);
// ===== 저장 핸들러 =====
const handleSave = useCallback(async () => {
// ===== 상태 변경 핸들러 =====
const handleStatusChange = useCallback(async () => {
if (selectedItems.size === 0) {
toast.warning('선택된 항목이 없습니다.');
return;
}
if (statusFilter === 'all') {
toast.warning('상태를 선택해주세요.');
if (!targetStatus) {
toast.warning('변경할 상태를 선택해주세요.');
return;
}
@@ -289,21 +281,28 @@ export function BillManagementClient({
let successCount = 0;
for (const id of selectedItems) {
const result = await updateBillStatus(id, statusFilter as BillStatus);
const result = await updateBillStatus(id, targetStatus as BillStatus);
if (result.success) {
successCount++;
}
}
if (successCount > 0) {
toast.success(`${successCount}건이 저장되었습니다.`);
invalidateDashboard('bill');
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
loadData(currentPage);
setSelectedItems(new Set());
setTargetStatus('');
} else {
toast.error('저장에 실패했습니다.');
toast.error('상태 변경에 실패했습니다.');
}
setIsLoading(false);
}, [selectedItems, statusFilter, loadData, currentPage]);
}, [selectedItems, targetStatus, loadData, currentPage]);
// 구분에 따른 상태 옵션
const statusChangeOptions = useMemo(() => {
return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS;
}, [billTypeFilter]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<BillRecord> = useMemo(
@@ -326,6 +325,25 @@ export function BillManagementClient({
totalCount: pagination.total,
};
},
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return { success: result.success, error: result.error };
},
},
// 삭제 확인 메시지
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
// 테이블 컬럼
@@ -348,32 +366,8 @@ export function BillManagementClient({
);
},
// 모바일 필터 설정
filterConfig: [
{
key: 'vendorFilter',
label: '거래처',
type: 'single',
options: vendorOptions.filter(o => o.value !== 'all'),
},
{
key: 'billType',
label: '구분',
type: 'single',
options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'status',
label: '상태',
type: 'single',
options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'),
},
],
initialFilters: {
vendorFilter: vendorFilter,
billType: billTypeFilter,
status: statusFilter,
},
// 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠)
filterConfig: [],
filterTitle: '어음 필터',
// 날짜 선택기
@@ -392,42 +386,28 @@ export function BillManagementClient({
icon: Plus,
},
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지)
// 데스크톱: 모두 표시
headerActions: () => (
<div className="flex items-center gap-3" style={{ display: 'flex' }}>
<div className="hidden xl:flex items-center gap-3">
<RadioGroup
value={billTypeFilter}
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
className="flex items-center gap-3"
>
<div className="flex items-center space-x-1">
<RadioGroupItem value="received" id="received" />
<Label htmlFor="received" className="cursor-pointer text-sm whitespace-nowrap"></Label>
</div>
<div className="flex items-center space-x-1">
<RadioGroupItem value="issued" id="issued" />
<Label htmlFor="issued" className="cursor-pointer text-sm whitespace-nowrap"></Label>
</div>
</RadioGroup>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
// 선택 시 상태 변경 액션
selectionActions: () => (
<div className="flex items-center gap-2">
<Select value={targetStatus} onValueChange={setTargetStatus}>
<SelectTrigger className="min-w-[130px] w-auto h-8">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{statusChangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleStatusChange}
disabled={!targetStatus || isLoading}
>
<RefreshCw className="h-4 w-4 mr-1" />
</Button>
</div>
),
@@ -448,7 +428,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Select value={billTypeFilter} onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}>
<Select value={billTypeFilter} onValueChange={setBillTypeFilter}>
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="구분" />
</SelectTrigger>
@@ -461,7 +441,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); loadData(1); }}>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-[110px] w-auto">
<SelectValue placeholder="보관중" />
</SelectTrigger>
@@ -493,7 +473,10 @@ export function BillManagementClient({
isLoading,
router,
loadData,
handleSave,
currentPage,
handleStatusChange,
statusChangeOptions,
targetStatus,
renderTableRow,
renderMobileCard,
]
@@ -519,14 +502,6 @@ export function BillManagementClient({
}}
/>
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={deleteDialog.isPending}
/>
</>
);
}

View File

@@ -19,7 +19,8 @@ interface BillSummaryApiData {
// ===== 어음 목록 조회 =====
export async function getBills(params: {
search?: string; billType?: string; status?: string; clientId?: string;
isElectronic?: boolean; issueStartDate?: string; issueEndDate?: string;
isElectronic?: boolean; instrumentType?: string; medium?: string;
issueStartDate?: string; issueEndDate?: string;
maturityStartDate?: string; maturityEndDate?: string;
sortBy?: string; sortDir?: string; perPage?: number; page?: number;
}) {
@@ -30,6 +31,8 @@ export async function getBills(params: {
status: params.status && params.status !== 'all' ? params.status : undefined,
client_id: params.clientId,
is_electronic: params.isElectronic,
instrument_type: params.instrumentType && params.instrumentType !== 'all' ? params.instrumentType : undefined,
medium: params.medium && params.medium !== 'all' ? params.medium : undefined,
issue_start_date: params.issueStartDate,
issue_end_date: params.issueEndDate,
maturity_start_date: params.maturityStartDate,
@@ -124,10 +127,38 @@ export async function getBillSummary(params: {
});
}
// ===== V8: 어음 상세 조회 (BillApiData 그대로 반환) =====
export async function getBillRaw(id: string): Promise<ActionResult<BillApiData>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/bills/${id}`),
errorMessage: '어음 조회에 실패했습니다.',
});
}
// ===== V8: 어음 등록 (raw payload) =====
export async function createBillRaw(data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
return executeServerAction({
url: buildApiUrl('/api/v1/bills'),
method: 'POST',
body: data,
errorMessage: '어음 등록에 실패했습니다.',
});
}
// ===== V8: 어음 수정 (raw payload) =====
export async function updateBillRaw(id: string, data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/bills/${id}`),
method: 'PUT',
body: data,
errorMessage: '어음 수정에 실패했습니다.',
});
}
// ===== 거래처 목록 조회 =====
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

@@ -9,8 +9,8 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
* (차수 관리 테이블 등 특수 기능 유지)
*/
export const billConfig: DetailConfig = {
title: '어음 상세',
description: '어음 및 수취어음 상세 현황을 관리합니다',
title: '어음/수표 상세',
description: '어음/수표 상세 현황을 관리합니다',
icon: FileText,
basePath: '/accounting/bills',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
@@ -25,8 +25,8 @@ export const billConfig: DetailConfig = {
submitLabel: '저장',
cancelLabel: '취소',
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
title: '어음/수표 삭제',
description: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
};

View File

@@ -0,0 +1,178 @@
// ===== 증권종류 =====
export const INSTRUMENT_TYPE_OPTIONS = [
{ value: 'promissory', label: '약속어음' },
{ value: 'exchange', label: '환어음' },
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
{ value: 'currentCheck', label: '당좌수표' },
] as const;
// ===== 거래방향 =====
export const DIRECTION_OPTIONS = [
{ value: 'received', label: '수취 (받을어음)' },
{ value: 'issued', label: '발행 (지급어음)' },
] as const;
// ===== 전자/지류 =====
export const MEDIUM_OPTIONS = [
{ value: 'electronic', label: '전자' },
{ value: 'paper', label: '지류 (종이)' },
] as const;
// ===== 배서 여부 =====
export const ENDORSEMENT_OPTIONS = [
{ value: 'endorsable', label: '배서 가능' },
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
] as const;
// ===== 어음구분 =====
export const BILL_CATEGORY_OPTIONS = [
{ value: 'commercial', label: '상업어음 (매출채권)' },
{ value: 'other', label: '기타어음 (대여금/미수금)' },
] as const;
// ===== 받을어음 - 결제상태 (어음용) =====
export const RECEIVED_STATUS_OPTIONS = [
{ value: 'stored', label: '보관중' },
{ value: 'endorsed', label: '배서양도' },
{ value: 'discounted', label: '할인' },
{ value: 'collected', label: '추심' },
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
{ value: 'maturityDeposit', label: '만기입금' },
{ value: 'paymentComplete', label: '결제완료' },
{ value: 'renewed', label: '개서 (만기연장)' },
{ value: 'recourse', label: '소구 (배서어음 상환)' },
{ value: 'buyback', label: '환매 (할인어음 부도)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 받을수표 - 결제상태 (수표용) =====
export const RECEIVED_CHECK_STATUS_OPTIONS = [
{ value: 'stored', label: '보관중' },
{ value: 'endorsed', label: '배서양도' },
{ value: 'collected', label: '추심' },
{ value: 'deposited', label: '추심입금' },
{ value: 'paymentComplete', label: '결제완료 (제시입금)' },
{ value: 'recourse', label: '소구 (수표법 제39조)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 지급어음 - 지급상태 =====
export const ISSUED_STATUS_OPTIONS = [
{ value: 'stored', label: '보관중' },
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
{ value: 'maturityPayment', label: '만기결제' },
{ value: 'paid', label: '결제완료' },
{ value: 'renewed', label: '개서 (만기연장)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 지급수표 - 지급상태 =====
export const ISSUED_CHECK_STATUS_OPTIONS = [
{ value: 'stored', label: '미결제' },
{ value: 'paid', label: '결제완료 (제시출금)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 결제방법 =====
export const PAYMENT_METHOD_OPTIONS = [
{ value: 'autoTransfer', label: '만기자동이체' },
{ value: 'currentAccount', label: '당좌결제' },
{ value: 'other', label: '기타' },
] as const;
// ===== 부도사유 =====
export const DISHONOR_REASON_OPTIONS = [
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
{ value: 'formal_defect', label: '형식불비' },
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
{ value: 'expired', label: '제시기간 경과' },
{ value: 'other', label: '기타' },
] as const;
// ===== 이력 처리구분 =====
export const HISTORY_TYPE_OPTIONS = [
{ value: 'received', label: '수취' },
{ value: 'endorsement', label: '배서양도' },
{ value: 'splitEndorsement', label: '분할배서' },
{ value: 'collection', label: '추심의뢰' },
{ value: 'collectionDeposit', label: '추심입금' },
{ value: 'discount', label: '할인' },
{ value: 'maturityDeposit', label: '만기입금' },
{ value: 'dishonored', label: '부도' },
{ value: 'recourse', label: '소구' },
{ value: 'buyback', label: '환매' },
{ value: 'renewal', label: '개서' },
{ value: 'other', label: '기타' },
] as const;
// ===== 배서차수 (지류: 4차, 전자: 20차) =====
export const ENDORSEMENT_ORDER_PAPER = [
{ value: '1', label: '1차 (발행인 직접수취)' },
{ value: '2', label: '2차 (1개 업체 경유)' },
{ value: '3', label: '3차 (2개 업체 경유)' },
{ value: '4', label: '4차 (3개 업체 경유)' },
] as const;
export const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({
value: String(i + 1),
label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`,
}));
// ===== 보관장소 =====
export const STORAGE_OPTIONS = [
{ value: 'safe', label: '금고' },
{ value: 'bank', label: '은행 보관' },
{ value: 'other', label: '기타' },
] as const;
// ===== 지급장소 (어음법 제75조) =====
export const PAYMENT_PLACE_OPTIONS = [
{ value: 'issuerBank', label: '발행은행 본점' },
{ value: 'issuerBankBranch', label: '발행은행 지점' },
{ value: 'payerAddress', label: '지급인 주소지' },
{ value: 'designatedBank', label: '지정 은행' },
{ value: 'other', label: '기타' },
] as const;
// ===== 수표 지급장소 (수표법 제3조: 은행만) =====
export const PAYMENT_PLACE_CHECK_OPTIONS = [
{ value: 'issuerBank', label: '발행은행 본점' },
{ value: 'issuerBankBranch', label: '발행은행 지점' },
{ value: 'designatedBank', label: '지정 은행' },
] as const;
// ===== 추심결과 =====
export const COLLECTION_RESULT_OPTIONS = [
{ value: 'success', label: '추심 성공 (입금완료)' },
{ value: 'partial', label: '일부 입금' },
{ value: 'failed', label: '추심 실패 (부도)' },
{ value: 'pending', label: '추심 진행중' },
] as const;
// ===== 소구사유 =====
export const RECOURSE_REASON_OPTIONS = [
{ value: 'endorsedDishonor', label: '배서양도 어음 부도' },
{ value: 'discountDishonor', label: '할인 어음 부도 (환매)' },
{ value: 'other', label: '기타' },
] as const;
// ===== 인수거절 사유 =====
export const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [
{ value: 'financialDifficulty', label: '자금 사정 곤란' },
{ value: 'disputeOfClaim', label: '채권 분쟁' },
{ value: 'amountDispute', label: '금액 이의' },
{ value: 'other', label: '기타' },
] as const;
// ===== 개서 사유 =====
export const RENEWAL_REASON_OPTIONS = [
{ value: 'maturityExtension', label: '만기일 연장' },
{ value: 'amountChange', label: '금액 변경' },
{ value: 'conditionChange', label: '조건 변경' },
{ value: 'other', label: '기타' },
] as const;
// ===== 수표 관련 유효 상태 목록 (증권종류 전환 시 검증용) =====
export const VALID_CHECK_RECEIVED_STATUSES = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored'];
export const VALID_CHECK_ISSUED_STATUSES = ['stored', 'paid', 'dishonored'];

View File

@@ -0,0 +1,69 @@
'use client';
import { useMemo } from 'react';
import type { BillFormData } from '../types';
import {
RECEIVED_STATUS_OPTIONS,
RECEIVED_CHECK_STATUS_OPTIONS,
ISSUED_STATUS_OPTIONS,
ISSUED_CHECK_STATUS_OPTIONS,
PAYMENT_PLACE_OPTIONS,
PAYMENT_PLACE_CHECK_OPTIONS,
} from '../constants';
export function useBillConditions(formData: BillFormData) {
return useMemo(() => {
const isReceived = formData.direction === 'received';
const isIssued = formData.direction === 'issued';
const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck';
const isBill = !isCheck;
const canBeElectronic = formData.instrumentType === 'promissory';
const isElectronic = formData.medium === 'electronic';
const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus;
// 조건부 섹션 표시 플래그
const showElectronic = isElectronic;
const showExchangeBill = formData.instrumentType === 'exchange';
const showDiscount = isReceived && formData.isDiscounted && isBill;
const showEndorsement = isReceived && formData.receivedStatus === 'endorsed';
const showCollection = isReceived && formData.receivedStatus === 'collected';
const showDishonored = currentStatus === 'dishonored';
const showRenewal = currentStatus === 'renewed' && isBill;
const showRecourse = isReceived && formData.receivedStatus === 'recourse';
const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill;
const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused';
// 현재 증권종류에 맞는 옵션 목록
const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS;
const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS;
const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS;
// 분할배서 최대 횟수
const maxSplitCount = isElectronic ? 4 : 10;
return {
isReceived,
isIssued,
isCheck,
isBill,
canBeElectronic,
isElectronic,
currentStatus,
showElectronic,
showExchangeBill,
showDiscount,
showEndorsement,
showCollection,
showDishonored,
showRenewal,
showRecourse,
showBuyback,
showAcceptanceRefusal,
receivedStatusOptions,
issuedStatusOptions,
paymentPlaceOptions,
maxSplitCount,
};
}, [formData.direction, formData.instrumentType, formData.medium, formData.isDiscounted, formData.receivedStatus, formData.issuedStatus, formData.acceptanceStatus]);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState, useCallback } from 'react';
import type { BillFormData } from '../types';
import { INITIAL_BILL_FORM_DATA } from '../types';
import {
VALID_CHECK_RECEIVED_STATUSES,
VALID_CHECK_ISSUED_STATUSES,
} from '../constants';
export function useBillForm(initialData?: Partial<BillFormData>) {
const [formData, setFormData] = useState<BillFormData>({
...INITIAL_BILL_FORM_DATA,
...initialData,
});
const updateField = useCallback(<K extends keyof BillFormData>(field: K, value: BillFormData[K]) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 증권종류 변경 시 연관 필드 초기화
const handleInstrumentTypeChange = useCallback((newType: string) => {
setFormData(prev => {
const next = { ...prev, instrumentType: newType as BillFormData['instrumentType'] };
const isCheckType = newType === 'cashierCheck' || newType === 'currentCheck';
// 약속어음 외에는 전자 불가 → 지류로 리셋
if (newType !== 'promissory' && prev.medium === 'electronic') {
next.medium = 'paper';
}
// 수표 전환 시: 만기일, 할인, 관련 필드 리셋
if (isCheckType) {
next.maturityDate = '';
next.isDiscounted = false;
if (!VALID_CHECK_RECEIVED_STATUSES.includes(prev.receivedStatus)) {
next.receivedStatus = 'stored';
}
if (!VALID_CHECK_ISSUED_STATUSES.includes(prev.issuedStatus)) {
next.issuedStatus = 'stored';
}
if (prev.paymentPlace === 'payerAddress' || prev.paymentPlace === 'other') {
next.paymentPlace = '';
}
}
return next;
});
}, []);
// 거래방향 변경 시 상태 초기화
const handleDirectionChange = useCallback((newDirection: string) => {
setFormData(prev => ({
...prev,
direction: newDirection as BillFormData['direction'],
receivedStatus: 'stored',
issuedStatus: 'stored',
}));
}, []);
// 이력 관리
const addInstallment = useCallback(() => {
setFormData(prev => ({
...prev,
installments: [
...prev.installments,
{ id: `inst-${Date.now()}`, date: '', type: 'other', amount: 0, counterparty: '', note: '' },
],
}));
}, []);
const removeInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const updateInstallment = useCallback((id: string, field: string, value: string | number) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// 폼 전체 덮어쓰기 (API 데이터 로드 시)
const setFormDataFull = useCallback((data: BillFormData) => {
setFormData(data);
}, []);
return {
formData,
updateField,
handleInstrumentTypeChange,
handleDirectionChange,
addInstallment,
removeInstallment,
updateInstallment,
setFormDataFull,
};
}

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

@@ -0,0 +1,288 @@
'use client';
import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import {
INSTRUMENT_TYPE_OPTIONS,
DIRECTION_OPTIONS,
MEDIUM_OPTIONS,
ENDORSEMENT_OPTIONS,
BILL_CATEGORY_OPTIONS,
STORAGE_OPTIONS,
PAYMENT_METHOD_OPTIONS,
ENDORSEMENT_ORDER_PAPER,
ENDORSEMENT_ORDER_ELECTRONIC,
} from '../constants';
interface BasicInfoSectionProps extends SectionProps {
clients: { id: string; name: string }[];
conditions: {
isReceived: boolean;
isIssued: boolean;
isCheck: boolean;
isBill: boolean;
canBeElectronic: boolean;
isElectronic: boolean;
receivedStatusOptions: readonly { value: string; label: string }[];
issuedStatusOptions: readonly { value: string; label: string }[];
paymentPlaceOptions: readonly { value: string; label: string }[];
};
onInstrumentTypeChange: (v: string) => void;
onDirectionChange: (v: string) => void;
}
export function BasicInfoSection({
formData, updateField, isViewMode, clients, conditions, onInstrumentTypeChange, onDirectionChange,
}: BasicInfoSectionProps) {
const {
isReceived, isIssued, isCheck, isBill, canBeElectronic, isElectronic,
receivedStatusOptions, issuedStatusOptions, paymentPlaceOptions,
} = conditions;
const endorsementOrderOptions = useMemo(
() => isElectronic ? ENDORSEMENT_ORDER_ELECTRONIC : [...ENDORSEMENT_ORDER_PAPER],
[isElectronic]
);
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input value={formData.billNumber} onChange={(e) => updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" disabled={isViewMode} />
</div>
{/* 증권종류 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.instrumentType} onValueChange={onInstrumentTypeChange} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{INSTRUMENT_TYPE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 거래방향 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.direction} onValueChange={onDirectionChange} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 전자/지류 */}
<div className="space-y-2">
<Label>/ <span className="text-red-500">*</span>
{!canBeElectronic && <span className="text-xs text-muted-foreground ml-1">(전자어음법: 약속어음만 )</span>}
</Label>
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v as 'electronic' | 'paper')} disabled={isViewMode || !canBeElectronic}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MEDIUM_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label>{isReceived ? '거래처 (발행인)' : '수취인 (거래처)'} <span className="text-red-500">*</span></Label>
<Select
value={isReceived ? formData.vendor : formData.payee}
onValueChange={(v) => updateField(isReceived ? 'vendor' : 'payee', v)}
disabled={isViewMode}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 금액 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<CurrencyInput value={formData.amount} onChange={(v) => updateField('amount', v ?? 0)} disabled={isViewMode} />
</div>
{/* 발행일 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.issueDate} onChange={(d) => updateField('issueDate', d)} disabled={isViewMode} />
</div>
{/* 만기일 (수표는 일람출급이므로 없음) */}
{isBill && (
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.maturityDate} onChange={(d) => updateField('maturityDate', d)} disabled={isViewMode} />
</div>
)}
{/* 은행 */}
<div className="space-y-2">
<Label>{isReceived ? '발행은행' : '결제은행'}</Label>
<Input
value={isReceived ? formData.issuerBank : formData.settlementBank}
onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)}
placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'}
disabled={isViewMode}
/>
</div>
{/* 지급장소 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span>
{isCheck && <span className="text-xs text-muted-foreground ml-1">(수표: 은행만)</span>}
</Label>
<Select value={formData.paymentPlace} onValueChange={(v) => updateField('paymentPlace', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{paymentPlaceOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 지급장소 상세 */}
{formData.paymentPlace === 'other' && (
<div className="space-y-2">
<Label> </Label>
<Input value={formData.paymentPlaceDetail} onChange={(e) => updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} />
</div>
)}
{/* 어음구분 (어음만) */}
{isBill && (
<div className="space-y-2">
<Label></Label>
<Select value={formData.billCategory} onValueChange={(v) => updateField('billCategory', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{BILL_CATEGORY_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
{/* ===== 받을어음 전용 필드 ===== */}
{isReceived && (
<>
<div className="space-y-2">
<Label> </Label>
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ENDORSEMENT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.endorsementOrder} onValueChange={(v) => updateField('endorsementOrder', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{endorsementOrderOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.storagePlace} onValueChange={(v) => updateField('storagePlace', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{STORAGE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.receivedStatus} onValueChange={(v) => updateField('receivedStatus', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{receivedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 할인여부 (수표 제외) */}
{isBill && (
<div className="space-y-2">
<Label></Label>
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
<Switch checked={formData.isDiscounted} onCheckedChange={(c) => {
updateField('isDiscounted', c);
if (c) updateField('receivedStatus', 'discounted');
}} disabled={isViewMode} />
<span className="text-sm">{formData.isDiscounted ? '할인 적용' : '미적용'}</span>
</div>
</div>
)}
</>
)}
{/* ===== 지급어음 전용 필드 ===== */}
{isIssued && (
<>
<div className="space-y-2">
<Label></Label>
<Select value={formData.paymentMethod} onValueChange={(v) => updateField('paymentMethod', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.issuedStatus} onValueChange={(v) => updateField('issuedStatus', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{issuedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker value={formData.actualPaymentDate} onChange={(d) => updateField('actualPaymentDate', d)} disabled={isViewMode} />
</div>
</>
)}
{/* 입출금 계좌 */}
<div className="space-y-2">
<Label>/ </Label>
<Input value={formData.bankAccountInfo} onChange={(e) => updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} />
</div>
{/* 비고 */}
<div className="space-y-2 lg:col-span-2">
<Label></Label>
<Input value={formData.note} onChange={(e) => updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} />
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SectionProps } from './types';
export function BuybackSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-orange-200 bg-orange-50/30">
<CardHeader>
<CardTitle className="text-lg text-orange-700"> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4"> () </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.buybackDate} onChange={(d) => updateField('buybackDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<CurrencyInput value={formData.buybackAmount} onChange={(v) => updateField('buybackAmount', v ?? 0)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> </Label>
<Input value={formData.buybackBank} onChange={(e) => updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} />
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { COLLECTION_RESULT_OPTIONS } from '../constants';
export function CollectionSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 추심 의뢰 */}
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input value={formData.collectionBank} onChange={(e) => updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.collectionRequestDate} onChange={(d) => updateField('collectionRequestDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<CurrencyInput value={formData.collectionFee} onChange={(v) => updateField('collectionFee', v ?? 0)} disabled={isViewMode} />
</div>
</div>
{/* 추심 결과 */}
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4"> </p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label></Label>
<Select value={formData.collectionResult} onValueChange={(v) => updateField('collectionResult', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{COLLECTION_RESULT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker value={formData.collectionCompleteDate} onChange={(d) => updateField('collectionCompleteDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker value={formData.collectionDepositDate} onChange={(d) => updateField('collectionDepositDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> ( )</Label>
<CurrencyInput value={formData.collectionDepositAmount} onChange={(v) => updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SectionProps } from './types';
export function DiscountInfoSection({ formData, updateField, isViewMode }: SectionProps) {
const calcNetReceived = useMemo(() => {
if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount;
return 0;
}, [formData.amount, formData.discountAmount]);
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.discountDate} onChange={(d) => updateField('discountDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> () <span className="text-red-500">*</span></Label>
<Input value={formData.discountBank} onChange={(e) => updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> (%)</Label>
<Input type="number" step="0.01" min={0} max={100} value={formData.discountRate || ''} onChange={(e) => {
const rate = parseFloat(e.target.value) || 0;
updateField('discountRate', rate);
if (formData.amount > 0 && rate > 0) updateField('discountAmount', Math.round(formData.amount * rate / 100));
}} placeholder="예: 3.5" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<CurrencyInput value={formData.discountAmount} onChange={(v) => updateField('discountAmount', v ?? 0)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> ()</Label>
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
{calcNetReceived > 0
? <span className="text-green-700"> {calcNetReceived.toLocaleString()}</span>
: <span className="text-gray-400"> - </span>}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { DISHONOR_REASON_OPTIONS } from '../constants';
export function DishonoredSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-red-200 bg-red-50/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
<Badge variant="destructive" className="text-xs"></Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.dishonoredDate} onChange={(d) => {
updateField('dishonoredDate', d);
if (d) {
const dt = new Date(d);
dt.setDate(dt.getDate() + 6);
updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]);
}
}} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
<SelectContent>
{DISHONOR_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 법적 프로세스 */}
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4"> ( 44·45)</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label> </Label>
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
<Switch checked={formData.hasProtest} onCheckedChange={(c) => updateField('hasProtest', c)} disabled={isViewMode} />
<span className="text-sm">{formData.hasProtest ? '작성 완료' : '미작성'}</span>
</div>
</div>
{formData.hasProtest && (
<div className="space-y-2">
<Label> </Label>
<DatePicker value={formData.protestDate} onChange={(d) => updateField('protestDate', d)} disabled={isViewMode} />
</div>
)}
<div className="space-y-2">
<Label> </Label>
<DatePicker value={formData.recourseNoticeDate} onChange={(d) => updateField('recourseNoticeDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> (자동: 부도일+4)</Label>
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm">
{formData.recourseNoticeDeadline ? (
<span className={
formData.recourseNoticeDate && formData.recourseNoticeDate <= formData.recourseNoticeDeadline
? 'text-green-700' : 'text-red-600 font-medium'
}>
{formData.recourseNoticeDeadline}
{formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'}
</span>
) : <span className="text-gray-400"> </span>}
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
export function ElectronicBillSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label> </Label>
<Input value={formData.electronicBillNo} onChange={(e) => updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="kftc"></SelectItem>
<SelectItem value="bank"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
export function EndorsementSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.endorsementDate} onChange={(d) => updateField('endorsementDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> () <span className="text-red-500">*</span></Label>
<Input value={formData.endorsee} onChange={(e) => updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> </Label>
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="payment"></SelectItem>
<SelectItem value="guarantee"></SelectItem>
<SelectItem value="collection"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { AlertTriangle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { ACCEPTANCE_REFUSAL_REASON_OPTIONS } from '../constants';
interface ExchangeBillSectionProps extends SectionProps {
showAcceptanceRefusal: boolean;
}
export function ExchangeBillSection({ formData, updateField, isViewMode, showAcceptanceRefusal }: ExchangeBillSectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> (Drawee) <span className="text-red-500">*</span></Label>
<Input value={formData.drawee} onChange={(e) => updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> </Label>
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="accepted"> </SelectItem>
<SelectItem value="pending"> </SelectItem>
<SelectItem value="refused"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{formData.acceptanceStatus === 'refused' ? '인수거절일' : '인수일자'}</Label>
<DatePicker
value={formData.acceptanceStatus === 'refused' ? formData.acceptanceRefusalDate : formData.acceptanceDate}
onChange={(d) => updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)}
disabled={isViewMode}
/>
</div>
</div>
{showAcceptanceRefusal && (
<div className="border-t pt-4">
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
<span> ( 43). .</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label> </Label>
<Select value={formData.acceptanceRefusalReason} onValueChange={(v) => updateField('acceptanceRefusalReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{ACCEPTANCE_REFUSAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useMemo } from 'react';
import { Plus, Trash2, AlertTriangle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import type { BillFormData } from '../types';
import { HISTORY_TYPE_OPTIONS } from '../constants';
interface HistorySectionProps {
formData: BillFormData;
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
isViewMode: boolean;
isElectronic: boolean;
maxSplitCount: number;
onAddInstallment: () => void;
onRemoveInstallment: (id: string) => void;
onUpdateInstallment: (id: string, field: string, value: string | number) => void;
}
export function HistorySection({
formData, updateField, isViewMode, isElectronic, maxSplitCount,
onAddInstallment, onRemoveInstallment, onUpdateInstallment,
}: HistorySectionProps) {
const splitEndorsementStats = useMemo(() => {
const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement');
const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0);
return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount };
}, [formData.installments, formData.amount]);
return (
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg"> </CardTitle>
{!isViewMode && (
<Button variant="outline" size="sm" onClick={onAddInstallment} className="text-orange-500 border-orange-300 hover:bg-orange-50">
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 분할배서 토글 */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<Switch checked={formData.isSplit} onCheckedChange={(c) => updateField('isSplit', c)} disabled={isViewMode} />
<Label> </Label>
{formData.isSplit && (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
{maxSplitCount}
</Badge>
)}
</div>
{formData.isSplit && isElectronic && (
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
<span> 분할배서: 최초 5 ( 6)</span>
</div>
)}
{formData.isSplit && splitEndorsementStats.count > 0 && (
<div className="flex items-center gap-4 text-sm bg-gray-50 rounded-md px-3 py-2">
<span className="text-muted-foreground">:</span>
<span className="font-semibold"> {formData.amount.toLocaleString()}</span>
<span className="text-muted-foreground">| :</span>
<span className="font-semibold text-blue-600"> {splitEndorsementStats.totalAmount.toLocaleString()}</span>
<span className="text-muted-foreground">| :</span>
<span className={`font-semibold ${splitEndorsementStats.remaining < 0 ? 'text-red-600' : 'text-green-600'}`}>
{splitEndorsementStats.remaining.toLocaleString()}
</span>
{splitEndorsementStats.remaining < 0 && (
<span className="text-red-500 text-xs flex items-center gap-1"><AlertTriangle className="h-3 w-3" /> </span>
)}
</div>
)}
</div>
{/* 이력 테이블 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 6 : 7} className="text-center text-gray-500 py-8"> </TableCell>
</TableRow>
) : formData.installments.map((inst, idx) => (
<TableRow key={inst.id} className={inst.type === 'splitEndorsement' ? 'bg-amber-50/50' : ''}>
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell>
<DatePicker value={inst.date} onChange={(d) => onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} />
</TableCell>
<TableCell>
<Select value={inst.type} onValueChange={(v) => onUpdateInstallment(inst.id, 'type', v)} disabled={isViewMode}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
{HISTORY_TYPE_OPTIONS
.filter(o => o.value !== 'splitEndorsement' || formData.isSplit)
.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)
}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<CurrencyInput value={inst.amount} onChange={(v) => onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} />
</TableCell>
<TableCell>
<Input value={inst.counterparty} onChange={(e) => onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} />
</TableCell>
<TableCell>
<Input value={inst.note} onChange={(e) => onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} />
</TableCell>
{!isViewMode && (
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => onRemoveInstallment(inst.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { RECOURSE_REASON_OPTIONS } from '../constants';
export function RecourseSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-orange-200 bg-orange-50/30">
<CardHeader>
<CardTitle className="text-lg text-orange-700"> () </CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4"> </p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.recourseDate} onChange={(d) => updateField('recourseDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<CurrencyInput value={formData.recourseAmount} onChange={(v) => updateField('recourseAmount', v ?? 0)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> ()</Label>
<Input value={formData.recourseTarget} onChange={(e) => updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.recourseReason} onValueChange={(v) => updateField('recourseReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{RECOURSE_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { RENEWAL_REASON_OPTIONS } from '../constants';
export function RenewalSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-amber-200 bg-amber-50/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700 bg-amber-50"></Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.renewalDate} onChange={(d) => updateField('renewalDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.renewalNewBillNo} onChange={(e) => updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.renewalReason} onValueChange={(v) => updateField('renewalReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
<SelectContent>
{RENEWAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
export { BasicInfoSection } from './BasicInfoSection';
export { ElectronicBillSection } from './ElectronicBillSection';
export { ExchangeBillSection } from './ExchangeBillSection';
export { DiscountInfoSection } from './DiscountInfoSection';
export { EndorsementSection } from './EndorsementSection';
export { CollectionSection } from './CollectionSection';
export { HistorySection } from './HistorySection';
export { RenewalSection } from './RenewalSection';
export { RecourseSection } from './RecourseSection';
export { BuybackSection } from './BuybackSection';
export { DishonoredSection } from './DishonoredSection';

View File

@@ -0,0 +1,7 @@
import type { BillFormData } from '../types';
export interface SectionProps {
formData: BillFormData;
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
isViewMode: boolean;
}

View File

@@ -174,8 +174,10 @@ export function getBillStatusOptions(billType: BillType) {
export interface BillApiInstallment {
id: number;
bill_id: number;
type?: string;
installment_date: string;
amount: string;
counterparty?: string | null;
note: string | null;
created_at: string;
updated_at: string;
@@ -190,7 +192,7 @@ export interface BillApiData {
client_name: string | null;
amount: string;
issue_date: string;
maturity_date: string;
maturity_date: string | null;
status: BillStatus;
reason: string | null;
installment_count: number;
@@ -211,6 +213,58 @@ export interface BillApiData {
account_name: string;
} | null;
installments?: BillApiInstallment[];
// V8 확장 필드
instrument_type?: string;
medium?: string;
bill_category?: string;
electronic_bill_no?: string | null;
registration_org?: string | null;
drawee?: string | null;
acceptance_status?: string | null;
acceptance_date?: string | null;
acceptance_refusal_date?: string | null;
acceptance_refusal_reason?: string | null;
endorsement?: string | null;
endorsement_order?: string | null;
storage_place?: string | null;
issuer_bank?: string | null;
is_discounted?: boolean;
discount_date?: string | null;
discount_bank?: string | null;
discount_rate?: string | null;
discount_amount?: string | null;
endorsement_date?: string | null;
endorsee?: string | null;
endorsement_reason?: string | null;
collection_bank?: string | null;
collection_request_date?: string | null;
collection_fee?: string | null;
collection_complete_date?: string | null;
collection_result?: string | null;
collection_deposit_date?: string | null;
collection_deposit_amount?: string | null;
settlement_bank?: string | null;
payment_method?: string | null;
actual_payment_date?: string | null;
payment_place?: string | null;
payment_place_detail?: string | null;
renewal_date?: string | null;
renewal_new_bill_no?: string | null;
renewal_reason?: string | null;
recourse_date?: string | null;
recourse_amount?: string | null;
recourse_target?: string | null;
recourse_reason?: string | null;
buyback_date?: string | null;
buyback_amount?: string | null;
buyback_bank?: string | null;
dishonored_date?: string | null;
dishonored_reason?: string | null;
has_protest?: boolean;
protest_date?: string | null;
recourse_notice_date?: string | null;
recourse_notice_deadline?: string | null;
is_split?: boolean;
}
export interface BillApiResponse {
@@ -235,7 +289,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
vendorName: apiData.client?.name || apiData.client_name || '',
amount: parseFloat(apiData.amount),
issueDate: apiData.issue_date,
maturityDate: apiData.maturity_date,
maturityDate: apiData.maturity_date || '',
status: apiData.status,
reason: apiData.reason || '',
installmentCount: apiData.installment_count,
@@ -251,7 +305,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
};
}
// ===== Frontend → API 변환 함수 =====
// ===== Frontend → API 변환 함수 (V8 전체 필드 전송) =====
export function transformFrontendToApi(data: Partial<BillRecord>): Record<string, unknown> {
const result: Record<string, unknown> = {};
@@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
if (data.vendorName !== undefined) result.client_name = data.vendorName || null;
if (data.amount !== undefined) result.amount = data.amount;
if (data.issueDate !== undefined) result.issue_date = data.issueDate;
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate;
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate || null;
if (data.status !== undefined) result.status = data.status;
if (data.reason !== undefined) result.reason = data.reason || null;
if (data.note !== undefined) result.note = data.note || null;
@@ -275,4 +329,334 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
}
return result;
}
// ===== BillFormData → API payload 변환 (V8 전체 필드 전송) =====
export function transformFormDataToApi(data: BillFormData, vendorName: string): Record<string, unknown> {
const isReceived = data.direction === 'received';
const orNull = (v: string) => v || null;
const orNullNum = (v: number) => v || null;
const orNullDate = (v: string) => v || null;
return {
// 기존 12개 필드
bill_number: data.billNumber,
bill_type: data.direction,
client_id: isReceived ? (data.vendor ? parseInt(data.vendor) : null) : (data.payee ? parseInt(data.payee) : null),
client_name: vendorName || null,
amount: data.amount,
issue_date: data.issueDate,
maturity_date: orNullDate(data.maturityDate),
status: isReceived ? data.receivedStatus : data.issuedStatus,
note: orNull(data.note),
is_electronic: data.medium === 'electronic',
// V8 확장 필드
instrument_type: data.instrumentType,
medium: data.medium,
bill_category: orNull(data.billCategory),
electronic_bill_no: orNull(data.electronicBillNo),
registration_org: orNull(data.registrationOrg),
drawee: orNull(data.drawee),
acceptance_status: orNull(data.acceptanceStatus),
acceptance_date: orNullDate(data.acceptanceDate),
acceptance_refusal_date: orNullDate(data.acceptanceRefusalDate),
acceptance_refusal_reason: orNull(data.acceptanceRefusalReason),
endorsement: orNull(data.endorsement),
endorsement_order: orNull(data.endorsementOrder),
storage_place: orNull(data.storagePlace),
issuer_bank: orNull(data.issuerBank),
is_discounted: data.isDiscounted,
discount_date: orNullDate(data.discountDate),
discount_bank: orNull(data.discountBank),
discount_rate: orNullNum(data.discountRate),
discount_amount: orNullNum(data.discountAmount),
endorsement_date: orNullDate(data.endorsementDate),
endorsee: orNull(data.endorsee),
endorsement_reason: orNull(data.endorsementReason),
collection_bank: orNull(data.collectionBank),
collection_request_date: orNullDate(data.collectionRequestDate),
collection_fee: orNullNum(data.collectionFee),
collection_complete_date: orNullDate(data.collectionCompleteDate),
collection_result: orNull(data.collectionResult),
collection_deposit_date: orNullDate(data.collectionDepositDate),
collection_deposit_amount: orNullNum(data.collectionDepositAmount),
settlement_bank: orNull(data.settlementBank),
payment_method: orNull(data.paymentMethod),
actual_payment_date: orNullDate(data.actualPaymentDate),
payment_place: orNull(data.paymentPlace),
payment_place_detail: orNull(data.paymentPlaceDetail),
renewal_date: orNullDate(data.renewalDate),
renewal_new_bill_no: orNull(data.renewalNewBillNo),
renewal_reason: orNull(data.renewalReason),
recourse_date: orNullDate(data.recourseDate),
recourse_amount: orNullNum(data.recourseAmount),
recourse_target: orNull(data.recourseTarget),
recourse_reason: orNull(data.recourseReason),
buyback_date: orNullDate(data.buybackDate),
buyback_amount: orNullNum(data.buybackAmount),
buyback_bank: orNull(data.buybackBank),
dishonored_date: orNullDate(data.dishonoredDate),
dishonored_reason: orNull(data.dishonoredReason),
has_protest: data.hasProtest,
protest_date: orNullDate(data.protestDate),
recourse_notice_date: orNullDate(data.recourseNoticeDate),
recourse_notice_deadline: orNullDate(data.recourseNoticeDeadline),
is_split: data.isSplit,
// 이력(차수)
installments: data.installments.map(inst => ({
date: inst.date,
type: inst.type || 'other',
amount: inst.amount,
counterparty: orNull(inst.counterparty),
note: orNull(inst.note),
})),
};
}
// =============================================
// V8 확장 타입 (프로토타입 → 실제 페이지 마이그레이션)
// =============================================
// ===== 증권종류 =====
export type InstrumentType = 'promissory' | 'exchange' | 'cashierCheck' | 'currentCheck';
// ===== 거래방향 (Direction = BillType alias) =====
export type Direction = 'received' | 'issued';
// ===== 매체 =====
export type Medium = 'electronic' | 'paper';
// ===== 이력 레코드 (V8: 처리구분/상대처 추가) =====
export interface HistoryRecord {
id: string;
date: string;
type: string; // 처리구분 (HISTORY_TYPE_OPTIONS)
amount: number;
counterparty: string; // 상대처
note: string;
}
// ===== V8 폼 데이터 (전체 ~45개 필드) =====
export interface BillFormData {
// === 공통 ===
billNumber: string;
instrumentType: InstrumentType;
direction: Direction;
medium: Medium;
amount: number;
issueDate: string;
maturityDate: string;
note: string;
// === 전자어음 (조건: medium=electronic) ===
electronicBillNo: string;
registrationOrg: string;
// === 환어음 (조건: instrumentType=exchange) ===
drawee: string;
acceptanceStatus: string;
acceptanceDate: string;
// === 받을어음 전용 ===
vendor: string;
billCategory: string;
issuerBank: string;
endorsement: string;
endorsementOrder: string;
storagePlace: string;
receivedStatus: string;
isDiscounted: boolean;
discountDate: string;
discountBank: string;
discountRate: number;
discountAmount: number;
// 배서양도
endorsementDate: string;
endorsee: string;
endorsementReason: string;
// 추심
collectionBank: string;
collectionRequestDate: string;
collectionFee: number;
collectionCompleteDate: string;
collectionResult: string;
collectionDepositDate: string;
collectionDepositAmount: number;
// === 지급어음 전용 ===
payee: string;
settlementBank: string;
paymentMethod: string;
issuedStatus: string;
actualPaymentDate: string;
// === 공통 ===
paymentPlace: string;
paymentPlaceDetail: string;
// === 개서 ===
renewalDate: string;
renewalNewBillNo: string;
renewalReason: string;
// === 소구/환매 ===
recourseDate: string;
recourseAmount: number;
recourseTarget: string;
recourseReason: string;
buybackDate: string;
buybackAmount: number;
buybackBank: string;
// === 환어음 인수거절 ===
acceptanceRefusalDate: string;
acceptanceRefusalReason: string;
// === 공통 조건부 ===
isSplit: boolean;
splitCount: number;
splitAmount: number;
dishonoredDate: string;
dishonoredReason: string;
// 부도 법적 프로세스
hasProtest: boolean;
protestDate: string;
recourseNoticeDate: string;
recourseNoticeDeadline: string;
// === 이력 관리 ===
installments: HistoryRecord[];
// === 입출금 계좌 ===
bankAccountInfo: string;
}
// ===== 초기 폼 데이터 =====
export const INITIAL_BILL_FORM_DATA: BillFormData = {
billNumber: '', instrumentType: 'promissory', direction: 'received',
medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '',
electronicBillNo: '', registrationOrg: '',
drawee: '', acceptanceStatus: '', acceptanceDate: '',
vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1',
storagePlace: '', receivedStatus: 'stored', isDiscounted: false,
discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0,
endorsementDate: '', endorsee: '', endorsementReason: '',
collectionBank: '', collectionRequestDate: '', collectionFee: 0,
collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0,
payee: '', settlementBank: '', paymentMethod: 'autoTransfer',
issuedStatus: 'stored', actualPaymentDate: '',
paymentPlace: '', paymentPlaceDetail: '',
renewalDate: '', renewalNewBillNo: '', renewalReason: '',
recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '',
buybackDate: '', buybackAmount: 0, buybackBank: '',
acceptanceRefusalDate: '', acceptanceRefusalReason: '',
isSplit: false, splitCount: 0, splitAmount: 0,
dishonoredDate: '', dishonoredReason: '',
hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '',
installments: [], bankAccountInfo: '',
};
// ===== BillApiData → BillFormData 직접 변환 (V8 전체 필드 매핑) =====
export function apiDataToFormData(apiData: BillApiData): BillFormData {
const pf = (v: string | null | undefined) => v ? parseFloat(v) : 0;
return {
...INITIAL_BILL_FORM_DATA,
billNumber: apiData.bill_number,
instrumentType: (apiData.instrument_type as InstrumentType) || 'promissory',
direction: apiData.bill_type as Direction,
medium: (apiData.medium as Medium) || (apiData.is_electronic ? 'electronic' : 'paper'),
amount: parseFloat(apiData.amount),
issueDate: apiData.issue_date,
maturityDate: apiData.maturity_date || '',
note: apiData.note || '',
// 전자어음
electronicBillNo: apiData.electronic_bill_no || '',
registrationOrg: apiData.registration_org || '',
// 환어음
drawee: apiData.drawee || '',
acceptanceStatus: apiData.acceptance_status || '',
acceptanceDate: apiData.acceptance_date || '',
acceptanceRefusalDate: apiData.acceptance_refusal_date || '',
acceptanceRefusalReason: apiData.acceptance_refusal_reason || '',
// 거래처
vendor: apiData.bill_type === 'received' && apiData.client_id ? String(apiData.client_id) : '',
payee: apiData.bill_type === 'issued' && apiData.client_id ? String(apiData.client_id) : '',
// 받을어음 전용
billCategory: apiData.bill_category || 'commercial',
issuerBank: apiData.issuer_bank || '',
endorsement: apiData.endorsement || 'endorsable',
endorsementOrder: apiData.endorsement_order || '1',
storagePlace: apiData.storage_place || '',
receivedStatus: apiData.bill_type === 'received' ? apiData.status : 'stored',
isDiscounted: apiData.is_discounted ?? false,
discountDate: apiData.discount_date || '',
discountBank: apiData.discount_bank || '',
discountRate: pf(apiData.discount_rate),
discountAmount: pf(apiData.discount_amount),
endorsementDate: apiData.endorsement_date || '',
endorsee: apiData.endorsee || '',
endorsementReason: apiData.endorsement_reason || '',
collectionBank: apiData.collection_bank || '',
collectionRequestDate: apiData.collection_request_date || '',
collectionFee: pf(apiData.collection_fee),
collectionCompleteDate: apiData.collection_complete_date || '',
collectionResult: apiData.collection_result || '',
collectionDepositDate: apiData.collection_deposit_date || '',
collectionDepositAmount: pf(apiData.collection_deposit_amount),
// 지급어음 전용
settlementBank: apiData.settlement_bank || '',
paymentMethod: apiData.payment_method || 'autoTransfer',
issuedStatus: apiData.bill_type === 'issued' ? apiData.status : 'stored',
actualPaymentDate: apiData.actual_payment_date || '',
// 공통
paymentPlace: apiData.payment_place || '',
paymentPlaceDetail: apiData.payment_place_detail || '',
// 개서
renewalDate: apiData.renewal_date || '',
renewalNewBillNo: apiData.renewal_new_bill_no || '',
renewalReason: apiData.renewal_reason || '',
// 소구/환매
recourseDate: apiData.recourse_date || '',
recourseAmount: pf(apiData.recourse_amount),
recourseTarget: apiData.recourse_target || '',
recourseReason: apiData.recourse_reason || '',
buybackDate: apiData.buyback_date || '',
buybackAmount: pf(apiData.buyback_amount),
buybackBank: apiData.buyback_bank || '',
// 부도
isSplit: apiData.is_split ?? false,
splitCount: 0,
splitAmount: 0,
dishonoredDate: apiData.dishonored_date || '',
dishonoredReason: apiData.dishonored_reason || '',
hasProtest: apiData.has_protest ?? false,
protestDate: apiData.protest_date || '',
recourseNoticeDate: apiData.recourse_notice_date || '',
recourseNoticeDeadline: apiData.recourse_notice_deadline || '',
// 이력
installments: (apiData.installments || []).map(inst => ({
id: String(inst.id),
date: inst.installment_date,
type: inst.type || 'other',
amount: parseFloat(inst.amount),
counterparty: inst.counterparty || '',
note: inst.note || '',
})),
bankAccountInfo: apiData.bank_account_id ? String(apiData.bank_account_id) : '',
};
}
// ===== BillRecord → BillFormData 변환 (하위호환 유지) =====
export function billRecordToFormData(record: BillRecord): BillFormData {
return {
...INITIAL_BILL_FORM_DATA,
billNumber: record.billNumber,
direction: record.billType as Direction,
amount: record.amount,
issueDate: record.issueDate,
maturityDate: record.maturityDate,
note: record.note,
receivedStatus: record.billType === 'received' ? record.status : 'stored',
issuedStatus: record.billType === 'issued' ? record.status : 'stored',
vendor: record.billType === 'received' ? record.vendorId : '',
payee: record.billType === 'issued' ? record.vendorId : '',
installments: record.installments.map(inst => ({
id: inst.id,
date: inst.date,
type: 'other',
amount: inst.amount,
counterparty: '',
note: inst.note,
})),
};
}

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,
@@ -55,6 +56,29 @@ import { JournalEntryModal } from './JournalEntryModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { filterByEnum } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<CardTransaction>[] = [
{ header: '사용일시', key: 'usedAt', width: 18 },
{ header: '카드사', key: 'cardCompany', width: 10 },
{ header: '카드번호', key: 'card', width: 12 },
{ header: '카드명', key: 'cardName', width: 12 },
{ header: '공제', key: 'deductionType', width: 10,
transform: (v) => v === 'deductible' ? '공제' : '불공제' },
{ header: '사업자번호', key: 'businessNumber', width: 15 },
{ header: '가맹점명', key: 'merchantName', width: 15 },
{ header: '증빙/판매자상호', key: 'vendorName', width: 18 },
{ header: '내역', key: 'description', width: 15 },
{ header: '합계금액', key: 'totalAmount', width: 12 },
{ header: '공급가액', key: 'supplyAmount', width: 12 },
{ header: '세액', key: 'taxAmount', width: 10 },
{ header: '계정과목', key: 'accountSubject', width: 12,
transform: (v) => {
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
return found?.label || String(v || '');
}},
];
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
const tableColumns = [
@@ -66,7 +90,7 @@ const tableColumns = [
{ key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false },
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' },
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' },
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false },
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', sortable: false },
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false },
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' },
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false },
@@ -269,9 +293,45 @@ export function CardTransactionInquiry() {
setShowJournalEntry(true);
}, []);
const handleExcelDownload = useCallback(() => {
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.');
}, []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: CardTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getCardTransactionList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<CardTransaction & Record<string, unknown>>({
data: allData as (CardTransaction & Record<string, unknown>)[],
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
filename: '카드사용내역',
sheetName: '카드사용내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<CardTransaction> = useMemo(
@@ -540,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

@@ -1,9 +1,9 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { format, parseISO } from 'date-fns';
import { format, parseISO, subMonths, startOfMonth, endOfMonth } from 'date-fns';
import { ko } from 'date-fns/locale';
import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react';
import { Download, FileText, Loader2, Printer, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
@@ -15,18 +15,28 @@ import {
TableRow,
TableFooter,
} from '@/components/ui/table';
import { DatePicker } from '@/components/ui/date-picker';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import { Input } from '@/components/ui/input';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { Badge } from '@/components/ui/badge';
import { printElement } from '@/lib/print-utils';
import type { NoteReceivableItem, DailyAccountItem } from './types';
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== 빠른 월 선택 버튼 정의 =====
const QUICK_MONTH_BUTTONS = [
{ label: '이번달', months: 0 },
{ label: '지난달', months: 1 },
{ label: 'D-2월', months: 2 },
{ label: 'D-3월', months: 3 },
{ label: 'D-4월', months: 4 },
{ label: 'D-5월', months: 5 },
] as const;
// ===== Props 인터페이스 =====
interface DailyReportProps {
initialNoteReceivables?: NoteReceivableItem[];
@@ -36,7 +46,9 @@ interface DailyReportProps {
export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) {
const { canExport } = usePermission();
// ===== 상태 관리 =====
const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
const [searchTerm, setSearchTerm] = useState('');
const [noteReceivables, setNoteReceivables] = useState<NoteReceivableItem[]>(initialNoteReceivables);
const [dailyAccounts, setDailyAccounts] = useState<DailyAccountItem[]>(initialDailyAccounts);
const [summary, setSummary] = useState<{
@@ -53,9 +65,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
setIsLoading(true);
try {
const [noteResult, accountResult, summaryResult] = await Promise.all([
getNoteReceivables({ date: selectedDate }),
getDailyAccounts({ date: selectedDate }),
getDailyReportSummary({ date: selectedDate }),
getNoteReceivables({ date: startDate }),
getDailyAccounts({ date: startDate }),
getDailyReportSummary({ date: startDate }),
]);
if (noteResult.success) {
@@ -81,20 +93,20 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
} finally {
setIsLoading(false);
}
}, [selectedDate]);
}, [startDate]);
// ===== 초기 로드 및 날짜 변경시 재로드 =====
const isInitialMount = useRef(true);
const prevDateRef = useRef(selectedDate);
const prevDateRef = useRef(startDate);
useEffect(() => {
// 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드
if (isInitialMount.current || prevDateRef.current !== selectedDate) {
if (isInitialMount.current || prevDateRef.current !== startDate) {
isInitialMount.current = false;
prevDateRef.current = selectedDate;
prevDateRef.current = startDate;
loadData();
}
}, [selectedDate, loadData]);
}, [startDate, loadData]);
// ===== 어음 합계 (API 요약 사용) =====
const noteReceivableTotal = useMemo(() => {
@@ -144,9 +156,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
}, [accountTotals]);
// ===== 선택된 날짜 정보 =====
const selectedDateInfo = useMemo(() => {
const startDateInfo = useMemo(() => {
try {
const date = parseISO(selectedDate);
const date = parseISO(startDate);
return {
formatted: format(date, 'yyyy년 M월 d일', { locale: ko }),
dayOfWeek: format(date, 'EEEE', { locale: ko }),
@@ -154,12 +166,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
} catch {
return { formatted: '', dayOfWeek: '' };
}
}, [selectedDate]);
}, [startDate]);
// ===== 엑셀 다운로드 (프록시 API 직접 호출) =====
const handleExcelDownload = useCallback(async () => {
try {
const url = `/api/proxy/daily-report/export?date=${selectedDate}`;
const url = `/api/proxy/daily-report/export?date=${startDate}`;
const response = await fetch(url);
if (!response.ok) {
@@ -169,7 +181,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`;
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${startDate}.xlsx`;
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -183,7 +195,55 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
} catch {
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
}
}, [selectedDate]);
}, [startDate]);
// ===== 빠른 월 선택 =====
const handleQuickMonth = useCallback((monthsAgo: number) => {
const target = monthsAgo === 0 ? new Date() : subMonths(new Date(), monthsAgo);
setStartDate(format(startOfMonth(target), 'yyyy-MM-dd'));
setEndDate(format(endOfMonth(target), 'yyyy-MM-dd'));
}, []);
// ===== 인쇄 =====
const printAreaRef = useRef<HTMLDivElement>(null);
const handlePrint = useCallback(() => {
if (printAreaRef.current) {
printElement(printAreaRef.current, {
title: `일일일보_${startDate}`,
styles: `
.print-container { font-size: 11px; }
table { width: 100%; margin-bottom: 12px; }
h3 { margin-bottom: 8px; }
`,
});
}
}, [startDate]);
// ===== USD 금액 포맷 =====
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
// ===== 검색 필터링 =====
const filteredNoteReceivables = useMemo(() => {
if (!searchTerm) return noteReceivables;
const term = searchTerm.toLowerCase();
return noteReceivables.filter(item =>
item.content.toLowerCase().includes(term)
);
}, [noteReceivables, searchTerm]);
const filteredDailyAccounts = useMemo(() => {
if (!searchTerm) return dailyAccounts;
const term = searchTerm.toLowerCase();
return dailyAccounts.filter(item =>
item.category.toLowerCase().includes(term)
);
}, [dailyAccounts, searchTerm]);
// ===== USD 데이터 존재 여부 =====
const hasUsdAccounts = useMemo(() =>
filteredDailyAccounts.some(item => item.currency === 'USD'),
[filteredDailyAccounts]
);
return (
<PageLayout>
@@ -194,62 +254,81 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
icon={FileText}
/>
{/* 헤더 액션 (날짜 선택, 버튼 등) */}
{/* 헤더 액션 (날짜 선택, 빠른 월 선택, 검색, 인쇄) */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 min-w-0">
<Calendar className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-sm font-medium text-gray-700 shrink-0"> </span>
<DatePicker
value={selectedDate}
onChange={setSelectedDate}
className="w-auto min-w-[140px]"
size="sm"
align="start"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={isLoading}
className="h-8 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
</Button>
{canExport && (
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-8 px-2 text-xs">
<Download className="mr-1 h-3.5 w-3.5" />
<CardContent className="p-3 md:p-4">
<div className="flex flex-col gap-2 md:gap-3">
{/* DateRange */}
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
size="sm"
className="w-full md:w-auto md:min-w-[280px]"
displayFormat="yyyy-MM-dd"
/>
{/* 빠른 월 선택 버튼 - 모바일: 가로 스크롤 */}
<div className="flex items-center gap-1.5 md:gap-2 overflow-x-auto pb-1 -mb-1">
{QUICK_MONTH_BUTTONS.map((btn) => (
<Button
key={btn.label}
variant="outline"
size="sm"
className="h-7 md:h-8 px-2 md:px-2.5 text-xs shrink-0"
onClick={() => handleQuickMonth(btn.months)}
>
{btn.label}
</Button>
)}
))}
</div>
{/* 검색 + 인쇄/엑셀 - 모바일: 세로 배치 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="relative flex-1 sm:max-w-[300px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색..."
className="pl-8 h-8 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handlePrint} className="h-7 md:h-8 px-2 md:px-3 text-xs">
<Printer className="mr-1 h-3.5 w-3.5" />
</Button>
{canExport && (
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-7 md:h-8 px-2 md:px-3 text-xs">
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 어음 및 외상매출채권현황 */}
{/* 인쇄 영역 */}
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
{/* 일자별 입출금 합계 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"> </h3>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-semibold">
: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
</h3>
</div>
<div className="rounded-md border overflow-x-auto">
<div className="min-w-[550px]">
<Table>
<TableHeader>
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
<div className="min-w-[420px] md:min-w-[650px]">
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="font-semibold max-w-[200px]"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"> </TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -258,129 +337,343 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
<TableCell colSpan={4} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500"> ...</span>
<span className="text-gray-500 text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : noteReceivables.length === 0 ? (
) : filteredDailyAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
noteReceivables.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[200px] truncate">{item.content}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.currentBalance)}</TableCell>
<TableCell className="text-center whitespace-nowrap">{item.issueDate}</TableCell>
<TableCell className="text-center whitespace-nowrap">{item.dueDate}</TableCell>
</TableRow>
))
)}
</TableBody>
{noteReceivables.length > 0 && (
<TableFooter>
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(noteReceivableTotal)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</div>
</div>
</CardContent>
</Card>
{/* 일자별 상세 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
: {selectedDateInfo.formatted} {selectedDateInfo.dayOfWeek}
</h3>
</div>
<div className="rounded-md border overflow-x-auto">
<div className="min-w-[650px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold max-w-[180px]"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"> </TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500"> ...</span>
</div>
</TableCell>
</TableRow>
) : dailyAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
<>
{/* KRW 계좌들 */}
{dailyAccounts
{filteredDailyAccounts
.filter(item => item.currency === 'KRW')
.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[180px] truncate">{item.category}</TableCell>
<TableCell className="text-center whitespace-nowrap">
<Badge className={MATCH_STATUS_COLORS[item.matchStatus]}>
{MATCH_STATUS_LABELS[item.matchStatus]}
</Badge>
</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.carryover)}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.balance)}</TableCell>
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
</TableRow>
))}
{/* KRW 소계 */}
{hasUsdAccounts && (
<TableRow className="bg-muted/30 font-medium">
<TableCell className="text-xs md:text-sm font-semibold">(KRW) </TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.balance)}</TableCell>
</TableRow>
)}
{/* USD 계좌들 */}
{hasUsdAccounts && filteredDailyAccounts
.filter(item => item.currency === 'USD')
.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.balance)}</TableCell>
</TableRow>
))}
{/* USD 소계 */}
{hasUsdAccounts && (
<TableRow className="bg-muted/30 font-medium">
<TableCell className="text-xs md:text-sm font-semibold">(USD) </TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.balance)}</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
{dailyAccounts.length > 0 && (
{filteredDailyAccounts.length > 0 && (
<TableFooter>
{/* 외화원 (USD) 합계 */}
<TableRow className="bg-blue-50/50">
<TableCell className="font-semibold whitespace-nowrap"> (USD) </TableCell>
<TableCell></TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.carryover)}</TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.balance)}</TableCell>
</TableRow>
{/* 현금성 자산 합계 */}
{/* 합계 */}
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold whitespace-nowrap"> </TableCell>
<TableCell></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.carryover)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.income)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.expense)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.balance)}</TableCell>
<TableCell className="font-bold whitespace-nowrap text-xs md:text-sm"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.balance)}</TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</table>
</div>
</div>
</CardContent>
</Card>
{/* 예금 입출금 내역 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
{/* KRW 입출금 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{/* KRW 입금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
<span className="font-semibold text-blue-700 text-sm md:text-base"> (KRW)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6">
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
</TableCell>
</TableRow>
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
.
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'KRW' && item.income > 0)
.map((item) => (
<TableRow key={`deposit-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-blue-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
{/* KRW 출금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
<span className="font-semibold text-red-700 text-sm md:text-base"> (KRW)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6">
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
</TableCell>
</TableRow>
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
.
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'KRW' && item.expense > 0)
.map((item) => (
<TableRow key={`withdrawal-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-red-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
</div>
{/* USD 입출금 — USD 데이터가 있을 때만 표시 */}
{hasUsdAccounts && (
<>
<div className="flex items-center justify-center mt-4 mb-3 py-1.5 md:py-2 bg-emerald-50 rounded-md">
<h3 className="text-base md:text-lg font-semibold text-emerald-800">(USD) </h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{/* USD 입금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-emerald-50 rounded-t-md border border-b-0">
<span className="font-semibold text-emerald-700 text-sm md:text-base"> (USD)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
USD .
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'USD' && item.income > 0)
.map((item) => (
<TableRow key={`usd-deposit-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-emerald-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.income)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
{/* USD 출금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-orange-50 rounded-t-md border border-b-0">
<span className="font-semibold text-orange-700 text-sm md:text-base"> (USD)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
USD .
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'USD' && item.expense > 0)
.map((item) => (
<TableRow key={`usd-withdrawal-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-orange-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.expense)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 어음 및 외상매출채권현황 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
<div className="rounded-md border overflow-x-auto max-h-[25vh] md:max-h-[30vh] overflow-y-auto">
<div className="min-w-[480px] md:min-w-[550px]">
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"> </TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500 text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : filteredNoteReceivables.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
filteredNoteReceivables.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
</TableRow>
))
)}
</TableBody>
{filteredNoteReceivables.length > 0 && (
<TableFooter className="sticky bottom-0 z-10 bg-background">
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold text-xs md:text-sm"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
)}
</table>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
);
}
}

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

@@ -1,144 +1,106 @@
/**
* 상품권 관리 서버 액션 (Mock)
* 상품권 관리 서버 액션
*
* API Endpoints (예정):
* - GET /api/v1/gift-certificates - 목록 조회
* - GET /api/v1/gift-certificates/{id} - 상세 조회
* - POST /api/v1/gift-certificates - 등록
* - PUT /api/v1/gift-certificates/{id} - 수정
* - DELETE /api/v1/gift-certificates/{id} - 삭제
* - GET /api/v1/gift-certificates/summary - 요약 통계
* API Endpoints (Loan API 재사용, category='gift_certificate'):
* - GET /api/v1/loans?category=gift_certificate - 목록 조회
* - GET /api/v1/loans/{id} - 상세 조회
* - POST /api/v1/loans - 등록
* - PUT /api/v1/loans/{id} - 수정
* - DELETE /api/v1/loans/{id} - 삭제
* - GET /api/v1/loans/summary?category=gift_certificate - 요약 통계
*/
'use server';
import type { ActionResult } from '@/lib/api/execute-server-action';
// import { executeServerAction } from '@/lib/api/execute-server-action';
// import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
// import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { executePaginatedAction, type PaginatedActionResult } from '@/lib/api/execute-paginated-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
GiftCertificateRecord,
GiftCertificateFormData,
LoanApiData,
} from './types';
import {
transformApiToRecord,
transformApiToFormData,
transformFormToApi,
} from './types';
// ===== 상품권 목록 조회 (Mock) =====
export async function getGiftCertificates(_params?: {
// ===== 상품권 목록 조회 =====
export async function getGiftCertificates(params?: {
page?: number;
perPage?: number;
startDate?: string;
endDate?: string;
status?: string;
}): Promise<ActionResult<GiftCertificateRecord[]>> {
// TODO: 실제 API 연동 시 교체
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
// transform: transformApiToFrontend,
// errorMessage: '상품권 목록 조회에 실패했습니다.',
// });
return { success: true, data: [] };
search?: string;
}): Promise<PaginatedActionResult<GiftCertificateRecord>> {
return executePaginatedAction<LoanApiData, GiftCertificateRecord>({
url: buildApiUrl('/api/v1/loans', {
category: 'gift_certificate',
page: params?.page,
per_page: params?.perPage,
start_date: params?.startDate,
end_date: params?.endDate,
status: params?.status && params.status !== 'all' ? params.status : undefined,
search: params?.search,
}),
transform: transformApiToRecord,
errorMessage: '상품권 목록 조회에 실패했습니다.',
});
}
// ===== 상품권 상세 조회 (Mock) =====
// ===== 상품권 상세 조회 =====
export async function getGiftCertificateById(
_id: string
id: string
): Promise<ActionResult<GiftCertificateFormData>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
// transform: transformDetailApiToFrontend,
// errorMessage: '상품권 조회에 실패했습니다.',
// });
return {
success: true,
data: {
serialNumber: 'GC-2026-001',
name: '신세계 상품권',
faceValue: 500000,
vendorId: '',
vendorName: '신세계백화점',
purchaseDate: '2026-02-10',
purchasePurpose: 'entertainment',
entertainmentExpense: 'applicable',
status: 'used',
usedDate: '2026-02-20',
recipientName: '홍길동',
recipientOrganization: '(주)테크솔루션',
usageDescription: '거래처 접대용',
memo: '2월 접대비 처리 완료',
},
};
return executeServerAction({
url: buildApiUrl(`/api/v1/loans/${id}`),
transform: (data: LoanApiData) => transformApiToFormData(data),
errorMessage: '상품권 조회에 실패했습니다.',
});
}
// ===== 상품권 등록 (Mock) =====
// ===== 상품권 등록 =====
export async function createGiftCertificate(
_data: GiftCertificateFormData
data: GiftCertificateFormData
): Promise<ActionResult<GiftCertificateRecord>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl('/api/v1/gift-certificates'),
// method: 'POST',
// body: transformFrontendToApi(data),
// transform: transformApiToFrontend,
// errorMessage: '상품권 등록에 실패했습니다.',
// });
return {
success: true,
data: {
id: crypto.randomUUID(),
serialNumber: _data.serialNumber || `GC-${Date.now()}`,
name: _data.name,
faceValue: _data.faceValue,
purchaseDate: _data.purchaseDate,
usedDate: _data.usedDate || null,
status: _data.status,
entertainmentExpense: _data.entertainmentExpense,
},
};
return executeServerAction({
url: buildApiUrl('/api/v1/loans'),
method: 'POST',
body: transformFormToApi(data),
transform: (d: LoanApiData) => transformApiToRecord(d),
errorMessage: '상품권 등록에 실패했습니다.',
});
}
// ===== 상품권 수정 (Mock) =====
// ===== 상품권 수정 =====
export async function updateGiftCertificate(
_id: string,
_data: GiftCertificateFormData
id: string,
data: GiftCertificateFormData
): Promise<ActionResult<GiftCertificateRecord>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
// method: 'PUT',
// body: transformFrontendToApi(data),
// transform: transformApiToFrontend,
// errorMessage: '상품권 수정에 실패했습니다.',
// });
return {
success: true,
data: {
id: _id,
serialNumber: _data.serialNumber,
name: _data.name,
faceValue: _data.faceValue,
purchaseDate: _data.purchaseDate,
usedDate: _data.usedDate || null,
status: _data.status,
entertainmentExpense: _data.entertainmentExpense,
},
};
return executeServerAction({
url: buildApiUrl(`/api/v1/loans/${id}`),
method: 'PUT',
body: transformFormToApi(data),
transform: (d: LoanApiData) => transformApiToRecord(d),
errorMessage: '상품권 수정에 실패했습니다.',
});
}
// ===== 상품권 삭제 (Mock) =====
// ===== 상품권 삭제 =====
export async function deleteGiftCertificate(
_id: string
id: string
): Promise<ActionResult> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
// method: 'DELETE',
// errorMessage: '상품권 삭제에 실패했습니다.',
// });
return { success: true };
return executeServerAction({
url: buildApiUrl(`/api/v1/loans/${id}`),
method: 'DELETE',
errorMessage: '상품권 삭제에 실패했습니다.',
});
}
// ===== 상품권 요약 통계 (Mock) =====
export async function getGiftCertificateSummary(_params?: {
// ===== 상품권 요약 통계 =====
export async function getGiftCertificateSummary(params?: {
startDate?: string;
endDate?: string;
}): Promise<ActionResult<{
@@ -151,23 +113,31 @@ export async function getGiftCertificateSummary(_params?: {
entertainmentCount: number;
entertainmentAmount: number;
}>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }),
// transform: transformSummary,
// errorMessage: '상품권 요약 조회에 실패했습니다.',
// });
return {
success: true,
data: {
totalCount: 0,
totalAmount: 0,
holdingCount: 0,
holdingAmount: 0,
usedCount: 0,
usedAmount: 0,
entertainmentCount: 0,
entertainmentAmount: 0,
},
};
return executeServerAction({
url: buildApiUrl('/api/v1/loans/summary', {
category: 'gift_certificate',
start_date: params?.startDate,
end_date: params?.endDate,
}),
transform: (data: {
total_count: number;
total_amount: number;
holding_count?: number;
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,
holdingCount: data.holding_count ?? 0,
holdingAmount: data.holding_amount ?? 0,
usedCount: data.used_count ?? 0,
usedAmount: data.used_amount ?? 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

@@ -104,3 +104,94 @@ export function createEmptyFormData(): GiftCertificateFormData {
// ===== 액면가 50만원 기준 =====
export const FACE_VALUE_THRESHOLD = 500000;
// ===== Loan API 응답 타입 =====
export interface LoanApiData {
id: number;
tenant_id: number;
user_id: number | null;
loan_date: string;
amount: string;
purpose: string | null;
settlement_date: string | null;
settlement_amount: string | null;
status: string;
category: string | null;
metadata: {
serial_number?: string;
cert_name?: string;
vendor_id?: string;
vendor_name?: string;
purchase_purpose?: string;
entertainment_expense?: string;
recipient_name?: string;
recipient_organization?: string;
usage_description?: string;
memo?: string;
} | null;
withdrawal_id: number | null;
created_by: number | null;
updated_by: number | null;
user?: { id: number; name: string; email: string } | null;
creator?: { id: number; name: string } | null;
}
// ===== API → 프론트 변환 (목록용) =====
export function transformApiToRecord(api: LoanApiData): GiftCertificateRecord {
const meta = api.metadata ?? {};
return {
id: String(api.id),
serialNumber: meta.serial_number ?? '',
name: meta.cert_name ?? '',
faceValue: parseFloat(api.amount) || 0,
purchaseDate: api.loan_date ?? '',
usedDate: api.settlement_date ?? null,
status: (api.status as GiftCertificateStatus) ?? 'holding',
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
};
}
// ===== API → 프론트 변환 (상세/폼용) =====
export function transformApiToFormData(api: LoanApiData): GiftCertificateFormData {
const meta = api.metadata ?? {};
return {
serialNumber: meta.serial_number ?? '',
name: meta.cert_name ?? '',
faceValue: parseFloat(api.amount) || 0,
vendorId: meta.vendor_id ?? '',
vendorName: meta.vendor_name ?? '',
purchaseDate: api.loan_date ?? '',
purchasePurpose: (meta.purchase_purpose as PurchasePurpose) ?? 'promotion',
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
status: (api.status as GiftCertificateStatus) ?? 'holding',
usedDate: api.settlement_date ?? '',
recipientName: meta.recipient_name ?? '',
recipientOrganization: meta.recipient_organization ?? '',
usageDescription: meta.usage_description ?? '',
memo: meta.memo ?? '',
};
}
// ===== 프론트 → API 변환 =====
export function transformFormToApi(data: GiftCertificateFormData): Record<string, unknown> {
return {
loan_date: data.purchaseDate,
amount: data.faceValue,
purpose: data.usageDescription || null,
category: 'gift_certificate',
status: data.status,
settlement_date: data.usedDate || null,
metadata: {
serial_number: data.serialNumber || null,
cert_name: data.name || null,
vendor_id: data.vendorId || null,
vendor_name: data.vendorName || null,
purchase_purpose: data.purchasePurpose || null,
entertainment_expense: data.entertainmentExpense || null,
recipient_name: data.recipientName || null,
recipient_organization: data.recipientOrganization || null,
usage_description: data.usageDescription || null,
memo: data.memo || null,
},
};
}

View File

@@ -24,8 +24,7 @@ import { purchaseConfig } from './purchaseConfig';
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
import { PURCHASE_TYPE_LABELS } from './types';
import type { PurchaseRecord, PurchaseItem } from './types';
import {
getPurchaseById,
createPurchase,
@@ -33,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';
@@ -74,7 +74,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [vendorName, setVendorName] = useState('');
const [purchaseType, setPurchaseType] = useState<PurchaseType>('unset');
// purchaseType 삭제됨 (기획서 P.109)
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
@@ -126,7 +126,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
setPurchaseDate(data.purchaseDate);
setVendorId(data.vendorId);
setVendorName(data.vendorName);
setPurchaseType(data.purchaseType);
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
setTaxInvoiceReceived(data.taxInvoiceReceived);
setSourceDocument(data.sourceDocument);
@@ -250,7 +249,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
supplyAmount: totals.supplyAmount,
vat: totals.vat,
totalAmount: totals.total,
purchaseType,
taxInvoiceReceived,
};
@@ -263,6 +261,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
}
if (result?.success) {
invalidateDashboard('purchase');
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
return { success: true };
} else {
@@ -275,7 +274,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
} finally {
setIsSaving(false);
}
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]);
}, [purchaseDate, vendorId, totals, taxInvoiceReceived, isNewMode, purchaseId]);
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
@@ -285,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 {
@@ -301,179 +301,101 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const renderFormContent = () => (
<>
<div className="space-y-6">
{/* ===== 기본 정보 섹션 ===== */}
{/* ===== 기본 정보 섹션 (품의서/지출결의서 + 예상비용) ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 품의서/지출결의서인 경우 전용 레이아웃 */}
{sourceDocument ? (
<>
{/* 문서 타입 및 열람 버튼 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getPresetStyle('orange')}>
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
<span className="text-sm text-muted-foreground"> </span>
</div>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 품의서/지출결의서 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
value={sourceDocument ? `${sourceDocument.documentNo} ${sourceDocument.title}` : ''}
readOnly
disabled
placeholder="연결된 품의서 없음"
className="bg-gray-50"
/>
<Button
variant="outline"
size="sm"
className="sm:ml-auto border-orange-300 text-orange-700 hover:bg-orange-100 w-full sm:w-auto"
className="shrink-0"
onClick={handleOpenDocument}
disabled={!sourceDocument}
>
<Eye className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 품의서/지출결의서용 필드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 품의서/지출결의서 제목 */}
<div className="space-y-2 md:col-span-2">
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} </Label>
<Input
value={sourceDocument.title}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 예상비용 */}
<div className="space-y-2">
<Label></Label>
<Input
value={`${formatAmount(sourceDocument.expectedCost)}`}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매입 유형 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={purchaseType}
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="매입 유형 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
) : (
/* 일반 매입 (품의서/지출결의서 없는 경우) */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입일 */}
<div className="space-y-2">
<Label></Label>
<DatePicker
value={purchaseDate}
onChange={setPurchaseDate}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매입 유형 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={purchaseType}
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="매입 유형 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 예상비용 */}
<div className="space-y-2">
<Label></Label>
<Input
value={sourceDocument ? `${formatAmount(sourceDocument.expectedCost)}` : ''}
readOnly
disabled
placeholder="-"
className="bg-gray-50"
/>
</div>
</div>
</CardContent>
</Card>
{/* ===== 매입 정보 섹션 ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입일 */}
<div className="space-y-2">
<Label></Label>
<DatePicker
value={purchaseDate}
onChange={setPurchaseDate}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>

View File

@@ -26,8 +26,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제)
import { Switch } from '@/components/ui/switch';
import {
Dialog,
@@ -57,12 +56,11 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import type { PurchaseRecord } from './types';
import {
SORT_OPTIONS,
PURCHASE_TYPE_LABELS,
PURCHASE_TYPE_FILTER_OPTIONS,
ISSUANCE_FILTER_OPTIONS,
TAX_INVOICE_RECEIVED_FILTER_OPTIONS,
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber } from '@/lib/utils/amount';
// ===== 테이블 컬럼 정의 =====
@@ -71,11 +69,9 @@ const tableColumns = [
{ key: 'purchaseNo', label: '매입번호', sortable: true },
{ key: 'purchaseDate', label: '매입일', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'sourceDocument', label: '연결문서', className: 'text-center', sortable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
{ key: 'purchaseType', label: '매입유형', className: 'text-center', sortable: true },
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
];
@@ -92,8 +88,7 @@ export function PurchaseManagement() {
// 통합 필터 상태 (filterConfig 기반)
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
vendor: 'all',
purchaseType: 'all',
issuance: 'all',
taxInvoiceReceived: 'all',
sort: 'latest',
});
@@ -142,9 +137,8 @@ export function PurchaseManagement() {
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, d) => sum + d.totalAmount, 0);
const unsetTypeCount = purchaseData.filter(d => d.purchaseType === 'unset').length;
const taxInvoicePendingCount = purchaseData.filter(d => !d.taxInvoiceReceived).length;
return { totalPurchaseAmount, monthlyAmount, unsetTypeCount, taxInvoicePendingCount };
return { totalPurchaseAmount, monthlyAmount, taxInvoicePendingCount };
}, [purchaseData]);
// ===== 거래처 목록 (필터용) =====
@@ -163,17 +157,10 @@ export function PurchaseManagement() {
allOptionLabel: '거래처 전체',
},
{
key: 'purchaseType',
label: '매입유형',
key: 'taxInvoiceReceived',
label: '세금계산서 수취여부',
type: 'single',
options: PURCHASE_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
key: 'issuance',
label: '발행여부',
type: 'single',
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
options: TAX_INVOICE_RECEIVED_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
@@ -194,8 +181,7 @@ export function PurchaseManagement() {
const handleFilterReset = useCallback(() => {
setFilterValues({
vendor: 'all',
purchaseType: 'all',
issuance: 'all',
taxInvoiceReceived: 'all',
sort: 'latest',
});
}, []);
@@ -268,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('매입이 삭제되었습니다.');
}
@@ -309,18 +296,16 @@ export function PurchaseManagement() {
}
const vendorVal = fv.vendor as string;
const purchaseTypeVal = fv.purchaseType as string;
const issuanceVal = fv.issuance as string;
const taxInvoiceReceivedVal = fv.taxInvoiceReceived as string;
// 거래처 필터
if (vendorVal !== 'all' && item.vendorName !== vendorVal) {
return false;
}
// 매입유형 필터
if (purchaseTypeVal !== 'all' && item.purchaseType !== purchaseTypeVal) {
// 세금계산서 수취여부 필터
if (taxInvoiceReceivedVal === 'received' && !item.taxInvoiceReceived) {
return false;
}
// 발행여부 필터
if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceReceived) {
if (taxInvoiceReceivedVal === 'notReceived' && item.taxInvoiceReceived) {
return false;
}
return true;
@@ -393,9 +378,8 @@ export function PurchaseManagement() {
// Stats 카드
computeStats: (): StatCard[] => [
{ label: '총 매입', value: `${formatNumber(stats.totalPurchaseAmount)}`, icon: Receipt, iconColor: 'text-blue-500' },
{ label: '총매입', value: `${formatNumber(stats.totalPurchaseAmount)}`, icon: Receipt, iconColor: 'text-blue-500' },
{ label: '당월 매입', value: `${formatNumber(stats.monthlyAmount)}`, icon: Receipt, iconColor: 'text-green-500' },
{ label: '매입유형 미설정', value: `${stats.unsetTypeCount}`, icon: Receipt, iconColor: 'text-orange-500' },
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}`, icon: Receipt, iconColor: 'text-red-500' },
],
@@ -406,13 +390,10 @@ export function PurchaseManagement() {
<TableCell className="text-center"></TableCell>
<TableCell className="font-bold"></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalSupplyAmount)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalVat)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -428,9 +409,7 @@ export function PurchaseManagement() {
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PurchaseRecord>
) => {
const isUnsetType = item.purchaseType === 'unset';
return (
) => (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
@@ -443,26 +422,9 @@ export function PurchaseManagement() {
<TableCell className="text-sm font-medium">{item.purchaseNo}</TableCell>
<TableCell>{item.purchaseDate}</TableCell>
<TableCell>{item.vendorName}</TableCell>
<TableCell className="text-center">
{item.sourceDocument ? (
<Badge variant="outline" className={`text-xs ${getPresetStyle('info')}`}>
{item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</TableCell>
<TableCell className="text-right">{formatNumber(item.supplyAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.vat)}</TableCell>
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={isUnsetType ? 'border-red-500 text-red-500 bg-red-50' : ''}
>
{PURCHASE_TYPE_LABELS[item.purchaseType]}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Switch
@@ -474,8 +436,7 @@ export function PurchaseManagement() {
</div>
</TableCell>
</TableRow>
);
},
),
// 모바일 카드 렌더링
renderMobileCard: (
@@ -488,14 +449,11 @@ export function PurchaseManagement() {
key={item.id}
title={item.vendorName}
subtitle={item.purchaseNo}
badge={PURCHASE_TYPE_LABELS[item.purchaseType]}
badgeVariant="outline"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '매입일', value: item.purchaseDate },
{ label: '연결문서', value: item.sourceDocument ? (item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서') : '-' },
{ label: '공급가액', value: `${formatNumber(item.supplyAmount)}` },
{ label: '합계금액', value: `${formatNumber(item.totalAmount)}` },
]}

View File

@@ -81,8 +81,8 @@ export interface PurchaseRecord {
// 정렬 옵션
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
// 발행여부 필터
export type IssuanceFilter = 'all' | 'taxInvoicePending';
// 세금계산서 수취여부 필터
export type TaxInvoiceReceivedFilter = 'all' | 'received' | 'notReceived';
// ===== 상수 정의 =====
@@ -154,10 +154,11 @@ export const PURCHASE_TYPE_FILTER_OPTIONS: { value: string; label: string }[] =
{ value: 'unset', label: '미설정' },
];
// 발행여부 필터 옵션
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
// 세금계산서 수취여부 필터 옵션
export const TAX_INVOICE_RECEIVED_FILTER_OPTIONS: { value: TaxInvoiceReceivedFilter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'taxInvoicePending', label: '세금계산서 미수취' },
{ value: 'received', label: '수취 확인' },
{ value: 'notReceived', label: '수취 미확인' },
];
// 계정과목명 셀렉터 옵션 (상단 일괄 변경용)

View File

@@ -32,9 +32,10 @@ import {
CATEGORY_LABELS,
SORT_OPTIONS,
} from './types';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions';
import { toast } from 'sonner';
import { filterByText } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
@@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
});
}, []);
// ===== 엑셀 다운로드 핸들러 =====
// ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => {
const result = await exportReceivablesExcel({
year: selectedYear,
search: searchQuery || undefined,
});
if (result.success && result.data) {
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '채권현황.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
try {
toast.info('엑셀 파일 생성 중...');
// 데이터가 이미 로드되어 있으므로 sortedData 사용
if (sortedData.length === 0) {
toast.warning('다운로드할 데이터가 없습니다.');
return;
}
// 동적 월 컬럼 포함 엑셀 컬럼 생성
const columns: ExcelColumn<Record<string, unknown>>[] = [
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '연체', key: 'isOverdue', width: 8 },
...monthLabels.map((label, idx) => ({
header: label, key: `month_${idx}`, width: 12,
})),
{ header: '합계', key: 'total', width: 14 },
{ header: '메모', key: 'memo', width: 20 },
];
// 미수금 카테고리 기준으로 플랫 데이터 생성
const exportData = sortedData.map(vendor => {
const receivable = vendor.categories.find(c => c.category === 'receivable');
const row: Record<string, unknown> = {
vendorName: vendor.vendorName,
isOverdue: vendor.isOverdue ? '연체' : '',
};
monthLabels.forEach((_, idx) => {
row[`month_${idx}`] = receivable?.amounts.values[idx] || 0;
});
row.total = receivable?.amounts.total || 0;
row.memo = vendor.memo || '';
return row;
});
await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' });
toast.success('엑셀 다운로드 완료');
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [selectedYear, searchQuery]);
}, [sortedData, monthLabels]);
// ===== 변경된 연체 항목 확인 =====
const changedOverdueItems = useMemo(() => {

View File

@@ -32,9 +32,9 @@ import {
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem, SalesType } from './types';
import { SALES_TYPE_OPTIONS } from './types';
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';
@@ -78,7 +78,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [vendorName, setVendorName] = useState('');
const [salesType, setSalesType] = useState<SalesType>('product');
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
@@ -126,7 +125,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
setSalesDate(data.salesDate);
setVendorId(data.vendorId);
setVendorName(data.vendorName);
setSalesType(data.salesType);
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
setTaxInvoiceIssued(data.taxInvoiceIssued);
setTransactionStatementIssued(data.transactionStatementIssued);
@@ -158,7 +156,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const saleData: Partial<SalesRecord> = {
salesDate,
vendorId,
salesType,
items,
totalSupplyAmount: totals.supplyAmount,
totalVat: totals.vat,
@@ -177,6 +174,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
}
if (result?.success) {
invalidateDashboard('sales');
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
return { success: true };
} else {
@@ -189,7 +187,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
} finally {
setIsSaving(false);
}
}, [salesDate, vendorId, salesType, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
}, [salesDate, vendorId, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
@@ -199,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 {
@@ -268,23 +267,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
</SelectContent>
</Select>
</div>
{/* 매출 유형 */}
<div className="space-y-2">
<Label htmlFor="salesType"> </Label>
<Select value={salesType} onValueChange={(v) => setSalesType(v as SalesType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
@@ -318,28 +300,42 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Label htmlFor="taxInvoice"> </Label>
<Switch
id="taxInvoice"
checked={taxInvoiceIssued}
onCheckedChange={setTaxInvoiceIssued}
disabled={isViewMode}
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Label htmlFor="taxInvoice"> </Label>
<Switch
id="taxInvoice"
checked={taxInvoiceIssued}
onCheckedChange={setTaxInvoiceIssued}
disabled={isViewMode}
/>
</div>
<div className="flex items-center gap-2">
{taxInvoiceIssued ? (
<span className="text-sm text-green-600 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
) : (
<span className="text-sm text-gray-500 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{taxInvoiceIssued ? (
<span className="text-sm text-green-600 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
) : (
<span className="text-sm text-gray-500 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
)}
<div className="flex justify-end">
<Button
variant="default"
className="bg-gray-900 hover:bg-gray-800 text-white"
onClick={() => {
toast.info('세금계산서 발행 기능 준비 중입니다.');
}}
>
<FileText className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</CardContent>

View File

@@ -25,7 +25,6 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
@@ -55,11 +54,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import type { SalesRecord } from './types';
import {
SORT_OPTIONS,
SALES_STATUS_LABELS,
SALES_STATUS_COLORS,
SALES_TYPE_LABELS,
SALES_TYPE_FILTER_OPTIONS,
ISSUANCE_FILTER_OPTIONS,
TAX_INVOICE_FILTER_OPTIONS,
TRANSACTION_STATEMENT_FILTER_OPTIONS,
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getSales, deleteSale, toggleSaleIssuance } from './actions';
@@ -83,7 +79,6 @@ const tableColumns = [
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
{ key: 'salesType', label: '매출유형', className: 'text-center', sortable: true },
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
];
@@ -113,8 +108,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
// 필터 초기값 (filterConfig 기반 - ULP가 내부 state로 관리)
const initialFilterValues: Record<string, string | string[]> = {
vendor: 'all',
salesType: 'all',
issuance: 'all',
taxInvoice: 'all',
transactionStatement: 'all',
sort: 'latest',
};
@@ -148,17 +143,17 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
allOptionLabel: '거래처 전체',
},
{
key: 'salesType',
label: '매출유형',
key: 'taxInvoice',
label: '세금계산서 발행여부',
type: 'single',
options: SALES_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
options: TAX_INVOICE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
key: 'issuance',
label: '발행여부',
key: 'transactionStatement',
label: '거래명세서 발행여부',
type: 'single',
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
options: TRANSACTION_STATEMENT_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
@@ -322,18 +317,24 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
// 검색은 searchFilter에서 처리하므로 여기서는 필터만 처리
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
const issuanceVal = fv.issuance as string;
const taxInvoiceVal = fv.taxInvoice as string;
const transactionStatementVal = fv.transactionStatement as string;
let result = applyFilters(items, [
enumFilter('vendorName', fv.vendor as string),
enumFilter('salesType', fv.salesType as string),
]);
// 발행여부 필터 (특수 로직 - enumFilter로 대체 불가)
if (issuanceVal === 'taxInvoicePending') {
// 세금계산서 발행여부 필터
if (taxInvoiceVal === 'issued') {
result = result.filter(item => item.taxInvoiceIssued);
} else if (taxInvoiceVal === 'notIssued') {
result = result.filter(item => !item.taxInvoiceIssued);
}
if (issuanceVal === 'transactionStatementPending') {
// 거래명세서 발행여부 필터
if (transactionStatementVal === 'issued') {
result = result.filter(item => item.transactionStatementIssued);
} else if (transactionStatementVal === 'notIssued') {
result = result.filter(item => !item.transactionStatementIssued);
}
@@ -411,7 +412,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -443,9 +443,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
<TableCell className="text-right">{formatNumber(item.totalSupplyAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.totalVat)}</TableCell>
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
<TableCell className="text-center">
<Badge variant="outline">{SALES_TYPE_LABELS[item.salesType]}</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Switch
@@ -480,8 +477,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
key={item.id}
title={item.vendorName}
subtitle={item.salesNo}
badge={SALES_TYPE_LABELS[item.salesType]}
badgeVariant="outline"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(item)}

View File

@@ -133,13 +133,22 @@ export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS = [
{ value: 'other', label: '기타매출' },
];
// ===== 발행여부 필터 =====
export type IssuanceFilter = 'all' | 'taxInvoicePending' | 'transactionStatementPending';
// ===== 세금계산서 발행여부 필터 =====
export type TaxInvoiceFilter = 'all' | 'issued' | 'notIssued';
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
export const TAX_INVOICE_FILTER_OPTIONS: { value: TaxInvoiceFilter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'taxInvoicePending', label: '세금계산서 미발행' },
{ value: 'transactionStatementPending', label: '거래명세서 미발행' },
{ value: 'issued', label: '발행완료' },
{ value: 'notIssued', label: '미발행' },
];
// ===== 거래명세서 발행여부 필터 =====
export type TransactionStatementFilter = 'all' | 'issued' | 'notIssued';
export const TRANSACTION_STATEMENT_FILTER_OPTIONS: { value: TransactionStatementFilter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'issued', label: '발행완료' },
{ value: 'notIssued', label: '미발행' },
];
// ===== 매출유형 필터 옵션 (스크린샷 기준) =====

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

@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { DatePicker } from '@/components/ui/date-picker';
import { FormField } from '@/components/molecules/FormField';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
import {
Dialog,
DialogContent,
@@ -199,12 +200,14 @@ export function ManualEntryModal({
onChange={(value) => handleChange('vendorName', value)}
placeholder="공급자명"
/>
<FormField
label="사업자번호"
value={formData.vendorBusinessNumber}
onChange={(value) => handleChange('vendorBusinessNumber', value)}
placeholder="사업자번호"
/>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<BusinessNumberInput
value={formData.vendorBusinessNumber}
onChange={(value) => handleChange('vendorBusinessNumber', value)}
placeholder="000-00-00000"
/>
</div>
</div>
</div>

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,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import {
getTaxInvoices,
getTaxInvoiceSummary,
downloadTaxInvoiceExcel,
} from './actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
const ManualEntryModal = dynamic(
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
@@ -58,6 +58,10 @@ import type {
TaxInvoiceMgmtRecord,
InvoiceTab,
TaxInvoiceSummary,
TaxType,
ReceiptType,
InvoiceStatus,
InvoiceSource,
} from './types';
import {
TAB_OPTIONS,
@@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
];
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[] = [
{ header: '작성일자', key: 'writeDate', width: 12 },
{ header: '발급일자', key: 'issueDate', width: 12 },
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '사업자번호', key: 'vendorBusinessNumber', width: 15 },
{ header: '과세형태', key: 'taxType', width: 10,
transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') },
{ header: '품목', key: 'itemName', width: 15 },
{ header: '공급가액', key: 'supplyAmount', width: 14 },
{ header: '세액', key: 'taxAmount', width: 14 },
{ header: '합계', key: 'totalAmount', width: 14 },
{ header: '영수청구', key: 'receiptType', width: 10,
transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') },
{ header: '상태', key: 'status', width: 10,
transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') },
{ header: '발급형태', key: 'source', width: 10,
transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') },
];
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
@@ -224,19 +248,46 @@ export function TaxInvoiceManagement() {
loadData();
}, [loadData]);
// ===== 엑셀 다운로드 =====
// ===== 엑셀 다운로드 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => {
const result = await downloadTaxInvoiceExcel({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
});
if (result.success && result.data) {
window.open(result.data.url, '_blank');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
try {
toast.info('엑셀 파일 생성 중...');
const allData: TaxInvoiceMgmtRecord[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getTaxInvoices({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
page,
perPage: 100,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (TaxInvoiceMgmtRecord & Record<string, unknown>)[],
columns: excelColumns,
filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`,
sheetName: activeTab === 'sales' ? '매출' : '매입',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [activeTab, dateType, startDate, endDate, vendorSearch]);

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,66 @@ 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_type: 'normal',
issue_date: data.writeDate,
// 매출: 거래처=공급받는자(buyer), 매입: 거래처=공급자(supplier)
// DB 컬럼이 NOT NULL이므로 빈 문자열로 전송
supplier_corp_name: isSales ? '' : data.vendorName,
supplier_corp_num: isSales ? '' : data.vendorBusinessNumber,
buyer_corp_name: isSales ? data.vendorName : '',
buyer_corp_num: isSales ? 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 +278,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

@@ -26,8 +26,9 @@ import {
type StatCard,
} from '@/components/templates/UniversalListPage';
import type { VendorLedgerItem, VendorLedgerSummary } from './types';
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
import { getVendorLedgerList, getVendorLedgerSummary } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
@@ -43,6 +44,16 @@ const tableColumns = [
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
];
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<VendorLedgerItem & Record<string, unknown>>[] = [
{ header: '거래처명', key: 'vendorName', width: 20 },
{ header: '이월잔액', key: 'carryoverBalance', width: 14 },
{ header: '매출', key: 'sales', width: 14 },
{ header: '수금', key: 'collection', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '결제일', key: 'paymentDate', width: 12 },
];
// ===== Props =====
interface VendorLedgerProps {
initialData?: VendorLedgerItem[];
@@ -144,24 +155,42 @@ export function VendorLedger({
);
const handleExcelDownload = useCallback(async () => {
const result = await exportVendorLedgerExcel({
startDate,
endDate,
search: searchQuery || undefined,
});
try {
toast.info('엑셀 파일 생성 중...');
const allData: VendorLedgerItem[] = [];
let page = 1;
let lastPage = 1;
if (result.success && result.data) {
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '거래처원장.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
do {
const result = await getVendorLedgerList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<VendorLedgerItem & Record<string, unknown>>({
data: allData as (VendorLedgerItem & Record<string, unknown>)[],
columns: excelColumns,
filename: '거래처원장',
sheetName: '거래처원장',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);

View File

@@ -10,12 +10,6 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import { vendorConfig } from './vendorConfig';
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
businessNumber: '사업자등록번호',
vendorName: '거래처명',
category: '거래처 유형',
};
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -29,7 +23,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
// 새 입력 컴포넌트
import { PhoneInput } from '@/components/ui/phone-input';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
@@ -186,13 +179,25 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
}
setValidationErrors(errors);
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
toast.error(firstError);
}
return Object.keys(errors).length === 0;
}, [formData.businessNumber, formData.vendorName, formData.category]);
// 필드 변경 핸들러
const handleChange = useCallback((field: string, value: string | number | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 에러 클리어
if (validationErrors[field]) {
setValidationErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
}
}, [validationErrors]);
// 파일 검증 및 추가
const validateAndAddFiles = useCallback((files: FileList | File[]) => {
@@ -265,7 +270,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
window.scrollTo({ top: 0, behavior: 'smooth' });
return { success: false, error: '입력 내용을 확인해주세요.' };
}
@@ -326,8 +330,9 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
placeholder={placeholder}
disabled={isViewMode || disabled}
className="bg-white"
className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}
/>
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
</div>
);
};
@@ -350,7 +355,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
onValueChange={(val) => handleChange(field, val)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectTrigger className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@@ -361,6 +366,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
))}
</SelectContent>
</Select>
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
</div>
);
};
@@ -368,35 +374,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
// 폼 콘텐츠 렌더링 (View/Edit 공통)
const renderFormContent = () => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 */}
<Card>
<CardHeader>
@@ -496,6 +473,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
showValidation={!isViewMode}
error={!!validationErrors.businessNumber}
/>
{validationErrors.businessNumber && <p className="text-sm text-red-500">{validationErrors.businessNumber}</p>}
</div>
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
import { Plus, Trash2, Upload } from 'lucide-react';
@@ -194,6 +195,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return { success: false, error: result.message || '저장에 실패했습니다.' };
}
invalidateDashboard('client');
router.refresh();
return { success: true };
} catch (error) {
@@ -214,6 +216,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return { success: false, error: result.message || '삭제에 실패했습니다.' };
}
invalidateDashboard('client');
router.refresh();
return { success: true };
} catch (error) {

View File

@@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
enumFilter('creditRating', creditRatingFilter),
enumFilter('transactionGrade', transactionGradeFilter),
enumFilter('badDebtStatus', badDebtFilter),
(items: Vendor[]) => items.filter((item) => {
if (!item.createdAt) return true;
const created = item.createdAt.slice(0, 10);
return created >= startDate && created <= endDate;
}),
]);
// 정렬
@@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
}
return result;
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;

View File

@@ -131,6 +131,8 @@ export async function getClients(params?: {
size?: number;
q?: string;
only_active?: boolean;
start_date?: string;
end_date?: string;
}): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/clients', {
@@ -138,6 +140,8 @@ export async function getClients(params?: {
size: params?.size,
q: params?.q,
only_active: params?.only_active,
start_date: params?.start_date,
end_date: params?.end_date,
}),
transform: (data: PaginatedResponse<ClientApiData>) => ({
items: data.data.map(transformApiToFrontend),

View File

@@ -11,6 +11,7 @@
*/
import { useState, useMemo, useCallback } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { formatNumber } from '@/lib/utils/amount';
@@ -129,6 +130,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
deleteItem: async (id: string) => {
const result = await deleteClient(id);
if (result.success) {
invalidateDashboard('client');
toast.success('거래처가 삭제되었습니다.');
}
return { success: result.success, error: result.error };
@@ -149,6 +151,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
dateField: 'createdAt',
},
// 데이터 변경 콜백 (Stats 계산용)

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

@@ -70,7 +70,7 @@ function mapTabToApiStatus(tabStatus: string): string | undefined {
function mapApprovalType(formCategory?: string): ApprovalType {
const typeMap: Record<string, ApprovalType> = {
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', '문서': 'document', 'document': 'document',
};
return typeMap[formCategory || ''] || 'proposal';
}
@@ -113,6 +113,7 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
export async function getInbox(params?: {
page?: number; per_page?: number; search?: string; status?: string;
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
start_date?: string; end_date?: string;
}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> {
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
url: buildApiUrl('/api/v1/approvals/inbox', {
@@ -123,6 +124,8 @@ export async function getInbox(params?: {
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
sort_by: params?.sort_by,
sort_dir: params?.sort_dir,
start_date: params?.start_date,
end_date: params?.end_date,
}),
errorMessage: '결재함 목록 조회에 실패했습니다.',
});
@@ -176,6 +179,127 @@ export async function approveDocumentsBulk(ids: string[], comment?: string): Pro
return { success: true };
}
// ============================================
// 연결 문서(Document) 조회
// ============================================
interface LinkedDocumentApiData {
id: number;
document_number: string;
title: string;
status: string;
drafter?: {
id: number; name: string; position?: string;
department?: { name: string };
tenant_profile?: { position_key?: string; department?: { name: string } };
};
steps?: InboxStepApiData[];
linkable?: {
id: number;
title: string;
document_no: string;
status: string;
created_at: string;
linkable_type?: string;
linkable_id?: number;
template?: { id: number; name: string; code: string };
data?: Array<{ id: number; field_key: string; field_label?: string; field_value?: unknown; value?: unknown }>;
approvals?: Array<{ id: number; step: number; status: string; acted_at?: string; user?: { id: number; name: string } }>;
attachments?: Array<{ id: number; display_name: string; file_path: string }>;
};
}
interface LinkedDocumentResult {
documentNo: string;
createdAt: string;
title: string;
templateName: string;
templateCode: string;
status: string;
workOrderId?: number;
documentData: Array<{ fieldKey: string; fieldLabel: string; value: unknown }>;
approvers: Array<{ id: string; name: string; position: string; department: string; status: 'pending' | 'approved' | 'rejected' | 'none' }>;
drafter: { id: string; name: string; position: string; department: string; status: 'approved' | 'pending' | 'rejected' | 'none' };
attachments?: Array<{ id: number; name: string; url: string }>;
}
function getPositionLabel(positionKey: string | null | undefined): string {
if (!positionKey) return '';
const labels: Record<string, string> = {
'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장',
'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴',
};
return labels[positionKey] ?? positionKey;
}
export async function getDocumentApprovalById(id: number): Promise<{
success: boolean;
data?: LinkedDocumentResult;
error?: string;
}> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await executeServerAction<any>({
url: buildApiUrl(`/api/v1/approvals/${id}`),
errorMessage: '문서 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const apiData = result.data as LinkedDocumentApiData;
const linkable = apiData.linkable;
const drafter = {
id: String(apiData.drafter?.id || ''),
name: apiData.drafter?.name || '',
position: apiData.drafter?.tenant_profile?.position_key
? getPositionLabel(apiData.drafter.tenant_profile.position_key)
: (apiData.drafter?.position || ''),
department: apiData.drafter?.tenant_profile?.department?.name
|| apiData.drafter?.department?.name || '',
status: 'approved' as const,
};
const approvers = (apiData.steps || [])
.filter(s => s.step_type === 'approval' || s.step_type === 'agreement')
.map(step => ({
id: String(step.approver?.id || step.approver_id),
name: step.approver?.name || '',
position: step.approver?.position || '',
department: step.approver?.department?.name || '',
status: (step.status === 'approved' ? 'approved'
: step.status === 'rejected' ? 'rejected'
: step.status === 'pending' ? 'pending'
: 'none') as 'pending' | 'approved' | 'rejected' | 'none',
}));
// work_order 연결 문서인 경우 workOrderId 추출
const workOrderId = linkable?.linkable_type === 'work_order' ? linkable.linkable_id : undefined;
return {
success: true,
data: {
documentNo: linkable?.document_no || apiData.document_number,
createdAt: linkable?.created_at || '',
title: linkable?.title || apiData.title,
templateName: linkable?.template?.name || '',
templateCode: linkable?.template?.code || '',
status: linkable?.status || apiData.status,
workOrderId,
documentData: (linkable?.data || []).map(d => ({
fieldKey: d.field_key,
fieldLabel: d.field_label || d.field_key,
value: d.field_value ?? d.value,
})),
approvers,
drafter,
attachments: (linkable?.attachments || []).map(a => ({
id: a.id,
name: a.display_name,
url: `/api/proxy/files/${a.id}/download`,
})),
},
};
}
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
const failedIds: string[] = [];

View File

@@ -19,6 +19,7 @@ import {
rejectDocument,
approveDocumentsBulk,
rejectDocumentsBulk,
getDocumentApprovalById,
} from './actions';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import { Button } from '@/components/ui/button';
@@ -58,6 +59,7 @@ import type {
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
} from '@/components/approval/DocumentDetail/types';
import type {
ApprovalTabType,
@@ -76,6 +78,7 @@ import {
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
// ===== 통계 타입 =====
interface InboxSummary {
@@ -111,9 +114,13 @@ export function ApprovalBox() {
// ===== 문서 상세 모달 상태 =====
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
const [isModalLoading, setIsModalLoading] = useState(false);
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
// API 데이터
const [data, setData] = useState<ApprovalRecord[]>([]);
const [totalCount, setTotalCount] = useState(0);
@@ -151,6 +158,8 @@ export function ApprovalBox() {
search: searchQuery || undefined,
status: activeTab !== 'all' ? activeTab : undefined,
approval_type: filterOption !== 'all' ? filterOption : undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
...sortConfig,
});
@@ -165,7 +174,7 @@ export function ApprovalBox() {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
// ===== 초기 로드 =====
useEffect(() => {
@@ -288,6 +297,27 @@ export function ApprovalBox() {
setIsModalOpen(true);
try {
// 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회
if (item.approvalType === 'document') {
const result = await getDocumentApprovalById(parseInt(item.id));
if (result.success && result.data) {
// work_order 연결 문서 → InspectionReportModal로 열기
if (result.data.workOrderId) {
setIsModalOpen(false);
setIsModalLoading(false);
setInspectionWorkOrderId(String(result.data.workOrderId));
setIsInspectionModalOpen(true);
return;
}
setModalData(result.data as LinkedDocumentData);
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
return;
}
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
@@ -439,6 +469,8 @@ export function ApprovalBox() {
return 'expenseEstimate';
case 'expense_report':
return 'expenseReport';
case 'document':
return 'document';
default:
return 'proposal';
}
@@ -514,7 +546,7 @@ export function ApprovalBox() {
dateRangeSelector: {
enabled: true,
showPresets: false,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
@@ -796,6 +828,19 @@ export function ApprovalBox() {
onReject={canApprove ? handleModalReject : undefined}
/>
)}
{/* 검사성적서 모달 (work_order 연결 문서) */}
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={(open) => {
setIsInspectionModalOpen(open);
if (!open) {
setInspectionWorkOrderId(null);
}
}}
workOrderId={inspectionWorkOrderId}
readOnly={true}
/>
</>
),
}),
@@ -827,6 +872,8 @@ export function ApprovalBox() {
handleModalApprove,
handleModalReject,
canApprove,
isInspectionModalOpen,
inspectionWorkOrderId,
]
);

View File

@@ -9,17 +9,18 @@ export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
// 결재 상태
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
// 필터 옵션
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'expense_report', label: '지출결의서' },
{ value: 'proposal', label: '품의서' },
{ value: 'expense_estimate', label: '지출예상내역서' },
{ value: 'document', label: '문서 결재' },
];
// 정렬 옵션
@@ -71,12 +72,14 @@ export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
expense_report: '지출결의서',
proposal: '품의서',
expense_estimate: '지출예상내역서',
document: '문서 결재',
};
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
expense_report: 'blue',
proposal: 'green',
expense_estimate: 'purple',
document: 'orange',
};
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
import { format } from 'date-fns';
@@ -50,7 +51,7 @@ import { getClients } from '@/components/accounting/VendorManagement/actions';
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
const getInitialBasicInfo = (): BasicInfo => ({
drafter: '홍길동',
drafter: '', // 클라이언트에서 currentUser로 설정
draftDate: '', // 클라이언트에서 설정
documentNo: '',
documentType: 'proposal',
@@ -118,14 +119,22 @@ export function DocumentCreate() {
const today = format(new Date(), 'yyyy-MM-dd');
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
setBasicInfo(prev => ({ ...prev, draftDate: prev.draftDate || now }));
// localStorage 'user' 키에서 사용자 이름 가져오기 (로그인 시 저장됨)
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
const userName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name || '';
setBasicInfo(prev => ({
...prev,
drafter: prev.drafter || userName,
draftDate: prev.draftDate || now,
}));
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
setExpenseReportData(prev => ({
...prev,
requestDate: prev.requestDate || today,
paymentDate: prev.paymentDate || today,
}));
}, []);
}, [currentUser?.name]);
// 미리보기 모달 상태
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
@@ -172,6 +181,7 @@ export function DocumentCreate() {
setBasicInfo(prev => ({
...prev,
...mockData.basicInfo,
drafter: currentUserName || prev.drafter,
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
documentType: (mockData.basicInfo.documentType || prev.documentType) as BasicInfo['documentType'],
}));
@@ -343,6 +353,7 @@ export function DocumentCreate() {
try {
const result = await deleteApproval(parseInt(documentId));
if (result.success) {
invalidateDashboard('approval');
toast.success('문서가 삭제되었습니다.');
router.back();
} else {
@@ -375,6 +386,7 @@ export function DocumentCreate() {
if (isEditMode && documentId) {
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('수정 및 상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -386,6 +398,7 @@ export function DocumentCreate() {
// 새 문서: 생성 후 상신
const result = await createAndSubmitApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -411,6 +424,7 @@ export function DocumentCreate() {
if (isEditMode && documentId) {
const result = await updateApproval(parseInt(documentId), formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -421,6 +435,7 @@ export function DocumentCreate() {
// 새 문서: 임시저장
const result = await createApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('임시저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});

View File

@@ -4,12 +4,14 @@ import { DocumentViewer } from '@/components/document-system';
import { ProposalDocument } from './ProposalDocument';
import { ExpenseReportDocument } from './ExpenseReportDocument';
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
import { LinkedDocumentContent } from './LinkedDocumentContent';
import type {
DocumentType,
DocumentDetailModalProps,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
} from './types';
/**
@@ -41,6 +43,8 @@ export function DocumentDetailModalV2({
return '지출결의서';
case 'expenseEstimate':
return '지출 예상 내역서';
case 'document':
return (data as LinkedDocumentData).templateName || '문서 결재';
default:
return '문서';
}
@@ -69,6 +73,8 @@ export function DocumentDetailModalV2({
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
case 'expenseEstimate':
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
case 'document':
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
default:
return null;
}

View File

@@ -0,0 +1,133 @@
'use client';
/**
* 연결 문서 콘텐츠 컴포넌트
*
* 문서관리에서 생성된 검사 성적서, 작업일지 등을
* 결재함 모달에서 렌더링합니다.
*/
import { ApprovalLineBox } from './ApprovalLineBox';
import type { LinkedDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
import { Badge } from '@/components/ui/badge';
import { FileText, Paperclip } from 'lucide-react';
interface LinkedDocumentContentProps {
data: LinkedDocumentData;
}
const STATUS_LABELS: Record<string, string> = {
DRAFT: '임시저장',
PENDING: '진행중',
APPROVED: '승인완료',
REJECTED: '반려',
CANCELLED: '회수',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-800',
PENDING: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
CANCELLED: 'bg-gray-100 text-gray-600',
};
export function LinkedDocumentContent({ data }: LinkedDocumentContentProps) {
return (
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title={data.templateName || '문서 결재'}
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt?.substring(0, 10) || '-'}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
/>
{/* 문서 기본 정보 */}
<div className="border border-gray-300 mb-4">
<div className="flex border-b border-gray-300">
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
</div>
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
</div>
<div className="grid grid-cols-2 border-b border-gray-300">
<div className="flex border-r border-gray-300">
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
</div>
<div className="flex-1 p-3 text-sm flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-500" />
{data.templateName || '-'}
</div>
</div>
<div className="flex">
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
</div>
<div className="flex-1 p-3 text-sm">
<Badge className={STATUS_COLORS[data.status] || 'bg-gray-100 text-gray-800'}>
{STATUS_LABELS[data.status] || data.status}
</Badge>
</div>
</div>
</div>
</div>
{/* 문서 데이터 */}
{data.documentData.length > 0 && (
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="divide-y divide-gray-300">
{data.documentData.map((field, index) => (
<div key={index} className="flex">
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
{field.fieldLabel}
</div>
<div className="flex-1 p-3 text-sm whitespace-pre-wrap">
{renderFieldValue(field.value)}
</div>
</div>
))}
</div>
</div>
)}
{/* 첨부파일 */}
{data.attachments && data.attachments.length > 0 && (
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-4 space-y-2">
{data.attachments.map((file) => (
<a
key={file.id}
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
>
<Paperclip className="w-4 h-4" />
{file.name}
</a>
))}
</div>
</div>
)}
</div>
);
}
function renderFieldValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'string') return value || '-';
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? '예' : '아니오';
if (Array.isArray(value)) return value.join(', ') || '-';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}

View File

@@ -1,6 +1,6 @@
// ===== 문서 상세 모달 타입 정의 =====
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
// 결재자 정보
export interface Approver {
@@ -72,6 +72,29 @@ export interface ExpenseEstimateDocumentData {
drafter: Approver;
}
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
export interface LinkedDocumentData {
documentNo: string;
createdAt: string;
title: string;
templateName: string;
templateCode: string;
status: string;
workOrderId?: number;
documentData: Array<{
fieldKey: string;
fieldLabel: string;
value: unknown;
}>;
approvers: Approver[];
drafter: Approver;
attachments?: Array<{
id: number;
name: string;
url: string;
}>;
}
// 문서 상세 모달 모드
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
@@ -83,7 +106,7 @@ export interface DocumentDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentType;
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
onEdit?: () => void;

Some files were not shown because too many files have changed in this diff Show More