Files
sam-react-prod/src/components/accounting/ReceivablesStatus/index.tsx
유병철 17c16028b1 feat(WEB): 리스트 페이지 권한 시스템 통합 및 중복 권한 로직 제거
- PermissionContext 기능 확장 (권한 조회 액션 추가)
- usePermission 훅 개선
- 회계 모듈 권한 통합: 매입/매출/입금/지출/채권/거래처/어음/일보/부실채권
- 인사 모듈 권한 통합: 근태/카드/급여 관리
- 전자결재 권한 통합: 기안함/결재함
- 게시판/품목/단가/팝업/구독 리스트 권한 적용
- UniversalListPage 권한 연동
- 각 컴포넌트 중복 권한 체크 코드 제거 (-828줄)
- 권한 검증 QA 체크리스트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:46:48 +09:00

631 lines
25 KiB
TypeScript

'use client';
import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from 'react';
import { Download, FileText, Save, Loader2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { SearchFilter } from '@/components/organisms/SearchFilter';
import type {
VendorReceivables,
CategoryType,
SortOption,
ReceivablesListResponse,
MemoUpdateRequest,
} from './types';
import {
CATEGORY_LABELS,
SORT_OPTIONS,
} from './types';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== Props 인터페이스 =====
interface ReceivablesStatusProps {
highlightVendorId?: string;
initialData?: ReceivablesListResponse;
initialSummary?: {
totalCarryForward: number;
totalSales: number;
totalDeposits: number;
totalBills: number;
totalReceivables: number;
vendorCount: number;
overdueVendorCount: number;
};
}
// ===== 연도 옵션 생성 (0 = 최근 1년) =====
const YEAR_RECENT = 0; // 최근 1년 옵션 값
const generateYearOptions = (): Array<{ value: number; label: string }> => {
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 5 }, (_, i) => ({
value: currentYear - i,
label: `${currentYear - i}`,
}));
return [{ value: YEAR_RECENT, label: '최근 1년' }, ...years];
};
// ===== 카테고리 순서 (메모 제외) =====
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
export function ReceivablesStatus({ highlightVendorId, initialData, initialSummary }: ReceivablesStatusProps) {
const { canExport } = usePermission();
// ===== Refs =====
const highlightRowRef = useRef<HTMLTableRowElement>(null);
// ===== 상태 관리 =====
const [isPending, startTransition] = useTransition();
const [selectedYear, setSelectedYear] = useState<number>(YEAR_RECENT); // 기본: 최근 1년
const [sortOption, setSortOption] = useState<SortOption>('overdueFirst'); // 기본: 연체 업체 우선
const [searchQuery, setSearchQuery] = useState('');
const [monthLabels, setMonthLabels] = useState<string[]>(initialData?.monthLabels || []);
const [data, setData] = useState<VendorReceivables[]>(initialData?.items || []);
const [originalOverdueMap, setOriginalOverdueMap] = useState<Map<string, boolean>>(new Map());
const [originalMemoMap, setOriginalMemoMap] = useState<Map<string, string>>(new Map());
const [summary, setSummary] = useState(initialSummary || {
totalCarryForward: 0,
totalSales: 0,
totalDeposits: 0,
totalBills: 0,
totalReceivables: 0,
vendorCount: 0,
overdueVendorCount: 0,
});
const [isLoading, setIsLoading] = useState(!initialData?.items?.length);
const [expandedMemos, setExpandedMemos] = useState<Set<string>>(new Set());
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, summaryResult] = await Promise.all([
getReceivablesList({ year: selectedYear, search: searchQuery || undefined }),
getReceivablesSummary({ year: selectedYear }),
]);
if (listResult.success) {
setMonthLabels(listResult.data.monthLabels);
setData(listResult.data.items);
// 원본 연체 상태 저장
const overdueMap = new Map<string, boolean>();
const memoMap = new Map<string, string>();
listResult.data.items.forEach(vendor => {
overdueMap.set(vendor.id, vendor.isOverdue);
memoMap.set(vendor.id, vendor.memo || '');
});
setOriginalOverdueMap(overdueMap);
setOriginalMemoMap(memoMap);
}
if (summaryResult.success && summaryResult.data) {
setSummary(summaryResult.data);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load receivables:', error);
} finally {
setIsLoading(false);
}
}, [selectedYear, searchQuery]);
// ===== 초기 로드 및 필터 변경시 재로드 =====
useEffect(() => {
loadData();
}, [loadData]);
// ===== 하이라이트된 행으로 스크롤 =====
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]);
// ===== 정렬된 데이터 =====
// 정렬에는 원본 연체 상태(originalOverdueMap)를 사용하여 토글 시 위치 이동 방지
const sortedData = useMemo(() => {
const sorted = [...filteredData];
switch (sortOption) {
case 'overdueFirst':
// 연체 업체 우선 (원본 연체 상태 기준으로 정렬, 토글해도 위치 유지)
sorted.sort((a, b) => {
const aOriginalOverdue = originalOverdueMap.get(a.id) ?? a.isOverdue;
const bOriginalOverdue = originalOverdueMap.get(b.id) ?? b.isOverdue;
if (aOriginalOverdue !== bOriginalOverdue) {
return aOriginalOverdue ? -1 : 1;
}
return a.vendorName.localeCompare(b.vendorName, 'ko');
});
break;
case 'vendorName':
// 거래처명 순
sorted.sort((a, b) => a.vendorName.localeCompare(b.vendorName, 'ko'));
break;
case 'totalDesc':
// 금액 높은순 (미수금 합계 기준)
sorted.sort((a, b) => {
const aTotal = a.categories.find(c => c.category === 'receivable')?.amounts.total || 0;
const bTotal = b.categories.find(c => c.category === 'receivable')?.amounts.total || 0;
return bTotal - aTotal;
});
break;
case 'totalAsc':
// 금액 낮은순 (미수금 합계 기준)
sorted.sort((a, b) => {
const aTotal = a.categories.find(c => c.category === 'receivable')?.amounts.total || 0;
const bTotal = b.categories.find(c => c.category === 'receivable')?.amounts.total || 0;
return aTotal - bTotal;
});
break;
}
return sorted;
}, [filteredData, sortOption, originalOverdueMap]);
// ===== 연체 토글 핸들러 =====
const handleOverdueToggle = useCallback((vendorId: string, checked: boolean) => {
setData(prev => prev.map(vendor =>
vendor.id === vendorId ? { ...vendor, isOverdue: checked } : vendor
));
}, []);
// ===== 메모 변경 핸들러 =====
const handleMemoChange = useCallback((vendorId: string, memo: string) => {
setData(prev => prev.map(vendor =>
vendor.id === vendorId ? { ...vendor, memo } : vendor
));
}, []);
// ===== 메모 펼치기/접기 토글 =====
const toggleMemoExpand = useCallback((vendorId: string) => {
setExpandedMemos(prev => {
const newSet = new Set(prev);
if (newSet.has(vendorId)) {
newSet.delete(vendorId);
} else {
newSet.add(vendorId);
}
return newSet;
});
}, []);
// ===== 엑셀 다운로드 핸들러 =====
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 || '엑셀 다운로드에 실패했습니다.');
}
}, [selectedYear, searchQuery]);
// ===== 변경된 연체 항목 확인 =====
const changedOverdueItems = useMemo(() => {
return data.filter(vendor => {
const originalValue = originalOverdueMap.get(vendor.id);
return originalValue !== undefined && originalValue !== vendor.isOverdue;
}).map(vendor => ({
id: vendor.id,
isOverdue: vendor.isOverdue,
}));
}, [data, originalOverdueMap]);
// ===== 변경된 메모 항목 확인 =====
const changedMemoItems = useMemo(() => {
return data.filter(vendor => {
const originalMemo = originalMemoMap.get(vendor.id);
return originalMemo !== undefined && originalMemo !== vendor.memo;
}).map(vendor => ({
id: vendor.id,
memo: vendor.memo,
}));
}, [data, originalMemoMap]);
// ===== 총 변경 항목 수 =====
const totalChangedCount = changedOverdueItems.length + changedMemoItems.length;
// ===== 저장 핸들러 =====
const handleSave = useCallback(async () => {
if (totalChangedCount === 0) {
toast.info('변경된 항목이 없습니다.');
return;
}
startTransition(async () => {
const promises: Promise<{ success: boolean; error?: string }>[] = [];
// 연체 상태 업데이트
if (changedOverdueItems.length > 0) {
promises.push(updateOverdueStatus(changedOverdueItems));
}
// 메모 업데이트
if (changedMemoItems.length > 0) {
promises.push(updateMemos(changedMemoItems as MemoUpdateRequest[]));
}
const results = await Promise.all(promises);
const allSuccess = results.every(r => r.success);
if (allSuccess) {
// 원본 상태 업데이트
const newOverdueMap = new Map(originalOverdueMap);
changedOverdueItems.forEach(item => {
newOverdueMap.set(item.id, item.isOverdue);
});
setOriginalOverdueMap(newOverdueMap);
const newMemoMap = new Map(originalMemoMap);
changedMemoItems.forEach(item => {
newMemoMap.set(item.id, item.memo);
});
setOriginalMemoMap(newMemoMap);
toast.success(`${totalChangedCount}건이 저장되었습니다.`);
} else {
const errors = results.filter(r => !r.success).map(r => r.error).join(', ');
toast.error(errors || '저장에 실패했습니다.');
}
});
}, [changedOverdueItems, changedMemoItems, totalChangedCount, originalOverdueMap, originalMemoMap]);
// ===== 금액 포맷 =====
const formatAmount = (amount: number) => {
if (amount === 0) return '';
return amount.toLocaleString();
};
// ===== 합계 계산 (동적 월) =====
const grandTotals = useMemo(() => {
const monthCount = monthLabels.length || 12;
const values = new Array(monthCount).fill(0);
let total = 0;
filteredData.forEach(vendor => {
const receivableCat = vendor.categories.find(c => c.category === 'receivable');
if (receivableCat) {
receivableCat.amounts.values.forEach((val, idx) => {
if (idx < monthCount) {
values[idx] += val;
}
});
total += receivableCat.amounts.total;
}
});
return { values, total };
}, [filteredData, monthLabels.length]);
// ===== 월 개수 (동적) =====
const monthCount = monthLabels.length || 12;
// ===== 카테고리 순서 (메모 제외 - 별도 렌더링) =====
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title="미수금 현황"
description="거래처별 월별 미수금 현황을 조회합니다."
icon={FileText}
/>
{/* 헤더 액션 (연도 선택, 버튼) */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* 연도 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select
value={String(selectedYear)}
onValueChange={(value) => setSelectedYear(Number(value))}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="연도 선택" />
</SelectTrigger>
<SelectContent>
{generateYearOptions().map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 정렬 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select
value={sortOption}
onValueChange={(value) => setSortOption(value as SortOption)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
</Button>
{canExport && (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
>
<Download className="mr-2 h-4 w-4" />
</Button>
)}
<Button
size="sm"
onClick={handleSave}
disabled={isPending || totalChangedCount === 0}
className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{totalChangedCount > 0 && `(${totalChangedCount})`}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 검색 */}
<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>
{/* 동적 월 레이블 - 스크롤 영역 */}
{monthLabels.map((month, idx) => (
<TableHead key={`${month}-${idx}`} 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>
{isLoading ? (
<TableRow>
<TableCell colSpan={monthCount + 3} className="h-24 text-center">
<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>
) : sortedData.length === 0 ? (
<TableRow>
<TableCell colSpan={monthCount + 3} className="h-24 text-center">
.
</TableCell>
</TableRow>
) : (
sortedData.map((vendor) => {
const isOverdueRow = vendor.isOverdue;
const isHighlighted = highlightVendorId === vendor.id;
// 하이라이트 > 연체 > 기본 순으로 배경색 결정
const rowBgClass = isHighlighted
? 'bg-yellow-100'
: isOverdueRow
? 'bg-red-50'
: 'bg-white';
// 구분별 행 (매출, 입금, 어음, 미수금) + 메모 행 = 총 5개 행
const rows = [];
// 구분별 행 렌더링
categoryOrder.forEach((category, catIndex) => {
const categoryData = vendor.categories.find(c => c.category === category);
if (!categoryData) return;
rows.push(
<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>
{/* 월별 금액 - 스크롤 영역 */}
{categoryData.amounts.values.map((amount, monthIndex) => (
<TableCell
key={`${vendor.id}-${category}-${monthIndex}`}
className={`text-right text-sm border-r border-gray-200 ${rowBgClass}`}
>
{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>
);
});
// 메모 행 추가 (마지막 행)
const isMemoExpanded = expandedMemos.has(vendor.id);
rows.push(
<TableRow key={`${vendor.id}-memo`}>
{/* 구분: 메모 + 접기/펼치기 버튼 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[120px] z-10 ${rowBgClass}`}>
<div className="flex items-center justify-center gap-1">
<span></span>
<button
type="button"
onClick={() => toggleMemoExpand(vendor.id)}
className="p-0.5 hover:bg-gray-200 rounded"
>
{isMemoExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</button>
</div>
</TableCell>
{/* 메모 입력 - 모든 월 컬럼 + 합계 컬럼 병합 */}
<TableCell colSpan={monthCount + 1} className={`p-1 ${rowBgClass}`}>
<Textarea
value={vendor.memo}
onChange={(e) => handleMemoChange(vendor.id, e.target.value)}
placeholder="거래처 메모를 입력하세요..."
className="w-full text-sm resize-none transition-all !min-h-0 !py-1"
style={{
height: isMemoExpanded ? '72px' : '24px',
lineHeight: '24px',
overflowY: isMemoExpanded ? 'auto' : 'hidden',
}}
/>
</TableCell>
</TableRow>
);
return rows;
})
)}
</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>
{/* 월별 합계 - 스크롤 영역 */}
{grandTotals.values.map((amount, idx) => (
<TableCell key={`total-${idx}`} className="text-right text-sm border-r border-gray-200">
{formatAmount(amount)}
</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>
);
}