feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합

- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화
- 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)
- 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-26 21:28:23 +09:00
parent 3f0a3584ec
commit 13d27553b9
107 changed files with 1703 additions and 970 deletions

View File

@@ -8,7 +8,9 @@ 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 { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter } from '@/components/ui/table';
import { MobileCard } from '@/components/organisms/MobileCard';
import {
Select,
SelectContent,
@@ -348,16 +350,16 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
{/* 헤더 액션 (연도 선택, 버튼) */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-1 gap-3 sm:flex sm:items-center sm:gap-4">
{/* 연도 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-sm font-medium text-gray-700 shrink-0"></span>
<Select
value={String(selectedYear)}
onValueChange={(value) => setSelectedYear(Number(value))}
>
<SelectTrigger className="min-w-[120px] w-auto">
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="연도 선택" />
</SelectTrigger>
<SelectContent>
@@ -372,12 +374,12 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
{/* 정렬 선택 */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-sm font-medium text-gray-700 shrink-0"></span>
<Select
value={sortOption}
onValueChange={(value) => setSortOption(value as SortOption)}
>
<SelectTrigger className="min-w-[140px] w-auto">
<SelectTrigger className="min-w-[110px] w-auto">
<SelectValue placeholder="정렬 선택" />
</SelectTrigger>
<SelectContent>
@@ -390,7 +392,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -444,19 +446,19 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
</CardContent>
</Card>
{/* 테이블 */}
<Card>
{/* 테이블 - xl 이상에서만 표시 */}
<Card className="hidden xl:block">
<CardContent className="pt-6">
<div className="rounded-md border overflow-x-auto">
<Table className="border-separate border-spacing-0" style={{ minWidth: `${200 + 70 + (monthCount * 100) + 120}px` }}>
<TableHeader>
<TableRow>
{/* 거래처/연체 - 왼쪽 고정 */}
<TableHead className="w-[200px] min-w-[200px] max-w-[200px] sticky left-0 z-20 bg-white border-r border-gray-200">
<TableHead className="w-[140px] sm:w-[200px] min-w-[140px] sm:min-w-[200px] max-w-[140px] sm:max-w-[200px] sticky left-0 z-20 bg-white border-r border-gray-200">
/
</TableHead>
{/* 구분 - 왼쪽 고정 (거래처 옆) */}
<TableHead className="w-[70px] min-w-[70px] text-center sticky left-[200px] z-20 bg-white border-r border-gray-200">
{/* 구분 - sm 이상에서만 왼쪽 고정 */}
<TableHead className="w-[70px] min-w-[70px] text-center sm:sticky sm:left-[200px] z-20 bg-white border-r border-gray-200">
</TableHead>
{/* 동적 월 레이블 - 스크롤 영역 */}
@@ -465,8 +467,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
{month}
</TableHead>
))}
{/* 합계 - 오른쪽 고정 */}
<TableHead className="w-[100px] min-w-[100px] text-right sticky right-0 z-20 bg-white border-l border-gray-200">
{/* 합계 - sm 이상에서만 오른쪽 고정 */}
<TableHead className="w-[100px] min-w-[100px] text-right sm:sticky sm:right-0 z-20 bg-white sm:border-l border-gray-200">
</TableHead>
</TableRow>
@@ -514,7 +516,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
>
{/* 거래처명 - 왼쪽 고정 (매 행마다 개별 셀, 첫 행만 내용 표시) */}
<TableCell
className={`font-medium border-r border-gray-200 sticky left-0 z-10 w-[200px] min-w-[200px] max-w-[200px] overflow-hidden whitespace-normal ${rowBgClass} ${catIndex === 0 ? 'align-top pt-3' : 'p-0'}`}
className={`font-medium border-r border-gray-200 sticky left-0 z-10 w-[140px] sm:w-[200px] min-w-[140px] sm:min-w-[200px] max-w-[140px] sm:max-w-[200px] overflow-hidden whitespace-normal ${rowBgClass} ${catIndex === 0 ? 'align-top pt-3' : 'p-0'}`}
>
{catIndex === 0 && (
<div className="flex flex-col gap-2 overflow-hidden">
@@ -533,8 +535,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
)}
</TableCell>
{/* 구분 - 왼쪽 고정 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[200px] z-10 ${rowBgClass}`}>
{/* 구분 - sm 이상에서만 왼쪽 고정 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sm:sticky sm:left-[200px] z-10 ${rowBgClass}`}>
{CATEGORY_LABELS[category]}
</TableCell>
@@ -548,8 +550,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
</TableCell>
))}
{/* 합계 - 오른쪽 고정 */}
<TableCell className={`text-right font-medium text-sm sticky right-0 z-10 border-l border-gray-200 ${rowBgClass}`}>
{/* 합계 - sm 이상에서만 오른쪽 고정 */}
<TableCell className={`text-right font-medium text-sm sm:sticky sm:right-0 z-10 sm:border-l border-gray-200 ${rowBgClass}`}>
{formatAmount(categoryData.amounts.total)}
</TableCell>
</TableRow>
@@ -561,10 +563,10 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
rows.push(
<TableRow key={`${vendor.id}-memo`}>
{/* 거래처명 셀 (빈 셀 - 시각적 병합 유지) */}
<TableCell className={`border-r border-gray-200 sticky left-0 z-10 w-[200px] min-w-[200px] max-w-[200px] overflow-hidden p-0 ${rowBgClass}`} />
<TableCell className={`border-r border-gray-200 sticky left-0 z-10 w-[140px] sm:w-[200px] min-w-[140px] sm:min-w-[200px] max-w-[140px] sm:max-w-[200px] overflow-hidden p-0 ${rowBgClass}`} />
{/* 구분: 메모 + 접기/펼치기 버튼 */}
<TableCell className={`text-center border-r border-gray-200 text-sm sticky left-[200px] z-10 ${rowBgClass}`}>
<TableCell className={`text-center border-r border-gray-200 text-sm sm:sticky sm:left-[200px] z-10 ${rowBgClass}`}>
<div className="flex items-center justify-center gap-1">
<span></span>
<button
@@ -608,7 +610,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
<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-[200px] z-10 bg-gray-100">
<TableCell className="text-center border-r border-gray-200 sm:sticky sm:left-[200px] z-10 bg-gray-100">
</TableCell>
{/* 월별 합계 - 스크롤 영역 */}
@@ -617,8 +619,8 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
{formatAmount(amount)}
</TableCell>
))}
{/* 총합계 - 오른쪽 고정 */}
<TableCell className="text-right font-bold sticky right-0 z-10 bg-gray-100 border-l border-gray-200">
{/* 총합계 - sm 이상에서만 오른쪽 고정 */}
<TableCell className="text-right font-bold sm:sticky sm:right-0 z-10 bg-gray-100 sm:border-l border-gray-200">
{formatAmount(grandTotals.total)}
</TableCell>
</TableRow>
@@ -627,6 +629,104 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
</div>
</CardContent>
</Card>
{/* 모바일 카드 뷰 - xl 미만에서만 표시 */}
<div className="xl:hidden space-y-3">
{isLoading ? (
<Card>
<CardContent className="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>
</CardContent>
</Card>
) : sortedData.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-gray-500">
.
</CardContent>
</Card>
) : (
<>
{sortedData.map((vendor) => {
const salesCat = vendor.categories.find(c => c.category === 'sales');
const depositCat = vendor.categories.find(c => c.category === 'deposit');
const billCat = vendor.categories.find(c => c.category === 'bill');
const receivableCat = vendor.categories.find(c => c.category === 'receivable');
const isHighlighted = highlightVendorId === vendor.id;
return (
<MobileCard
key={vendor.id}
title={vendor.vendorName}
className={
isHighlighted
? 'border-yellow-400 bg-yellow-50'
: vendor.isOverdue
? 'border-red-300 bg-red-50/50'
: undefined
}
headerBadges={
vendor.isOverdue ? (
<Badge variant="destructive" className="text-xs"></Badge>
) : null
}
subtitle={`미수금 합계: ${formatNumber(receivableCat?.amounts.total || 0)}`}
collapsible
defaultExpanded={false}
infoGrid={
<div className="space-y-2 text-sm">
{[
{ label: '매출', value: salesCat?.amounts.total || 0 },
{ label: '입금', value: depositCat?.amounts.total || 0 },
{ label: '어음', value: billCat?.amounts.total || 0 },
{ label: '미수금', value: receivableCat?.amounts.total || 0, bold: true },
].map((item) => (
<div key={item.label} className="flex justify-between items-center">
<span className="text-muted-foreground">{item.label}</span>
<span className={item.bold ? 'font-bold' : 'font-medium'}>
{formatNumber(item.value)}
</span>
</div>
))}
</div>
}
bottomContent={
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Switch
checked={vendor.isOverdue}
onCheckedChange={(checked) => handleOverdueToggle(vendor.id, checked)}
className="data-[state=checked]:bg-red-500"
/>
<span className="text-sm"> </span>
</div>
<Textarea
value={vendor.memo}
onChange={(e) => handleMemoChange(vendor.id, e.target.value)}
placeholder="거래처 메모를 입력하세요..."
className="text-sm resize-none"
rows={2}
/>
</div>
}
/>
);
})}
{/* 합계 카드 */}
<Card className="bg-gray-50 border-gray-300">
<CardContent className="p-4">
<div className="flex justify-between items-center">
<span className="font-semibold text-sm"> </span>
<span className="font-bold text-lg">{formatNumber(grandTotals.total)}</span>
</div>
</CardContent>
</Card>
</>
)}
</div>
</PageLayout>
);
}