feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선 - DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가 - useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱 - 전 도메인 날짜 필드 DatePicker 표준화 - 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화 - 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등) - 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user