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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user