251 lines
9.9 KiB
TypeScript
251 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect } from "react";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface DateRangePickerProps {
|
|
from: string; // YYYY-MM-DD
|
|
to: string; // YYYY-MM-DD
|
|
onChange: (from: string, to: string) => void;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function daysInMonth(year: number, month: number): number {
|
|
return new Date(year, month + 1, 0).getDate();
|
|
}
|
|
|
|
function firstDayOfMonth(year: number, month: number): number {
|
|
return new Date(year, month, 1).getDay(); // 0=Sun
|
|
}
|
|
|
|
function fmt(d: Date): string {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
function fmtDisplay(dateStr: string): string {
|
|
if (!dateStr) return "";
|
|
const [y, m, d] = dateStr.split("-");
|
|
return `${y}.${m}.${d}`;
|
|
}
|
|
|
|
function isSame(a: string, b: string): boolean {
|
|
return a === b;
|
|
}
|
|
|
|
function isBetween(date: string, from: string, to: string): boolean {
|
|
return date >= from && date <= to;
|
|
}
|
|
|
|
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
|
|
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [selecting, setSelecting] = useState<"from" | "to" | null>(null);
|
|
const [tempFrom, setTempFrom] = useState(from);
|
|
const [tempTo, setTempTo] = useState(to);
|
|
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
|
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
if (open) document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [open]);
|
|
|
|
const handleOpen = () => {
|
|
setTempFrom(from);
|
|
setTempTo(to);
|
|
setSelecting("from");
|
|
const d = from ? new Date(from) : new Date();
|
|
setViewYear(d.getFullYear());
|
|
setViewMonth(d.getMonth());
|
|
setOpen(true);
|
|
};
|
|
|
|
const handleDayClick = (dateStr: string) => {
|
|
if (selecting === "from") {
|
|
setTempFrom(dateStr);
|
|
setTempTo(dateStr); // 같은 날짜 = 당일
|
|
setSelecting("to");
|
|
} else {
|
|
// to 선택
|
|
if (dateStr < tempFrom) {
|
|
// 시작일보다 이전 선택 → 시작일로 교체
|
|
setTempFrom(dateStr);
|
|
setTempTo(dateStr);
|
|
setSelecting("to");
|
|
} else {
|
|
setTempTo(dateStr);
|
|
onChange(tempFrom, dateStr);
|
|
setOpen(false);
|
|
setSelecting(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const prevMonth = () => {
|
|
if (viewMonth === 0) { setViewYear(viewYear - 1); setViewMonth(11); }
|
|
else setViewMonth(viewMonth - 1);
|
|
};
|
|
|
|
const nextMonth = () => {
|
|
if (viewMonth === 11) { setViewYear(viewYear + 1); setViewMonth(0); }
|
|
else setViewMonth(viewMonth + 1);
|
|
};
|
|
|
|
// Quick select presets
|
|
const today = fmt(new Date());
|
|
const presets = [
|
|
{ label: "오늘", from: today, to: today },
|
|
{ label: "이번주", from: fmt(new Date(new Date().setDate(new Date().getDate() - new Date().getDay()))), to: today },
|
|
{ label: "이번달", from: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-01`, to: today },
|
|
];
|
|
|
|
// Display text
|
|
const displayText = from && to
|
|
? isSame(from, to)
|
|
? fmtDisplay(from)
|
|
: `${fmtDisplay(from)} ~ ${fmtDisplay(to)}`
|
|
: "기간 선택";
|
|
|
|
// Build calendar grid
|
|
const totalDays = daysInMonth(viewYear, viewMonth);
|
|
const startDay = firstDayOfMonth(viewYear, viewMonth);
|
|
const cells: (string | null)[] = [];
|
|
for (let i = 0; i < startDay; i++) cells.push(null);
|
|
for (let d = 1; d <= totalDays; d++) {
|
|
const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
cells.push(dateStr);
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
{/* Trigger Button */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">기간</label>
|
|
<button
|
|
onClick={handleOpen}
|
|
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left focus:outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-100 bg-white flex items-center justify-between gap-2"
|
|
>
|
|
<span className={from ? "text-gray-900 font-medium" : "text-gray-400"}>{displayText}</span>
|
|
<svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Calendar Popup */}
|
|
{open && (
|
|
<div className="absolute left-0 top-full mt-2 z-50 bg-white rounded-2xl shadow-xl border border-gray-200 p-4 w-[320px]">
|
|
{/* Header hint */}
|
|
<p className="text-[10px] text-center text-gray-400 mb-2">
|
|
{selecting === "from" ? "시작일을 선택하세요" : "종료일을 선택하세요 (같은 날 = 당일)"}
|
|
</p>
|
|
|
|
{/* Quick Presets */}
|
|
<div className="flex gap-1.5 mb-3">
|
|
{presets.map((p) => (
|
|
<button
|
|
key={p.label}
|
|
onClick={() => { onChange(p.from, p.to); setOpen(false); }}
|
|
className="flex-1 py-1.5 rounded-lg text-[11px] font-semibold text-cyan-700 bg-cyan-50 hover:bg-cyan-100 active:scale-95 transition-all"
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Month Navigation */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<button onClick={prevMonth} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>
|
|
</button>
|
|
<span className="text-sm font-bold text-gray-900">{viewYear}년 {MONTH_NAMES[viewMonth]}</span>
|
|
<button onClick={nextMonth} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Weekday Headers */}
|
|
<div className="grid grid-cols-7 gap-0 mb-1">
|
|
{WEEKDAYS.map((d, i) => (
|
|
<div key={d} className={`text-center text-[10px] font-semibold py-1 ${i === 0 ? "text-red-400" : i === 6 ? "text-blue-400" : "text-gray-400"}`}>
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Day Grid */}
|
|
<div className="grid grid-cols-7 gap-0">
|
|
{cells.map((dateStr, idx) => {
|
|
if (!dateStr) return <div key={`empty-${idx}`} className="h-10" />;
|
|
|
|
const day = parseInt(dateStr.split("-")[2], 10);
|
|
const dayOfWeek = new Date(dateStr).getDay();
|
|
const isStart = isSame(dateStr, tempFrom);
|
|
const isEnd = isSame(dateStr, tempTo);
|
|
const isInRange = tempFrom && tempTo && isBetween(dateStr, tempFrom, tempTo);
|
|
const isToday = isSame(dateStr, today);
|
|
|
|
let bgClass = "hover:bg-gray-100";
|
|
let textClass = dayOfWeek === 0 ? "text-red-500" : dayOfWeek === 6 ? "text-blue-500" : "text-gray-700";
|
|
|
|
if (isStart || isEnd) {
|
|
bgClass = "bg-cyan-600 text-white";
|
|
textClass = "text-white";
|
|
} else if (isInRange) {
|
|
bgClass = "bg-cyan-50";
|
|
textClass = "text-cyan-700";
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={dateStr}
|
|
onClick={() => handleDayClick(dateStr)}
|
|
className={`h-10 flex items-center justify-center text-sm font-medium rounded-lg transition-all active:scale-90 ${bgClass} ${textClass}`}
|
|
>
|
|
<span className="relative">
|
|
{day}
|
|
{isToday && !isStart && !isEnd && (
|
|
<span className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-cyan-500" />
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Selected Range Display */}
|
|
{tempFrom && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100 text-center">
|
|
<span className="text-xs text-gray-500">
|
|
{isSame(tempFrom, tempTo) ? fmtDisplay(tempFrom) : `${fmtDisplay(tempFrom)} ~ ${fmtDisplay(tempTo)}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|