Some checks failed
Build and Push Images / build-and-push (push) Failing after 49s
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단 - 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선 - 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거 - 검사관리+입출고관리: 신규 화면 (quality, inventory) - 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
349 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|