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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-26 21:27:40 +09:00
parent 2777ecf664
commit b1686aaf66
107 changed files with 1703 additions and 970 deletions

View File

@@ -9,6 +9,7 @@ import { cn } from "./utils";
import { Button } from "./button";
import { Calendar } from "./calendar";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { useCalendarScheduleInit } from "@/hooks/useCalendarScheduleInit";
interface DatePickerProps {
/** 선택된 날짜 (YYYY-MM-DD 형식 문자열) */
@@ -74,6 +75,9 @@ function DatePicker({
const [showMonthPicker, setShowMonthPicker] = React.useState(false);
const [pickerYear, setPickerYear] = React.useState(new Date().getFullYear());
// 표시 중인 연도의 공휴일/일정 데이터를 스토어에 로드
useCalendarScheduleInit(displayMonth?.getFullYear() ?? new Date().getFullYear());
// 문자열 → Date 변환
const selectedDate = React.useMemo(() => {
if (!value) return undefined;
@@ -154,7 +158,21 @@ function DatePicker({
<span className="truncate">{displayText}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align} side={side} sideOffset={sideOffset}>
<PopoverContent
className="w-auto p-0"
align={align}
side={side}
sideOffset={sideOffset}
onPointerDownOutside={(e) => {
// Popover가 Portal로 렌더링되어, Dialog 안이나 Windows 환경에서
// 날짜 클릭이 "외부 클릭"으로 오인되는 것을 방지
// 닫기는 handleSelect(날짜선택) / Escape / 트리거 토글로 처리
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<div className="p-3">
{showMonthPicker ? (
/* 연월 선택 피커 */

View File

@@ -10,6 +10,7 @@ import { cn } from "./utils";
import { Button } from "./button";
import { Calendar } from "./calendar";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { useCalendarScheduleInit } from "@/hooks/useCalendarScheduleInit";
interface DateRangePickerProps {
/** 시작 날짜 (yyyy-MM-dd 형식) */
@@ -62,6 +63,9 @@ function DateRangePicker({
const [showMonthPicker, setShowMonthPicker] = React.useState(false);
const [pickerYear, setPickerYear] = React.useState(new Date().getFullYear());
// 표시 중인 연도의 공휴일/일정 데이터를 스토어에 로드
useCalendarScheduleInit(displayMonth?.getFullYear() ?? new Date().getFullYear());
// 내부 범위 상태 (팝오버 내 선택 중간 상태 관리)
const [internalRange, setInternalRange] = React.useState<DateRange | undefined>();
// 선택 단계: 'idle' → 'selecting-end' (시작일 선택 후 종료일 대기)
@@ -192,7 +196,12 @@ function DateRangePicker({
<span className="truncate">{displayText}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<PopoverContent
className="w-auto p-0"
align={align}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<div className="p-3">
{showMonthPicker ? (
/* 연월 선택 피커 */

View File

@@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { cn } from "./utils";
import { DatePicker } from "./date-picker";
import { TimePicker } from "./time-picker";
interface DateTimePickerProps {
/** "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DDTHH:mm" 형식 */
value?: string;
/** 동일 형식으로 반환 */
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
/** DatePicker/TimePicker에 전달 */
size?: "default" | "sm" | "lg";
/** TimePicker 분 단위 간격 (기본 5) */
minuteStep?: number;
datePlaceholder?: string;
timePlaceholder?: string;
}
function DateTimePicker({
value,
onChange,
disabled = false,
className,
size = "default",
minuteStep = 5,
datePlaceholder = "날짜 선택",
timePlaceholder = "시간 선택",
}: DateTimePickerProps) {
// 구분자 감지: T 또는 공백
const separator = React.useMemo(() => {
if (!value) return "T";
return value.includes("T") ? "T" : " ";
}, [value]);
// value를 date / time 파트로 분리
const [datePart, timePart] = React.useMemo(() => {
if (!value) return ["", ""];
const sep = value.includes("T") ? "T" : " ";
const parts = value.split(sep);
return [parts[0] || "", parts[1] || ""];
}, [value]);
const handleDateChange = React.useCallback(
(newDate: string) => {
const time = timePart || "00:00";
onChange?.(`${newDate}${separator}${time}`);
},
[timePart, separator, onChange]
);
const handleTimeChange = React.useCallback(
(newTime: string) => {
const date = datePart || new Date().toISOString().slice(0, 10);
onChange?.(`${date}${separator}${newTime}`);
},
[datePart, separator, onChange]
);
const sizeClasses = {
default: "h-10",
sm: "h-8 text-sm",
lg: "h-12 text-base",
};
return (
<div className={cn("flex gap-2", className)}>
<DatePicker
value={datePart}
onChange={handleDateChange}
disabled={disabled}
size={size}
placeholder={datePlaceholder}
className="flex-1 min-w-0"
/>
<TimePicker
value={timePart}
onChange={handleTimeChange}
disabled={disabled}
minuteStep={minuteStep}
placeholder={timePlaceholder}
className={cn("w-[130px] shrink-0", sizeClasses[size])}
/>
</div>
);
}
export { DateTimePicker };
export type { DateTimePickerProps };

View File

@@ -85,7 +85,12 @@ export function MultiSelectCombobox({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<PopoverContent
className="w-[200px] p-0"
align="start"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>

View File

@@ -167,7 +167,12 @@ export function SearchableSelect({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Command shouldFilter={!isServerSearch}>
<CommandInput
placeholder={searchPlaceholder}

View File

@@ -135,7 +135,12 @@ function TimePicker({
{displayValue}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<PopoverContent
className="w-auto p-0"
align="start"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<div className="p-3">
{/* 헤더 */}
<div className="text-center mb-3">