From 5565caa42512c8c22bafab6d2579ee52f13b7b95 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 06:06:43 +0900 Subject: [PATCH 1/3] feat(web): improve schedule calendar and time inputs --- .../components/mvp-availability-page.tsx | 2 +- .../components/mvp-dashboard-page.tsx | 90 +------ .../components/mvp-organization-edit-page.tsx | 2 +- .../components/organization-form.tsx | 86 ++++-- .../components/mvp-schedule-history-page.tsx | 87 +----- .../confirmed-schedule-calendar.tsx | 255 ++++++++++++++++++ 6 files changed, 339 insertions(+), 183 deletions(-) create mode 100644 apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx 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..f97a03c 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -557,7 +557,7 @@ export function MvpAvailabilityPage() { void saveAvailability(); }} > - {replaceAvailabilityMutation.isPending ? "저장 중" : "변경사항 저장"} + {replaceAvailabilityMutation.isPending ? "저장 중" : "저장"} } 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 cc14055..8489db8 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -9,6 +9,7 @@ import { Building2, CalendarX, Clock, Download, Pencil } from "lucide-react"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { Badge } from "@/components/ui/badge"; import { useOrganizationQuery } from "@/features/organization/queries/organization-queries"; +import { ConfirmedScheduleCalendar } from "@/features/schedules/components/confirmed-schedule-calendar"; type ConfirmedSchedule = { id: string; @@ -20,7 +21,6 @@ type ConfirmedSchedule = { const scheduleSummary = { scheduleRange: "2026-06-20 ~ 2026-07-05", - confirmedScheduleCount: 20, }; const DAY_LABELS = { @@ -32,7 +32,6 @@ const DAY_LABELS = { SAT: "토", SUN: "일", } as const; -const WEEKDAY_LABELS = Object.values(DAY_LABELS); const CONFIRMED_START_DATE = "2026-06-20"; const CONFIRMED_END_DATE = "2026-07-05"; const CONFIRMED_WORKER_NAMES = [ @@ -85,37 +84,6 @@ const CONFIRMED_SCHEDULES: ConfirmedSchedule[] = CONFIRMED_WORKER_NAMES.map((wor }; }); -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 formatBusinessHourRange(businessHour: OrganizationDetail["businessHours"][number]) { if (!businessHour.openTime || !businessHour.closeTime) { return "운영 시간 미설정"; @@ -165,7 +133,6 @@ function getClosedDayLabels(organization: OrganizationDetail) { export function MvpDashboardPage() { const organizationQuery = useOrganizationQuery(); const [exportMessage, setExportMessage] = useState(""); - const calendarDates = createCalendarDates(CONFIRMED_START_DATE, CONFIRMED_END_DATE); const organization = organizationQuery.data; const organizationName = organizationQuery.isPending ? "조직 정보를 불러오는 중입니다" @@ -270,12 +237,9 @@ export function MvpDashboardPage() {
-

확정 스케줄 달력

+

최근 확정 스케줄

{scheduleSummary.scheduleRange}

-

- 총 {scheduleSummary.confirmedScheduleCount}개 배정이 확정되었습니다. -

-
-
- {WEEKDAY_LABELS.map((weekday) => ( -
- {weekday} -
- ))} - {calendarDates.map((date) => { - const inRange = date >= CONFIRMED_START_DATE && date <= CONFIRMED_END_DATE; - const dateSchedules = CONFIRMED_SCHEDULES.filter( - (schedule) => schedule.date === date, - ); - - return ( -
-
-

{date.slice(8, 10)}

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

- {schedule.startTime}-{schedule.endTime} {schedule.workerName} -

- ))} -
-
- ); - })} -
-
+ ); 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 153eef4..67a2075 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 @@ -1,10 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Button } from "@moyeorak/design-system"; import { Download } from "lucide-react"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; +import { ConfirmedScheduleCalendar } from "@/features/schedules/components/confirmed-schedule-calendar"; import { Select, SelectContent, @@ -27,11 +28,9 @@ type ScheduleHistory = { startDate: string; endDate: string; confirmedAt: string; - scheduleCount: number; schedules: ConfirmedSchedule[]; }; -const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const WORKER_NAMES = [ "김민지", "박준호", @@ -80,19 +79,6 @@ function createDateRange(startDate: string, endDate: string) { 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 createConfirmedSchedules(startDate: string, endDate: string) { const dates = createDateRange(startDate, endDate); @@ -117,7 +103,6 @@ const SCHEDULE_HISTORIES: ScheduleHistory[] = [ startDate: "2026-06-20", endDate: "2026-07-05", confirmedAt: "2026-06-20", - scheduleCount: 20, schedules: createConfirmedSchedules("2026-06-20", "2026-07-05"), }, { @@ -126,7 +111,6 @@ const SCHEDULE_HISTORIES: ScheduleHistory[] = [ startDate: "2026-06-01", endDate: "2026-06-15", confirmedAt: "2026-05-30", - scheduleCount: 18, schedules: createConfirmedSchedules("2026-06-01", "2026-06-15").slice(0, 18), }, { @@ -135,7 +119,6 @@ const SCHEDULE_HISTORIES: ScheduleHistory[] = [ startDate: "2026-05-16", endDate: "2026-05-31", confirmedAt: "2026-05-14", - scheduleCount: 16, schedules: createConfirmedSchedules("2026-05-16", "2026-05-31").slice(0, 16), }, { @@ -144,7 +127,6 @@ const SCHEDULE_HISTORIES: ScheduleHistory[] = [ startDate: "2025-12-16", endDate: "2025-12-31", confirmedAt: "2025-12-14", - scheduleCount: 15, schedules: createConfirmedSchedules("2025-12-16", "2025-12-31").slice(0, 15), }, ]; @@ -175,11 +157,6 @@ export function MvpScheduleHistoryPage() { filteredHistories[0] ?? selectedYearHistories[0] ?? SCHEDULE_HISTORIES[0]; - const calendarDates = useMemo( - () => createCalendarDates(selectedHistory.startDate, selectedHistory.endDate), - [selectedHistory.endDate, selectedHistory.startDate], - ); - return (
-

읽기 모드 달력

+

확정 스케줄 기록

- 확정일 {selectedHistory.confirmedAt} · 총 {selectedHistory.scheduleCount}개 배정 + 확정일 {selectedHistory.confirmedAt}

@@ -276,57 +253,11 @@ export function MvpScheduleHistoryPage() {
-
-
- {WEEKDAY_LABELS.map((weekday) => ( -
- {weekday} -
- ))} - - {calendarDates.map((date) => { - const inRange = - date >= selectedHistory.startDate && date <= selectedHistory.endDate; - const dateSchedules = selectedHistory.schedules.filter( - (schedule) => schedule.date === date, - ); - - return ( -
-
-

{date.slice(8, 10)}

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

- {schedule.startTime}-{schedule.endTime} {schedule.workerName} -

- ))} -
-
- ); - })} -
-
+ 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..9bff21d --- /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} + + ))} +
+
+ ))} +
+
+
+ + ); +} From 0c39c11a5567d60d89ef48d594a49c925df32c04 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 14:50:04 +0900 Subject: [PATCH 2/3] fix: address schedule review feedback --- apps/api/test/organization.routes.test.ts | 23 ++ .../components/mvp-schedule-history-page.tsx | 1 + .../confirmed-schedule-calendar.tsx | 2 +- .../components/mvp-schedules-page.tsx | 352 ++++++++++++++---- 4 files changed, 301 insertions(+), 77 deletions(-) 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/schedule-history/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 67a2075..ef91ace 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 @@ -254,6 +254,7 @@ export function MvpScheduleHistoryPage() {
{getTimeRangeLabel(group)} - + {getWorkerNamesPreview(group.workerNames)} 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..fc2388f 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,18 @@ 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 selectedDateCandidates = candidates.filter( + (item) => item.date === selectedDate && !item.isRecommended, + ); const selectedDateUnfilledConditions = unfilledConditions.filter( (condition) => condition.date === selectedDate, ); + 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 +651,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 +919,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 +1033,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 +1147,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 +1202,7 @@ export function MvpSchedulesPage() { selectedDateCandidates.map((candidate) => (

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

- {candidate.isRecommended ? ( - - - ) : ( - - 후보 - - )} + + 후보 +
)) ) : ( From 75390dfe5749778310807bf4d5119078e31dc1e9 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 15:46:56 +0900 Subject: [PATCH 3/3] fix: address remaining schedule review comments --- .../features/schedules/components/mvp-schedules-page.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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 fc2388f..f1495e7 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -612,9 +612,8 @@ export function MvpSchedulesPage() { const selectedDateCandidates = candidates.filter( (item) => item.date === selectedDate && !item.isRecommended, ); - const selectedDateUnfilledConditions = unfilledConditions.filter( - (condition) => condition.date === selectedDate, - ); + const selectedDateUnfilledConditions = + unfilledConditionGroups.find((group) => group.date === selectedDate)?.conditions ?? []; const visibleUnfilledConditionGroups = unfilledConditionGroups.slice( 0, DEFAULT_VISIBLE_UNFILLED_GROUPS, @@ -1091,7 +1090,7 @@ export function MvpSchedulesPage() { {getTimeRangeLabel(group)} - + {getWorkerNamesPreview(group.items.map((item) => item.workerName))}