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"
+ }
+ >
+
+
+
+ {visibleGroups.map((group) => (
+
+
+ {getTimeRangeLabel(group)}
+
+
+ {getWorkerNamesPreview(group.workerNames)}
+
+
+ ))}
+
+ {hiddenGroupCount > 0 ? (
+ + 더보기
+ ) : null}
+
+ |
+ );
+ })}
+
+
+
+
+ >
+ );
+}
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 (