diff --git a/apps/api/test/organization.routes.test.ts b/apps/api/test/organization.routes.test.ts index 2314715..2dd72a5 100644 --- a/apps/api/test/organization.routes.test.ts +++ b/apps/api/test/organization.routes.test.ts @@ -50,6 +50,29 @@ describe("organization routes", () => { }); }); + it("rejects organization business hours outside the 30-minute boundary", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/organization") + .set("Authorization", authHeader()) + .send({ + name: "프래그먼트 카페", + businessHours: createBusinessHoursInput({ + MON: { + openTime: "09:15", + }, + }), + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + it("creates an organization and derives overnight business hours", async () => { const { prisma } = createFakePrisma(); const app = createApp({ prisma }); diff --git a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx index 363ed13..9bf63aa 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { OrganizationDetail, ScheduleAssignment } from "@fragment/shared"; import { Button } from "@moyeorak/design-system"; import { Building2, CalendarX, Clock, Download, Pencil } from "lucide-react"; @@ -10,8 +10,11 @@ import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { Badge } from "@/components/ui/badge"; import { useDashboardQuery } from "@/features/dashboard/queries/dashboard-queries"; import { useExportScheduleHistoryCsvMutation } from "@/features/schedule-history/queries/schedule-history-queries"; +import { + ConfirmedScheduleCalendar, + type ConfirmedScheduleCalendarItem, +} from "@/features/schedules/components/confirmed-schedule-calendar"; import { getApiErrorMessage } from "@/lib/api-error-message"; -import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; const DAY_LABELS = { MON: "월", @@ -22,38 +25,6 @@ const DAY_LABELS = { SAT: "토", SUN: "일", } as const; -const WEEKDAY_LABELS = Object.values(DAY_LABELS); - -function addDays(date: string, days: number) { - const [year, month, day] = date.split("-").map(Number); - const nextDate = new Date(Date.UTC(year, month - 1, day + days)); - return nextDate.toISOString().slice(0, 10); -} - -function createDateRange(startDate: string, endDate: string) { - const dates: string[] = []; - let currentDate = startDate; - - while (currentDate <= endDate) { - dates.push(currentDate); - currentDate = addDays(currentDate, 1); - } - - return dates; -} - -function getMonday(date: string) { - const [year, month, day] = date.split("-").map(Number); - const targetDate = new Date(Date.UTC(year, month - 1, day)); - const dayIndex = targetDate.getUTCDay(); - const diff = dayIndex === 0 ? -6 : 1 - dayIndex; - - return addDays(date, diff); -} - -function createCalendarDates(startDate: string, endDate: string) { - return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); -} function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; @@ -75,6 +46,24 @@ function downloadBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } +function formatAssignmentTime(workDate: string, dateTime: string) { + const time = dateTime.slice(11, 16); + + return dateTime.slice(0, 10) > workDate ? `${time}+1` : time; +} + +function scheduleAssignmentToCalendarItem( + assignment: ScheduleAssignment, +): ConfirmedScheduleCalendarItem { + return { + date: assignment.workDate, + endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt), + id: assignment.id, + startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt), + workerName: assignment.workerNameSnapshot, + }; +} + function formatBusinessHourRange(businessHour: OrganizationDetail["businessHours"][number]) { if (!businessHour.openTime || !businessHour.closeTime) { return "운영 시간 미설정"; @@ -127,9 +116,10 @@ export function MvpDashboardPage() { const [exportMessage, setExportMessage] = useState(""); const organization = dashboardQuery.data?.organization; const latestConfirmedSchedule = dashboardQuery.data?.latestConfirmedSchedule ?? null; - const calendarDates = latestConfirmedSchedule - ? createCalendarDates(latestConfirmedSchedule.startDate, latestConfirmedSchedule.endDate) - : []; + const latestConfirmedSchedules = useMemo( + () => latestConfirmedSchedule?.assignments.map(scheduleAssignmentToCalendarItem) ?? [], + [latestConfirmedSchedule], + ); const organizationName = dashboardQuery.isPending ? "조직 정보를 불러오는 중입니다" : dashboardQuery.isError @@ -255,7 +245,7 @@ export function MvpDashboardPage() {
-

확정 스케줄 달력

+

최근 확정 스케줄

{dashboardQuery.isPending ? (

@@ -303,57 +293,11 @@ export function MvpDashboardPage() {

잠시 후 다시 시도해 주세요.

) : latestConfirmedSchedule ? ( -
-
- {WEEKDAY_LABELS.map((weekday) => ( -
- {weekday} -
- ))} - {calendarDates.map((date) => { - const inRange = - date >= latestConfirmedSchedule.startDate && - date <= latestConfirmedSchedule.endDate; - const dateSchedules = latestConfirmedSchedule.assignments.filter( - (assignment) => assignment.workDate === date, - ); - - return ( -
-
-

{date.slice(8, 10)}

- {inRange ? ( - - {dateSchedules.length}명 - - ) : null} -
-
- {dateSchedules.map((assignment: ScheduleAssignment) => ( -

- {formatScheduleAssignmentTimeRange(assignment)}{" "} - {assignment.workerNameSnapshot} -

- ))} -
-
- ); - })} -
-
+ ) : (

확정된 스케줄이 없습니다.

diff --git a/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx b/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx index d72c51f..9463c81 100644 --- a/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx +++ b/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx @@ -47,7 +47,7 @@ export function MvpOrganizationEditPage() { cancelHref="/dashboard" initialValues={formValues} submitError={submitError} - submitLabel="변경사항 저장" + submitLabel="저장" submittingLabel="저장 중" onSubmit={async (request) => { setSubmitError(null); diff --git a/apps/web/src/features/organization/components/organization-form.tsx b/apps/web/src/features/organization/components/organization-form.tsx index 19b59cc..2bc7e34 100644 --- a/apps/web/src/features/organization/components/organization-form.tsx +++ b/apps/web/src/features/organization/components/organization-form.tsx @@ -3,10 +3,17 @@ import Link from "next/link"; import { createOrganizationRequestSchema } from "@fragment/shared"; import { Button } from "@moyeorak/design-system"; -import { type FieldErrors, type Resolver, useForm } from "react-hook-form"; +import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { createOrganizationRequestFromForm, @@ -24,6 +31,21 @@ type OrganizationFormProps = { submittingLabel: string; }; +const TIME_OPTION_STEP_MINUTES = 30; +const MINUTES_IN_DAY = 24 * 60; + +const TIME_OPTIONS = Array.from( + { length: MINUTES_IN_DAY / TIME_OPTION_STEP_MINUTES }, + (_, index) => { + const minutes = index * TIME_OPTION_STEP_MINUTES; + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + const value = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + + return { label: value, value }; + }, +); + const organizationFormResolver: Resolver = (values) => { const request = createOrganizationRequestFromForm(values); const result = createOrganizationRequestSchema.safeParse(request); @@ -85,6 +107,7 @@ export function OrganizationForm({ submittingLabel, }: OrganizationFormProps) { const { + control, formState: { errors, isSubmitting }, handleSubmit, register, @@ -124,13 +147,6 @@ export function OrganizationForm({ className="rounded-xl border border-border bg-card p-6 shadow-card" onSubmit={handleSubmit(submitForm)} > -
-

조직 정보

-

- 휴무일로 지정한 요일은 운영 시간 입력이 비활성화됩니다. -

-
-
@@ -202,19 +218,49 @@ export function OrganizationForm({
- ( + + )} /> - ( + + )} />
{dayError ?

{dayError}

: null} diff --git a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 8f96df3..509e510 100644 --- a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx +++ b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx @@ -18,43 +18,14 @@ import { useScheduleHistoryDetailQuery, useScheduleHistoryQuery, } from "@/features/schedule-history/queries/schedule-history-queries"; +import { + ConfirmedScheduleCalendar, + type ConfirmedScheduleCalendarItem, +} from "@/features/schedules/components/confirmed-schedule-calendar"; import { getApiErrorMessage } from "@/lib/api-error-message"; -import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; -const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const INITIAL_HISTORY_YEAR = new Date().getFullYear(); -function addDays(date: string, days: number) { - const [year, month, day] = date.split("-").map(Number); - const nextDate = new Date(Date.UTC(year, month - 1, day + days)); - return nextDate.toISOString().slice(0, 10); -} - -function createDateRange(startDate: string, endDate: string) { - const dates: string[] = []; - let currentDate = startDate; - - while (currentDate <= endDate) { - dates.push(currentDate); - currentDate = addDays(currentDate, 1); - } - - return dates; -} - -function getMonday(date: string) { - const [year, month, day] = date.split("-").map(Number); - const targetDate = new Date(Date.UTC(year, month - 1, day)); - const dayIndex = targetDate.getUTCDay(); - const diff = dayIndex === 0 ? -6 : 1 - dayIndex; - - return addDays(date, diff); -} - -function createCalendarDates(startDate: string, endDate: string) { - return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); -} - function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; } @@ -79,6 +50,24 @@ function downloadBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } +function formatAssignmentTime(workDate: string, dateTime: string) { + const time = dateTime.slice(11, 16); + + return dateTime.slice(0, 10) > workDate ? `${time}+1` : time; +} + +function scheduleAssignmentToCalendarItem( + assignment: ScheduleAssignment, +): ConfirmedScheduleCalendarItem { + return { + date: assignment.workDate, + endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt), + id: assignment.id, + startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt), + workerName: assignment.workerNameSnapshot, + }; +} + function findSelectedHistory(items: ScheduleHistoryItem[], selectedHistoryId: string) { return items.find((history) => history.id === selectedHistoryId) ?? items[0] ?? null; } @@ -159,15 +148,12 @@ export function MvpScheduleHistoryPage() { ); const exportScheduleHistoryCsvMutation = useExportScheduleHistoryCsvMutation(); const selectedHistory = scheduleHistoryDetailQuery.data ?? null; - const isScheduleDetailLoading = selectedScheduleId !== "" && scheduleHistoryDetailQuery.isPending; - const isScheduleHistoryLoading = scheduleHistoryQuery.isPending || isScheduleDetailLoading; - const calendarDates = useMemo( - () => - selectedHistory - ? createCalendarDates(selectedHistory.startDate, selectedHistory.endDate) - : [], + const selectedHistorySchedules = useMemo( + () => selectedHistory?.assignments.map(scheduleAssignmentToCalendarItem) ?? [], [selectedHistory], ); + const isScheduleDetailLoading = selectedScheduleId !== "" && scheduleHistoryDetailQuery.isPending; + const isScheduleHistoryLoading = scheduleHistoryQuery.isPending || isScheduleDetailLoading; const canExportSchedule = selectedHistory !== null && !exportScheduleHistoryCsvMutation.isPending; const handleExportSchedule = async () => { @@ -205,7 +191,7 @@ export function MvpScheduleHistoryPage() {
-

읽기 모드 달력

+

확정 스케줄 기록

{isScheduleHistoryLoading ? (

@@ -319,58 +305,12 @@ export function MvpScheduleHistoryPage() {

잠시 후 다시 시도해 주세요.

) : selectedHistory ? ( -
-
- {WEEKDAY_LABELS.map((weekday) => ( -
- {weekday} -
- ))} - - {calendarDates.map((date) => { - const inRange = - date >= selectedHistory.startDate && date <= selectedHistory.endDate; - const dateSchedules = selectedHistory.assignments.filter( - (assignment) => assignment.workDate === date, - ); - - return ( -
-
-

{date.slice(8, 10)}

- {inRange ? ( - - {dateSchedules.length}명 - - ) : null} -
- -
- {dateSchedules.map((assignment: ScheduleAssignment) => ( -

- {formatScheduleAssignmentTimeRange(assignment)}{" "} - {assignment.workerNameSnapshot} -

- ))} -
-
- ); - })} -
-
+ ) : (

확정된 스케줄이 없습니다.

diff --git a/apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx b/apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx new file mode 100644 index 0000000..623622d --- /dev/null +++ b/apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { X } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export type ConfirmedScheduleCalendarItem = { + id: string; + date: string; + startTime: string; + endTime: string; + workerName: string; +}; + +type ConfirmedScheduleCalendarProps = { + endDate: string; + schedules: ConfirmedScheduleCalendarItem[]; + startDate: string; +}; + +type ScheduleTimeGroup = { + endTime: string; + id: string; + startTime: string; + workerNames: string[]; +}; + +const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; +const MAX_VISIBLE_GROUPS = 3; +const MAX_VISIBLE_WORKER_NAMES = 2; + +function addDays(date: string, days: number) { + const [year, month, day] = date.split("-").map(Number); + const nextDate = new Date(Date.UTC(year, month - 1, day + days)); + return nextDate.toISOString().slice(0, 10); +} + +function createDateRange(startDate: string, endDate: string) { + const dates: string[] = []; + let currentDate = startDate; + + while (currentDate <= endDate) { + dates.push(currentDate); + currentDate = addDays(currentDate, 1); + } + + return dates; +} + +function getMonday(date: string) { + const [year, month, day] = date.split("-").map(Number); + const targetDate = new Date(Date.UTC(year, month - 1, day)); + const dayIndex = targetDate.getUTCDay(); + const diff = dayIndex === 0 ? -6 : 1 - dayIndex; + + return addDays(date, diff); +} + +function createCalendarDates(startDate: string, endDate: string) { + return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); +} + +function formatDateTitle(date: string) { + const [, month, day] = date.split("-"); + + return `${Number(month)}월 ${Number(day)}일`; +} + +function getTimeRangeLabel(group: ScheduleTimeGroup) { + return `${group.startTime}-${group.endTime}`; +} + +function getWorkerNamesPreview(workerNames: string[]) { + const visibleWorkerNames = workerNames.slice(0, MAX_VISIBLE_WORKER_NAMES); + const hiddenWorkerCount = workerNames.length - visibleWorkerNames.length; + const visibleLabel = visibleWorkerNames.join(", "); + + if (hiddenWorkerCount <= 0) { + return visibleLabel; + } + + return `${visibleLabel} 외 ${hiddenWorkerCount}명`; +} + +function groupSchedulesByTime(schedules: ConfirmedScheduleCalendarItem[]) { + const groupsByTime = new Map(); + + schedules.forEach((schedule) => { + const key = `${schedule.startTime}-${schedule.endTime}`; + const group = groupsByTime.get(key); + + if (group) { + group.workerNames.push(schedule.workerName); + return; + } + + groupsByTime.set(key, { + endTime: schedule.endTime, + id: key, + startTime: schedule.startTime, + workerNames: [schedule.workerName], + }); + }); + + return [...groupsByTime.values()].sort((left, right) => { + const startCompare = left.startTime.localeCompare(right.startTime); + + if (startCompare !== 0) { + return startCompare; + } + + return left.endTime.localeCompare(right.endTime); + }); +} + +export function ConfirmedScheduleCalendar({ + endDate, + schedules, + startDate, +}: ConfirmedScheduleCalendarProps) { + const [selectedDate, setSelectedDate] = useState(null); + const calendarDates = useMemo( + () => createCalendarDates(startDate, endDate), + [endDate, startDate], + ); + const schedulesByDate = useMemo(() => { + const nextSchedulesByDate = new Map(); + + schedules.forEach((schedule) => { + const dateSchedules = nextSchedulesByDate.get(schedule.date) ?? []; + nextSchedulesByDate.set(schedule.date, [...dateSchedules, schedule]); + }); + + return nextSchedulesByDate; + }, [schedules]); + const selectedDateSchedules = selectedDate ? (schedulesByDate.get(selectedDate) ?? []) : []; + const selectedDateGroups = groupSchedulesByTime(selectedDateSchedules); + + return ( + <> +
+
+ {WEEKDAY_LABELS.map((weekday) => ( +
+ {weekday} +
+ ))} + + {calendarDates.map((date) => { + const inRange = date >= startDate && date <= endDate; + const dateSchedules = schedulesByDate.get(date) ?? []; + const timeGroups = groupSchedulesByTime(dateSchedules); + const visibleGroups = timeGroups.slice(0, MAX_VISIBLE_GROUPS); + const hiddenGroupCount = timeGroups.length - visibleGroups.length; + const canOpenDetail = inRange && dateSchedules.length > 0; + const Cell = canOpenDetail ? "button" : "div"; + + return ( + setSelectedDate(date) : undefined} + className={ + inRange + ? [ + "flex min-h-36 w-full flex-col items-stretch border-b border-r border-border bg-card p-3 text-left", + canOpenDetail + ? "cursor-pointer transition-colors hover:bg-accent/60 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring" + : "", + ].join(" ") + : "flex min-h-36 w-full flex-col items-stretch border-b border-r border-border bg-surface-secondary p-3 text-left opacity-45" + } + > +
+

{date.slice(8, 10)}

+
+ +
+ {visibleGroups.map((group) => ( +
+ + {getTimeRangeLabel(group)} + + + {getWorkerNamesPreview(group.workerNames)} + +
+ ))} + + {hiddenGroupCount > 0 ? ( +

+ 더보기

+ ) : null} +
+
+ ); + })} +
+
+ + !open && setSelectedDate(null)}> + +
+ + + {selectedDate ? `${formatDateTitle(selectedDate)} 확정 스케줄` : "확정 스케줄"} + + + + 닫기 + +
+ +
+ {selectedDateGroups.map((group) => ( +
+
+

+ {getTimeRangeLabel(group)} +

+
+
+ {group.workerNames.map((workerName, index) => ( + + {workerName} + + ))} +
+
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx index cf7007f..f1495e7 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -11,7 +11,7 @@ import type { } from "@fragment/shared"; import { useEffect, useMemo, useState } from "react"; import { Badge, Button } from "@moyeorak/design-system"; -import { CalendarDays, Check, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react"; +import { CalendarDays, CircleAlert, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react"; import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form"; import { z } from "zod"; @@ -69,6 +69,13 @@ type ScheduleCandidateItem = { workerName: string; }; +type ScheduleTimeGroup = { + endTime: string; + id: string; + items: ScheduleItem[]; + startTime: string; +}; + type ScheduleDraft = { date: string; endTime: string; @@ -87,6 +94,12 @@ type UnfilledCondition = { timeRange: string; }; +type UnfilledConditionGroup = { + conditions: UnfilledCondition[]; + date: string; + dayLabel: string; +}; + type WorkerOption = { id: string; name: string; @@ -99,6 +112,10 @@ type OperationMessage = { const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const EMPTY_WORKERS: Worker[] = []; +const DEFAULT_VISIBLE_UNFILLED_GROUPS = 5; +const DEFAULT_VISIBLE_SCHEDULE_GROUPS = 3; +const MAX_VISIBLE_WORKER_NAMES = 2; +const MAX_PREVIEW_UNFILLED_CONDITIONS = 3; const EMPTY_DRAFT: ScheduleDraft = { date: "", @@ -316,6 +333,12 @@ function formatDateTitle(date: string) { return `${date} ${getDayLabel(date)}`; } +function formatShortDateTitle(date: string) { + const [, month, day] = date.split("-"); + + return `${Number(month)}월 ${Number(day)}일`; +} + function formatShortageTimeRange(shortage: ScheduleWorkerShortage) { return `${shortage.startTime}-${shortage.endTime}${shortage.endsNextDay ? "+1" : ""}`; } @@ -344,6 +367,53 @@ function candidateToScheduleCandidateItem(candidate: ScheduleCandidate): Schedul }; } +function getTimeRangeLabel(group: Pick) { + return `${group.startTime}-${group.endTime}`; +} + +function getWorkerNamesPreview(workerNames: string[]) { + const visibleWorkerNames = workerNames.slice(0, MAX_VISIBLE_WORKER_NAMES); + const hiddenWorkerCount = workerNames.length - visibleWorkerNames.length; + const visibleLabel = visibleWorkerNames.join(", "); + + if (hiddenWorkerCount <= 0) { + return visibleLabel; + } + + return `${visibleLabel} 외 ${hiddenWorkerCount}명`; +} + +function groupSchedulesByTime(schedules: ScheduleItem[]): ScheduleTimeGroup[] { + const groupsByTime = new Map(); + + schedules.forEach((schedule) => { + const key = `${schedule.startTime}-${schedule.endTime}`; + const group = groupsByTime.get(key); + + if (group) { + group.items.push(schedule); + return; + } + + groupsByTime.set(key, { + endTime: schedule.endTime, + id: key, + items: [schedule], + startTime: schedule.startTime, + }); + }); + + return [...groupsByTime.values()].sort((left, right) => { + const startCompare = left.startTime.localeCompare(right.startTime); + + if (startCompare !== 0) { + return startCompare; + } + + return left.endTime.localeCompare(right.endTime); + }); +} + function shortageToUnfilledCondition(shortage: ScheduleWorkerShortage): UnfilledCondition { return { assignedWorkers: shortage.assignedCount, @@ -355,6 +425,54 @@ function shortageToUnfilledCondition(shortage: ScheduleWorkerShortage): Unfilled }; } +function groupUnfilledConditionsByDate(conditions: UnfilledCondition[]): UnfilledConditionGroup[] { + const groupsByDate = new Map(); + + conditions.forEach((condition) => { + const group = groupsByDate.get(condition.date); + + if (group) { + group.conditions.push(condition); + return; + } + + groupsByDate.set(condition.date, { + conditions: [condition], + date: condition.date, + dayLabel: condition.dayLabel, + }); + }); + + return [...groupsByDate.values()] + .map((group) => ({ + ...group, + conditions: [...group.conditions].sort((left, right) => + left.timeRange.localeCompare(right.timeRange), + ), + })) + .sort((left, right) => left.date.localeCompare(right.date)); +} + +function getShortageCount(condition: UnfilledCondition) { + return Math.max(condition.requiredWorkers - condition.assignedWorkers, 0); +} + +function getUnfilledConditionLabel(condition: UnfilledCondition) { + return `${condition.timeRange} ${getShortageCount(condition)}명 부족`; +} + +function getUnfilledConditionPreview(conditions: UnfilledCondition[]) { + const visibleConditions = conditions.slice(0, MAX_PREVIEW_UNFILLED_CONDITIONS); + const hiddenConditionCount = conditions.length - visibleConditions.length; + const visibleLabel = visibleConditions.map(getUnfilledConditionLabel).join(" · "); + + if (hiddenConditionCount <= 0) { + return visibleLabel; + } + + return `${visibleLabel} · +${hiddenConditionCount}개`; +} + function createWorkerOptions(workers: Worker[], editingSchedule: ScheduleItem | null) { const options: WorkerOption[] = workers.map((worker) => ({ id: worker.id, @@ -411,6 +529,10 @@ export function MvpSchedulesPage() { () => schedule?.workerShortages.map(shortageToUnfilledCondition) ?? [], [schedule], ); + const unfilledConditionGroups = useMemo( + () => groupUnfilledConditionsByDate(unfilledConditions), + [unfilledConditions], + ); const scheduleTargetDates = useMemo( () => new Set([ @@ -442,6 +564,7 @@ export function MvpSchedulesPage() { const [confirmOpen, setConfirmOpen] = useState(false); const [selectedDate, setSelectedDate] = useState(""); const [dateDetailDismissed, setDateDetailDismissed] = useState(false); + const [showUnfilledDetails, setShowUnfilledDetails] = useState(false); const [operationMessage, setOperationMessage] = useState(null); const scheduleFormDate = watch("date"); const scheduleFormStartTime = watch("startTime"); @@ -486,10 +609,17 @@ export function MvpSchedulesPage() { !queryErrorMessage; const formTitle = formMode === "create" ? "스케줄 추가" : "스케줄 수정"; const selectedDateSchedules = schedules.filter((item) => item.date === selectedDate); - const selectedDateCandidates = candidates.filter((item) => item.date === selectedDate); - const selectedDateUnfilledConditions = unfilledConditions.filter( - (condition) => condition.date === selectedDate, + const selectedDateCandidates = candidates.filter( + (item) => item.date === selectedDate && !item.isRecommended, + ); + const selectedDateUnfilledConditions = + unfilledConditionGroups.find((group) => group.date === selectedDate)?.conditions ?? []; + const visibleUnfilledConditionGroups = unfilledConditionGroups.slice( + 0, + DEFAULT_VISIBLE_UNFILLED_GROUPS, ); + const hiddenUnfilledGroupCount = + unfilledConditionGroups.length - visibleUnfilledConditionGroups.length; const isSelectedDateScheduleTarget = scheduleTargetDates.has(selectedDate); const scheduleEndTimeOptions = useMemo( () => createScheduleEndTimeOptions(scheduleFormStartTime), @@ -520,7 +650,14 @@ export function MvpSchedulesPage() { } setSelectedDate(firstScheduleTargetDate); - }, [dateDetailDismissed, firstScheduleTargetDate, schedule, scheduleTargetDates, selectedDate]); + }, [ + dateDetailDismissed, + firstScheduleTargetDate, + reset, + schedule, + scheduleTargetDates, + selectedDate, + ]); function openCreateForm(date: string) { if (!isDraft) { @@ -781,36 +918,85 @@ export function MvpSchedulesPage() { ) : null} {unfilledConditions.length > 0 && isDraft ? ( -
-
-

미충족 조건

+
+
+
+
+ + 미충족 + +

미충족 조건

+
+

+ {unfilledConditions.length}개 조건이 {unfilledConditionGroups.length}일에 걸쳐 + 부족합니다. +

+
+
-
- - - - - - - - - - - {unfilledConditions.map((condition) => ( - - - - - - - ))} - -
요일시간대필요 인원추천 배정
{condition.dayLabel}{condition.timeRange} - {condition.requiredWorkers}명 - - {condition.assignedWorkers}명 -
+ +
+ {visibleUnfilledConditionGroups.map((group) => ( + + ))} + {hiddenUnfilledGroupCount > 0 ? ( + + +{hiddenUnfilledGroupCount}일 + + ) : null}
+ + {showUnfilledDetails ? ( +
+ {unfilledConditionGroups.map((group) => ( + + ))} +
+ ) : null}
) : null} @@ -846,11 +1032,16 @@ export function MvpSchedulesPage() { {calendarDates.map((date) => { const inWorkRange = date >= workStartDate && date <= workEndDate; const dateSchedules = schedules.filter((item) => item.date === date); + const dateScheduleGroups = groupSchedulesByTime(dateSchedules); + const visibleScheduleGroups = dateScheduleGroups.slice( + 0, + DEFAULT_VISIBLE_SCHEDULE_GROUPS, + ); + const hiddenScheduleGroupCount = + dateScheduleGroups.length - visibleScheduleGroups.length; const unfilled = unfilledConditions.some((condition) => condition.date === date); const hasScheduleTarget = scheduleTargetDates.has(date); const selected = selectedDate === date; - const visibleSchedules = dateSchedules.slice(0, 3); - const hiddenScheduleCount = dateSchedules.length - visibleSchedules.length; return ( @@ -959,14 +1146,37 @@ export function MvpSchedulesPage() {
{selectedDateUnfilledConditions.length > 0 ? ( -
-

미충족 조건

-
- {selectedDateUnfilledConditions.map((condition) => ( -

- {condition.timeRange} · 필요 {condition.requiredWorkers}명 / 추천{" "} - {condition.assignedWorkers}명 +

+
+
+
+
+

최소 인원 미달

+

+ 시간대별 기준에 미달한 항목만 표시합니다.

+
+
+
+ {selectedDateUnfilledConditions.map((condition) => ( +
+ + {condition.timeRange} + + + 배정 {condition.assignedWorkers}명 · 최소 {condition.requiredWorkers}명 + + + {getShortageCount(condition)}명 부족 + +
))}
@@ -991,11 +1201,7 @@ export function MvpSchedulesPage() { selectedDateCandidates.map((candidate) => (

@@ -1005,16 +1211,9 @@ export function MvpSchedulesPage() { {candidate.workerName}

- {candidate.isRecommended ? ( - - - ) : ( - - 후보 - - )} + + 후보 +
)) ) : (