- PermissionContext 기능 확장 (권한 조회 액션 추가) - usePermission 훅 개선 - 회계 모듈 권한 통합: 매입/매출/입금/지출/채권/거래처/어음/일보/부실채권 - 인사 모듈 권한 통합: 근태/카드/급여 관리 - 전자결재 권한 통합: 기안함/결재함 - 게시판/품목/단가/팝업/구독 리스트 권한 적용 - UniversalListPage 권한 연동 - 각 컴포넌트 중복 권한 체크 코드 제거 (-828줄) - 권한 검증 QA 체크리스트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
631 lines
25 KiB
TypeScript
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>
|
|
);
|
|
} |