diff --git a/apps/web/src/features/availability/components/mvp-availability-page.tsx b/apps/web/src/features/availability/components/mvp-availability-page.tsx index 65c6136..d7f8764 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -9,9 +9,9 @@ import { ReplaceAvailabilityRequest, Worker, } from "@fragment/shared"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Badge, Button } from "@moyeorak/design-system"; -import { Check } from "lucide-react"; +import { CalendarDays, Check, ChevronLeft, ChevronRight } from "lucide-react"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { @@ -25,15 +25,17 @@ import { } from "@/features/organization/queries/organization-queries"; import { useMinimumStaffingRulesQuery } from "@/features/staffing-rules/queries/staffing-rules-queries"; import { useWorkersQuery } from "@/features/workers/queries/workers-queries"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; import { getApiErrorMessage } from "@/lib/api-error-message"; import { cn } from "@/lib/utils"; @@ -57,6 +59,7 @@ const DEFAULT_TIMETABLE_END_MINUTES = 22 * 60; const EMPTY_WORKERS: Worker[] = []; const EMPTY_BUSINESS_HOURS: BusinessHour[] = []; const EMPTY_STAFFING_RULES: MinimumStaffingRule[] = []; +const CALENDAR_WEEKDAYS = ["월", "화", "수", "목", "금", "토", "일"]; const DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", { month: "2-digit", @@ -69,6 +72,19 @@ const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", }); +const MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", { + month: "long", + timeZone: "Asia/Seoul", + year: "numeric", +}); + +const TODAY_FORMATTER = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone: "Asia/Seoul", + year: "numeric", +}); + function timeToMinutes(time: string) { const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes; @@ -95,6 +111,30 @@ function addDays(date: string, days: number) { return nextDate.toISOString().slice(0, 10); } +function addMonths(date: string, months: number) { + const [year, month] = date.split("-").map(Number); + const nextDate = new Date(Date.UTC(year, month - 1 + months, 1)); + return nextDate.toISOString().slice(0, 10); +} + +function getMonthStartDate(date: string) { + return `${date.slice(0, 7)}-01`; +} + +function createCalendarMonthDates(monthDate: string) { + const [year, month] = monthDate.split("-").map(Number); + const firstDate = new Date(Date.UTC(year, month - 1, 1)); + const day = firstDate.getUTCDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + const firstCalendarDate = addDays(monthDate, mondayOffset); + + return Array.from({ length: 42 }, (_, index) => addDays(firstCalendarDate, index)); +} + +function getTodayDate() { + return TODAY_FORMATTER.format(new Date()); +} + function createDateRange(startDate: string, endDate: string) { if (!startDate || !endDate || endDate < startDate) { return []; @@ -228,6 +268,14 @@ function formatWeekday(date: string) { return WEEKDAY_FORMATTER.format(new Date(`${date}T00:00:00+09:00`)); } +function formatDateWithWeekday(date: string) { + return `${formatDateLabel(date)}(${formatWeekday(date)})`; +} + +function formatMonthLabel(date: string) { + return MONTH_FORMATTER.format(new Date(`${date}T00:00:00+09:00`)); +} + function createLocalDateTime(date: string, minutes: number) { const [year, month, day] = date.split("-").map(Number); @@ -235,7 +283,7 @@ function createLocalDateTime(date: string, minutes: number) { } function formatSlotTime(minutes: number) { - return minutes >= 24 * 60 ? `${minutesToTime(minutes)}+1` : minutesToTime(minutes); + return minutes >= 24 * 60 ? `익일 ${minutesToTime(minutes)}` : minutesToTime(minutes); } function getSlotKey(workerId: string, date: string, startMinutes: number) { @@ -341,11 +389,22 @@ export function MvpAvailabilityPage() { const activePlanningPeriod = planningPeriodQuery.data?.period ?? null; const [selectedWorkerId, setSelectedWorkerId] = useState(""); const [selectedAvailabilityDate, setSelectedAvailabilityDate] = useState(""); + const [workPeriodDialogOpen, setWorkPeriodDialogOpen] = useState(false); + const [calendarMonthDate, setCalendarMonthDate] = useState(() => + getMonthStartDate(getTodayDate()), + ); const [workStartDate, setWorkStartDate] = useState(""); const [workEndDate, setWorkEndDate] = useState(""); const [savedAvailability, setSavedAvailability] = useState([]); const [draftAvailability, setDraftAvailability] = useState([]); const [saveMessage, setSaveMessage] = useState(""); + const dragSelectionRef = useRef<{ + lastColumnIndex: number; + lastRowIndex: number; + shouldSelect: boolean; + visitedKeys: Set; + } | null>(null); + const suppressNextCellClickRef = useRef(false); const availabilityQuery: AvailabilityQuery = useMemo( () => ({ endDate: workEndDate, @@ -354,14 +413,15 @@ export function MvpAvailabilityPage() { }), [selectedWorkerId, workEndDate, workStartDate], ); - const canFetchAvailability = - Boolean(selectedWorkerId && workStartDate && workEndDate) && workStartDate <= workEndDate; - const availabilityResult = useAvailabilityQuery(availabilityQuery, canFetchAvailability); const isPageLoading = organizationQuery.isLoading || planningPeriodQuery.isLoading || staffingRulesQuery.isLoading || workersQuery.isLoading; + const hasValidDateRange = Boolean(workStartDate && workEndDate) && workStartDate <= workEndDate; + const hasSavedPlanningPeriod = Boolean(activePlanningPeriod && hasValidDateRange); + const canFetchAvailability = hasSavedPlanningPeriod && Boolean(selectedWorkerId); + const availabilityResult = useAvailabilityQuery(availabilityQuery, canFetchAvailability); const queryError = organizationQuery.error ?? planningPeriodQuery.error ?? @@ -371,11 +431,30 @@ export function MvpAvailabilityPage() { const queryErrorMessage = queryError ? getApiErrorMessage(queryError, "가능 시간 정보를 불러오지 못했습니다.") : ""; - const hasValidDateRange = Boolean(workStartDate && workEndDate) && workStartDate <= workEndDate; const dateRange = useMemo( () => createDateRange(workStartDate, workEndDate), [workEndDate, workStartDate], ); + const closedDates = useMemo( + () => dateRange.filter((date) => isClosedDate(businessHours, date)), + [businessHours, dateRange], + ); + const datesWithoutStaffingRules = useMemo( + () => + dateRange.filter( + (date) => + !isClosedDate(businessHours, date) && + getSchedulableRanges(businessHours, staffingRules, date).length === 0, + ), + [businessHours, dateRange, staffingRules], + ); + const visibleTimetableDates = useMemo( + () => + dateRange.filter( + (date) => getSchedulableRanges(businessHours, staffingRules, date).length > 0, + ), + [businessHours, dateRange, staffingRules], + ); const timetableRange = useMemo( () => getTimetableRange(businessHours, staffingRules, dateRange), [businessHours, dateRange, staffingRules], @@ -384,6 +463,10 @@ export function MvpAvailabilityPage() { () => createTimeSlots(timetableRange.startMinutes, timetableRange.endMinutes), [timetableRange], ); + const calendarDates = useMemo( + () => createCalendarMonthDates(calendarMonthDate), + [calendarMonthDate], + ); useEffect(() => { if (!planningPeriodQuery.data) { @@ -398,6 +481,7 @@ export function MvpAvailabilityPage() { setWorkStartDate(activePlanningPeriod.startDate); setWorkEndDate(activePlanningPeriod.endDate); + setCalendarMonthDate(getMonthStartDate(activePlanningPeriod.startDate)); }, [activePlanningPeriod, planningPeriodQuery.data]); useEffect(() => { @@ -422,7 +506,23 @@ export function MvpAvailabilityPage() { setSaveMessage(""); }, [availabilityResult.data]); - const selectedWorker = workers.find((worker) => worker.id === selectedWorkerId); + useEffect(() => { + function stopDragSelection() { + dragSelectionRef.current = null; + window.setTimeout(() => { + suppressNextCellClickRef.current = false; + }, 0); + } + + window.addEventListener("pointerup", stopDragSelection); + window.addEventListener("pointercancel", stopDragSelection); + + return () => { + window.removeEventListener("pointerup", stopDragSelection); + window.removeEventListener("pointercancel", stopDragSelection); + }; + }, []); + const selectedDraftAvailability = draftAvailability.filter( (slot) => slot.workerId === selectedWorkerId && @@ -437,10 +537,6 @@ export function MvpAvailabilityPage() { ""; const activeAvailabilityRanges = selectedAvailabilityByDate.find((group) => group.date === activeAvailabilityDate)?.ranges ?? []; - const selectedAvailabilityRangeCount = selectedAvailabilityByDate.reduce( - (total, group) => total + group.ranges.length, - 0, - ); const draftSlotKeys = new Set( selectedDraftAvailability.map((slot) => getSlotKey(slot.workerId, slot.date, slot.startMinutes), @@ -449,40 +545,159 @@ export function MvpAvailabilityPage() { const hasUnsavedChanges = !hasSameSlots(savedAvailability, draftAvailability); const canSaveAvailability = hasUnsavedChanges && + hasSavedPlanningPeriod && hasValidDateRange && Boolean(selectedWorkerId) && !availabilityResult.isFetching && !queryErrorMessage; - function toggleSlot(date: string, startMinutes: number) { + function updateSlotSelections( + slots: { date: string; startMinutes: number }[], + shouldSelect: boolean, + ) { if (!selectedWorkerId || queryErrorMessage || availabilityResult.isFetching) { return; } - if (!isSlotSchedulable(businessHours, staffingRules, date, startMinutes)) { + const schedulableSlots = slots.filter((slot) => + isSlotSchedulable(businessHours, staffingRules, slot.date, slot.startMinutes), + ); + + if (schedulableSlots.length === 0) { return; } - const key = getSlotKey(selectedWorkerId, date, startMinutes); - const selected = draftSlotKeys.has(key); - - setDraftAvailability((currentAvailability) => - selected - ? currentAvailability.filter( - (slot) => getSlotKey(slot.workerId, slot.date, slot.startMinutes) !== key, - ) - : [ - ...currentAvailability, - { - workerId: selectedWorkerId, - date, - startMinutes, - }, - ], + const targetKeys = new Set( + schedulableSlots.map((slot) => getSlotKey(selectedWorkerId, slot.date, slot.startMinutes)), ); + + setDraftAvailability((currentAvailability) => { + const currentKeys = new Set( + currentAvailability.map((slot) => getSlotKey(slot.workerId, slot.date, slot.startMinutes)), + ); + + return shouldSelect + ? [ + ...currentAvailability, + ...schedulableSlots + .filter( + (slot) => + !currentKeys.has(getSlotKey(selectedWorkerId, slot.date, slot.startMinutes)), + ) + .map((slot) => ({ + date: slot.date, + startMinutes: slot.startMinutes, + workerId: selectedWorkerId, + })), + ] + : currentAvailability.filter( + (slot) => !targetKeys.has(getSlotKey(slot.workerId, slot.date, slot.startMinutes)), + ); + }); setSaveMessage(""); } + function toggleSlot(date: string, startMinutes: number) { + const key = getSlotKey(selectedWorkerId, date, startMinutes); + updateSlotSelections([{ date, startMinutes }], !draftSlotKeys.has(key)); + } + + function applySlotDragRange( + fromRowIndex: number, + fromColumnIndex: number, + toRowIndex: number, + toColumnIndex: number, + ) { + const dragSelection = dragSelectionRef.current; + + if (!dragSelection) { + return; + } + + const rowStart = Math.min(fromRowIndex, toRowIndex); + const rowEnd = Math.max(fromRowIndex, toRowIndex); + const columnStart = Math.min(fromColumnIndex, toColumnIndex); + const columnEnd = Math.max(fromColumnIndex, toColumnIndex); + const slots: { date: string; startMinutes: number }[] = []; + + for (let rowIndex = rowStart; rowIndex <= rowEnd; rowIndex += 1) { + const startMinutes = timeSlots[rowIndex]; + + if (startMinutes === undefined) { + continue; + } + + for (let columnIndex = columnStart; columnIndex <= columnEnd; columnIndex += 1) { + const date = visibleTimetableDates[columnIndex]; + + if (!date) { + continue; + } + + const key = getSlotKey(selectedWorkerId, date, startMinutes); + + if (dragSelection.visitedKeys.has(key)) { + continue; + } + + dragSelection.visitedKeys.add(key); + slots.push({ date, startMinutes }); + } + } + + updateSlotSelections(slots, dragSelection.shouldSelect); + } + + function startSlotDrag( + date: string, + startMinutes: number, + rowIndex: number, + columnIndex: number, + ) { + const key = getSlotKey(selectedWorkerId, date, startMinutes); + const shouldSelect = !draftSlotKeys.has(key); + + dragSelectionRef.current = { + lastColumnIndex: columnIndex, + lastRowIndex: rowIndex, + shouldSelect, + visitedKeys: new Set(), + }; + suppressNextCellClickRef.current = true; + applySlotDragRange(rowIndex, columnIndex, rowIndex, columnIndex); + } + + function moveSlotDrag(clientX: number, clientY: number) { + const dragSelection = dragSelectionRef.current; + + if (!dragSelection) { + return; + } + + const element = document.elementFromPoint(clientX, clientY); + const cell = element?.closest("[data-availability-cell='true']"); + + if (!cell) { + return; + } + + const rowIndex = Number(cell.dataset.rowIndex); + const columnIndex = Number(cell.dataset.columnIndex); + + if (!Number.isInteger(rowIndex) || !Number.isInteger(columnIndex)) { + return; + } + + applySlotDragRange( + dragSelection.lastRowIndex, + dragSelection.lastColumnIndex, + rowIndex, + columnIndex, + ); + dragSelection.lastRowIndex = rowIndex; + dragSelection.lastColumnIndex = columnIndex; + } + async function saveAvailability() { if (!canSaveAvailability) { return; @@ -532,35 +747,30 @@ export function MvpAvailabilityPage() { ); } + function selectWorkDate(date: string) { + if (isPageLoading) { + return; + } + + if (!workStartDate || (workStartDate && workEndDate) || date < workStartDate) { + setWorkStartDate(date); + setWorkEndDate(""); + setSelectedAvailabilityDate(""); + setSaveMessage(""); + return; + } + + setWorkEndDate(date); + setSelectedAvailabilityDate(""); + setSaveMessage(""); + setWorkPeriodDialogOpen(false); + savePlanningPeriod(workStartDate, date); + } + return ( - - - - } containerClassName="max-w-none" contentClassName="space-y-6" > @@ -577,256 +787,408 @@ export function MvpAvailabilityPage() { ) : null}
-
+

