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,7 @@
*/
import React from 'react';
import { Plus, X } from 'lucide-react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { QuantityInput } from '@/components/ui/quantity-input';
@@ -85,15 +85,15 @@ export function LineItemsTable<T extends { id: string }>({
return (
<>
<div className="overflow-x-auto">
<Table>
<Table className="min-w-[1000px]">
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="min-w-[200px]"></TableHead>
<TableHead className="w-[130px] text-right"></TableHead>
<TableHead className="w-[160px] text-right"></TableHead>
<TableHead className="w-[160px] text-right"></TableHead>
<TableHead className="w-[140px] text-right"></TableHead>
{renderExtraHeaders?.()}
{showNote && <TableHead></TableHead>}
<TableHead className="w-[50px]"></TableHead>
@@ -111,7 +111,7 @@ export function LineItemsTable<T extends { id: string }>({
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<TableCell className="text-right">
<QuantityInput
value={getQuantity(item)}
onChange={(value) => onItemChange(index, 'quantity', value ?? 0)}
@@ -119,7 +119,7 @@ export function LineItemsTable<T extends { id: string }>({
min={1}
/>
</TableCell>
<TableCell>
<TableCell className="text-right">
<CurrencyInput
value={getUnitPrice(item)}
onChange={(value) => onItemChange(index, 'unitPrice', value ?? 0)}
@@ -151,7 +151,7 @@ export function LineItemsTable<T extends { id: string }>({
className="h-8 w-8 text-red-600 hover:text-red-700"
onClick={() => onRemoveItem(index)}
>
<X className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>

View File

@@ -1,13 +1,13 @@
'use client';
import { ReactNode, ComponentType, memo } from 'react';
import { ReactNode, ComponentType, memo, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { LucideIcon } from 'lucide-react';
import { LucideIcon, ChevronDown } from 'lucide-react';
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
@@ -106,12 +106,16 @@ export interface MobileCardProps {
}>;
// === Layout ===
detailsColumns?: 1 | 2 | 3; // details 그리드 컬럼 수 (기본: 2)
detailsColumns?: 1 | 2 | 3; // details 그리드 컬럼 수 (기본: 1)
showSeparator?: boolean; // 구분선 표시 여부 (기본: infoGrid 사용시 true)
// === 추가 콘텐츠 ===
topContent?: ReactNode;
bottomContent?: ReactNode;
// === Collapsible ===
collapsible?: boolean; // 기본값: true (접기/펼치기 자동 적용)
defaultExpanded?: boolean; // 기본값: false (접힌 상태로 시작)
}
export function MobileCard({
@@ -143,11 +147,14 @@ export function MobileCard({
// Actions
actions,
// Layout
detailsColumns = 2,
detailsColumns = 1,
showSeparator,
// 추가 콘텐츠
topContent,
bottomContent,
// Collapsible
collapsible: collapsibleProp = true,
defaultExpanded = false,
}: MobileCardProps) {
// === 별칭 통합 ===
const handleClick = onClick || onCardClick;
@@ -156,6 +163,12 @@ export function MobileCard({
const shouldShowCheckbox = showCheckbox ?? !!handleToggle;
const shouldShowSeparator = showSeparator ?? (!!infoGrid || !!headerBadges);
// === Collapsible 로직 ===
const hasDetailContent =
itemDetails.length > 0 || !!infoGrid || !!actions || !!bottomContent || !!description;
const isCollapsible = collapsibleProp && hasDetailContent;
const [expanded, setExpanded] = useState(defaultExpanded);
// === Badge 렌더링 ===
const renderBadge = () => {
// statusBadge 우선 (ReactNode)
@@ -261,83 +274,151 @@ export function MobileCard({
return renderDetails();
};
// === 헤더 클릭 핸들러 ===
const handleHeaderClick = () => {
if (isCollapsible) {
setExpanded((prev) => !prev);
} else {
handleClick?.();
}
};
// === 세부 영역 클릭 핸들러 ===
const handleDetailClick = () => {
handleClick?.();
};
return (
<div
className={cn(
'border rounded-lg p-5 space-y-4 bg-white dark:bg-card transition-all overflow-hidden',
handleClick && 'cursor-pointer',
'border rounded-lg bg-white dark:bg-card transition-all overflow-hidden',
isSelected
? 'border-blue-500 bg-blue-50/50'
: 'border-gray-200 hover:border-primary/50',
className
)}
onClick={handleClick}
>
{/* 상단 추가 콘텐츠 */}
{topContent}
{topContent && <div className="px-5 pt-5">{topContent}</div>}
{/* 헤더 영역 */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Checkbox */}
{/* 헤더 영역 - 항상 보임 */}
<div
className={cn(
'p-5 space-y-2',
isCollapsible ? 'cursor-pointer select-none' : handleClick && 'cursor-pointer',
)}
onClick={handleHeaderClick}
>
{/* 1행: 체크박스 + 뱃지들 + 상태뱃지 (flex-wrap으로 자연 줄바꿈) */}
<div className="flex items-center gap-2 flex-wrap">
{shouldShowCheckbox && handleToggle && (
<Checkbox
checked={isSelected}
onCheckedChange={handleToggle}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 h-5 w-5"
className="h-5 w-5 shrink-0"
/>
)}
{/* Icon */}
{icon && <div className="mt-0.5 shrink-0">{icon}</div>}
<div className="flex-1 min-w-0">
{/* 헤더 뱃지들 */}
{headerBadges && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{headerBadges}
</div>
)}
{/* 제목 */}
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{title}
</h3>
{/* 부제목 */}
{subtitle && (
<p className="text-sm text-muted-foreground truncate">
{subtitle}
</p>
)}
</div>
{icon && <div className="shrink-0">{icon}</div>}
{headerBadges}
{renderBadge()}
</div>
{/* 우측 상단: 뱃지 */}
<div className="shrink-0">{renderBadge()}</div>
{/* 3행: 제목 + 쉐브론 */}
<div className="flex items-start gap-2">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 break-words min-w-0 flex-1">
{title}
</h3>
{isCollapsible && (
<ChevronDown
className={cn(
'w-5 h-5 text-muted-foreground transition-transform duration-200 shrink-0 mt-0.5',
expanded && 'rotate-180'
)}
/>
)}
</div>
{/* 4행: 부제목 */}
{subtitle && (
<p className="text-sm text-muted-foreground break-words">
{subtitle}
</p>
)}
</div>
{/* 설명 */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
{/* 세부 영역 - collapsible 시 접기/펼치기 */}
{isCollapsible ? (
<div
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out',
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
>
<div className="overflow-hidden">
<div
className={cn(
'px-5 pb-5 space-y-4',
handleClick && 'cursor-pointer'
)}
onClick={handleDetailClick}
>
{/* 설명 */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
{/* 구분선 */}
{shouldShowSeparator && (infoGrid || itemDetails.length > 0) && (
<Separator className="bg-gray-100" />
)}
{/* 정보 영역 */}
{renderInfoArea()}
{/* 액션 버튼 */}
{renderActions()}
{/* 하단 추가 콘텐츠 */}
{bottomContent}
</div>
</div>
</div>
) : (
/* 비-collapsible: 기존과 동일 */
hasDetailContent && (
<div
className={cn(
'px-5 pb-5 space-y-4',
handleClick && 'cursor-pointer'
)}
onClick={handleClick}
>
{/* 설명 */}
{description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{description}
</p>
)}
{/* 구분선 */}
{shouldShowSeparator && (infoGrid || itemDetails.length > 0) && (
<Separator className="bg-gray-100" />
)}
{/* 정보 영역 */}
{renderInfoArea()}
{/* 액션 버튼 */}
{renderActions()}
{/* 하단 추가 콘텐츠 */}
{bottomContent}
</div>
)
)}
{/* 구분선 (infoGrid/headerBadges 사용시) */}
{shouldShowSeparator && (infoGrid || itemDetails.length > 0) && (
<Separator className="bg-gray-100" />
)}
{/* 정보 영역 */}
{renderInfoArea()}
{/* 액션 버튼 */}
{renderActions()}
{/* 하단 추가 콘텐츠 */}
{bottomContent}
</div>
);
}

View File

@@ -41,6 +41,8 @@ export function StatCards({ stats }: StatCardsProps) {
isClickable ? 'cursor-pointer hover:border-primary/50' : ''
} ${
stat.isActive ? 'border-primary bg-primary/5' : ''
} ${
count % 2 === 1 && index === count - 1 ? 'col-span-2 sm:col-span-1' : ''
}`}
onClick={stat.onClick}
>