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:
@@ -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 ? (
|
||||
/* 연월 선택 피커 */
|
||||
|
||||
@@ -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 ? (
|
||||
/* 연월 선택 피커 */
|
||||
|
||||
92
src/components/ui/date-time-picker.tsx
Normal file
92
src/components/ui/date-time-picker.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user