Files
vexplor_dev/frontend/components/pop/hardcoded/inventory/DateRangePicker.tsx
SeongHyun Kim 327b4d01c2
Some checks failed
Build and Push Images / build-and-push (push) Failing after 49s
feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 자동 창고 매칭
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단
- 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선
- 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거
- 검사관리+입출고관리: 신규 화면 (quality, inventory)
- 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
2026-04-09 14:38:28 +09:00

349 lines
9.7 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState } 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>
);
}