feat: 신규 페이지 구현 및 HR/설정 기능 개선
신규 페이지: - 회계관리: 거래처, 예상비용, 청구서, 발주서 - 게시판: 공지사항, 자료실, 커뮤니티 - 고객센터: 문의/FAQ - 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역 - 리포트 (차트 시각화) - 개발자 테스트 URL 페이지 기능 개선: - HR 직원관리/휴가관리/카드관리 강화 - IntegratedListTemplateV2 확장 - AuthenticatedLayout 패딩 표준화 - 로그인 페이지 UI 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
378
src/components/accounting/ReceivablesStatus/index.tsx
Normal file
378
src/components/accounting/ReceivablesStatus/index.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Download, FileText, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { SearchFilter } from '@/components/organisms/SearchFilter';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import type {
|
||||
VendorReceivables,
|
||||
CategoryType,
|
||||
MonthlyAmount,
|
||||
} from './types';
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
MONTH_LABELS,
|
||||
MONTH_KEYS,
|
||||
} from './types';
|
||||
|
||||
// ===== Props 인터페이스 =====
|
||||
interface ReceivablesStatusProps {
|
||||
highlightVendorId?: string;
|
||||
}
|
||||
|
||||
// ===== Mock 데이터 생성 =====
|
||||
const generateMockData = (): VendorReceivables[] => {
|
||||
const emptyAmounts = { month1: 0, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 0 };
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'vendor-1',
|
||||
vendorName: '회사명',
|
||||
isOverdue: false,
|
||||
overdueMonths: [],
|
||||
categories: [
|
||||
{ category: 'sales', amounts: { month1: 10000000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 500000, total: 10500000 } },
|
||||
{ category: 'deposit', amounts: { month1: 1500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 500000, total: 2000000 } },
|
||||
{ category: 'bill', amounts: { ...emptyAmounts } },
|
||||
{ category: 'receivable', amounts: { month1: 8500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 8500000 } },
|
||||
{ category: 'memo', amounts: { ...emptyAmounts } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vendor-2',
|
||||
vendorName: '회사명',
|
||||
isOverdue: true,
|
||||
overdueMonths: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
categories: [
|
||||
{ category: 'sales', amounts: { month1: 10000000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 500000, total: 10500000 } },
|
||||
{ category: 'deposit', amounts: { month1: 0, month2: 500000, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 500000 } },
|
||||
{ category: 'bill', amounts: { month1: 2000000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 500000, total: 2500000 } },
|
||||
{ category: 'receivable', amounts: { month1: 8500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 500000, month10: 0, month11: 0, month12: 0, total: 7500000 } },
|
||||
{ category: 'memo', amounts: { ...emptyAmounts } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vendor-3',
|
||||
vendorName: '회사명',
|
||||
isOverdue: false,
|
||||
overdueMonths: [],
|
||||
categories: [
|
||||
{ category: 'sales', amounts: { month1: 10000000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 10000000 } },
|
||||
{ category: 'deposit', amounts: { month1: 2500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 2500000 } },
|
||||
{ category: 'bill', amounts: { ...emptyAmounts } },
|
||||
{ category: 'receivable', amounts: { month1: 7500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 7500000 } },
|
||||
{ category: 'memo', amounts: { ...emptyAmounts } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vendor-4',
|
||||
vendorName: '회사명',
|
||||
isOverdue: false,
|
||||
overdueMonths: [],
|
||||
categories: [
|
||||
{ category: 'sales', amounts: { month1: 500000, month2: 0, month3: 2000000, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 2500000 } },
|
||||
{ category: 'deposit', amounts: { month1: 500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 2000000, total: 2500000 } },
|
||||
{ category: 'bill', amounts: { ...emptyAmounts } },
|
||||
{ category: 'receivable', amounts: { ...emptyAmounts } },
|
||||
{ category: 'memo', amounts: { ...emptyAmounts } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vendor-5',
|
||||
vendorName: '회사명',
|
||||
isOverdue: true,
|
||||
overdueMonths: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
categories: [
|
||||
{ category: 'sales', amounts: { month1: 10000000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 10000000 } },
|
||||
{ category: 'deposit', amounts: { month1: 500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 500000 } },
|
||||
{ category: 'bill', amounts: { ...emptyAmounts } },
|
||||
{ category: 'receivable', amounts: { month1: 9500000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 9500000 } },
|
||||
{ category: 'memo', amounts: { ...emptyAmounts } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vendor-6',
|
||||
vendorName: '고객센터',
|
||||
isOverdue: false,
|
||||
overdueMonths: [],
|
||||
categories: [
|
||||
{ category: 'sales', amounts: { ...emptyAmounts } },
|
||||
{ category: 'deposit', amounts: { ...emptyAmounts } },
|
||||
{ category: 'bill', amounts: { ...emptyAmounts } },
|
||||
{ category: 'receivable', amounts: { month1: 81000000, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0, month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0, total: 81000000 } },
|
||||
{ category: 'memo', amounts: { ...emptyAmounts } },
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function ReceivablesStatus({ highlightVendorId }: ReceivablesStatusProps = {}) {
|
||||
// ===== Refs =====
|
||||
const highlightRowRef = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [startDate, setStartDate] = useState(() => format(new Date(2025, 8, 1), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(new Date(2025, 8, 3), 'yyyy-MM-dd'));
|
||||
const [data, setData] = useState<VendorReceivables[]>(generateMockData);
|
||||
|
||||
// ===== 하이라이트된 행으로 스크롤 =====
|
||||
useEffect(() => {
|
||||
if (highlightVendorId && highlightRowRef.current) {
|
||||
highlightRowRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [highlightVendorId]);
|
||||
|
||||
// ===== 필터링된 데이터 =====
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery) return data;
|
||||
return data.filter(item =>
|
||||
item.vendorName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [data, searchQuery]);
|
||||
|
||||
// ===== 연체 토글 핸들러 =====
|
||||
const handleOverdueToggle = useCallback((vendorId: string, checked: boolean) => {
|
||||
setData(prev => prev.map(vendor =>
|
||||
vendor.id === vendorId ? { ...vendor, isOverdue: checked } : vendor
|
||||
));
|
||||
}, []);
|
||||
|
||||
// ===== 엑셀 다운로드 핸들러 =====
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
console.log('엑셀 다운로드');
|
||||
}, []);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSave = useCallback(() => {
|
||||
console.log('저장');
|
||||
}, []);
|
||||
|
||||
// ===== 금액 포맷 =====
|
||||
const formatAmount = (amount: number) => {
|
||||
if (amount === 0) return '';
|
||||
return amount.toLocaleString();
|
||||
};
|
||||
|
||||
// ===== 연체 여부 확인 =====
|
||||
const isOverdueCell = (vendor: VendorReceivables, monthIndex: number) => {
|
||||
return vendor.isOverdue && vendor.overdueMonths?.includes(monthIndex + 1);
|
||||
};
|
||||
|
||||
// ===== 합계 계산 =====
|
||||
const grandTotals = useMemo(() => {
|
||||
const totals: MonthlyAmount = {
|
||||
month1: 0, month2: 0, month3: 0, month4: 0, month5: 0, month6: 0,
|
||||
month7: 0, month8: 0, month9: 0, month10: 0, month11: 0, month12: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
filteredData.forEach(vendor => {
|
||||
const receivableCat = vendor.categories.find(c => c.category === 'receivable');
|
||||
if (receivableCat) {
|
||||
MONTH_KEYS.forEach(key => {
|
||||
totals[key] += receivableCat.amounts[key];
|
||||
});
|
||||
totals.total += receivableCat.amounts.total;
|
||||
}
|
||||
});
|
||||
|
||||
return totals;
|
||||
}, [filteredData]);
|
||||
|
||||
// ===== 카테고리 순서 =====
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable', 'memo'];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<PageHeader
|
||||
title="미수금 현황"
|
||||
description="거래처별 월별 미수금 현황을 조회합니다."
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (달력, 버튼) */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExcelDownload}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 검색 */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SearchFilter
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="거래처 검색..."
|
||||
filterButton={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 거래처/연체 - 왼쪽 고정 */}
|
||||
<TableHead className="w-[120px] min-w-[120px] sticky left-0 z-20 bg-white">
|
||||
거래처 / 연체
|
||||
</TableHead>
|
||||
{/* 구분 - 왼쪽 고정 (거래처 옆) */}
|
||||
<TableHead className="w-[70px] min-w-[70px] text-center sticky left-[120px] z-20 bg-white border-r border-gray-200">
|
||||
구분
|
||||
</TableHead>
|
||||
{/* 1월~12월 - 스크롤 영역 */}
|
||||
{MONTH_LABELS.map((month) => (
|
||||
<TableHead key={month} className="w-[80px] min-w-[80px] text-right">
|
||||
{month}
|
||||
</TableHead>
|
||||
))}
|
||||
{/* 합계 - 오른쪽 고정 */}
|
||||
<TableHead className="w-[100px] min-w-[100px] text-right sticky right-0 z-20 bg-white border-l border-gray-200">
|
||||
합계
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={15} className="h-24 text-center">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((vendor) => (
|
||||
categoryOrder.map((category, catIndex) => {
|
||||
const categoryData = vendor.categories.find(c => c.category === category);
|
||||
if (!categoryData) return null;
|
||||
|
||||
const isOverdueRow = vendor.isOverdue;
|
||||
const isHighlighted = highlightVendorId === vendor.id;
|
||||
// 하이라이트 > 연체 > 기본 순으로 배경색 결정
|
||||
const rowBgClass = isHighlighted
|
||||
? 'bg-yellow-100'
|
||||
: isOverdueRow
|
||||
? 'bg-red-50'
|
||||
: 'bg-white';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={`${vendor.id}-${category}`}
|
||||
ref={catIndex === 0 && isHighlighted ? highlightRowRef : undefined}
|
||||
className={`${catIndex === 0 ? 'border-t-2 border-gray-300' : ''} ${isHighlighted ? 'ring-2 ring-yellow-400 ring-inset' : ''}`}
|
||||
>
|
||||
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
|
||||
{catIndex === 0 && (
|
||||
<TableCell
|
||||
rowSpan={5}
|
||||
className={`font-medium border-r border-gray-200 align-top pt-3 sticky left-0 z-10 ${rowBgClass}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm">{vendor.vendorName}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={vendor.isOverdue}
|
||||
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
|
||||
className="data-[state=checked]:bg-red-500"
|
||||
/>
|
||||
{vendor.isOverdue && (
|
||||
<span className="text-xs text-red-500 font-medium">연체</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 구분 - 왼쪽 고정 */}
|
||||
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</TableCell>
|
||||
|
||||
{/* 월별 금액 - 스크롤 영역 */}
|
||||
{MONTH_KEYS.map((monthKey, monthIndex) => {
|
||||
const amount = categoryData.amounts[monthKey] || 0;
|
||||
const isOverdue = isOverdueCell(vendor, monthIndex);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={monthKey}
|
||||
className={`text-right text-sm border-r border-gray-200 ${
|
||||
isOverdue ? 'bg-red-100 text-red-700' : ''
|
||||
}`}
|
||||
>
|
||||
{formatAmount(amount)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 합계 - 오른쪽 고정 */}
|
||||
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
|
||||
{formatAmount(categoryData.amounts.total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-gray-100 font-semibold border-t-2 border-gray-300">
|
||||
{/* 합계 행 - 왼쪽 고정 */}
|
||||
<TableCell className="border-r border-gray-200 sticky left-0 z-10 bg-gray-100">
|
||||
합계
|
||||
</TableCell>
|
||||
<TableCell className="text-center border-r border-gray-200 sticky left-[120px] z-10 bg-gray-100">
|
||||
미수금
|
||||
</TableCell>
|
||||
{/* 월별 합계 - 스크롤 영역 */}
|
||||
{MONTH_KEYS.map((monthKey) => (
|
||||
<TableCell key={monthKey} className="text-right text-sm border-r border-gray-200">
|
||||
{formatAmount(grandTotals[monthKey])}
|
||||
</TableCell>
|
||||
))}
|
||||
{/* 총합계 - 오른쪽 고정 */}
|
||||
<TableCell className="text-right font-bold sticky right-0 z-10 bg-gray-100 border-l border-gray-200">
|
||||
{formatAmount(grandTotals.total)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
81
src/components/accounting/ReceivablesStatus/types.ts
Normal file
81
src/components/accounting/ReceivablesStatus/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 미수금 현황 타입 정의
|
||||
*/
|
||||
|
||||
/**
|
||||
* 월별 금액 데이터
|
||||
*/
|
||||
export interface MonthlyAmount {
|
||||
month1: number;
|
||||
month2: number;
|
||||
month3: number;
|
||||
month4: number;
|
||||
month5: number;
|
||||
month6: number;
|
||||
month7: number;
|
||||
month8: number;
|
||||
month9: number;
|
||||
month10: number;
|
||||
month11: number;
|
||||
month12: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구분 타입 (매출, 입금, 어음, 미수금, 메모)
|
||||
*/
|
||||
export type CategoryType = 'sales' | 'deposit' | 'bill' | 'receivable' | 'memo';
|
||||
|
||||
/**
|
||||
* 구분 레이블
|
||||
*/
|
||||
export const CATEGORY_LABELS: Record<CategoryType, string> = {
|
||||
sales: '매출',
|
||||
deposit: '입금',
|
||||
bill: '어음',
|
||||
receivable: '미수금',
|
||||
memo: '메모',
|
||||
};
|
||||
|
||||
/**
|
||||
* 구분별 데이터
|
||||
*/
|
||||
export interface CategoryData {
|
||||
category: CategoryType;
|
||||
amounts: MonthlyAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처별 미수금 현황 데이터
|
||||
*/
|
||||
export interface VendorReceivables {
|
||||
id: string;
|
||||
vendorName: string;
|
||||
isOverdue: boolean; // 연체 토글 상태
|
||||
overdueMonths: number[]; // 연체 월 (1-12)
|
||||
categories: CategoryData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 옵션
|
||||
*/
|
||||
export type SortOption = 'vendorName' | 'totalDesc' | 'totalAsc';
|
||||
|
||||
export const SORT_OPTIONS: Array<{ value: SortOption; label: string }> = [
|
||||
{ value: 'vendorName', label: '거래처명 순' },
|
||||
{ value: 'totalDesc', label: '금액 높은순' },
|
||||
{ value: 'totalAsc', label: '금액 낮은순' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 월 레이블 (1월~12월)
|
||||
*/
|
||||
export const MONTH_LABELS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
|
||||
/**
|
||||
* 월별 데이터 키
|
||||
*/
|
||||
export const MONTH_KEYS: (keyof MonthlyAmount)[] = [
|
||||
'month1', 'month2', 'month3', 'month4', 'month5', 'month6',
|
||||
'month7', 'month8', 'month9', 'month10', 'month11', 'month12',
|
||||
];
|
||||
Reference in New Issue
Block a user