근무 기간 설정

모든 근무자의 가능 시간 입력에 공통으로 적용되는 기간입니다.

-
-
- - { - const nextStartDate = event.target.value; - - setWorkStartDate(nextStartDate); - setSelectedAvailabilityDate(""); - setSaveMessage(""); - savePlanningPeriod(nextStartDate, workEndDate); - }} - /> -
-
- - { - const nextEndDate = event.target.value; - - setWorkEndDate(nextEndDate); - setSelectedAvailabilityDate(""); - setSaveMessage(""); - savePlanningPeriod(workStartDate, nextEndDate); - }} - /> + +
+
+
-
-
-
+ + + + + + + 근무 기간 선택 + + 시작일을 먼저 선택하고 종료일을 선택하면 근무 기간이 저장됩니다. + + + +
+
+ +

+ {formatMonthLabel(calendarMonthDate)} +

+ +
-
-
-
- - -
+
+ {CALENDAR_WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} +
+
+ {calendarDates.map((date) => { + const inCurrentMonth = date.slice(0, 7) === calendarMonthDate.slice(0, 7); + const rangeStart = date === workStartDate; + const rangeEnd = date === workEndDate; + const inRange = Boolean(workStartDate && workEndDate) + ? date > workStartDate && date < workEndDate + : false; + const selected = rangeStart || rangeEnd; -
- {selectedWorker ? ( - <> -

{selectedWorker.name}

-

- 주간 계약 시간: 주 {selectedWorker.weeklyContractHours}시간 · 운영 시간은 요일별 - 조직 설정과 최소 인원 조건을 따릅니다. -

- - ) : ( -

등록된 근무자가 없습니다.

- )} -
-
-
+ return ( + + ); + })} +
+ -
-
-
-

