feat(WEB): 회계/HR/주문관리 모듈 개선 및 알림설정 리팩토링
- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선 - HR: 직원 관리 및 출퇴근 설정 기능 수정 - 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables) - 알림설정: 컴포넌트 구조 단순화 및 리팩토링 - 캘린더: 헤더 및 일정 타입 개선 - 출고관리: 액션 및 타입 정의 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
|
||||
category: cat.category,
|
||||
amounts: cat.amounts,
|
||||
})),
|
||||
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, useTransition } from 'react';
|
||||
import { Download, FileText, Save, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef, useTransition, Fragment } 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 { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -68,6 +68,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
overdueVendorCount: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(!initialData.length);
|
||||
const [expandedMemos, setExpandedMemos] = useState<Set<string>>(new Set());
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -125,6 +126,19 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
));
|
||||
}, []);
|
||||
|
||||
// ===== 메모 펼치기/접기 토글 =====
|
||||
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({
|
||||
@@ -213,8 +227,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
return totals;
|
||||
}, [filteredData]);
|
||||
|
||||
// ===== 카테고리 순서 =====
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable', 'memo'];
|
||||
// ===== 카테고리 순서 (메모 제외 - 별도 렌더링) =====
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -275,7 +289,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending || changedItems.length === 0}
|
||||
className="bg-orange-500 hover:bg-orange-600 disabled:opacity-50"
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -345,78 +359,131 @@ export function ReceivablesStatus({ highlightVendorId, initialData = [], initial
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredData.map((vendor) => (
|
||||
categoryOrder.map((category, catIndex) => {
|
||||
const categoryData = vendor.categories.find(c => c.category === category);
|
||||
if (!categoryData) return null;
|
||||
filteredData.map((vendor) => {
|
||||
const isOverdueRow = vendor.isOverdue;
|
||||
const isHighlighted = highlightVendorId === vendor.id;
|
||||
const rowBgClass = isHighlighted
|
||||
? 'bg-yellow-100'
|
||||
: isOverdueRow
|
||||
? 'bg-red-50'
|
||||
: 'bg-white';
|
||||
const isExpanded = expandedMemos.has(vendor.id);
|
||||
const hasMemo = vendor.memo && vendor.memo.trim().length > 0;
|
||||
|
||||
const isOverdueRow = vendor.isOverdue;
|
||||
const isHighlighted = highlightVendorId === vendor.id;
|
||||
// 하이라이트 > 연체 > 기본 순으로 배경색 결정
|
||||
const rowBgClass = isHighlighted
|
||||
? 'bg-yellow-100'
|
||||
: isOverdueRow
|
||||
? 'bg-red-50'
|
||||
: 'bg-white';
|
||||
return (
|
||||
<Fragment key={vendor.id}>
|
||||
{/* 카테고리 행들 (매출, 입금, 어음, 미수금) */}
|
||||
{categoryOrder.map((category, catIndex) => {
|
||||
const categoryData = vendor.categories.find(c => c.category === category);
|
||||
if (!categoryData) return null;
|
||||
|
||||
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}`}
|
||||
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' : ''}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{/* 거래처명 + 연체 토글 - 왼쪽 고정 */}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 메모 행 - 별도 렌더링 */}
|
||||
<TableRow key={`${vendor.id}-memo`}>
|
||||
{/* 구분 - 왼쪽 고정 */}
|
||||
<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);
|
||||
{/* 메모 내용 - 월별 컬럼 + 합계 컬럼 병합 */}
|
||||
<TableCell colSpan={13} className="p-2">
|
||||
{hasMemo ? (
|
||||
<div className="relative">
|
||||
{/* 메모 내용 */}
|
||||
<div
|
||||
className={`text-sm text-gray-700 whitespace-pre-wrap ${
|
||||
isExpanded
|
||||
? 'max-h-40 overflow-y-auto'
|
||||
: 'max-h-10 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
{vendor.memo}
|
||||
</div>
|
||||
|
||||
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)}
|
||||
{/* 펼치기/접기 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMemoExpand(vendor.id)}
|
||||
className="mt-1 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
더보기
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
))
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface VendorReceivables {
|
||||
isOverdue: boolean; // 연체 토글 상태
|
||||
overdueMonths: number[]; // 연체 월 (1-12)
|
||||
categories: CategoryData[];
|
||||
memo?: string; // 거래처별 메모 (단일 텍스트)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user