From 77d204be345307633510b72edcd6ffe3f7949043 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 11:54:25 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20=EA=B0=80=EB=8A=A5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B4=80=EB=A6=AC=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-availability-page.tsx | 702 +++++++++++------- 1 file changed, 446 insertions(+), 256 deletions(-) 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..633e482 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -11,7 +11,7 @@ import { } from "@fragment/shared"; import { useEffect, useMemo, 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,6 +389,10 @@ 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([]); @@ -354,14 +406,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 +424,19 @@ 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 = dateRange.filter((date) => isClosedDate(businessHours, date)); + const datesWithoutStaffingRules = dateRange.filter( + (date) => + !isClosedDate(businessHours, date) && + getSchedulableRanges(businessHours, staffingRules, date).length === 0, + ); + const visibleTimetableDates = dateRange.filter( + (date) => getSchedulableRanges(businessHours, staffingRules, date).length > 0, + ); const timetableRange = useMemo( () => getTimetableRange(businessHours, staffingRules, dateRange), [businessHours, dateRange, staffingRules], @@ -384,6 +445,10 @@ export function MvpAvailabilityPage() { () => createTimeSlots(timetableRange.startMinutes, timetableRange.endMinutes), [timetableRange], ); + const calendarDates = useMemo( + () => createCalendarMonthDates(calendarMonthDate), + [calendarMonthDate], + ); useEffect(() => { if (!planningPeriodQuery.data) { @@ -398,6 +463,7 @@ export function MvpAvailabilityPage() { setWorkStartDate(activePlanningPeriod.startDate); setWorkEndDate(activePlanningPeriod.endDate); + setCalendarMonthDate(getMonthStartDate(activePlanningPeriod.startDate)); }, [activePlanningPeriod, planningPeriodQuery.data]); useEffect(() => { @@ -422,7 +488,6 @@ export function MvpAvailabilityPage() { setSaveMessage(""); }, [availabilityResult.data]); - const selectedWorker = workers.find((worker) => worker.id === selectedWorkerId); const selectedDraftAvailability = draftAvailability.filter( (slot) => slot.workerId === selectedWorkerId && @@ -437,10 +502,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,6 +510,7 @@ export function MvpAvailabilityPage() { const hasUnsavedChanges = !hasSameSlots(savedAvailability, draftAvailability); const canSaveAvailability = hasUnsavedChanges && + hasSavedPlanningPeriod && hasValidDateRange && Boolean(selectedWorkerId) && !availabilityResult.isFetching && @@ -532,35 +594,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 +634,389 @@ 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 ? ( +
+
+
+
+ {visibleTimetableDates.map((date) => ( +
+

+ {formatWeekday(date)} +

+

+ {formatDateLabel(date)} +

+
+ ))} + + {timeSlots.map((startMinutes) => { + const showHourLabel = startMinutes % 60 === 0; + const timeLabel = formatSlotTime(startMinutes); + + return ( +
+
+ {showHourLabel ? timeLabel : ""} +
+ {visibleTimetableDates.map((date) => { + 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} ); } From 7735228cea0a042bc2d19526ceb6ac9082c224b2 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 13:15:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(web):=20=EA=B0=80=EB=8A=A5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-availability-page.tsx | 207 ++++++++++++++++-- 1 file changed, 184 insertions(+), 23 deletions(-) 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 633e482..c708e3f 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -9,7 +9,7 @@ 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 { CalendarDays, Check, ChevronLeft, ChevronRight } from "lucide-react"; @@ -398,6 +398,13 @@ export function MvpAvailabilityPage() { 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, @@ -488,6 +495,23 @@ export function MvpAvailabilityPage() { setSaveMessage(""); }, [availabilityResult.data]); + 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 && @@ -516,35 +540,153 @@ export function MvpAvailabilityPage() { !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; @@ -939,6 +1081,7 @@ export function MvpAvailabilityPage() { style={{ gridTemplateColumns: `${TIMETABLE_TIME_COLUMN_WIDTH}px repeat(${visibleTimetableDates.length}, ${TIMETABLE_DATE_COLUMN_WIDTH}px)`, }} + onPointerMove={(event) => moveSlotDrag(event.clientX, event.clientY)} >
{visibleTimetableDates.map((date) => ( @@ -952,7 +1095,7 @@ export function MvpAvailabilityPage() {
))} - {timeSlots.map((startMinutes) => { + {timeSlots.map((startMinutes, rowIndex) => { const showHourLabel = startMinutes % 60 === 0; const timeLabel = formatSlotTime(startMinutes); @@ -961,7 +1104,7 @@ export function MvpAvailabilityPage() {
{showHourLabel ? timeLabel : ""}
- {visibleTimetableDates.map((date) => { + {visibleTimetableDates.map((date, columnIndex) => { const disabled = !isSlotSchedulable( businessHours, staffingRules, @@ -982,20 +1125,38 @@ export function MvpAvailabilityPage() { From d52807af1696b8add20f1768fd5d6ddd2ebc87b5 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 13:48:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(web):=20=EA=B0=80=EB=8A=A5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20UI=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-availability-page.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) 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 c708e3f..d7f8764 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -435,14 +435,25 @@ export function MvpAvailabilityPage() { () => createDateRange(workStartDate, workEndDate), [workEndDate, workStartDate], ); - const closedDates = dateRange.filter((date) => isClosedDate(businessHours, date)); - const datesWithoutStaffingRules = dateRange.filter( - (date) => - !isClosedDate(businessHours, date) && - getSchedulableRanges(businessHours, staffingRules, date).length === 0, + const closedDates = useMemo( + () => dateRange.filter((date) => isClosedDate(businessHours, date)), + [businessHours, dateRange], ); - const visibleTimetableDates = dateRange.filter( - (date) => getSchedulableRanges(businessHours, staffingRules, date).length > 0, + 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), @@ -802,7 +813,7 @@ export function MvpAvailabilityPage() { 기간 선택 - + 근무 기간 선택 @@ -1134,7 +1145,7 @@ export function MvpAvailabilityPage() { selected ? "해제" : "선택" }`} className={cn( - "flex h-8 touch-none select-none items-center justify-center rounded-md border text-[10px] transition-colors", + "flex h-8 touch-none select-none items-center justify-center rounded-md border transition-colors", disabledByState ? "cursor-not-allowed border-border/40 bg-surface-secondary/40 opacity-45" : selected