선택된 가능 시간

-

- 날짜를 선택하면 해당 날짜의 가능 시간이 표시됩니다. -

+ + + + + + +
- {selectedAvailabilityRangeCount}개 구간 - {selectedAvailabilityByDate.length > 0 ? ( - <> -
-
- {selectedAvailabilityByDate.map((group) => { - const active = group.date === activeAvailabilityDate; - - return ( - - ); - })} + {hasValidDateRange ? ( +
+

입력 제외 날짜

+
+
+ 휴무일 + {closedDates.length > 0 ? ( +
+ {closedDates.map((date) => ( + + {formatDateWithWeekday(date)} + + ))} +
+ ) : ( + 없음 + )}
-
-
-

- {formatDateLabel(activeAvailabilityDate)}({formatWeekday(activeAvailabilityDate)}) -

-
- {activeAvailabilityRanges.map((range) => ( - - {formatSlotTime(range.startMinutes)}~{formatSlotTime(range.endMinutes)} - - ))} +
+ 최소 인원 조건 미설정일 + {datesWithoutStaffingRules.length > 0 ? ( +
+ {datesWithoutStaffingRules.map((date) => ( + + {formatDateWithWeekday(date)} + + ))} +
+ ) : ( + 없음 + )}
- - ) : ( -

선택된 가능 시간이 없습니다.

- )} - - - {saveMessage ? ( -
- {saveMessage} -
- ) : null} - -
-
-
-
-

가능 시간 타임테이블

-

- 최소 인원 조건이 있는 셀만 선택할 수 있습니다. 선택된 셀을 다시 누르면 삭제됩니다. -

-
- {hasUnsavedChanges ? 저장 전 변경 있음 : null} - {availabilityResult.isFetching ? ( - 불러오는 중 - ) : null}
-
+ ) : null} +
-
-
-
-
- {dateRange.map((date) => ( -
-

- {formatWeekday(date)} + {hasSavedPlanningPeriod ? ( +

+
+
+
+
+

가능 시간 입력

+

+ 근무자를 선택한 뒤 최소 인원 조건이 있는 셀만 가능 시간으로 지정합니다.

-

{formatDateLabel(date)}

- {isClosedDate(businessHours, date) ? ( - - 휴무 - - ) : getSchedulableRanges(businessHours, staffingRules, date).length === 0 ? ( - - 조건 없음 - +
+
+ {availabilityResult.isFetching ? ( + 불러오는 중 + ) : null} + {hasUnsavedChanges ? ( + <> + + + ) : null}
- ))} +
- {timeSlots.map((startMinutes) => { - const showHourLabel = startMinutes % 60 === 0; - const timeLabel = formatSlotTime(startMinutes); +
+ + {workers.length > 0 ? ( +
+ {workers.map((worker) => { + const selected = worker.id === selectedWorkerId; - return ( -
-
- {showHourLabel ? timeLabel : ""} -
- {dateRange.map((date) => { - const disabled = !isSlotSchedulable( - businessHours, - staffingRules, - date, - startMinutes, - ); - const selected = draftSlotKeys.has( - getSlotKey(selectedWorkerId, date, startMinutes), + return ( + ); - const disabledByState = - disabled || - !selectedWorkerId || - !hasValidDateRange || - Boolean(queryErrorMessage) || - availabilityResult.isFetching; + })} +
+ ) : ( +

등록된 근무자가 없습니다.

+ )} +
+
+
+ + {saveMessage ? ( +
+ {saveMessage} +
+ ) : null} + +
+

선택된 가능 시간

+ + {selectedAvailabilityByDate.length > 0 ? ( +
+
+
+ {selectedAvailabilityByDate.map((group) => { + const selected = group.date === activeAvailabilityDate; return ( ); })}
- ); - })} -
+
+ +
+
+ {activeAvailabilityRanges.map((range) => ( + + {formatSlotTime(range.startMinutes)}~{formatSlotTime(range.endMinutes)} + + ))} +
+
+
+ ) : ( +

선택된 가능 시간이 없습니다.

+ )}
-
- + +
+

가능 시간 타임테이블

+

+ 최소 인원 조건이 있는 셀만 선택할 수 있습니다. 선택된 셀을 다시 누르면 삭제됩니다. +

+
+ + {visibleTimetableDates.length > 0 ? ( +
+
+
moveSlotDrag(event.clientX, event.clientY)} + > +
+ {visibleTimetableDates.map((date) => ( +
+

+ {formatWeekday(date)} +

+

+ {formatDateLabel(date)} +

+
+ ))} + + {timeSlots.map((startMinutes, rowIndex) => { + const showHourLabel = startMinutes % 60 === 0; + const timeLabel = formatSlotTime(startMinutes); + + return ( +
+
+ {showHourLabel ? timeLabel : ""} +
+ {visibleTimetableDates.map((date, columnIndex) => { + const disabled = !isSlotSchedulable( + businessHours, + staffingRules, + date, + startMinutes, + ); + const selected = draftSlotKeys.has( + getSlotKey(selectedWorkerId, date, startMinutes), + ); + const disabledByState = + disabled || + !selectedWorkerId || + !hasValidDateRange || + Boolean(queryErrorMessage) || + availabilityResult.isFetching; + + return ( + + ); + })} +
+ ); + })} +
+
+
+ ) : ( +
+

입력 가능한 날짜가 없습니다.

+

+ 근무 기간 설정에서 휴무일과 최소 인원 조건 미설정일을 확인해 주세요. +

+
+ )} + + ) : null} ); }