diff --git a/apps/api/src/modules/availability/availability.service.spec.ts b/apps/api/src/modules/availability/availability.service.spec.ts
index 14fc74c..badc47c 100644
--- a/apps/api/src/modules/availability/availability.service.spec.ts
+++ b/apps/api/src/modules/availability/availability.service.spec.ts
@@ -48,12 +48,40 @@ const createBusinessHourRow = ({
updatedAt: timestamp,
});
+const createMinimumStaffingRuleRow = ({
+ id = BigInt(1),
+ organizationId = BigInt(1),
+ dayOfWeek = "WED",
+ startTime = timeOfDay("09:00"),
+ endTime = timeOfDay("18:00"),
+ endsNextDay = false,
+ requiredCount = 1,
+} = {}) => ({
+ id,
+ organizationId,
+ dayOfWeek,
+ startTime,
+ endTime,
+ endsNextDay,
+ requiredCount,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+});
+
const createPrismaMock = ({
- organization = { id: BigInt(1), businessHours: [createBusinessHourRow()] },
+ organization = {
+ id: BigInt(1),
+ businessHours: [createBusinessHourRow()],
+ minimumStaffingRules: [createMinimumStaffingRuleRow()],
+ },
worker = { id: BigInt(1) },
workerAvailableTime = {},
}: {
- organization?: { businessHours: ReturnType[]; id: bigint } | null;
+ organization?: {
+ businessHours: ReturnType[];
+ id: bigint;
+ minimumStaffingRules: ReturnType[];
+ } | null;
worker?: { id: bigint } | null;
workerAvailableTime?: {
createMany?: jest.Mock;
@@ -65,15 +93,27 @@ const createPrismaMock = ({
.fn<() => Promise<{ id: bigint } | null>>()
.mockResolvedValue(organization);
const workerFindFirst = jest.fn<() => Promise<{ id: bigint } | null>>().mockResolvedValue(worker);
+ const workerAvailableTimeCreateMany =
+ workerAvailableTime.createMany ??
+ jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: 0 });
+ const workerAvailableTimeDeleteMany =
+ workerAvailableTime.deleteMany ??
+ jest.fn<() => Promise<{ count: number }>>().mockResolvedValue({ count: 0 });
+ const workerAvailableTimeFindMany =
+ workerAvailableTime.findMany ??
+ jest.fn<() => Promise[]>>().mockResolvedValue([]);
return {
prisma: {
+ activeSchedulePlanningPeriod: {
+ findUnique: jest.fn<() => Promise>().mockResolvedValue(null),
+ },
$transaction: jest.fn(async (callback: (transaction: unknown) => unknown) =>
callback({
workerAvailableTime: {
- createMany: workerAvailableTime.createMany ?? jest.fn(),
- deleteMany: workerAvailableTime.deleteMany ?? jest.fn(),
- findMany: workerAvailableTime.findMany ?? jest.fn(),
+ createMany: workerAvailableTimeCreateMany,
+ deleteMany: workerAvailableTimeDeleteMany,
+ findMany: workerAvailableTimeFindMany,
},
}),
),
@@ -84,9 +124,9 @@ const createPrismaMock = ({
findFirst: workerFindFirst,
},
workerAvailableTime: {
- createMany: workerAvailableTime.createMany ?? jest.fn(),
- deleteMany: workerAvailableTime.deleteMany ?? jest.fn(),
- findMany: workerAvailableTime.findMany ?? jest.fn(),
+ createMany: workerAvailableTimeCreateMany,
+ deleteMany: workerAvailableTimeDeleteMany,
+ findMany: workerAvailableTimeFindMany,
},
} as unknown as PrismaClient,
organizationFindUnique,
@@ -334,6 +374,7 @@ describe("replaceAvailability", () => {
});
it("rejects availability on closed days", async () => {
+ const availableTimeCreateMany = jest.fn();
const { prisma } = createPrismaMock({
organization: {
id: BigInt(1),
@@ -344,6 +385,10 @@ describe("replaceAvailability", () => {
closeTime: null,
}),
],
+ minimumStaffingRules: [createMinimumStaffingRuleRow()],
+ },
+ workerAvailableTime: {
+ createMany: availableTimeCreateMany,
},
});
@@ -360,14 +405,19 @@ describe("replaceAvailability", () => {
},
],
}),
- ).rejects.toMatchObject({
- code: ERROR_CODES.CLOSED_DAY,
- statusCode: 400,
+ ).resolves.toEqual({
+ items: [],
});
+ expect(availableTimeCreateMany).not.toHaveBeenCalled();
});
- it("rejects availability outside business hours", async () => {
- const { prisma } = createPrismaMock();
+ it("trims availability to schedulable ranges", async () => {
+ const availableTimeCreateMany = jest.fn();
+ const { prisma } = createPrismaMock({
+ workerAvailableTime: {
+ createMany: availableTimeCreateMany,
+ },
+ });
await expect(
replaceAvailability(prisma, "1", {
@@ -382,9 +432,18 @@ describe("replaceAvailability", () => {
},
],
}),
- ).rejects.toMatchObject({
- code: ERROR_CODES.INVALID_TIME_RANGE,
- statusCode: 400,
+ ).resolves.toEqual({
+ items: [],
+ });
+ expect(availableTimeCreateMany).toHaveBeenCalledWith({
+ data: [
+ {
+ workerId: BigInt(1),
+ availableDate: dateOnly("2026-07-01"),
+ startsAt: new Date("2026-07-01T09:00:00.000Z"),
+ endsAt: new Date("2026-07-01T10:00:00.000Z"),
+ },
+ ],
});
});
@@ -437,6 +496,14 @@ describe("replaceAvailability", () => {
closesNextDay: true,
}),
],
+ minimumStaffingRules: [
+ createMinimumStaffingRuleRow({
+ dayOfWeek: "FRI",
+ startTime: timeOfDay("22:00"),
+ endTime: timeOfDay("02:00"),
+ endsNextDay: true,
+ }),
+ ],
},
workerAvailableTime: {
createMany: availableTimeCreateMany,
diff --git a/apps/api/src/modules/availability/availability.service.ts b/apps/api/src/modules/availability/availability.service.ts
index a9ad590..71fe07e 100644
--- a/apps/api/src/modules/availability/availability.service.ts
+++ b/apps/api/src/modules/availability/availability.service.ts
@@ -13,27 +13,21 @@ import type {
import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
import {
- addDays,
- createDateTimeOnDate,
- dateStringToDate,
- dateTimeStringToDate,
- dateToDateString,
-} from "@/utils/date-time";
-import { getDayOfWeek } from "@/utils/day-of-week";
+ pruneAvailabilityTimesToSchedulableRanges,
+ type PrunedAvailabilityTime,
+} from "@/modules/scheduling-time-policy";
+import { dateStringToDate, dateTimeStringToDate, dateToDateString } from "@/utils/date-time";
import { toApiId, toPrismaId } from "@/utils/mapper";
type AvailabilityDatabaseClient = PrismaClient | Prisma.TransactionClient;
type OrganizationForAvailability = Prisma.OrganizationGetPayload<{
- include: { businessHours: true };
+ include: { businessHours: true; minimumStaffingRules: true };
}>;
const createInvalidTimeRangeError = () =>
new HttpError(400, ERROR_CODES.INVALID_TIME_RANGE, "가능 시간이 조직 운영시간을 벗어났습니다.");
-const createClosedDayError = () =>
- new HttpError(400, ERROR_CODES.CLOSED_DAY, "휴무일에는 가능 시간을 저장할 수 없습니다.");
-
const findOrganizationByUserId = async (
prisma: PrismaClient,
userId: string,
@@ -44,6 +38,7 @@ const findOrganizationByUserId = async (
},
include: {
businessHours: true,
+ minimumStaffingRules: true,
},
});
@@ -77,41 +72,34 @@ const toAvailability = (availability: PrismaWorkerAvailableTime): Availability =
endsAt: availability.endsAt.toISOString(),
});
-const assertAvailabilityItemInBusinessHours = (
- organization: OrganizationForAvailability,
- input: ReplaceAvailabilityRequest,
-) => {
+const isPrismaClient = (client: AvailabilityDatabaseClient): client is PrismaClient =>
+ "$transaction" in client;
+
+const assertAvailabilityItemsInDateRange = (input: ReplaceAvailabilityRequest) => {
for (const item of input.items) {
if (item.availableDate < input.startDate || item.availableDate > input.endDate) {
throw createInvalidTimeRangeError();
}
- const availableDate = dateStringToDate(item.availableDate);
- const businessHour = organization.businessHours.find(
- (currentBusinessHour) => currentBusinessHour.dayOfWeek === getDayOfWeek(availableDate),
- );
-
- if (
- !businessHour ||
- businessHour.isClosed ||
- !businessHour.openTime ||
- !businessHour.closeTime
- ) {
- throw createClosedDayError();
- }
-
- const startsAt = dateTimeStringToDate(item.startsAt);
- const endsAt = dateTimeStringToDate(item.endsAt);
- const openAt = createDateTimeOnDate(availableDate, businessHour.openTime);
- const closeDate = businessHour.closesNextDay ? addDays(availableDate, 1) : availableDate;
- const closeAt = createDateTimeOnDate(closeDate, businessHour.closeTime);
-
- if (startsAt >= endsAt || startsAt < openAt || endsAt > closeAt) {
+ if (dateTimeStringToDate(item.startsAt) >= dateTimeStringToDate(item.endsAt)) {
throw createInvalidTimeRangeError();
}
}
};
+const createPrunedAvailabilityItems = (
+ organization: OrganizationForAvailability,
+ availableTimes: Pick<
+ PrismaWorkerAvailableTime,
+ "availableDate" | "endsAt" | "startsAt" | "workerId"
+ >[],
+): PrunedAvailabilityTime[] =>
+ pruneAvailabilityTimesToSchedulableRanges({
+ availableTimes,
+ businessHours: organization.businessHours,
+ rules: organization.minimumStaffingRules,
+ });
+
export async function listAvailability(
prisma: PrismaClient,
userId: string,
@@ -126,6 +114,8 @@ export async function listAvailability(
throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다.");
}
+ await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationId);
+
const items = await prisma.workerAvailableTime.findMany({
where: {
workerId,
@@ -156,7 +146,17 @@ export async function replaceAvailability(
throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다.");
}
- assertAvailabilityItemInBusinessHours(organization, input);
+ assertAvailabilityItemsInDateRange(input);
+
+ const prunedItems = createPrunedAvailabilityItems(
+ organization,
+ input.items.map((item) => ({
+ workerId,
+ availableDate: dateStringToDate(item.availableDate),
+ startsAt: dateTimeStringToDate(item.startsAt),
+ endsAt: dateTimeStringToDate(item.endsAt),
+ })),
+ );
return prisma.$transaction(async (transaction) => {
const dateRange = {
@@ -171,14 +171,9 @@ export async function replaceAvailability(
},
});
- if (input.items.length > 0) {
+ if (prunedItems.length > 0) {
await transaction.workerAvailableTime.createMany({
- data: input.items.map((item) => ({
- workerId,
- availableDate: dateStringToDate(item.availableDate),
- startsAt: dateTimeStringToDate(item.startsAt),
- endsAt: dateTimeStringToDate(item.endsAt),
- })),
+ data: prunedItems,
});
}
@@ -195,3 +190,71 @@ export async function replaceAvailability(
};
});
}
+
+export async function pruneOrganizationAvailabilityForActivePlanningPeriod(
+ client: AvailabilityDatabaseClient,
+ organizationId: bigint,
+): Promise {
+ const activePeriod = await client.activeSchedulePlanningPeriod.findUnique({
+ where: {
+ organizationId,
+ },
+ });
+
+ if (!activePeriod) {
+ return;
+ }
+
+ const organization = await client.organization.findUnique({
+ where: {
+ id: organizationId,
+ },
+ include: {
+ businessHours: true,
+ minimumStaffingRules: true,
+ },
+ });
+
+ if (!organization) {
+ return;
+ }
+
+ const dateRange = {
+ gte: activePeriod.startDate,
+ lte: activePeriod.endDate,
+ };
+ const existingItems = await client.workerAvailableTime.findMany({
+ where: {
+ worker: {
+ organizationId,
+ },
+ availableDate: dateRange,
+ },
+ orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
+ });
+ const prunedItems = createPrunedAvailabilityItems(organization, existingItems);
+
+ const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => {
+ await transaction.workerAvailableTime.deleteMany({
+ where: {
+ worker: {
+ organizationId,
+ },
+ availableDate: dateRange,
+ },
+ });
+
+ if (prunedItems.length > 0) {
+ await transaction.workerAvailableTime.createMany({
+ data: prunedItems,
+ });
+ }
+ };
+
+ if (isPrismaClient(client)) {
+ await client.$transaction(replaceAvailability);
+ return;
+ }
+
+ await replaceAvailability(client);
+}
diff --git a/apps/api/src/modules/schedules/schedules.mapper.ts b/apps/api/src/modules/schedules/schedules.mapper.ts
index 1a3ff0f..9a67655 100644
--- a/apps/api/src/modules/schedules/schedules.mapper.ts
+++ b/apps/api/src/modules/schedules/schedules.mapper.ts
@@ -2,15 +2,22 @@ import type {
Prisma,
Schedule,
ScheduleAssignment as PrismaScheduleAssignment,
+ ScheduleCandidate as PrismaScheduleCandidate,
ScheduleWorkerShortage as PrismaScheduleWorkerShortage,
} from "@fragment/database";
-import type { ScheduleAssignment, ScheduleDetail, ScheduleWorkerShortage } from "@fragment/shared";
+import type {
+ ScheduleAssignment,
+ ScheduleCandidate,
+ ScheduleDetail,
+ ScheduleWorkerShortage,
+} from "@fragment/shared";
import { dateToDateString, dateToTimeString } from "@/utils/date-time";
import { toApiId } from "@/utils/mapper";
type ScheduleWithDetails = Schedule & {
assignments: PrismaScheduleAssignment[];
+ candidates: PrismaScheduleCandidate[];
workerShortages: PrismaScheduleWorkerShortage[];
};
@@ -18,6 +25,9 @@ export const scheduleDetailInclude = {
assignments: {
orderBy: [{ workDate: "asc" }, { startsAt: "asc" }, { id: "asc" }],
},
+ candidates: {
+ orderBy: [{ workDate: "asc" }, { startsAt: "asc" }, { employeeCodeSnapshot: "asc" }],
+ },
workerShortages: {
orderBy: [{ workDate: "asc" }, { startTime: "asc" }, { id: "asc" }],
},
@@ -34,6 +44,37 @@ export const toScheduleAssignment = (assignment: PrismaScheduleAssignment): Sche
employeeCodeSnapshot: assignment.employeeCodeSnapshot,
});
+const isCandidateRecommended = (
+ candidate: PrismaScheduleCandidate,
+ assignments: PrismaScheduleAssignment[],
+) =>
+ assignments.some(
+ (assignment) =>
+ assignment.workDate.getTime() === candidate.workDate.getTime() &&
+ assignment.startsAt.getTime() === candidate.startsAt.getTime() &&
+ assignment.endsAt.getTime() === candidate.endsAt.getTime() &&
+ (candidate.workerId !== null
+ ? assignment.workerId === candidate.workerId
+ : assignment.workerId === null &&
+ assignment.workerNameSnapshot === candidate.workerNameSnapshot &&
+ assignment.employeeCodeSnapshot === candidate.employeeCodeSnapshot),
+ );
+
+export const toScheduleCandidate = (
+ candidate: PrismaScheduleCandidate,
+ assignments: PrismaScheduleAssignment[],
+): ScheduleCandidate => ({
+ id: toApiId(candidate.id),
+ scheduleId: toApiId(candidate.scheduleId),
+ workerId: candidate.workerId === null ? null : toApiId(candidate.workerId),
+ workDate: dateToDateString(candidate.workDate),
+ startsAt: candidate.startsAt.toISOString(),
+ endsAt: candidate.endsAt.toISOString(),
+ workerNameSnapshot: candidate.workerNameSnapshot,
+ employeeCodeSnapshot: candidate.employeeCodeSnapshot,
+ isRecommended: isCandidateRecommended(candidate, assignments),
+});
+
export const toScheduleWorkerShortage = (
shortage: PrismaScheduleWorkerShortage,
): ScheduleWorkerShortage => ({
@@ -56,5 +97,8 @@ export const toScheduleDetail = (schedule: ScheduleWithDetails): ScheduleDetail
generatedAt: schedule.generatedAt.toISOString(),
confirmedAt: schedule.confirmedAt ? schedule.confirmedAt.toISOString() : null,
assignments: schedule.assignments.map(toScheduleAssignment),
+ candidates: schedule.candidates.map((candidate) =>
+ toScheduleCandidate(candidate, schedule.assignments),
+ ),
workerShortages: schedule.workerShortages.map(toScheduleWorkerShortage),
});
diff --git a/apps/api/src/modules/schedules/schedules.recommendation.ts b/apps/api/src/modules/schedules/schedules.recommendation.ts
index da85416..f2be20e 100644
--- a/apps/api/src/modules/schedules/schedules.recommendation.ts
+++ b/apps/api/src/modules/schedules/schedules.recommendation.ts
@@ -2,19 +2,20 @@ import { createHash } from "node:crypto";
import type {
DayOfWeek,
MinimumStaffingRule,
+ OrganizationBusinessHour,
Worker,
WorkerAvailableTime,
} from "@fragment/database";
-import {
- addDays,
- createDateTimeOnDate,
- dateToDateString,
- dateToTimeString,
-} from "@/utils/date-time";
+import { dateToDateString, dateToTimeString } from "@/utils/date-time";
import { eachDateInRange } from "@/utils/date-range";
import { getDayOfWeek } from "@/utils/day-of-week";
import { toApiId } from "@/utils/mapper";
+import {
+ createDateTimeFromMinutes,
+ getSchedulableRangesForDate,
+ minutesToTimeDate,
+} from "@/modules/scheduling-time-policy";
type RecommendationAssignment = {
workerId: bigint;
@@ -25,6 +26,8 @@ type RecommendationAssignment = {
employeeCodeSnapshot: string;
};
+type RecommendationCandidate = RecommendationAssignment;
+
type RecommendationShortage = {
workDate: Date;
dayOfWeek: DayOfWeek;
@@ -35,12 +38,23 @@ type RecommendationShortage = {
assignedCount: number;
};
-const createScheduleDateTimeOnDate = (date: Date, time: Date, addDay = false) => {
- const targetDate = addDay ? addDays(date, 1) : date;
-
- return createDateTimeOnDate(targetDate, time);
+type DemandWindow = {
+ workDate: Date;
+ dayOfWeek: DayOfWeek;
+ startMinutes: number;
+ endMinutes: number;
+ startsAt: Date;
+ endsAt: Date;
+ startTime: Date;
+ endTime: Date;
+ endsNextDay: boolean;
+ requiredCount: number;
};
+const MINUTES_IN_DAY = 24 * 60;
+
+const compareBigInt = (left: bigint, right: bigint) => (left < right ? -1 : left > right ? 1 : 0);
+
const hasOverlap = (
assignments: RecommendationAssignment[],
workerId: bigint,
@@ -54,14 +68,214 @@ const hasOverlap = (
startsAt < assignment.endsAt,
);
+const mergeAdjacentAssignments = (assignments: RecommendationAssignment[]) => {
+ const sortedAssignments = [...assignments].sort((a, b) => {
+ if (a.workerId !== b.workerId) {
+ return Number(a.workerId - b.workerId);
+ }
+
+ if (a.workDate.getTime() !== b.workDate.getTime()) {
+ return a.workDate.getTime() - b.workDate.getTime();
+ }
+
+ return a.startsAt.getTime() - b.startsAt.getTime();
+ });
+ const mergedAssignments: RecommendationAssignment[] = [];
+
+ for (const assignment of sortedAssignments) {
+ const previous = mergedAssignments.at(-1);
+
+ if (
+ previous &&
+ previous.workerId === assignment.workerId &&
+ previous.workDate.getTime() === assignment.workDate.getTime() &&
+ previous.endsAt.getTime() === assignment.startsAt.getTime()
+ ) {
+ previous.endsAt = assignment.endsAt;
+ continue;
+ }
+
+ mergedAssignments.push({ ...assignment });
+ }
+
+ return mergedAssignments.sort((a, b) => {
+ if (a.workDate.getTime() !== b.workDate.getTime()) {
+ return a.workDate.getTime() - b.workDate.getTime();
+ }
+
+ if (a.startsAt.getTime() !== b.startsAt.getTime()) {
+ return a.startsAt.getTime() - b.startsAt.getTime();
+ }
+
+ return Number(a.workerId - b.workerId);
+ });
+};
+
+const getAssignmentDurationMinutes = (
+ assignment: Pick,
+) => Math.round((assignment.endsAt.getTime() - assignment.startsAt.getTime()) / 60000);
+
+const getWeekStartDate = (date: Date) => {
+ const day = date.getUTCDay();
+ const mondayFirstOffset = day === 0 ? -6 : 1 - day;
+
+ return new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + mondayFirstOffset),
+ );
+};
+
+const getAssignedMinutes = (
+ assignments: RecommendationAssignment[],
+ workerId: bigint,
+ predicate: (assignment: RecommendationAssignment) => boolean,
+) =>
+ assignments
+ .filter((assignment) => assignment.workerId === workerId && predicate(assignment))
+ .reduce((total, assignment) => total + getAssignmentDurationMinutes(assignment), 0);
+
+const dateTimeToMinutesFromWorkDate = (workDate: Date, dateTime: Date) =>
+ Math.round((dateTime.getTime() - workDate.getTime()) / 60000);
+
+const compareCandidatesByBalance = ({
+ assignments,
+ firstWorker,
+ secondWorker,
+ workDate,
+}: {
+ assignments: RecommendationAssignment[];
+ firstWorker: Worker;
+ secondWorker: Worker;
+ workDate: Date;
+}) => {
+ const weekStartDate = getWeekStartDate(workDate);
+ const weekEndDate = new Date(
+ Date.UTC(
+ weekStartDate.getUTCFullYear(),
+ weekStartDate.getUTCMonth(),
+ weekStartDate.getUTCDate() + 7,
+ ),
+ );
+ const isSameWeek = (assignment: RecommendationAssignment) =>
+ assignment.workDate >= weekStartDate && assignment.workDate < weekEndDate;
+ const isSameDay = (assignment: RecommendationAssignment) =>
+ assignment.workDate.getTime() === workDate.getTime();
+ const firstWeeklyMinutes = getAssignedMinutes(assignments, firstWorker.id, isSameWeek);
+ const secondWeeklyMinutes = getAssignedMinutes(assignments, secondWorker.id, isSameWeek);
+ const firstContractMinutes = firstWorker.weeklyContractHours * 60;
+ const secondContractMinutes = secondWorker.weeklyContractHours * 60;
+ const firstUsage = firstWeeklyMinutes / firstContractMinutes;
+ const secondUsage = secondWeeklyMinutes / secondContractMinutes;
+ const allCandidatesOverContract = firstUsage >= 1 && secondUsage >= 1;
+
+ if (allCandidatesOverContract) {
+ const firstOverage = Math.max(0, firstWeeklyMinutes - firstContractMinutes);
+ const secondOverage = Math.max(0, secondWeeklyMinutes - secondContractMinutes);
+
+ if (firstOverage !== secondOverage) {
+ return firstOverage - secondOverage;
+ }
+ }
+
+ if (firstUsage !== secondUsage) {
+ return firstUsage - secondUsage;
+ }
+
+ const firstDailyMinutes = getAssignedMinutes(assignments, firstWorker.id, isSameDay);
+ const secondDailyMinutes = getAssignedMinutes(assignments, secondWorker.id, isSameDay);
+
+ if (firstDailyMinutes !== secondDailyMinutes) {
+ return firstDailyMinutes - secondDailyMinutes;
+ }
+
+ return firstWorker.employeeCode.localeCompare(secondWorker.employeeCode);
+};
+
+const createDemandWindowsForDate = ({
+ availableTimes,
+ businessHours,
+ rules,
+ workDate,
+}: {
+ availableTimes: WorkerAvailableTime[];
+ businessHours: OrganizationBusinessHour[];
+ rules: MinimumStaffingRule[];
+ workDate: Date;
+}): DemandWindow[] => {
+ const dayOfWeek = getDayOfWeek(workDate);
+ const demandSources = getSchedulableRangesForDate({
+ businessHours,
+ rules,
+ workDate,
+ });
+
+ const boundaries = [
+ ...new Set([
+ ...demandSources.flatMap((source) => [source.startMinutes, source.endMinutes]),
+ ...availableTimes
+ .filter((availableTime) => availableTime.availableDate.getTime() === workDate.getTime())
+ .flatMap((availableTime) => {
+ const startMinutes = dateTimeToMinutesFromWorkDate(workDate, availableTime.startsAt);
+ const endMinutes = dateTimeToMinutesFromWorkDate(workDate, availableTime.endsAt);
+
+ return demandSources.flatMap((source) => {
+ if (source.startMinutes >= endMinutes || startMinutes >= source.endMinutes) {
+ return [];
+ }
+
+ return [
+ Math.max(startMinutes, source.startMinutes),
+ Math.min(endMinutes, source.endMinutes),
+ ];
+ });
+ }),
+ ]),
+ ]
+ .filter((boundary) => Number.isFinite(boundary))
+ .sort((a, b) => a - b);
+ const demandWindows: DemandWindow[] = [];
+
+ for (let index = 0; index < boundaries.length - 1; index += 1) {
+ const startMinutes = boundaries[index];
+ const endMinutes = boundaries[index + 1];
+ const activeDemandSources = demandSources.filter(
+ (source) => source.startMinutes < endMinutes && startMinutes < source.endMinutes,
+ );
+
+ if (activeDemandSources.length === 0) {
+ continue;
+ }
+
+ const requiredCount = Math.max(...activeDemandSources.map((source) => source.requiredCount));
+ const startsAt = createDateTimeFromMinutes(workDate, startMinutes);
+ const endsAt = createDateTimeFromMinutes(workDate, endMinutes);
+
+ demandWindows.push({
+ dayOfWeek,
+ endMinutes,
+ endsAt,
+ endsNextDay: endMinutes >= MINUTES_IN_DAY,
+ endTime: minutesToTimeDate(endMinutes),
+ requiredCount,
+ startMinutes,
+ startsAt,
+ startTime: minutesToTimeDate(startMinutes),
+ workDate,
+ });
+ }
+
+ return demandWindows;
+};
+
export const createInputHash = ({
availableTimes,
+ businessHours,
endDate,
rules,
startDate,
workers,
}: {
availableTimes: WorkerAvailableTime[];
+ businessHours: OrganizationBusinessHour[];
endDate: Date;
rules: MinimumStaffingRule[];
startDate: Date;
@@ -70,25 +284,53 @@ export const createInputHash = ({
const payload = {
startDate: dateToDateString(startDate),
endDate: dateToDateString(endDate),
- workers: workers.map((worker) => ({
- id: toApiId(worker.id),
- employeeCode: worker.employeeCode,
- name: worker.name,
- weeklyContractHours: worker.weeklyContractHours,
- })),
- availableTimes: availableTimes.map((availableTime) => ({
- workerId: toApiId(availableTime.workerId),
- availableDate: dateToDateString(availableTime.availableDate),
- startsAt: availableTime.startsAt.toISOString(),
- endsAt: availableTime.endsAt.toISOString(),
- })),
- rules: rules.map((rule) => ({
- dayOfWeek: rule.dayOfWeek,
- startTime: dateToTimeString(rule.startTime),
- endTime: dateToTimeString(rule.endTime),
- endsNextDay: rule.endsNextDay,
- requiredCount: rule.requiredCount,
- })),
+ workers: [...workers]
+ .sort((left, right) => compareBigInt(left.id, right.id))
+ .map((worker) => ({
+ id: toApiId(worker.id),
+ employeeCode: worker.employeeCode,
+ name: worker.name,
+ weeklyContractHours: worker.weeklyContractHours,
+ })),
+ availableTimes: [...availableTimes]
+ .sort(
+ (left, right) =>
+ compareBigInt(left.workerId, right.workerId) ||
+ left.availableDate.getTime() - right.availableDate.getTime() ||
+ left.startsAt.getTime() - right.startsAt.getTime() ||
+ left.endsAt.getTime() - right.endsAt.getTime(),
+ )
+ .map((availableTime) => ({
+ workerId: toApiId(availableTime.workerId),
+ availableDate: dateToDateString(availableTime.availableDate),
+ startsAt: availableTime.startsAt.toISOString(),
+ endsAt: availableTime.endsAt.toISOString(),
+ })),
+ businessHours: [...businessHours]
+ .sort((left, right) => left.dayOfWeek.localeCompare(right.dayOfWeek))
+ .map((businessHour) => ({
+ dayOfWeek: businessHour.dayOfWeek,
+ isClosed: businessHour.isClosed,
+ openTime: businessHour.openTime ? dateToTimeString(businessHour.openTime) : null,
+ closeTime: businessHour.closeTime ? dateToTimeString(businessHour.closeTime) : null,
+ closesNextDay: businessHour.closesNextDay,
+ })),
+ rules: [...rules]
+ .sort(
+ (left, right) =>
+ left.dayOfWeek.localeCompare(right.dayOfWeek) ||
+ left.startTime.getTime() - right.startTime.getTime() ||
+ left.endTime.getTime() - right.endTime.getTime() ||
+ Number(left.endsNextDay) - Number(right.endsNextDay) ||
+ left.requiredCount - right.requiredCount,
+ )
+ .map((rule) => ({
+ dayOfWeek: rule.dayOfWeek,
+ startTime: dateToTimeString(rule.startTime),
+ endTime: dateToTimeString(rule.endTime),
+ endsNextDay: rule.endsNextDay,
+ requiredCount: rule.requiredCount,
+ })),
};
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
@@ -96,66 +338,99 @@ export const createInputHash = ({
export const createRecommendations = ({
availableTimes,
+ businessHours,
endDate,
rules,
startDate,
workers,
}: {
availableTimes: WorkerAvailableTime[];
+ businessHours: OrganizationBusinessHour[];
endDate: Date;
rules: MinimumStaffingRule[];
startDate: Date;
workers: Worker[];
}): {
assignments: RecommendationAssignment[];
+ candidates: RecommendationCandidate[];
shortages: RecommendationShortage[];
} => {
const assignments: RecommendationAssignment[] = [];
+ const candidates: RecommendationCandidate[] = [];
const shortages: RecommendationShortage[] = [];
for (const workDate of eachDateInRange(startDate, endDate)) {
- const dayOfWeek = getDayOfWeek(workDate);
- const dayRules = rules.filter((rule) => rule.dayOfWeek === dayOfWeek);
-
- for (const rule of dayRules) {
- const startsAt = createScheduleDateTimeOnDate(workDate, rule.startTime);
- const endsAt = createScheduleDateTimeOnDate(workDate, rule.endTime, rule.endsNextDay);
- const candidates = workers.filter((worker) =>
- availableTimes.some(
- (availableTime) =>
- availableTime.workerId === worker.id &&
- availableTime.availableDate.getTime() === workDate.getTime() &&
- availableTime.startsAt <= startsAt &&
- availableTime.endsAt >= endsAt &&
- !hasOverlap(assignments, worker.id, startsAt, endsAt),
- ),
+ const demandWindows = createDemandWindowsForDate({
+ availableTimes,
+ businessHours,
+ rules,
+ workDate,
+ });
+
+ for (const demandWindow of demandWindows) {
+ const availableCandidates = workers
+ .filter((worker) =>
+ availableTimes.some(
+ (availableTime) =>
+ availableTime.workerId === worker.id &&
+ availableTime.availableDate.getTime() === workDate.getTime() &&
+ availableTime.startsAt <= demandWindow.startsAt &&
+ availableTime.endsAt >= demandWindow.endsAt,
+ ),
+ )
+ .sort((firstWorker, secondWorker) =>
+ firstWorker.employeeCode.localeCompare(secondWorker.employeeCode),
+ );
+ const selectableCandidates = availableCandidates
+ .filter(
+ (worker) =>
+ !hasOverlap(assignments, worker.id, demandWindow.startsAt, demandWindow.endsAt),
+ )
+ .sort((firstWorker, secondWorker) =>
+ compareCandidatesByBalance({
+ assignments,
+ firstWorker,
+ secondWorker,
+ workDate,
+ }),
+ );
+ const selectedWorkers = selectableCandidates.slice(0, demandWindow.requiredCount);
+
+ candidates.push(
+ ...availableCandidates.map((worker) => ({
+ workerId: worker.id,
+ workDate,
+ startsAt: demandWindow.startsAt,
+ endsAt: demandWindow.endsAt,
+ workerNameSnapshot: worker.name,
+ employeeCodeSnapshot: worker.employeeCode,
+ })),
);
- const selectedWorkers = candidates.slice(0, rule.requiredCount);
assignments.push(
...selectedWorkers.map((worker) => ({
workerId: worker.id,
workDate,
- startsAt,
- endsAt,
+ startsAt: demandWindow.startsAt,
+ endsAt: demandWindow.endsAt,
workerNameSnapshot: worker.name,
employeeCodeSnapshot: worker.employeeCode,
})),
);
- if (selectedWorkers.length < rule.requiredCount) {
+ if (selectedWorkers.length < demandWindow.requiredCount) {
shortages.push({
workDate,
- dayOfWeek,
- startTime: rule.startTime,
- endTime: rule.endTime,
- endsNextDay: rule.endsNextDay,
- requiredCount: rule.requiredCount,
+ dayOfWeek: demandWindow.dayOfWeek,
+ startTime: demandWindow.startTime,
+ endTime: demandWindow.endTime,
+ endsNextDay: demandWindow.endsNextDay,
+ requiredCount: demandWindow.requiredCount,
assignedCount: selectedWorkers.length,
});
}
}
}
- return { assignments, shortages };
+ return { assignments: mergeAdjacentAssignments(assignments), candidates, shortages };
};
diff --git a/apps/api/src/modules/schedules/schedules.service.spec.ts b/apps/api/src/modules/schedules/schedules.service.spec.ts
index f8823a5..c897dc5 100644
--- a/apps/api/src/modules/schedules/schedules.service.spec.ts
+++ b/apps/api/src/modules/schedules/schedules.service.spec.ts
@@ -6,6 +6,7 @@ import { createFakePrisma, createTimeDate } from "../../../test/helpers/fake-pri
import {
confirmSchedule,
createScheduleAssignment,
+ getDraftSchedule,
recommendSchedule,
updateScheduleAssignment,
} from "./schedules.service";
@@ -13,11 +14,41 @@ import {
const timestamp = new Date("2026-06-25T00:00:00.000Z");
const dateOnly = (date: string) => new Date(`${date}T00:00:00.000Z`);
-const createOrganization = (id = BigInt(1), userId = BigInt(1)) => ({
+const createBusinessHour = ({
+ closeTime = "14:00",
+ closesNextDay = false,
+ dayOfWeek = "WED",
+ id = BigInt(1),
+ isClosed = false,
+ openTime = "10:00",
+ organizationId = BigInt(1),
+}: {
+ closeTime?: string;
+ closesNextDay?: boolean;
+ dayOfWeek?: DayOfWeek;
+ id?: bigint;
+ isClosed?: boolean;
+ openTime?: string;
+ organizationId?: bigint;
+} = {}) => ({
+ id,
+ organizationId,
+ dayOfWeek,
+ isClosed,
+ openTime: isClosed ? null : createTimeDate(openTime),
+ closeTime: isClosed ? null : createTimeDate(closeTime),
+ closesNextDay,
+});
+
+const createOrganization = (
+ id = BigInt(1),
+ userId = BigInt(1),
+ businessHours = [createBusinessHour({ organizationId: id })],
+) => ({
id,
userId,
name: "프래그먼트 카페",
- businessHours: [],
+ businessHours,
});
const createPlanningPeriod = ({ endDate = "2026-07-01", startDate = "2026-07-01" } = {}) => ({
@@ -34,12 +65,13 @@ const createWorker = ({
organizationId = BigInt(1),
employeeCode = "W-0001",
name = "김민수",
+ weeklyContractHours = 40,
} = {}) => ({
id,
organizationId,
employeeCode,
name,
- weeklyContractHours: 40,
+ weeklyContractHours,
});
const createAvailableTime = ({
@@ -169,6 +201,233 @@ describe("recommendSchedule", () => {
]);
});
+ it("blocks recommendation when no minimum staffing rule exists", async () => {
+ const { prisma } = createFakePrisma({
+ activeSchedulePlanningPeriods: [createPlanningPeriod()],
+ availableTimes: [createAvailableTime()],
+ minimumStaffingRules: [],
+ organizations: [createOrganization()],
+ workers: [createWorker()],
+ });
+
+ await expect(
+ recommendSchedule(prisma, "1", {
+ startDate: "2026-07-01",
+ endDate: "2026-07-01",
+ }),
+ ).rejects.toMatchObject({
+ code: ERROR_CODES.VALIDATION_ERROR,
+ statusCode: 400,
+ });
+ });
+
+ it("creates recommendations only for minimum staffing rule windows", async () => {
+ const { prisma } = createFakePrisma({
+ activeSchedulePlanningPeriods: [createPlanningPeriod()],
+ availableTimes: [
+ createAvailableTime(),
+ createAvailableTime({
+ id: BigInt(2),
+ workerId: BigInt(2),
+ }),
+ ],
+ minimumStaffingRules: [
+ createMinimumStaffingRule({
+ startTime: "12:00",
+ endTime: "14:00",
+ requiredCount: 2,
+ }),
+ ],
+ organizations: [
+ createOrganization(BigInt(1), BigInt(1), [
+ createBusinessHour({
+ closeTime: "18:00",
+ openTime: "09:00",
+ }),
+ ]),
+ ],
+ workers: [
+ createWorker(),
+ createWorker({
+ id: BigInt(2),
+ employeeCode: "W-0002",
+ name: "박지민",
+ }),
+ ],
+ });
+
+ const schedule = await recommendSchedule(prisma, "1", {
+ startDate: "2026-07-01",
+ endDate: "2026-07-01",
+ });
+
+ expect(schedule.assignments).toEqual([
+ expect.objectContaining({
+ workerId: "1",
+ startsAt: "2026-07-01T12:00:00.000Z",
+ endsAt: "2026-07-01T14:00:00.000Z",
+ }),
+ expect.objectContaining({
+ workerId: "2",
+ startsAt: "2026-07-01T12:00:00.000Z",
+ endsAt: "2026-07-01T14:00:00.000Z",
+ }),
+ ]);
+ expect(schedule.workerShortages).toEqual([]);
+ });
+
+ it("returns all candidates while marking selected recommendations", async () => {
+ const { prisma } = createFakePrisma({
+ activeSchedulePlanningPeriods: [createPlanningPeriod()],
+ availableTimes: [
+ createAvailableTime(),
+ createAvailableTime({
+ id: BigInt(2),
+ workerId: BigInt(2),
+ }),
+ ],
+ minimumStaffingRules: [
+ createMinimumStaffingRule({
+ requiredCount: 1,
+ }),
+ ],
+ organizations: [createOrganization()],
+ workers: [
+ createWorker(),
+ createWorker({
+ id: BigInt(2),
+ employeeCode: "W-0002",
+ name: "박지민",
+ }),
+ ],
+ });
+
+ const schedule = await recommendSchedule(prisma, "1", {
+ startDate: "2026-07-01",
+ endDate: "2026-07-01",
+ });
+
+ expect(schedule.assignments).toHaveLength(1);
+ expect(schedule.candidates).toEqual([
+ expect.objectContaining({
+ workerId: "1",
+ startsAt: "2026-07-01T10:00:00.000Z",
+ endsAt: "2026-07-01T14:00:00.000Z",
+ isRecommended: true,
+ }),
+ expect.objectContaining({
+ workerId: "2",
+ startsAt: "2026-07-01T10:00:00.000Z",
+ endsAt: "2026-07-01T14:00:00.000Z",
+ isRecommended: false,
+ }),
+ ]);
+ });
+
+ it("splits recommendation windows by availability boundaries inside a staffing rule", async () => {
+ const { prisma } = createFakePrisma({
+ activeSchedulePlanningPeriods: [createPlanningPeriod()],
+ availableTimes: [
+ createAvailableTime({
+ endsAt: "2026-07-01T11:00:00.000Z",
+ startsAt: "2026-07-01T09:00:00.000Z",
+ }),
+ createAvailableTime({
+ id: BigInt(2),
+ workerId: BigInt(2),
+ endsAt: "2026-07-01T11:00:00.000Z",
+ startsAt: "2026-07-01T09:00:00.000Z",
+ }),
+ createAvailableTime({
+ id: BigInt(3),
+ workerId: BigInt(2),
+ endsAt: "2026-07-01T16:00:00.000Z",
+ startsAt: "2026-07-01T12:00:00.000Z",
+ }),
+ ],
+ minimumStaffingRules: [
+ createMinimumStaffingRule({
+ endTime: "16:00",
+ requiredCount: 2,
+ startTime: "09:00",
+ }),
+ ],
+ organizations: [
+ createOrganization(BigInt(1), BigInt(1), [
+ createBusinessHour({
+ closeTime: "16:00",
+ openTime: "09:00",
+ }),
+ ]),
+ ],
+ workers: [
+ createWorker(),
+ createWorker({
+ id: BigInt(2),
+ employeeCode: "W-0002",
+ name: "박지민",
+ }),
+ ],
+ });
+
+ const schedule = await recommendSchedule(prisma, "1", {
+ startDate: "2026-07-01",
+ endDate: "2026-07-01",
+ });
+
+ expect(schedule.candidates).toEqual([
+ expect.objectContaining({
+ workerId: "1",
+ startsAt: "2026-07-01T09:00:00.000Z",
+ endsAt: "2026-07-01T11:00:00.000Z",
+ isRecommended: true,
+ }),
+ expect.objectContaining({
+ workerId: "2",
+ startsAt: "2026-07-01T09:00:00.000Z",
+ endsAt: "2026-07-01T11:00:00.000Z",
+ isRecommended: true,
+ }),
+ expect.objectContaining({
+ workerId: "2",
+ startsAt: "2026-07-01T12:00:00.000Z",
+ endsAt: "2026-07-01T16:00:00.000Z",
+ isRecommended: true,
+ }),
+ ]);
+ expect(schedule.assignments).toEqual([
+ expect.objectContaining({
+ workerId: "1",
+ startsAt: "2026-07-01T09:00:00.000Z",
+ endsAt: "2026-07-01T11:00:00.000Z",
+ }),
+ expect.objectContaining({
+ workerId: "2",
+ startsAt: "2026-07-01T09:00:00.000Z",
+ endsAt: "2026-07-01T11:00:00.000Z",
+ }),
+ expect.objectContaining({
+ workerId: "2",
+ startsAt: "2026-07-01T12:00:00.000Z",
+ endsAt: "2026-07-01T16:00:00.000Z",
+ }),
+ ]);
+ expect(schedule.workerShortages).toEqual([
+ expect.objectContaining({
+ startTime: "11:00",
+ endTime: "12:00",
+ requiredCount: 2,
+ assignedCount: 0,
+ }),
+ expect.objectContaining({
+ startTime: "12:00",
+ endTime: "16:00",
+ requiredCount: 2,
+ assignedCount: 1,
+ }),
+ ]);
+ });
+
it("creates next-day assignments for overnight staffing rules", async () => {
const { prisma } = createFakePrisma({
activeSchedulePlanningPeriods: [
@@ -192,7 +451,16 @@ describe("recommendSchedule", () => {
endsNextDay: true,
}),
],
- organizations: [createOrganization()],
+ organizations: [
+ createOrganization(BigInt(1), BigInt(1), [
+ createBusinessHour({
+ closeTime: "02:00",
+ closesNextDay: true,
+ dayOfWeek: "FRI",
+ openTime: "22:00",
+ }),
+ ]),
+ ],
workers: [createWorker()],
});
@@ -237,6 +505,25 @@ describe("recommendSchedule", () => {
});
});
+describe("getDraftSchedule", () => {
+ it("returns the latest draft schedule for the same period", async () => {
+ const { prisma } = createFakePrisma({
+ schedules: [createDraftSchedule({ id: BigInt(1) }), createDraftSchedule({ id: BigInt(2) })],
+ });
+
+ await expect(
+ getDraftSchedule(prisma, "1", {
+ startDate: "2026-07-01",
+ endDate: "2026-07-01",
+ }),
+ ).resolves.toMatchObject({
+ schedule: {
+ id: "2",
+ },
+ });
+ });
+});
+
describe("schedule assignments", () => {
it("rejects workers from another organization when adding an assignment", async () => {
const { prisma } = createFakePrisma({
diff --git a/apps/api/src/modules/schedules/schedules.service.ts b/apps/api/src/modules/schedules/schedules.service.ts
index c1915fe..932e4f2 100644
--- a/apps/api/src/modules/schedules/schedules.service.ts
+++ b/apps/api/src/modules/schedules/schedules.service.ts
@@ -11,6 +11,7 @@ import type {
import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
+import { pruneOrganizationAvailabilityForActivePlanningPeriod } from "@/modules/availability/availability.service";
import { dateStringToDate, dateTimeStringToDate } from "@/utils/date-time";
import { toPrismaId } from "@/utils/mapper";
import { scheduleDetailInclude, toScheduleAssignment, toScheduleDetail } from "./schedules.mapper";
@@ -118,7 +119,15 @@ export async function recommendSchedule(
throw createValidationError("활성 스케줄 계획 기간과 요청 기간이 일치해야 합니다.");
}
- const [workers, availableTimes, rules] = await Promise.all([
+ const [organization, workers, rules] = await Promise.all([
+ prisma.organization.findUnique({
+ where: {
+ id: organizationDatabaseId,
+ },
+ include: {
+ businessHours: true,
+ },
+ }),
prisma.worker.findMany({
where: {
organizationId: organizationDatabaseId,
@@ -127,18 +136,6 @@ export async function recommendSchedule(
employeeCode: "asc",
},
}),
- prisma.workerAvailableTime.findMany({
- where: {
- worker: {
- organizationId: organizationDatabaseId,
- },
- availableDate: {
- gte: startDate,
- lte: endDate,
- },
- },
- orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
- }),
prisma.minimumStaffingRule.findMany({
where: {
organizationId: organizationDatabaseId,
@@ -147,21 +144,41 @@ export async function recommendSchedule(
}),
]);
- if (workers.length === 0 || availableTimes.length === 0 || rules.length === 0) {
+ const hasOpenBusinessHours =
+ organization?.businessHours.some((businessHour) => !businessHour.isClosed) ?? false;
+
+ if (!organization || workers.length === 0 || !hasOpenBusinessHours || rules.length === 0) {
throw createValidationError(
- "스케줄 추천에 필요한 근무자, 가능 시간, 최소 인원 조건이 부족합니다.",
+ "스케줄 추천에 필요한 근무자, 영업시간 또는 최소 인원 조건이 부족합니다.",
);
}
+ await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationDatabaseId);
+
+ const availableTimes = await prisma.workerAvailableTime.findMany({
+ where: {
+ worker: {
+ organizationId: organizationDatabaseId,
+ },
+ availableDate: {
+ gte: startDate,
+ lte: endDate,
+ },
+ },
+ orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }],
+ });
+
const inputHash = createInputHash({
availableTimes,
+ businessHours: organization.businessHours,
endDate,
rules,
startDate,
workers,
});
- const { assignments, shortages } = createRecommendations({
+ const { assignments, candidates, shortages } = createRecommendations({
availableTimes,
+ businessHours: organization.businessHours,
endDate,
rules,
startDate,
@@ -211,6 +228,20 @@ export async function recommendSchedule(
employeeCodeSnapshot: assignment.employeeCodeSnapshot,
})),
},
+ candidates: {
+ create: candidates.map((candidate) => ({
+ worker: {
+ connect: {
+ id: candidate.workerId,
+ },
+ },
+ workDate: candidate.workDate,
+ startsAt: candidate.startsAt,
+ endsAt: candidate.endsAt,
+ workerNameSnapshot: candidate.workerNameSnapshot,
+ employeeCodeSnapshot: candidate.employeeCodeSnapshot,
+ })),
+ },
workerShortages: {
create: shortages,
},
@@ -234,6 +265,9 @@ export async function getDraftSchedule(
startDate: dateStringToDate(query.startDate),
endDate: dateStringToDate(query.endDate),
},
+ orderBy: {
+ id: "desc",
+ },
include: scheduleDetailInclude,
});
diff --git a/apps/api/src/modules/scheduling-time-policy.ts b/apps/api/src/modules/scheduling-time-policy.ts
new file mode 100644
index 0000000..0b29057
--- /dev/null
+++ b/apps/api/src/modules/scheduling-time-policy.ts
@@ -0,0 +1,211 @@
+import type {
+ MinimumStaffingRule,
+ OrganizationBusinessHour,
+ WorkerAvailableTime,
+} from "@fragment/database";
+
+import { addDays, dateToTimeString, parseTimeToMinutes, timeStringToDate } from "@/utils/date-time";
+import { getDayOfWeek } from "@/utils/day-of-week";
+
+type TimeRange = {
+ startMinutes: number;
+ endMinutes: number;
+};
+
+export type SchedulableRange = TimeRange & {
+ requiredCount: number;
+};
+
+export type PrunedAvailabilityTime = {
+ workerId: bigint;
+ availableDate: Date;
+ startsAt: Date;
+ endsAt: Date;
+};
+
+const MINUTES_IN_DAY = 24 * 60;
+
+const timeDateToMinutes = (time: Date) => parseTimeToMinutes(dateToTimeString(time));
+
+export const createDateTimeFromMinutes = (date: Date, minutes: number) => {
+ const dayOffset = Math.floor(minutes / MINUTES_IN_DAY);
+ const minutesInDay = minutes % MINUTES_IN_DAY;
+ const hour = Math.floor(minutesInDay / 60);
+ const minute = minutesInDay % 60;
+ const targetDate = addDays(date, dayOffset);
+
+ return new Date(
+ Date.UTC(
+ targetDate.getUTCFullYear(),
+ targetDate.getUTCMonth(),
+ targetDate.getUTCDate(),
+ hour,
+ minute,
+ 0,
+ ),
+ );
+};
+
+export const minutesToTimeDate = (minutes: number) => {
+ const minutesInDay = minutes % MINUTES_IN_DAY;
+ const hour = Math.floor(minutesInDay / 60);
+ const minute = minutesInDay % 60;
+
+ return timeStringToDate(`${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`);
+};
+
+const getBusinessHourRange = (
+ businessHours: OrganizationBusinessHour[],
+ workDate: Date,
+): TimeRange | null => {
+ const dayOfWeek = getDayOfWeek(workDate);
+ const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek);
+
+ if (!businessHour || businessHour.isClosed || !businessHour.openTime || !businessHour.closeTime) {
+ return null;
+ }
+
+ const startMinutes = timeDateToMinutes(businessHour.openTime);
+ const rawEndMinutes = timeDateToMinutes(businessHour.closeTime);
+
+ return {
+ endMinutes: businessHour.closesNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes,
+ startMinutes,
+ };
+};
+
+const getRuleRange = (rule: MinimumStaffingRule): SchedulableRange => {
+ const startMinutes = timeDateToMinutes(rule.startTime);
+ const rawEndMinutes = timeDateToMinutes(rule.endTime);
+
+ return {
+ endMinutes: rule.endsNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes,
+ requiredCount: rule.requiredCount,
+ startMinutes,
+ };
+};
+
+const mergeRanges = (ranges: TimeRange[]): TimeRange[] => {
+ const sortedRanges = [...ranges]
+ .filter((range) => range.startMinutes < range.endMinutes)
+ .sort((first, second) => first.startMinutes - second.startMinutes);
+ const mergedRanges: TimeRange[] = [];
+
+ for (const range of sortedRanges) {
+ const previous = mergedRanges.at(-1);
+
+ if (previous && range.startMinutes <= previous.endMinutes) {
+ previous.endMinutes = Math.max(previous.endMinutes, range.endMinutes);
+ continue;
+ }
+
+ mergedRanges.push({ ...range });
+ }
+
+ return mergedRanges;
+};
+
+const dateTimeToMinutesFromWorkDate = (workDate: Date, dateTime: Date) =>
+ Math.round((dateTime.getTime() - workDate.getTime()) / 60000);
+
+export const getSchedulableRangesForDate = ({
+ businessHours,
+ rules,
+ workDate,
+}: {
+ businessHours: OrganizationBusinessHour[];
+ rules: MinimumStaffingRule[];
+ workDate: Date;
+}): SchedulableRange[] => {
+ const businessHourRange = getBusinessHourRange(businessHours, workDate);
+
+ if (!businessHourRange) {
+ return [];
+ }
+
+ return rules
+ .filter((rule) => rule.dayOfWeek === getDayOfWeek(workDate))
+ .map(getRuleRange)
+ .map((ruleRange) => ({
+ endMinutes: Math.min(ruleRange.endMinutes, businessHourRange.endMinutes),
+ requiredCount: ruleRange.requiredCount,
+ startMinutes: Math.max(ruleRange.startMinutes, businessHourRange.startMinutes),
+ }))
+ .filter((range) => range.startMinutes < range.endMinutes);
+};
+
+export const getMergedSchedulableRangesForDate = ({
+ businessHours,
+ rules,
+ workDate,
+}: {
+ businessHours: OrganizationBusinessHour[];
+ rules: MinimumStaffingRule[];
+ workDate: Date;
+}): TimeRange[] =>
+ mergeRanges(
+ getSchedulableRangesForDate({
+ businessHours,
+ rules,
+ workDate,
+ }),
+ );
+
+export const pruneAvailabilityTimeToSchedulableRanges = ({
+ availableTime,
+ businessHours,
+ rules,
+}: {
+ availableTime: Pick;
+ businessHours: OrganizationBusinessHour[];
+ rules: MinimumStaffingRule[];
+}): PrunedAvailabilityTime[] => {
+ const ranges = getMergedSchedulableRangesForDate({
+ businessHours,
+ rules,
+ workDate: availableTime.availableDate,
+ });
+ const availabilityStartMinutes = dateTimeToMinutesFromWorkDate(
+ availableTime.availableDate,
+ availableTime.startsAt,
+ );
+ const availabilityEndMinutes = dateTimeToMinutesFromWorkDate(
+ availableTime.availableDate,
+ availableTime.endsAt,
+ );
+
+ return ranges.flatMap((range) => {
+ const startMinutes = Math.max(availabilityStartMinutes, range.startMinutes);
+ const endMinutes = Math.min(availabilityEndMinutes, range.endMinutes);
+
+ if (startMinutes >= endMinutes) {
+ return [];
+ }
+
+ return [
+ {
+ availableDate: availableTime.availableDate,
+ endsAt: createDateTimeFromMinutes(availableTime.availableDate, endMinutes),
+ startsAt: createDateTimeFromMinutes(availableTime.availableDate, startMinutes),
+ workerId: availableTime.workerId,
+ },
+ ];
+ });
+};
+
+export const pruneAvailabilityTimesToSchedulableRanges = ({
+ availableTimes,
+ businessHours,
+ rules,
+}: {
+ availableTimes: Pick[];
+ businessHours: OrganizationBusinessHour[];
+ rules: MinimumStaffingRule[];
+}): PrunedAvailabilityTime[] =>
+ availableTimes.flatMap((availableTime) =>
+ pruneAvailabilityTimeToSchedulableRanges({
+ availableTime,
+ businessHours,
+ rules,
+ }),
+ );
diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts b/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts
index f401110..fefbf12 100644
--- a/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts
+++ b/apps/api/src/modules/staffing-rules/staffing-rules.service.spec.ts
@@ -1,4 +1,4 @@
-import type { PrismaClient } from "@fragment/database";
+import type { DayOfWeek, OrganizationBusinessHour, PrismaClient } from "@fragment/database";
import { describe, expect, it, jest } from "@jest/globals";
import { ERROR_CODES } from "@/common/constants/error-codes";
@@ -12,10 +12,30 @@ import {
const timeOfDay = (hour: number, minute: number) => new Date(Date.UTC(1970, 0, 1, hour, minute));
const timestamp = new Date("2026-06-25T00:00:00.000Z");
+const createBusinessHour = ({
+ closeTime = timeOfDay(18, 0),
+ closesNextDay = false,
+ dayOfWeek = "MON",
+ id = BigInt(1),
+ isClosed = false,
+ openTime = timeOfDay(9, 0),
+ organizationId = BigInt(1),
+}: Partial = {}): OrganizationBusinessHour => ({
+ id,
+ organizationId,
+ dayOfWeek,
+ isClosed,
+ openTime,
+ closeTime,
+ closesNextDay,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+});
+
const createRuleRow = ({
id = BigInt(10),
organizationId = BigInt(1),
- dayOfWeek = "MON",
+ dayOfWeek = "MON" as DayOfWeek,
startTime = timeOfDay(10, 0),
endTime = timeOfDay(14, 0),
endsNextDay = false,
@@ -32,11 +52,16 @@ const createRuleRow = ({
updatedAt: timestamp,
});
+type OrganizationRow = {
+ businessHours: OrganizationBusinessHour[];
+ id: bigint;
+};
+
const createPrismaMock = ({
- organization = { id: BigInt(1) },
+ organization = { businessHours: [createBusinessHour()], id: BigInt(1) },
minimumStaffingRule = {},
}: {
- organization?: { id: bigint } | null;
+ organization?: OrganizationRow | null;
minimumStaffingRule?: {
create?: jest.Mock;
deleteMany?: jest.Mock;
@@ -46,11 +71,14 @@ const createPrismaMock = ({
};
} = {}) => {
const organizationFindUnique = jest
- .fn<() => Promise<{ id: bigint } | null>>()
+ .fn<() => Promise>()
.mockResolvedValue(organization);
return {
prisma: {
+ activeSchedulePlanningPeriod: {
+ findUnique: jest.fn<() => Promise>().mockResolvedValue(null),
+ },
organization: {
findUnique: organizationFindUnique,
},
@@ -133,6 +161,17 @@ describe("createMinimumStaffingRule", () => {
}),
);
const { prisma } = createPrismaMock({
+ organization: {
+ id: BigInt(1),
+ businessHours: [
+ createBusinessHour({
+ closeTime: timeOfDay(2, 0),
+ closesNextDay: true,
+ dayOfWeek: "FRI",
+ openTime: timeOfDay(22, 0),
+ }),
+ ],
+ },
minimumStaffingRule: {
create: minimumStaffingRuleCreate,
},
@@ -163,6 +202,63 @@ describe("createMinimumStaffingRule", () => {
},
});
});
+
+ it("rejects rules on closed business days", async () => {
+ const minimumStaffingRuleCreate = jest.fn();
+ const { prisma } = createPrismaMock({
+ organization: {
+ id: BigInt(1),
+ businessHours: [
+ createBusinessHour({
+ closeTime: null,
+ dayOfWeek: "MON",
+ isClosed: true,
+ openTime: null,
+ }),
+ ],
+ },
+ minimumStaffingRule: {
+ create: minimumStaffingRuleCreate,
+ },
+ });
+
+ await expect(
+ createMinimumStaffingRule(prisma, "1", {
+ dayOfWeek: "MON",
+ startTime: "10:00",
+ endTime: "14:00",
+ endsNextDay: false,
+ requiredCount: 2,
+ }),
+ ).rejects.toMatchObject({
+ code: ERROR_CODES.CLOSED_DAY,
+ statusCode: 400,
+ });
+ expect(minimumStaffingRuleCreate).not.toHaveBeenCalled();
+ });
+
+ it("rejects rules outside business hours", async () => {
+ const minimumStaffingRuleCreate = jest.fn();
+ const { prisma } = createPrismaMock({
+ minimumStaffingRule: {
+ create: minimumStaffingRuleCreate,
+ },
+ });
+
+ await expect(
+ createMinimumStaffingRule(prisma, "1", {
+ dayOfWeek: "MON",
+ startTime: "08:00",
+ endTime: "10:00",
+ endsNextDay: false,
+ requiredCount: 2,
+ }),
+ ).rejects.toMatchObject({
+ code: ERROR_CODES.INVALID_TIME_RANGE,
+ statusCode: 400,
+ });
+ expect(minimumStaffingRuleCreate).not.toHaveBeenCalled();
+ });
});
describe("updateMinimumStaffingRule", () => {
@@ -190,6 +286,7 @@ describe("updateMinimumStaffingRule", () => {
organizationId: BigInt(1),
},
select: {
+ dayOfWeek: true,
endTime: true,
endsNextDay: true,
id: true,
@@ -203,6 +300,7 @@ describe("updateMinimumStaffingRule", () => {
const minimumStaffingRuleFindFirst = jest
.fn<
() => Promise<{
+ dayOfWeek: DayOfWeek;
endTime: Date;
endsNextDay: boolean;
id: bigint;
@@ -210,6 +308,7 @@ describe("updateMinimumStaffingRule", () => {
}>
>()
.mockResolvedValue({
+ dayOfWeek: "MON",
id: BigInt(10),
startTime: timeOfDay(10, 0),
endTime: timeOfDay(14, 0),
@@ -233,6 +332,43 @@ describe("updateMinimumStaffingRule", () => {
});
expect(minimumStaffingRuleUpdate).not.toHaveBeenCalled();
});
+
+ it("rejects updates that move a rule outside business hours", async () => {
+ const minimumStaffingRuleFindFirst = jest
+ .fn<
+ () => Promise<{
+ dayOfWeek: DayOfWeek;
+ endTime: Date;
+ endsNextDay: boolean;
+ id: bigint;
+ startTime: Date;
+ }>
+ >()
+ .mockResolvedValue({
+ dayOfWeek: "MON",
+ id: BigInt(10),
+ startTime: timeOfDay(10, 0),
+ endTime: timeOfDay(14, 0),
+ endsNextDay: false,
+ });
+ const minimumStaffingRuleUpdate = jest.fn();
+ const { prisma } = createPrismaMock({
+ minimumStaffingRule: {
+ findFirst: minimumStaffingRuleFindFirst,
+ update: minimumStaffingRuleUpdate,
+ },
+ });
+
+ await expect(
+ updateMinimumStaffingRule(prisma, "1", "10", {
+ startTime: "08:00",
+ }),
+ ).rejects.toMatchObject({
+ code: ERROR_CODES.INVALID_TIME_RANGE,
+ statusCode: 400,
+ });
+ expect(minimumStaffingRuleUpdate).not.toHaveBeenCalled();
+ });
});
describe("deleteMinimumStaffingRule", () => {
diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts
index 0ef48d0..e681542 100644
--- a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts
+++ b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts
@@ -1,5 +1,6 @@
import type {
MinimumStaffingRule as PrismaMinimumStaffingRule,
+ OrganizationBusinessHour,
Prisma,
PrismaClient,
} from "@fragment/database";
@@ -12,15 +13,33 @@ import type {
import { ERROR_CODES } from "@/common/constants/error-codes";
import { HttpError } from "@/errors/http-error";
-import { dateToTimeString, timeStringToDate } from "@/utils/date-time";
+import { pruneOrganizationAvailabilityForActivePlanningPeriod } from "@/modules/availability/availability.service";
+import { dateToTimeString, parseTimeToMinutes, timeStringToDate } from "@/utils/date-time";
import { toApiId, toPrismaId } from "@/utils/mapper";
+const MINUTES_IN_DAY = 24 * 60;
+
+type StaffingRulesOrganization = {
+ businessHours: OrganizationBusinessHour[];
+ id: bigint;
+};
+
const createMinimumStaffingRuleNotFoundError = () =>
new HttpError(404, ERROR_CODES.NOT_FOUND, "최소 인원 조건을 찾을 수 없습니다.");
const createMinimumStaffingRuleValidationError = () =>
new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다.");
+const createMinimumStaffingRuleClosedDayError = () =>
+ new HttpError(400, ERROR_CODES.CLOSED_DAY, "휴무일에는 최소 인원 조건을 등록할 수 없습니다.");
+
+const createMinimumStaffingRuleOutsideBusinessHoursError = () =>
+ new HttpError(
+ 400,
+ ERROR_CODES.INVALID_TIME_RANGE,
+ "최소 인원 조건은 조직 운영시간 안에서만 등록할 수 있습니다.",
+ );
+
const assertMinimumStaffingRuleTimeRange = ({
endTime,
endsNextDay,
@@ -39,12 +58,52 @@ const assertMinimumStaffingRuleTimeRange = ({
}
};
-const findOrganizationIdByUserId = async (prisma: PrismaClient, userId: string) => {
+const dateToMinutes = (time: Date) => parseTimeToMinutes(dateToTimeString(time));
+
+const getRangeEndMinutes = (endTime: string, endsNextDay: boolean) => {
+ const rawEndMinutes = parseTimeToMinutes(endTime);
+
+ return endsNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes;
+};
+
+const assertMinimumStaffingRuleInsideBusinessHours = ({
+ businessHours,
+ dayOfWeek,
+ endTime,
+ endsNextDay,
+ startTime,
+}: Pick & {
+ businessHours: OrganizationBusinessHour[];
+}) => {
+ const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek);
+
+ if (!businessHour || businessHour.isClosed || !businessHour.openTime || !businessHour.closeTime) {
+ throw createMinimumStaffingRuleClosedDayError();
+ }
+
+ const businessStartMinutes = dateToMinutes(businessHour.openTime);
+ const rawBusinessEndMinutes = dateToMinutes(businessHour.closeTime);
+ const businessEndMinutes = businessHour.closesNextDay
+ ? rawBusinessEndMinutes + MINUTES_IN_DAY
+ : rawBusinessEndMinutes;
+ const ruleStartMinutes = parseTimeToMinutes(startTime);
+ const ruleEndMinutes = getRangeEndMinutes(endTime, endsNextDay);
+
+ if (ruleStartMinutes < businessStartMinutes || ruleEndMinutes > businessEndMinutes) {
+ throw createMinimumStaffingRuleOutsideBusinessHoursError();
+ }
+};
+
+const findOrganizationByUserId = async (
+ prisma: PrismaClient,
+ userId: string,
+): Promise => {
const organization = await prisma.organization.findUnique({
where: {
userId: toPrismaId(userId),
},
select: {
+ businessHours: true,
id: true,
},
});
@@ -53,7 +112,7 @@ const findOrganizationIdByUserId = async (prisma: PrismaClient, userId: string)
throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다.");
}
- return organization.id;
+ return organization;
};
export const toMinimumStaffingRule = (rule: PrismaMinimumStaffingRule): MinimumStaffingRule => ({
@@ -91,11 +150,11 @@ export async function getMinimumStaffingRules(
prisma: PrismaClient,
userId: string,
): Promise {
- const organizationId = await findOrganizationIdByUserId(prisma, userId);
+ const organization = await findOrganizationByUserId(prisma, userId);
const rules = await prisma.minimumStaffingRule.findMany({
where: {
- organizationId,
+ organizationId: organization.id,
},
orderBy: [{ dayOfWeek: "asc" }, { startTime: "asc" }],
});
@@ -110,11 +169,20 @@ export async function createMinimumStaffingRule(
userId: string,
input: CreateMinimumStaffingRuleRequest,
): Promise {
- const organizationId = await findOrganizationIdByUserId(prisma, userId);
+ const organization = await findOrganizationByUserId(prisma, userId);
+
+ assertMinimumStaffingRuleTimeRange(input);
+ assertMinimumStaffingRuleInsideBusinessHours({
+ businessHours: organization.businessHours,
+ ...input,
+ });
+
const rule = await prisma.minimumStaffingRule.create({
- data: createMinimumStaffingRuleData(organizationId, input),
+ data: createMinimumStaffingRuleData(organization.id, input),
});
+ await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id);
+
return toMinimumStaffingRule(rule);
}
@@ -124,15 +192,16 @@ export async function updateMinimumStaffingRule(
ruleId: string,
input: UpdateMinimumStaffingRuleRequest,
): Promise {
- const organizationId = await findOrganizationIdByUserId(prisma, userId);
+ const organization = await findOrganizationByUserId(prisma, userId);
const ruleDatabaseId = toPrismaId(ruleId);
const existingRule = await prisma.minimumStaffingRule.findFirst({
where: {
id: ruleDatabaseId,
- organizationId,
+ organizationId: organization.id,
},
select: {
+ dayOfWeek: true,
endTime: true,
endsNextDay: true,
id: true,
@@ -144,10 +213,17 @@ export async function updateMinimumStaffingRule(
throw createMinimumStaffingRuleNotFoundError();
}
- assertMinimumStaffingRuleTimeRange({
+ const mergedRule = {
+ dayOfWeek: input.dayOfWeek ?? existingRule.dayOfWeek,
startTime: input.startTime ?? dateToTimeString(existingRule.startTime),
endTime: input.endTime ?? dateToTimeString(existingRule.endTime),
endsNextDay: input.endsNextDay ?? existingRule.endsNextDay,
+ };
+
+ assertMinimumStaffingRuleTimeRange(mergedRule);
+ assertMinimumStaffingRuleInsideBusinessHours({
+ businessHours: organization.businessHours,
+ ...mergedRule,
});
const updatedRule = await prisma.minimumStaffingRule.update({
@@ -157,6 +233,8 @@ export async function updateMinimumStaffingRule(
data: createMinimumStaffingRuleUpdateData(input),
});
+ await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id);
+
return toMinimumStaffingRule(updatedRule);
}
@@ -165,15 +243,17 @@ export async function deleteMinimumStaffingRule(
userId: string,
ruleId: string,
): Promise {
- const organizationId = await findOrganizationIdByUserId(prisma, userId);
+ const organization = await findOrganizationByUserId(prisma, userId);
const result = await prisma.minimumStaffingRule.deleteMany({
where: {
id: toPrismaId(ruleId),
- organizationId,
+ organizationId: organization.id,
},
});
if (result.count === 0) {
throw createMinimumStaffingRuleNotFoundError();
}
+
+ await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id);
}
diff --git a/apps/api/test/availability.routes.test.ts b/apps/api/test/availability.routes.test.ts
index fa183c0..82af400 100644
--- a/apps/api/test/availability.routes.test.ts
+++ b/apps/api/test/availability.routes.test.ts
@@ -5,6 +5,8 @@ import { createApp } from "@/app";
import { createAccessToken } from "@/modules/auth/auth.tokens";
import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma";
+const now = new Date("2026-06-25T00:00:00.000Z");
+
const createOrganization = (
businessHours = [
{
@@ -32,7 +34,22 @@ const createWorker = () => ({
weeklyContractHours: 40,
});
-const now = new Date("2026-06-25T00:00:00.000Z");
+const createMinimumStaffingRule = ({
+ dayOfWeek = "WED" as const,
+ endTime = "18:00",
+ endsNextDay = false,
+ startTime = "09:00",
+} = {}) => ({
+ id: BigInt(1),
+ organizationId: BigInt(1),
+ dayOfWeek,
+ startTime: createTimeDate(startTime),
+ endTime: createTimeDate(endTime),
+ endsNextDay,
+ requiredCount: 1,
+ createdAt: now,
+ updatedAt: now,
+});
describe("availability routes", () => {
beforeEach(() => {
@@ -205,6 +222,12 @@ describe("availability routes", () => {
it("accepts bulk save times on the original offset 30-minute boundary", async () => {
const { prisma } = createFakePrisma({
+ minimumStaffingRules: [
+ createMinimumStaffingRule({
+ endTime: "02:00",
+ startTime: "00:00",
+ }),
+ ],
organizations: [
createOrganization([
{
@@ -265,6 +288,7 @@ describe("availability routes", () => {
updatedAt: now,
},
],
+ minimumStaffingRules: [createMinimumStaffingRule()],
organizations: [createOrganization()],
workers: [createWorker()],
});
diff --git a/apps/api/test/helpers/fake-prisma.ts b/apps/api/test/helpers/fake-prisma.ts
index 735d01b..5001de0 100644
--- a/apps/api/test/helpers/fake-prisma.ts
+++ b/apps/api/test/helpers/fake-prisma.ts
@@ -82,6 +82,19 @@ type FakeScheduleAssignment = {
updatedAt: Date;
};
+type FakeScheduleCandidate = {
+ id: bigint;
+ scheduleId: bigint;
+ workerId: bigint | null;
+ workDate: Date;
+ startsAt: Date;
+ endsAt: Date;
+ workerNameSnapshot: string;
+ employeeCodeSnapshot: string;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
type FakeScheduleWorkerShortage = {
id: bigint;
scheduleId: bigint;
@@ -119,6 +132,7 @@ type CreateFakePrismaOptions = {
organizations?: FakeOrganization[];
refreshTokens?: FakeRefreshToken[];
scheduleAssignments?: FakeScheduleAssignment[];
+ scheduleCandidates?: FakeScheduleCandidate[];
scheduleWorkerShortages?: FakeScheduleWorkerShortage[];
schedules?: FakeSchedule[];
users?: FakeUser[];
@@ -184,6 +198,15 @@ const cloneScheduleAssignment = (assignment: FakeScheduleAssignment): FakeSchedu
workDate: cloneRequiredDate(assignment.workDate),
});
+const cloneScheduleCandidate = (candidate: FakeScheduleCandidate): FakeScheduleCandidate => ({
+ ...candidate,
+ createdAt: cloneRequiredDate(candidate.createdAt),
+ endsAt: cloneRequiredDate(candidate.endsAt),
+ startsAt: cloneRequiredDate(candidate.startsAt),
+ updatedAt: cloneRequiredDate(candidate.updatedAt),
+ workDate: cloneRequiredDate(candidate.workDate),
+});
+
const cloneScheduleWorkerShortage = (
shortage: FakeScheduleWorkerShortage,
): FakeScheduleWorkerShortage => ({
@@ -209,6 +232,7 @@ export function createFakePrisma({
organizations = [],
refreshTokens = [],
scheduleAssignments = [],
+ scheduleCandidates = [],
scheduleWorkerShortages = [],
schedules = [],
users = [],
@@ -223,6 +247,7 @@ export function createFakePrisma({
nextPlanningPeriodId: getNextId(activeSchedulePlanningPeriods),
nextRefreshTokenId: getNextId(refreshTokens),
nextScheduleAssignmentId: getNextId(scheduleAssignments),
+ nextScheduleCandidateId: getNextId(scheduleCandidates),
nextScheduleId: getNextId(schedules),
nextScheduleWorkerShortageId: getNextId(scheduleWorkerShortages),
nextUserId: getNextId(users),
@@ -237,6 +262,7 @@ export function createFakePrisma({
})),
refreshTokens: refreshTokens.map((refreshToken) => ({ ...refreshToken })),
scheduleAssignments: scheduleAssignments.map(cloneScheduleAssignment),
+ scheduleCandidates: scheduleCandidates.map(cloneScheduleCandidate),
scheduleWorkerShortages: scheduleWorkerShortages.map(cloneScheduleWorkerShortage),
schedules: schedules.map(cloneSchedule),
users: [...users],
@@ -262,6 +288,22 @@ export function createFakePrisma({
);
}
+ if (options?.include) {
+ return {
+ ...organization,
+ ...(options.include.businessHours
+ ? { businessHours: organization.businessHours.map(cloneBusinessHour) }
+ : {}),
+ ...(options.include.minimumStaffingRules
+ ? {
+ minimumStaffingRules: state.minimumStaffingRules
+ .filter((rule) => rule.organizationId === organization.id)
+ .map(cloneMinimumStaffingRule),
+ }
+ : {}),
+ };
+ }
+
return {
...organization,
businessHours: organization.businessHours.map(cloneBusinessHour),
@@ -288,6 +330,19 @@ export function createFakePrisma({
return Number(a.id - b.id);
});
+ const sortScheduleCandidates = (candidates: FakeScheduleCandidate[]) =>
+ candidates.sort((a, b) => {
+ if (a.workDate.getTime() !== b.workDate.getTime()) {
+ return a.workDate.getTime() - b.workDate.getTime();
+ }
+
+ if (a.startsAt.getTime() !== b.startsAt.getTime()) {
+ return a.startsAt.getTime() - b.startsAt.getTime();
+ }
+
+ return a.employeeCodeSnapshot.localeCompare(b.employeeCodeSnapshot);
+ });
+
const sortScheduleWorkerShortages = (shortages: FakeScheduleWorkerShortage[]) =>
shortages.sort((a, b) => {
if (a.workDate.getTime() !== b.workDate.getTime()) {
@@ -320,6 +375,11 @@ export function createFakePrisma({
.filter((assignment) => assignment.scheduleId === schedule.id)
.map(cloneScheduleAssignment),
),
+ candidates: sortScheduleCandidates(
+ state.scheduleCandidates
+ .filter((candidate) => candidate.scheduleId === schedule.id)
+ .map(cloneScheduleCandidate),
+ ),
workerShortages: sortScheduleWorkerShortages(
state.scheduleWorkerShortages
.filter((shortage) => shortage.scheduleId === schedule.id)
@@ -436,6 +496,62 @@ export function createFakePrisma({
},
},
minimumStaffingRule: {
+ create: async ({ data }: any) => {
+ const now = new Date("2026-06-25T00:00:00.000Z");
+ const rule: FakeMinimumStaffingRule = {
+ id: getNextId(state.minimumStaffingRules),
+ createdAt: now,
+ updatedAt: now,
+ ...data,
+ };
+
+ state.minimumStaffingRules.push(rule);
+
+ return cloneMinimumStaffingRule(rule);
+ },
+ deleteMany: async ({ where }: any) => {
+ const initialCount = state.minimumStaffingRules.length;
+ state.minimumStaffingRules = state.minimumStaffingRules.filter((rule) => {
+ if (where.id !== undefined && rule.id !== where.id) {
+ return true;
+ }
+
+ if (where.organizationId !== undefined && rule.organizationId !== where.organizationId) {
+ return true;
+ }
+
+ return false;
+ });
+
+ return { count: initialCount - state.minimumStaffingRules.length };
+ },
+ findFirst: async ({ select, where }: any) => {
+ const rule =
+ state.minimumStaffingRules.find((currentRule) => {
+ if (where.id !== undefined && currentRule.id !== where.id) {
+ return false;
+ }
+
+ if (
+ where.organizationId !== undefined &&
+ currentRule.organizationId !== where.organizationId
+ ) {
+ return false;
+ }
+
+ return true;
+ }) ?? null;
+
+ if (!rule || !select) {
+ return rule ? cloneMinimumStaffingRule(rule) : null;
+ }
+
+ return Object.fromEntries(
+ Object.keys(select)
+ .filter((key) => select[key])
+ .map((key) => [key, rule[key as keyof FakeMinimumStaffingRule]]),
+ );
+ },
findMany: async ({ orderBy, where }: any) => {
const rules = state.minimumStaffingRules
.filter((rule) => rule.organizationId === where.organizationId)
@@ -459,6 +575,20 @@ export function createFakePrisma({
return rules;
},
+ update: async ({ data, where }: any) => {
+ const rule = state.minimumStaffingRules.find((currentRule) => currentRule.id === where.id);
+
+ if (!rule) {
+ return null;
+ }
+
+ Object.assign(rule, {
+ ...data,
+ updatedAt: new Date("2026-06-25T00:00:00.000Z"),
+ });
+
+ return cloneMinimumStaffingRule(rule);
+ },
},
organization: {
create: async ({ data }: any) => {
@@ -577,6 +707,16 @@ export function createFakePrisma({
return true;
}
+ if (where.worker?.organizationId !== undefined) {
+ const worker = state.workers.find(
+ (currentWorker) => currentWorker.id === availableTime.workerId,
+ );
+
+ if (!worker || worker.organizationId !== where.worker.organizationId) {
+ return true;
+ }
+ }
+
if (
where.availableDate?.gte !== undefined &&
availableTime.availableDate < where.availableDate.gte
@@ -688,6 +828,22 @@ export function createFakePrisma({
})),
);
+ const candidatesToCreate = data.candidates?.create ?? [];
+ state.scheduleCandidates.push(
+ ...candidatesToCreate.map((candidate: any) => ({
+ id: state.nextScheduleCandidateId++,
+ scheduleId: schedule.id,
+ workerId: candidate.worker?.connect?.id ?? candidate.workerId ?? null,
+ workDate: cloneRequiredDate(candidate.workDate),
+ startsAt: cloneRequiredDate(candidate.startsAt),
+ endsAt: cloneRequiredDate(candidate.endsAt),
+ workerNameSnapshot: candidate.workerNameSnapshot,
+ employeeCodeSnapshot: candidate.employeeCodeSnapshot,
+ createdAt: now,
+ updatedAt: now,
+ })),
+ );
+
const shortagesToCreate = data.workerShortages?.create ?? [];
state.scheduleWorkerShortages.push(
...shortagesToCreate.map((shortage: any) => ({
@@ -707,11 +863,19 @@ export function createFakePrisma({
return toScheduleResult(schedule, options);
},
- findFirst: async ({ where, ...options }: any) => {
- const schedule = state.schedules.find((currentSchedule) =>
+ findFirst: async ({ orderBy, where, ...options }: any) => {
+ let schedules = state.schedules.filter((currentSchedule) =>
matchesScheduleWhere(currentSchedule, where),
);
+ if (orderBy?.id === "desc") {
+ schedules = [...schedules].sort((first, second) =>
+ first.id < second.id ? 1 : first.id > second.id ? -1 : 0,
+ );
+ }
+
+ const schedule = schedules[0];
+
return toScheduleResult(schedule ?? null, options);
},
update: async ({ data, where, ...options }: any) => {
diff --git a/apps/api/test/schedules.routes.test.ts b/apps/api/test/schedules.routes.test.ts
index 80b6532..f8f3e33 100644
--- a/apps/api/test/schedules.routes.test.ts
+++ b/apps/api/test/schedules.routes.test.ts
@@ -8,11 +8,21 @@ import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma";
const timestamp = new Date("2026-06-25T00:00:00.000Z");
const dateOnly = (date: string) => new Date(`${date}T00:00:00.000Z`);
+const createBusinessHour = () => ({
+ id: BigInt(1),
+ organizationId: BigInt(1),
+ dayOfWeek: "WED" as const,
+ isClosed: false,
+ openTime: createTimeDate("10:00"),
+ closeTime: createTimeDate("14:00"),
+ closesNextDay: false,
+});
+
const createOrganization = () => ({
id: BigInt(1),
userId: BigInt(1),
name: "프래그먼트 카페",
- businessHours: [],
+ businessHours: [createBusinessHour()],
});
const createPlanningPeriod = () => ({
@@ -160,6 +170,15 @@ describe("schedules routes", () => {
employeeCodeSnapshot: "W-0001",
},
],
+ candidates: [
+ {
+ workerId: "1",
+ workDate: "2026-07-01",
+ workerNameSnapshot: "김민수",
+ employeeCodeSnapshot: "W-0001",
+ isRecommended: true,
+ },
+ ],
workerShortages: [],
});
});
diff --git a/apps/api/test/staffing-rules.routes.test.ts b/apps/api/test/staffing-rules.routes.test.ts
index f6ca8e5..e1ee4c1 100644
--- a/apps/api/test/staffing-rules.routes.test.ts
+++ b/apps/api/test/staffing-rules.routes.test.ts
@@ -5,11 +5,25 @@ import { createApp } from "@/app";
import { createAccessToken } from "@/modules/auth/auth.tokens";
import { createFakePrisma, createTimeDate } from "./helpers/fake-prisma";
-const createOrganization = () => ({
+const now = new Date("2026-06-25T00:00:00.000Z");
+
+const createOrganization = (
+ businessHours = [
+ {
+ id: BigInt(1),
+ organizationId: BigInt(1),
+ dayOfWeek: "WED" as const,
+ isClosed: false,
+ openTime: createTimeDate("09:00"),
+ closeTime: createTimeDate("18:00"),
+ closesNextDay: false,
+ },
+ ],
+) => ({
id: BigInt(1),
userId: BigInt(1),
name: "프래그먼트 카페",
- businessHours: [],
+ businessHours,
});
describe("staffing rules routes", () => {
@@ -57,7 +71,6 @@ describe("staffing rules routes", () => {
});
it("returns the authenticated user's staffing rules", async () => {
- const now = new Date("2026-06-25T00:00:00.000Z");
const { prisma } = createFakePrisma({
minimumStaffingRules: [
{
@@ -96,4 +109,74 @@ describe("staffing rules routes", () => {
],
});
});
+
+ it("prunes availability when a staffing rule is deleted", async () => {
+ const { prisma } = createFakePrisma({
+ activeSchedulePlanningPeriods: [
+ {
+ id: BigInt(1),
+ organizationId: BigInt(1),
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
+ endDate: new Date("2026-07-01T00:00:00.000Z"),
+ createdAt: now,
+ updatedAt: now,
+ },
+ ],
+ availableTimes: [
+ {
+ id: BigInt(1),
+ workerId: BigInt(1),
+ availableDate: new Date("2026-07-01T00:00:00.000Z"),
+ startsAt: new Date("2026-07-01T12:00:00.000Z"),
+ endsAt: new Date("2026-07-01T14:00:00.000Z"),
+ createdAt: now,
+ updatedAt: now,
+ },
+ ],
+ minimumStaffingRules: [
+ {
+ id: BigInt(10),
+ organizationId: BigInt(1),
+ dayOfWeek: "WED",
+ startTime: createTimeDate("12:00"),
+ endTime: createTimeDate("14:00"),
+ endsNextDay: false,
+ requiredCount: 1,
+ createdAt: now,
+ updatedAt: now,
+ },
+ ],
+ organizations: [createOrganization()],
+ workers: [
+ {
+ id: BigInt(1),
+ organizationId: BigInt(1),
+ employeeCode: "W-0001",
+ name: "김민수",
+ weeklyContractHours: 40,
+ },
+ ],
+ });
+ const app = createApp({ prisma });
+
+ const deleteResponse = await request(app)
+ .delete("/api/staffing-rules/10")
+ .set("Authorization", authHeader());
+
+ expect(deleteResponse.status).toBe(204);
+
+ const availabilityResponse = await request(app)
+ .get("/api/availability")
+ .set("Authorization", authHeader())
+ .query({
+ workerId: "1",
+ startDate: "2026-07-01",
+ endDate: "2026-07-01",
+ });
+
+ expect(availabilityResponse.status).toBe(200);
+ expect(availabilityResponse.body).toEqual({
+ items: [],
+ });
+ });
});
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index fd94248..67aa17b 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -1,7 +1,23 @@
import type { NextConfig } from "next";
+const apiProxyOrigin =
+ process.env.API_PROXY_ORIGIN ??
+ (process.env.NODE_ENV === "development" ? "http://localhost:3001" : undefined);
+
const nextConfig: NextConfig = {
transpilePackages: ["@fragment/shared"],
+ ...(apiProxyOrigin
+ ? {
+ async rewrites() {
+ return [
+ {
+ source: "/api/:path*",
+ destination: `${apiProxyOrigin.replace(/\/$/, "")}/api/:path*`,
+ },
+ ];
+ },
+ }
+ : {}),
};
export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index d0c606b..2b5497c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev",
+ "dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "eslint src/",
diff --git a/apps/web/src/components/common/password-input.tsx b/apps/web/src/components/common/password-input.tsx
index 4213b9e..a1a7877 100644
--- a/apps/web/src/components/common/password-input.tsx
+++ b/apps/web/src/components/common/password-input.tsx
@@ -23,7 +23,7 @@ export function PasswordInput({ className, ...props }: PasswordInputProps) {
className="absolute right-1 top-1/2 size-9 -translate-y-1/2 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => setVisible((current) => !current)}
>
- {visible ? : }
+ {visible ? : }
);
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 9d89955..cf2d39f 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -43,7 +43,7 @@ function SelectContent({
{children}
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 4da4c53..65c6136 100644
--- a/apps/web/src/features/availability/components/mvp-availability-page.tsx
+++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx
@@ -5,6 +5,7 @@ import {
type OrganizationDetail,
Availability,
AvailabilityQuery,
+ MinimumStaffingRule,
ReplaceAvailabilityRequest,
Worker,
} from "@fragment/shared";
@@ -22,6 +23,7 @@ import {
useOrganizationQuery,
useUpsertActiveSchedulePlanningPeriodMutation,
} 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";
@@ -42,6 +44,10 @@ type AvailabilitySlot = {
};
type BusinessHour = OrganizationDetail["businessHours"][number];
+type TimeRange = {
+ endMinutes: number;
+ startMinutes: number;
+};
const SLOT_INTERVAL_MINUTES = 30;
const TIMETABLE_DATE_COLUMN_WIDTH = 76;
@@ -50,6 +56,7 @@ const DEFAULT_TIMETABLE_START_MINUTES = 9 * 60;
const DEFAULT_TIMETABLE_END_MINUTES = 22 * 60;
const EMPTY_WORKERS: Worker[] = [];
const EMPTY_BUSINESS_HOURS: BusinessHour[] = [];
+const EMPTY_STAFFING_RULES: MinimumStaffingRule[] = [];
const DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
month: "2-digit",
@@ -140,10 +147,48 @@ function getBusinessHourRange(businessHour: BusinessHour | undefined) {
};
}
-function getTimetableRange(businessHours: BusinessHour[], dateRange: string[]) {
+function getStaffingRuleRanges(staffingRules: MinimumStaffingRule[], date: string): TimeRange[] {
+ return staffingRules
+ .filter((rule) => rule.dayOfWeek === getDayOfWeek(date))
+ .map((rule) => {
+ const startMinutes = timeToMinutes(rule.startTime);
+ const endMinutes = timeToMinutes(rule.endTime) + (rule.endsNextDay ? 24 * 60 : 0);
+
+ return {
+ endMinutes,
+ startMinutes,
+ };
+ })
+ .filter((range) => range.startMinutes < range.endMinutes);
+}
+
+function getSchedulableRanges(
+ businessHours: BusinessHour[],
+ staffingRules: MinimumStaffingRule[],
+ date: string,
+): TimeRange[] {
+ const businessHourRange = getBusinessHourRange(getBusinessHourForDate(businessHours, date));
+
+ if (!businessHourRange) {
+ return [];
+ }
+
+ return getStaffingRuleRanges(staffingRules, date)
+ .map((range) => ({
+ endMinutes: Math.min(range.endMinutes, businessHourRange.endMinutes),
+ startMinutes: Math.max(range.startMinutes, businessHourRange.startMinutes),
+ }))
+ .filter((range) => range.startMinutes < range.endMinutes);
+}
+
+function getTimetableRange(
+ businessHours: BusinessHour[],
+ staffingRules: MinimumStaffingRule[],
+ dateRange: string[],
+) {
const ranges = dateRange
- .map((date) => getBusinessHourRange(getBusinessHourForDate(businessHours, date)))
- .filter((range): range is NonNullable => Boolean(range));
+ .flatMap((date) => getSchedulableRanges(businessHours, staffingRules, date))
+ .filter((range) => Boolean(range));
if (ranges.length === 0) {
return {
@@ -162,14 +207,17 @@ function isClosedDate(businessHours: BusinessHour[], date: string) {
return !getBusinessHourRange(getBusinessHourForDate(businessHours, date));
}
-function isSlotInBusinessHours(businessHours: BusinessHour[], date: string, startMinutes: number) {
- const range = getBusinessHourRange(getBusinessHourForDate(businessHours, date));
-
- if (!range) {
- return false;
- }
+function isSlotSchedulable(
+ businessHours: BusinessHour[],
+ staffingRules: MinimumStaffingRule[],
+ date: string,
+ startMinutes: number,
+) {
+ const endMinutes = startMinutes + SLOT_INTERVAL_MINUTES;
- return startMinutes >= range.startMinutes && startMinutes < range.endMinutes;
+ return getSchedulableRanges(businessHours, staffingRules, date).some(
+ (range) => startMinutes >= range.startMinutes && endMinutes <= range.endMinutes,
+ );
}
function formatDateLabel(date: string) {
@@ -283,11 +331,13 @@ function createAvailabilityItems(slots: AvailabilitySlot[]) {
export function MvpAvailabilityPage() {
const workersQuery = useWorkersQuery();
const organizationQuery = useOrganizationQuery();
+ const staffingRulesQuery = useMinimumStaffingRulesQuery();
const planningPeriodQuery = useActiveSchedulePlanningPeriodQuery();
const upsertPlanningPeriodMutation = useUpsertActiveSchedulePlanningPeriodMutation();
const replaceAvailabilityMutation = useReplaceAvailabilityMutation();
const workers = workersQuery.data?.items ?? EMPTY_WORKERS;
const businessHours = organizationQuery.data?.businessHours ?? EMPTY_BUSINESS_HOURS;
+ const staffingRules = staffingRulesQuery.data?.items ?? EMPTY_STAFFING_RULES;
const activePlanningPeriod = planningPeriodQuery.data?.period ?? null;
const [selectedWorkerId, setSelectedWorkerId] = useState("");
const [selectedAvailabilityDate, setSelectedAvailabilityDate] = useState("");
@@ -308,10 +358,14 @@ export function MvpAvailabilityPage() {
Boolean(selectedWorkerId && workStartDate && workEndDate) && workStartDate <= workEndDate;
const availabilityResult = useAvailabilityQuery(availabilityQuery, canFetchAvailability);
const isPageLoading =
- organizationQuery.isLoading || planningPeriodQuery.isLoading || workersQuery.isLoading;
+ organizationQuery.isLoading ||
+ planningPeriodQuery.isLoading ||
+ staffingRulesQuery.isLoading ||
+ workersQuery.isLoading;
const queryError =
organizationQuery.error ??
planningPeriodQuery.error ??
+ staffingRulesQuery.error ??
workersQuery.error ??
availabilityResult.error;
const queryErrorMessage = queryError
@@ -323,8 +377,8 @@ export function MvpAvailabilityPage() {
[workEndDate, workStartDate],
);
const timetableRange = useMemo(
- () => getTimetableRange(businessHours, dateRange),
- [businessHours, dateRange],
+ () => getTimetableRange(businessHours, staffingRules, dateRange),
+ [businessHours, dateRange, staffingRules],
);
const timeSlots = useMemo(
() => createTimeSlots(timetableRange.startMinutes, timetableRange.endMinutes),
@@ -371,7 +425,10 @@ export function MvpAvailabilityPage() {
const selectedWorker = workers.find((worker) => worker.id === selectedWorkerId);
const selectedDraftAvailability = draftAvailability.filter(
(slot) =>
- slot.workerId === selectedWorkerId && slot.date >= workStartDate && slot.date <= workEndDate,
+ slot.workerId === selectedWorkerId &&
+ slot.date >= workStartDate &&
+ slot.date <= workEndDate &&
+ isSlotSchedulable(businessHours, staffingRules, slot.date, slot.startMinutes),
);
const selectedAvailabilityByDate = summarizeAvailabilityByDate(selectedDraftAvailability);
const activeAvailabilityDate =
@@ -402,7 +459,7 @@ export function MvpAvailabilityPage() {
return;
}
- if (!isSlotInBusinessHours(businessHours, date, startMinutes)) {
+ if (!isSlotSchedulable(businessHours, staffingRules, date, startMinutes)) {
return;
}
@@ -478,7 +535,7 @@ export function MvpAvailabilityPage() {
return (
{selectedWorker.name}
주간 계약 시간: 주 {selectedWorker.weeklyContractHours}시간 · 운영 시간은 요일별
- 조직 설정을 따릅니다.
+ 조직 설정과 최소 인원 조건을 따릅니다.
>
) : (
@@ -679,8 +736,7 @@ export function MvpAvailabilityPage() {
가능 시간 타임테이블
- 셀을 선택한 뒤 상단의 변경사항 저장 버튼을 눌러 등록합니다. 선택된 셀을 다시 누르면
- 삭제됩니다.
+ 최소 인원 조건이 있는 셀만 선택할 수 있습니다. 선택된 셀을 다시 누르면 삭제됩니다.
{hasUnsavedChanges ? 저장 전 변경 있음 : null}
@@ -709,6 +765,10 @@ export function MvpAvailabilityPage() {
휴무
+ ) : getSchedulableRanges(businessHours, staffingRules, date).length === 0 ? (
+
+ 조건 없음
+
) : null}
))}
@@ -723,7 +783,12 @@ export function MvpAvailabilityPage() {
{showHourLabel ? timeLabel : ""}
{dateRange.map((date) => {
- const disabled = !isSlotInBusinessHours(businessHours, date, startMinutes);
+ const disabled = !isSlotSchedulable(
+ businessHours,
+ staffingRules,
+ date,
+ startMinutes,
+ );
const selected = draftSlotKeys.has(
getSlotKey(selectedWorkerId, date, startMinutes),
);
diff --git a/apps/web/src/features/schedules/api/schedules-api.ts b/apps/web/src/features/schedules/api/schedules-api.ts
new file mode 100644
index 0000000..6e92d52
--- /dev/null
+++ b/apps/web/src/features/schedules/api/schedules-api.ts
@@ -0,0 +1,67 @@
+import type {
+ DraftScheduleQuery,
+ DraftScheduleResponse,
+ RecommendScheduleRequest,
+ ScheduleAssignment,
+ ScheduleAssignmentInput,
+ ScheduleDetail,
+ UpdateScheduleAssignmentRequest,
+} from "@fragment/shared";
+
+import { apiClient } from "@/lib/api-client";
+
+function createDraftScheduleSearchParams(query: DraftScheduleQuery) {
+ const searchParams = new URLSearchParams({
+ endDate: query.endDate,
+ startDate: query.startDate,
+ });
+
+ return searchParams.toString();
+}
+
+export function getDraftSchedule(query: DraftScheduleQuery) {
+ return apiClient(
+ `/schedules/draft?${createDraftScheduleSearchParams(query)}`,
+ {
+ method: "GET",
+ },
+ );
+}
+
+export function recommendSchedule(request: RecommendScheduleRequest) {
+ return apiClient("/schedules/recommend", {
+ method: "POST",
+ body: request,
+ });
+}
+
+export function createScheduleAssignment(scheduleId: string, request: ScheduleAssignmentInput) {
+ return apiClient(`/schedules/${scheduleId}/assignments`, {
+ method: "POST",
+ body: request,
+ });
+}
+
+export function updateScheduleAssignment(
+ scheduleId: string,
+ assignmentId: string,
+ request: UpdateScheduleAssignmentRequest,
+) {
+ return apiClient(`/schedules/${scheduleId}/assignments/${assignmentId}`, {
+ method: "PATCH",
+ body: request,
+ });
+}
+
+export function deleteScheduleAssignment(scheduleId: string, assignmentId: string) {
+ return apiClient(`/schedules/${scheduleId}/assignments/${assignmentId}`, {
+ method: "DELETE",
+ responseType: "void",
+ });
+}
+
+export function confirmSchedule(scheduleId: string) {
+ return apiClient(`/schedules/${scheduleId}/confirm`, {
+ method: "POST",
+ });
+}
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 cf896ee..cf7007f 100644
--- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx
+++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx
@@ -1,8 +1,19 @@
"use client";
-import { useMemo, useState } from "react";
+import type {
+ DraftScheduleQuery,
+ ScheduleAssignment,
+ ScheduleAssignmentInput,
+ ScheduleCandidate,
+ ScheduleDetail,
+ ScheduleWorkerShortage,
+ Worker,
+} from "@fragment/shared";
+import { useEffect, useMemo, useState } from "react";
import { Badge, Button } from "@moyeorak/design-system";
-import { CalendarDays, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react";
+import { CalendarDays, Check, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react";
+import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form";
+import { z } from "zod";
import { AdminPageShell } from "@/components/layout/admin-page-shell";
import {
@@ -15,7 +26,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -24,27 +34,45 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-
-type ScheduleStatus = "DRAFT" | "CONFIRMED";
-
-type Worker = {
- id: string;
- name: string;
-};
+import { useActiveSchedulePlanningPeriodQuery } from "@/features/organization/queries/organization-queries";
+import {
+ useConfirmScheduleMutation,
+ useCreateScheduleAssignmentMutation,
+ useDeleteScheduleAssignmentMutation,
+ useDraftScheduleQuery,
+ useRecommendScheduleMutation,
+ useUpdateScheduleAssignmentMutation,
+} from "@/features/schedules/queries/schedules-queries";
+import { useMinimumStaffingRulesQuery } from "@/features/staffing-rules/queries/staffing-rules-queries";
+import { useWorkersQuery } from "@/features/workers/queries/workers-queries";
+import { getApiErrorMessage } from "@/lib/api-error-message";
+
+type ScheduleStatus = ScheduleDetail["status"];
type ScheduleItem = {
id: string;
date: string;
dayLabel: string;
+ endTime: string;
startTime: string;
+ workerId: string | null;
+ workerName: string;
+};
+
+type ScheduleCandidateItem = {
+ id: string;
+ date: string;
endTime: string;
- workerId: string;
+ isRecommended: boolean;
+ startTime: string;
+ workerId: string | null;
+ workerName: string;
};
type ScheduleDraft = {
date: string;
- startTime: string;
endTime: string;
+ startTime: string;
workerId: string;
};
@@ -52,67 +80,40 @@ type ScheduleFormMode = "create" | "edit";
type UnfilledCondition = {
id: string;
+ assignedWorkers: number;
date: string;
dayLabel: string;
- timeRange: string;
requiredWorkers: number;
- assignedWorkers: number;
+ timeRange: string;
};
-const WORKERS: Worker[] = [
- { id: "worker-1", name: "김민지" },
- { id: "worker-2", name: "박준호" },
- { id: "worker-3", name: "이서연" },
- { id: "worker-4", name: "최유나" },
- { id: "worker-5", name: "정도윤" },
- { id: "worker-6", name: "한서준" },
- { id: "worker-7", name: "오하린" },
- { id: "worker-8", name: "강지우" },
- { id: "worker-9", name: "윤태오" },
- { id: "worker-10", name: "임서아" },
- { id: "worker-11", name: "조민규" },
- { id: "worker-12", name: "배수빈" },
- { id: "worker-13", name: "문지훈" },
- { id: "worker-14", name: "신예린" },
- { id: "worker-15", name: "남현우" },
- { id: "worker-16", name: "서다은" },
- { id: "worker-17", name: "권도현" },
- { id: "worker-18", name: "백지민" },
- { id: "worker-19", name: "유시우" },
- { id: "worker-20", name: "홍나연" },
-];
-
-const INITIAL_UNFILLED_CONDITIONS: UnfilledCondition[] = [
- {
- id: "unfilled-1",
- date: "2026-06-26",
- dayLabel: "금요일",
- timeRange: "18:00-22:00",
- requiredWorkers: 20,
- assignedWorkers: 17,
- },
-];
+type WorkerOption = {
+ id: string;
+ name: string;
+};
+
+type OperationMessage = {
+ tone: "success" | "error";
+ text: string;
+};
const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"];
-const RECOMMENDATION_WORK_START_DATE = "2026-06-20";
-const RECOMMENDATION_WORK_END_DATE = "2026-07-05";
+const EMPTY_WORKERS: Worker[] = [];
const EMPTY_DRAFT: ScheduleDraft = {
date: "",
- startTime: "",
endTime: "",
+ startTime: "",
workerId: "",
};
+const TIME_OPTION_STEP_MINUTES = 30;
+
const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", {
weekday: "long",
timeZone: "Asia/Seoul",
});
-function getWorkerName(workerId: string) {
- return WORKERS.find((worker) => worker.id === workerId)?.name ?? "알 수 없음";
-}
-
function getDayLabel(date: string) {
return KST_DATE_FORMATTER.format(new Date(`${date}T00:00:00+09:00`));
}
@@ -120,6 +121,7 @@ function getDayLabel(date: string) {
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);
}
@@ -160,32 +162,144 @@ function createCalendarDates(startDate: string, endDate: string) {
return createDateRange(getMonday(startDate), getSunday(endDate));
}
+function minutesToTime(minutes: number) {
+ const minutesInDay = 24 * 60;
+ const normalizedMinutes = ((minutes % minutesInDay) + minutesInDay) % minutesInDay;
+ const hours = Math.floor(normalizedMinutes / 60);
+ const restMinutes = normalizedMinutes % 60;
+
+ return `${String(hours).padStart(2, "0")}:${String(restMinutes).padStart(2, "0")}`;
+}
+
+function createTimeOptions() {
+ const options: { label: string; value: string }[] = [];
+
+ for (let minutes = 0; minutes < 24 * 60; minutes += TIME_OPTION_STEP_MINUTES) {
+ const time = minutesToTime(minutes);
+
+ options.push({
+ label: time,
+ value: time,
+ });
+ }
+
+ return options;
+}
+
+const SCHEDULE_TIME_OPTIONS = createTimeOptions();
+
+function dateTimeToMinutes(date: string, dateTime: string) {
+ const baseDate = new Date(`${date}T00:00:00.000Z`);
+ const targetDate = new Date(dateTime);
+
+ return Math.round((targetDate.getTime() - baseDate.getTime()) / 60000);
+}
+
+function dateTimeToTime(date: string, dateTime: string) {
+ return minutesToTime(dateTimeToMinutes(date, dateTime));
+}
+
+function createDateTime(date: string, time: string, addDay = false) {
+ const [year, month, day] = date.split("-").map(Number);
+ const [hours, minutes] = time.split(":").map(Number);
+
+ return new Date(Date.UTC(year, month - 1, day + (addDay ? 1 : 0), hours, minutes)).toISOString();
+}
+
function createDraftFromSchedule(schedule: ScheduleItem): ScheduleDraft {
return {
date: schedule.date,
- startTime: schedule.startTime,
endTime: schedule.endTime,
- workerId: schedule.workerId,
+ startTime: schedule.startTime,
+ workerId: schedule.workerId ?? "",
};
}
-function validateScheduleDraft(draft: ScheduleDraft, workStartDate: string, workEndDate: string) {
+function createScheduleAssignmentInput(draft: ScheduleDraft): ScheduleAssignmentInput {
+ const endsNextDay = draft.endTime < draft.startTime;
+
return {
- date: !draft.date
- ? "날짜를 선택하세요."
- : draft.date < workStartDate || draft.date > workEndDate
- ? "근무 시작 날짜와 종료 날짜 사이에서만 선택할 수 있습니다."
- : "",
- startTime: !draft.startTime ? "시작 시간을 선택하세요." : "",
- endTime: !draft.endTime
- ? "종료 시간을 선택하세요."
- : draft.startTime && draft.endTime <= draft.startTime
- ? "종료 시간은 시작 시간보다 늦어야 합니다."
- : "",
- workerId: !draft.workerId ? "근무자를 선택하세요." : "",
+ endsAt: createDateTime(draft.date, draft.endTime, endsNextDay),
+ startsAt: createDateTime(draft.date, draft.startTime),
+ workDate: draft.date,
+ workerId: draft.workerId,
};
}
+const createScheduleFormSchema = (workStartDate: string, workEndDate: string) =>
+ z
+ .object({
+ date: z.string().min(1, "날짜를 선택하세요."),
+ endTime: z.string().min(1, "종료 시간을 선택하세요."),
+ startTime: z.string().min(1, "시작 시간을 선택하세요."),
+ workerId: z.string().min(1, "근무자를 선택하세요."),
+ })
+ .superRefine((value, context) => {
+ if (
+ value.date &&
+ workStartDate &&
+ workEndDate &&
+ (value.date < workStartDate || value.date > workEndDate)
+ ) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "근무 시작 날짜와 종료 날짜 사이에서만 선택할 수 있습니다.",
+ path: ["date"],
+ });
+ }
+
+ if (value.startTime && value.endTime && value.startTime === value.endTime) {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "종료 시간은 시작 시간과 달라야 합니다.",
+ path: ["endTime"],
+ });
+ }
+ });
+
+const createScheduleFormResolver =
+ (workStartDate: string, workEndDate: string): Resolver =>
+ (values) => {
+ const result = createScheduleFormSchema(workStartDate, workEndDate).safeParse(values);
+
+ if (result.success) {
+ return {
+ errors: {},
+ values,
+ };
+ }
+
+ const errors: FieldErrors = {};
+
+ result.error.issues.forEach((issue) => {
+ const [field] = issue.path;
+
+ if (
+ field === "date" ||
+ field === "startTime" ||
+ field === "endTime" ||
+ field === "workerId"
+ ) {
+ errors[field] = {
+ type: "manual",
+ message: issue.message,
+ };
+ }
+ });
+
+ return {
+ errors,
+ values: {},
+ };
+ };
+
+function createScheduleEndTimeOptions(startTime: string) {
+ return SCHEDULE_TIME_OPTIONS.filter((option) => option.value !== startTime).map((option) => ({
+ ...option,
+ label: startTime && option.value < startTime ? `${option.value} 다음날` : option.label,
+ }));
+}
+
function getStatusLabel(status: ScheduleStatus | null) {
if (status === "DRAFT") {
return "DRAFT";
@@ -202,154 +316,391 @@ function formatDateTitle(date: string) {
return `${date} ${getDayLabel(date)}`;
}
-function createRecommendedSchedules(workStartDate: string, workEndDate: string) {
- const availableDates = createDateRange(workStartDate, workEndDate);
- const denseDate = "2026-06-26";
- const timeRanges = [
- ["09:00", "13:00"],
- ["10:00", "14:00"],
- ["13:00", "17:00"],
- ["14:00", "18:00"],
- ["18:00", "22:00"],
- ];
-
- return WORKERS.map((worker, index) => {
- const [startTime, endTime] = timeRanges[index % timeRanges.length];
- const date =
- index < 17 ? denseDate : (availableDates[(index + 2) % availableDates.length] ?? workEndDate);
+function formatShortageTimeRange(shortage: ScheduleWorkerShortage) {
+ return `${shortage.startTime}-${shortage.endTime}${shortage.endsNextDay ? "+1" : ""}`;
+}
- return {
- id: `schedule-${index + 1}`,
- date,
- startTime,
- endTime,
- workerId: worker.id,
- };
- }).map((schedule) => ({
- ...schedule,
- dayLabel: getDayLabel(schedule.date),
+function assignmentToScheduleItem(assignment: ScheduleAssignment): ScheduleItem {
+ return {
+ date: assignment.workDate,
+ dayLabel: getDayLabel(assignment.workDate),
+ endTime: dateTimeToTime(assignment.workDate, assignment.endsAt),
+ id: assignment.id,
+ startTime: dateTimeToTime(assignment.workDate, assignment.startsAt),
+ workerId: assignment.workerId,
+ workerName: assignment.workerNameSnapshot,
+ };
+}
+
+function candidateToScheduleCandidateItem(candidate: ScheduleCandidate): ScheduleCandidateItem {
+ return {
+ date: candidate.workDate,
+ endTime: dateTimeToTime(candidate.workDate, candidate.endsAt),
+ id: candidate.id,
+ isRecommended: candidate.isRecommended,
+ startTime: dateTimeToTime(candidate.workDate, candidate.startsAt),
+ workerId: candidate.workerId,
+ workerName: candidate.workerNameSnapshot,
+ };
+}
+
+function shortageToUnfilledCondition(shortage: ScheduleWorkerShortage): UnfilledCondition {
+ return {
+ assignedWorkers: shortage.assignedCount,
+ date: shortage.workDate,
+ dayLabel: getDayLabel(shortage.workDate),
+ id: shortage.id,
+ requiredWorkers: shortage.requiredCount,
+ timeRange: formatShortageTimeRange(shortage),
+ };
+}
+
+function createWorkerOptions(workers: Worker[], editingSchedule: ScheduleItem | null) {
+ const options: WorkerOption[] = workers.map((worker) => ({
+ id: worker.id,
+ name: worker.name,
}));
+
+ if (
+ editingSchedule?.workerId &&
+ !options.some((option) => option.id === editingSchedule.workerId)
+ ) {
+ options.push({
+ id: editingSchedule.workerId,
+ name: editingSchedule.workerName,
+ });
+ }
+
+ return options;
}
export function MvpSchedulesPage() {
- const [scheduleStatus, setScheduleStatus] = useState(null);
- const [schedules, setSchedules] = useState([]);
- const [unfilledConditions, setUnfilledConditions] = useState([]);
+ const planningPeriodQuery = useActiveSchedulePlanningPeriodQuery();
+ const workersQuery = useWorkersQuery();
+ const staffingRulesQuery = useMinimumStaffingRulesQuery();
+ const recommendScheduleMutation = useRecommendScheduleMutation();
+ const createScheduleAssignmentMutation = useCreateScheduleAssignmentMutation();
+ const updateScheduleAssignmentMutation = useUpdateScheduleAssignmentMutation();
+ const deleteScheduleAssignmentMutation = useDeleteScheduleAssignmentMutation();
+ const confirmScheduleMutation = useConfirmScheduleMutation();
+ const activePlanningPeriod = planningPeriodQuery.data?.period ?? null;
+ const workStartDate = activePlanningPeriod?.startDate ?? "";
+ const workEndDate = activePlanningPeriod?.endDate ?? "";
+ const draftScheduleQuery: DraftScheduleQuery = useMemo(
+ () => ({
+ endDate: workEndDate,
+ startDate: workStartDate,
+ }),
+ [workEndDate, workStartDate],
+ );
+ const canQueryDraftSchedule = Boolean(workStartDate && workEndDate);
+ const draftScheduleResult = useDraftScheduleQuery(draftScheduleQuery, canQueryDraftSchedule);
+ const workers = workersQuery.data?.items ?? EMPTY_WORKERS;
+ const staffingRules = staffingRulesQuery.data?.items ?? [];
+ const schedule = draftScheduleResult.data?.schedule ?? null;
+ const scheduleStatus = schedule?.status ?? null;
+ const schedules = useMemo(
+ () => schedule?.assignments.map(assignmentToScheduleItem) ?? [],
+ [schedule],
+ );
+ const candidates = useMemo(
+ () => schedule?.candidates.map(candidateToScheduleCandidateItem) ?? [],
+ [schedule],
+ );
+ const unfilledConditions = useMemo(
+ () => schedule?.workerShortages.map(shortageToUnfilledCondition) ?? [],
+ [schedule],
+ );
+ const scheduleTargetDates = useMemo(
+ () =>
+ new Set([
+ ...schedules.map((item) => item.date),
+ ...candidates.map((item) => item.date),
+ ...unfilledConditions.map((condition) => condition.date),
+ ]),
+ [candidates, schedules, unfilledConditions],
+ );
+ const firstScheduleTargetDate = useMemo(
+ () => unfilledConditions[0]?.date ?? schedules[0]?.date ?? candidates[0]?.date ?? "",
+ [candidates, schedules, unfilledConditions],
+ );
+ const {
+ control,
+ formState: { errors },
+ handleSubmit,
+ reset,
+ setValue,
+ watch,
+ } = useForm({
+ defaultValues: EMPTY_DRAFT,
+ resolver: createScheduleFormResolver(workStartDate, workEndDate),
+ });
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState("create");
const [editingScheduleId, setEditingScheduleId] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState("");
- const [draft, setDraft] = useState(EMPTY_DRAFT);
- const [submitted, setSubmitted] = useState(false);
-
- const errors = useMemo(
- () =>
- validateScheduleDraft(draft, RECOMMENDATION_WORK_START_DATE, RECOMMENDATION_WORK_END_DATE),
- [draft],
- );
+ const [dateDetailDismissed, setDateDetailDismissed] = useState(false);
+ const [operationMessage, setOperationMessage] = useState(null);
+ const scheduleFormDate = watch("date");
+ const scheduleFormStartTime = watch("startTime");
const calendarDates = useMemo(
- () => createCalendarDates(RECOMMENDATION_WORK_START_DATE, RECOMMENDATION_WORK_END_DATE),
- [],
+ () => createCalendarDates(workStartDate, workEndDate),
+ [workEndDate, workStartDate],
);
- const hasErrors = Object.values(errors).some(Boolean);
- const canGenerate = scheduleStatus === null;
+ const editingSchedule = useMemo(
+ () => schedules.find((item) => item.id === editingScheduleId) ?? null,
+ [editingScheduleId, schedules],
+ );
+ const workerOptions = useMemo(
+ () => createWorkerOptions(workers, editingSchedule),
+ [editingSchedule, workers],
+ );
+ const isPageLoading =
+ planningPeriodQuery.isPending ||
+ staffingRulesQuery.isPending ||
+ workersQuery.isPending ||
+ (canQueryDraftSchedule && draftScheduleResult.isPending);
+ const queryError =
+ planningPeriodQuery.error ??
+ staffingRulesQuery.error ??
+ workersQuery.error ??
+ draftScheduleResult.error;
+ const queryErrorMessage = queryError
+ ? getApiErrorMessage(queryError, "스케줄 정보를 불러오지 못했습니다.")
+ : "";
const isDraft = scheduleStatus === "DRAFT";
+ const canGenerate =
+ Boolean(activePlanningPeriod) &&
+ staffingRules.length > 0 &&
+ (!schedule || isDraft) &&
+ !draftScheduleResult.isFetching &&
+ !recommendScheduleMutation.isPending &&
+ !queryErrorMessage;
+ const canConfirm =
+ Boolean(schedule) &&
+ isDraft &&
+ schedules.length > 0 &&
+ !confirmScheduleMutation.isPending &&
+ !queryErrorMessage;
const formTitle = formMode === "create" ? "스케줄 추가" : "스케줄 수정";
- const selectedDateSchedules = schedules.filter((schedule) => schedule.date === selectedDate);
+ 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 isSelectedDateScheduleTarget = scheduleTargetDates.has(selectedDate);
+ const scheduleEndTimeOptions = useMemo(
+ () => createScheduleEndTimeOptions(scheduleFormStartTime),
+ [scheduleFormStartTime],
+ );
+
+ useEffect(() => {
+ if (!schedule) {
+ setSelectedDate("");
+ setDateDetailDismissed(false);
+ setFormOpen(false);
+ setEditingScheduleId(null);
+ reset(EMPTY_DRAFT);
+ return;
+ }
+
+ if (dateDetailDismissed) {
+ return;
+ }
+
+ if (
+ selectedDate &&
+ selectedDate >= schedule.startDate &&
+ selectedDate <= schedule.endDate &&
+ scheduleTargetDates.has(selectedDate)
+ ) {
+ return;
+ }
+
+ setSelectedDate(firstScheduleTargetDate);
+ }, [dateDetailDismissed, firstScheduleTargetDate, schedule, scheduleTargetDates, selectedDate]);
function openCreateForm(date: string) {
+ if (!isDraft) {
+ return;
+ }
+
setFormMode("create");
setEditingScheduleId(null);
+ setDateDetailDismissed(false);
setSelectedDate(date);
- setDraft({ ...EMPTY_DRAFT, date });
- setSubmitted(false);
+ reset({ ...EMPTY_DRAFT, date });
+ setOperationMessage(null);
setFormOpen(true);
}
- function openEditForm(schedule: ScheduleItem) {
+ function openEditForm(scheduleItem: ScheduleItem) {
+ if (!isDraft) {
+ return;
+ }
+
setFormMode("edit");
- setEditingScheduleId(schedule.id);
- setSelectedDate(schedule.date);
- setDraft(createDraftFromSchedule(schedule));
- setSubmitted(false);
+ setEditingScheduleId(scheduleItem.id);
+ setDateDetailDismissed(false);
+ setSelectedDate(scheduleItem.date);
+ reset(createDraftFromSchedule(scheduleItem));
+ setOperationMessage(null);
setFormOpen(true);
}
function closeForm() {
setFormOpen(false);
- setSubmitted(false);
setEditingScheduleId(null);
- setDraft(EMPTY_DRAFT);
+ reset(EMPTY_DRAFT);
}
- function generateRecommendation() {
- if (!canGenerate) {
+ async function generateRecommendation() {
+ if (!canGenerate || !activePlanningPeriod) {
return;
}
- setScheduleStatus("DRAFT");
- setSchedules(
- createRecommendedSchedules(RECOMMENDATION_WORK_START_DATE, RECOMMENDATION_WORK_END_DATE),
- );
- setUnfilledConditions(INITIAL_UNFILLED_CONDITIONS);
- setSelectedDate(INITIAL_UNFILLED_CONDITIONS[0]?.date ?? RECOMMENDATION_WORK_START_DATE);
+ setOperationMessage(null);
+
+ try {
+ const response = await recommendScheduleMutation.mutateAsync({
+ endDate: activePlanningPeriod.endDate,
+ startDate: activePlanningPeriod.startDate,
+ });
+
+ setDateDetailDismissed(false);
+ setSelectedDate(response.workerShortages[0]?.workDate ?? response.startDate);
+ setOperationMessage({
+ tone: "success",
+ text: schedule ? "추천 스케줄이 다시 생성되었습니다." : "추천 스케줄이 생성되었습니다.",
+ });
+ } catch (error) {
+ setOperationMessage({
+ tone: "error",
+ text: getApiErrorMessage(error, "추천 스케줄을 생성하지 못했습니다."),
+ });
+ }
}
- function saveSchedule() {
- setSubmitted(true);
+ async function saveSchedule(values: ScheduleDraft) {
+ setOperationMessage(null);
- if (hasErrors) {
+ if (!schedule || !isDraft) {
return;
}
- const nextSchedule = {
- date: draft.date,
- dayLabel: getDayLabel(draft.date),
- startTime: draft.startTime,
- endTime: draft.endTime,
- workerId: draft.workerId,
- };
+ const request = createScheduleAssignmentInput(values);
+
+ try {
+ if (formMode === "create") {
+ await createScheduleAssignmentMutation.mutateAsync({
+ query: draftScheduleQuery,
+ request,
+ scheduleId: schedule.id,
+ });
+ } else if (editingScheduleId) {
+ await updateScheduleAssignmentMutation.mutateAsync({
+ assignmentId: editingScheduleId,
+ query: draftScheduleQuery,
+ request,
+ scheduleId: schedule.id,
+ });
+ }
- if (formMode === "create") {
- setSchedules((currentSchedules) => [
- ...currentSchedules,
- {
- id: `schedule-${Date.now()}`,
- ...nextSchedule,
- },
- ]);
- setScheduleStatus("DRAFT");
- } else if (editingScheduleId) {
- setSchedules((currentSchedules) =>
- currentSchedules.map((schedule) =>
- schedule.id === editingScheduleId ? { ...schedule, ...nextSchedule } : schedule,
- ),
- );
+ setSelectedDate(values.date);
+ setDateDetailDismissed(false);
+ closeForm();
+ setOperationMessage({
+ tone: "success",
+ text: "스케줄이 저장되었습니다.",
+ });
+ } catch (error) {
+ setOperationMessage({
+ tone: "error",
+ text: getApiErrorMessage(error, "스케줄을 저장하지 못했습니다."),
+ });
+ }
+ }
+
+ async function deleteSchedule() {
+ if (!schedule || !deleteTarget || deleteScheduleAssignmentMutation.isPending) {
+ return;
}
- closeForm();
+ setOperationMessage(null);
+
+ try {
+ await deleteScheduleAssignmentMutation.mutateAsync({
+ assignmentId: deleteTarget.id,
+ query: draftScheduleQuery,
+ scheduleId: schedule.id,
+ });
+
+ setDeleteTarget(null);
+ closeForm();
+ setOperationMessage({
+ tone: "success",
+ text: "스케줄이 삭제되었습니다.",
+ });
+ } catch (error) {
+ setOperationMessage({
+ tone: "error",
+ text: getApiErrorMessage(error, "스케줄을 삭제하지 못했습니다."),
+ });
+ }
+ }
+
+ async function confirmCurrentSchedule() {
+ if (!schedule || !canConfirm) {
+ return;
+ }
+
+ setOperationMessage(null);
+
+ try {
+ await confirmScheduleMutation.mutateAsync({
+ query: draftScheduleQuery,
+ scheduleId: schedule.id,
+ });
+
+ setDateDetailDismissed(false);
+ setSelectedDate("");
+ closeForm();
+ setConfirmOpen(false);
+ setOperationMessage({
+ tone: "success",
+ text: "스케줄이 확정되었습니다.",
+ });
+ } catch (error) {
+ setOperationMessage({
+ tone: "error",
+ text: getApiErrorMessage(error, "스케줄을 확정하지 못했습니다."),
+ });
+ }
}
return (
- {scheduleStatus === null ? (
+ {scheduleStatus === null || isDraft ? (
{
+ void generateRecommendation();
+ }}
disabled={!canGenerate}
>
- 추천 생성
+ {recommendScheduleMutation.isPending
+ ? "생성 중"
+ : schedule
+ ? "추천 다시 생성"
+ : "추천 생성"}
) : null}
{isDraft ? (
@@ -357,9 +708,9 @@ export function MvpSchedulesPage() {
type="button"
variant="brand"
onClick={() => setConfirmOpen(true)}
- disabled={schedules.length === 0}
+ disabled={!canConfirm}
>
- 스케줄 확정
+ {confirmScheduleMutation.isPending ? "확정 중" : "스케줄 확정"}
) : null}
@@ -367,26 +718,69 @@ export function MvpSchedulesPage() {
containerClassName="max-w-none"
contentClassName="space-y-6"
>
- {scheduleStatus === null ? (
+ {isPageLoading ? (
+
+ 스케줄 정보를 불러오는 중입니다.
+
+ ) : null}
+
+ {queryErrorMessage ? (
+
+ {queryErrorMessage}
+
+ ) : null}
+
+ {operationMessage ? (
+
+ {operationMessage.text}
+
+ ) : null}
+
+ {!activePlanningPeriod && !planningPeriodQuery.isPending ? (
+
+
+
+
근무 기간 없음
+
+ 가능 시간 관리에서 근무 기간을 먼저 설정하면 추천 스케줄을 생성할 수 있습니다.
+
+
+
{getStatusLabel(null)}
+
+
+ ) : null}
+
+ {activePlanningPeriod && !schedule && !draftScheduleResult.isPending ? (
추천 결과 없음
- 가능 시간 관리에서 설정한 {RECOMMENDATION_WORK_START_DATE}~
- {RECOMMENDATION_WORK_END_DATE} 기간을 기준으로 추천 결과를 생성합니다.
+ 가능 시간 관리에서 설정한 {workStartDate}~{workEndDate} 기간을 기준으로 추천 결과를
+ 생성합니다.
- 동일 입력 조건에서는 중복 생성하지 않습니다. 근무자, 가능 시간, 최소 인원 조건, 근무
- 기간 중 하나가 변경되면 다시 추천 생성할 수 있습니다.
+ 동일 입력 조건에서는 중복 생성하지 않습니다. 최소 인원 조건이 있는 시간대만 추천
+ 대상으로 사용합니다.
+ {staffingRules.length === 0 ? (
+
+ 최소 인원 조건을 먼저 등록해야 추천 스케줄을 생성할 수 있습니다.
+
+ ) : null}
{getStatusLabel(scheduleStatus)}
) : null}
- {unfilledConditions.length > 0 && scheduleStatus === "DRAFT" ? (
+ {unfilledConditions.length > 0 && isDraft ? (
미충족 조건
@@ -398,7 +792,7 @@ export function MvpSchedulesPage() {
요일
시간대
필요 인원
-
배정 인원
+
추천 배정
@@ -420,7 +814,7 @@ export function MvpSchedulesPage() {
) : null}
- {scheduleStatus !== null ? (
+ {schedule ? (
@@ -430,10 +824,10 @@ export function MvpSchedulesPage() {
근무 기간 달력
- {RECOMMENDATION_WORK_START_DATE}~{RECOMMENDATION_WORK_END_DATE} 추천 결과를
- 달력에서 검토하고 직접 조정합니다.
+ {workStartDate}~{workEndDate} 추천 결과를 달력에서 검토하고 직접 조정합니다.
+ {getStatusLabel(scheduleStatus)}
@@ -450,10 +844,10 @@ export function MvpSchedulesPage() {
))}
{calendarDates.map((date) => {
- const inWorkRange =
- date >= RECOMMENDATION_WORK_START_DATE && date <= RECOMMENDATION_WORK_END_DATE;
- const dateSchedules = schedules.filter((schedule) => schedule.date === date);
+ const inWorkRange = date >= workStartDate && date <= workEndDate;
+ const dateSchedules = schedules.filter((item) => item.date === date);
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;
@@ -462,22 +856,25 @@ export function MvpSchedulesPage() {
{
+ setDateDetailDismissed(false);
setSelectedDate(date);
closeForm();
}}
className={
inWorkRange
? [
- "min-h-40 border-b border-r p-3 text-left transition-colors",
- selected
- ? "border-primary bg-accent"
- : unfilled
- ? "border-destructive bg-destructive/5 hover:bg-destructive/10"
- : "border-border bg-card hover:bg-accent/60",
+ "flex min-h-40 flex-col items-stretch border-b border-r p-3 text-left transition-colors",
+ !hasScheduleTarget
+ ? "cursor-not-allowed border-border bg-surface-secondary opacity-45"
+ : selected
+ ? "border-primary bg-accent"
+ : unfilled
+ ? "border-destructive bg-destructive/5 hover:bg-destructive/10"
+ : "border-border bg-card hover:bg-accent/60",
].join(" ")
- : "min-h-40 cursor-not-allowed border-b border-r border-border bg-surface-secondary p-3 text-left opacity-45"
+ : "flex min-h-40 cursor-not-allowed flex-col items-stretch border-b border-r border-border bg-surface-secondary p-3 text-left opacity-45"
}
>
@@ -490,31 +887,26 @@ export function MvpSchedulesPage() {
>
미충족
- ) : !inWorkRange ? (
- 범위 밖
) : null}
-
- {dateSchedules.length}명 배정
-
+ {inWorkRange && hasScheduleTarget ? (
+
+ 추천 {dateSchedules.length}건
+
+ ) : null}
- {visibleSchedules.length > 0 ? (
- visibleSchedules.map((schedule) => (
-
- {schedule.startTime}-{schedule.endTime}{" "}
- {getWorkerName(schedule.workerId)}
-
- ))
- ) : !inWorkRange ? null : (
-
- 배정 없음
-
- )}
+ {visibleSchedules.length > 0
+ ? visibleSchedules.map((item) => (
+
+ {item.startTime}-{item.endTime} {item.workerName}
+
+ ))
+ : null}
{hiddenScheduleCount > 0 ? (
+{hiddenScheduleCount}개 더보기
@@ -537,7 +929,7 @@ export function MvpSchedulesPage() {
) : null}
- {selectedDate && scheduleStatus !== null ? (
+ {selectedDate && schedule && isSelectedDateScheduleTarget ? (
@@ -547,7 +939,7 @@ export function MvpSchedulesPage() {
{formatDateTitle(selectedDate)}
- {selectedDateSchedules.length}명 배정
+ 추천 {selectedDateSchedules.length}건
{
+ setDateDetailDismissed(true);
setSelectedDate("");
closeForm();
}}
@@ -571,7 +964,7 @@ export function MvpSchedulesPage() {
{selectedDateUnfilledConditions.map((condition) => (
- {condition.timeRange} · 필요 {condition.requiredWorkers}명 / 배정{" "}
+ {condition.timeRange} · 필요 {condition.requiredWorkers}명 / 추천{" "}
{condition.assignedWorkers}명
))}
@@ -580,7 +973,7 @@ export function MvpSchedulesPage() {
) : null}
-
배정 목록
+
가능 후보 목록
{isDraft ? (
+
+ {selectedDateCandidates.length > 0 ? (
+ selectedDateCandidates.map((candidate) => (
+
+
+
+ {candidate.startTime}-{candidate.endTime}
+
+
+ {candidate.workerName}
+
+
+ {candidate.isRecommended ? (
+
+
+ 추천
+
+ ) : (
+
+ 후보
+
+ )}
+
+ ))
+ ) : (
+
+ 이 날짜에 가능 후보가 없습니다.
+
+ )}
+
+
{formOpen ? (
-
+
{formTitle}
- {formatDateTitle(draft.date)}
+ {scheduleFormDate ? formatDateTitle(scheduleFormDate) : "날짜 미선택"}
시작 시간
-
- setDraft((current) => ({ ...current, startTime: event.target.value }))
- }
+
(
+ {
+ field.onChange(value);
+
+ if (watch("endTime") === value) {
+ setValue("endTime", "");
+ }
+ }}
+ >
+
+
+
+
+ {SCHEDULE_TIME_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
/>
- {submitted && errors.startTime ? (
- {errors.startTime}
+ {errors.startTime ? (
+ {errors.startTime.message}
) : null}
종료 시간
-
- setDraft((current) => ({ ...current, endTime: event.target.value }))
- }
+
(
+
+
+
+
+
+ {scheduleEndTimeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
/>
- {submitted && errors.endTime ? (
- {errors.endTime}
+ {errors.endTime ? (
+ {errors.endTime.message}
) : null}
근무자
-
- setDraft((current) => ({ ...current, workerId: value }))
- }
- >
-
-
-
-
- {WORKERS.map((worker) => (
-
- {worker.name}
-
- ))}
-
-
- {submitted && errors.workerId ? (
-
{errors.workerId}
+
(
+
+
+
+
+
+ {workerOptions.map((worker) => (
+
+ {worker.name}
+
+ ))}
+
+
+ )}
+ />
+ {errors.workerId ? (
+ {errors.workerId.message}
) : null}
@@ -679,57 +1144,68 @@ export function MvpSchedulesPage() {
취소
-
- 저장
+
+ {createScheduleAssignmentMutation.isPending ||
+ updateScheduleAssignmentMutation.isPending
+ ? "저장 중"
+ : "저장"}
) : null}
-
- {selectedDateSchedules.length > 0 ? (
- selectedDateSchedules.map((schedule) => (
-
-
-
- {schedule.startTime}-{schedule.endTime}
-
-
- {getWorkerName(schedule.workerId)}
-
-
- {isDraft ? (
-
-
openEditForm(schedule)}
- >
-
-
-
setDeleteTarget(schedule)}
- >
-
-
+
+
추천 배정 목록
+
+
+
+ {selectedDateSchedules.length > 0
+ ? selectedDateSchedules.map((item) => (
+
+
+
+ {item.startTime}-{item.endTime}
+
+
+ {item.workerName}
+
- ) : null}
-
- ))
- ) : (
-
- 이 날짜에 배정된 스케줄이 없습니다.
-
- )}
+ {isDraft ? (
+
+
openEditForm(item)}
+ >
+
+
+
setDeleteTarget(item)}
+ >
+
+
+
+ ) : null}
+
+ ))
+ : null}
@@ -743,24 +1219,20 @@ export function MvpSchedulesPage() {
스케줄 삭제
- 이 스케줄을 삭제하면 해당 근무 배정이 목록에서 제거됩니다.
+ 이 스케줄을 삭제하면 해당 근무 후보가 목록에서 제거됩니다.
- 취소
+
+ 취소
+
{
- if (!deleteTarget) {
- return;
- }
-
- setSchedules((currentSchedules) =>
- currentSchedules.filter((schedule) => schedule.id !== deleteTarget.id),
- );
- setDeleteTarget(null);
+ void deleteSchedule();
}}
+ disabled={deleteScheduleAssignmentMutation.isPending}
>
- 삭제
+ {deleteScheduleAssignmentMutation.isPending ? "삭제 중" : "삭제"}
@@ -771,22 +1243,18 @@ export function MvpSchedulesPage() {
스케줄 확정
- 확정 후에는 이 근무 기간의 스케줄을 최종본으로 관리합니다.
+ 확정 후에는 이 근무 기간의 DRAFT 스케줄을 더 이상 수정할 수 없습니다.
- 취소
+ 취소
{
- setScheduleStatus(null);
- setSchedules([]);
- setUnfilledConditions([]);
- setSelectedDate("");
- closeForm();
- setConfirmOpen(false);
+ void confirmCurrentSchedule();
}}
+ disabled={!canConfirm}
>
- 확정
+ {confirmScheduleMutation.isPending ? "확정 중" : "확정"}
diff --git a/apps/web/src/features/schedules/queries/schedules-queries.ts b/apps/web/src/features/schedules/queries/schedules-queries.ts
new file mode 100644
index 0000000..34f3c84
--- /dev/null
+++ b/apps/web/src/features/schedules/queries/schedules-queries.ts
@@ -0,0 +1,129 @@
+"use client";
+
+import type {
+ DraftScheduleQuery,
+ DraftScheduleResponse,
+ RecommendScheduleRequest,
+ ScheduleAssignmentInput,
+ UpdateScheduleAssignmentRequest,
+} from "@fragment/shared";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ confirmSchedule,
+ createScheduleAssignment,
+ deleteScheduleAssignment,
+ getDraftSchedule,
+ recommendSchedule,
+ updateScheduleAssignment,
+} from "@/features/schedules/api/schedules-api";
+import { schedulesQueryKeys } from "./schedules-query-keys";
+
+export { schedulesQueryKeys } from "./schedules-query-keys";
+
+type ScheduleMutationContext = {
+ query: DraftScheduleQuery;
+};
+
+type CreateScheduleAssignmentVariables = ScheduleMutationContext & {
+ request: ScheduleAssignmentInput;
+ scheduleId: string;
+};
+
+type UpdateScheduleAssignmentVariables = ScheduleMutationContext & {
+ assignmentId: string;
+ request: UpdateScheduleAssignmentRequest;
+ scheduleId: string;
+};
+
+type DeleteScheduleAssignmentVariables = ScheduleMutationContext & {
+ assignmentId: string;
+ scheduleId: string;
+};
+
+type ConfirmScheduleVariables = ScheduleMutationContext & {
+ scheduleId: string;
+};
+
+function useInvalidateDraftSchedule() {
+ const queryClient = useQueryClient();
+
+ return (query: DraftScheduleQuery) =>
+ queryClient.invalidateQueries({
+ queryKey: schedulesQueryKeys.draft(query),
+ });
+}
+
+export function useDraftScheduleQuery(query: DraftScheduleQuery, enabled = true) {
+ return useQuery({
+ enabled,
+ queryFn: () => getDraftSchedule(query),
+ queryKey: schedulesQueryKeys.draft(query),
+ });
+}
+
+export function useRecommendScheduleMutation() {
+ const invalidateDraftSchedule = useInvalidateDraftSchedule();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (request: RecommendScheduleRequest) => recommendSchedule(request),
+ onSuccess: (data, variables) => {
+ queryClient.setQueryData
(schedulesQueryKeys.draft(variables), {
+ schedule: data,
+ });
+ void invalidateDraftSchedule(variables);
+ },
+ });
+}
+
+export function useCreateScheduleAssignmentMutation() {
+ const invalidateDraftSchedule = useInvalidateDraftSchedule();
+
+ return useMutation({
+ mutationFn: ({ request, scheduleId }: CreateScheduleAssignmentVariables) =>
+ createScheduleAssignment(scheduleId, request),
+ onSuccess: (_data, variables) => {
+ void invalidateDraftSchedule(variables.query);
+ },
+ });
+}
+
+export function useUpdateScheduleAssignmentMutation() {
+ const invalidateDraftSchedule = useInvalidateDraftSchedule();
+
+ return useMutation({
+ mutationFn: ({ assignmentId, request, scheduleId }: UpdateScheduleAssignmentVariables) =>
+ updateScheduleAssignment(scheduleId, assignmentId, request),
+ onSuccess: (_data, variables) => {
+ void invalidateDraftSchedule(variables.query);
+ },
+ });
+}
+
+export function useDeleteScheduleAssignmentMutation() {
+ const invalidateDraftSchedule = useInvalidateDraftSchedule();
+
+ return useMutation({
+ mutationFn: ({ assignmentId, scheduleId }: DeleteScheduleAssignmentVariables) =>
+ deleteScheduleAssignment(scheduleId, assignmentId),
+ onSuccess: (_data, variables) => {
+ void invalidateDraftSchedule(variables.query);
+ },
+ });
+}
+
+export function useConfirmScheduleMutation() {
+ const invalidateDraftSchedule = useInvalidateDraftSchedule();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ scheduleId }: ConfirmScheduleVariables) => confirmSchedule(scheduleId),
+ onSuccess: (_data, variables) => {
+ queryClient.setQueryData(schedulesQueryKeys.draft(variables.query), {
+ schedule: null,
+ });
+ void invalidateDraftSchedule(variables.query);
+ },
+ });
+}
diff --git a/apps/web/src/features/schedules/queries/schedules-query-keys.ts b/apps/web/src/features/schedules/queries/schedules-query-keys.ts
new file mode 100644
index 0000000..558559c
--- /dev/null
+++ b/apps/web/src/features/schedules/queries/schedules-query-keys.ts
@@ -0,0 +1,6 @@
+import type { DraftScheduleQuery } from "@fragment/shared";
+
+export const schedulesQueryKeys = {
+ all: ["schedules"] as const,
+ draft: (query: DraftScheduleQuery) => [...schedulesQueryKeys.all, "draft", query] as const,
+};
diff --git a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx
index e9663de..b08f0ff 100644
--- a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx
+++ b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx
@@ -4,10 +4,11 @@ import {
createMinimumStaffingRuleRequestSchema,
type CreateMinimumStaffingRuleRequest,
type MinimumStaffingRule,
+ type OrganizationDetail,
} from "@fragment/shared";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { Button } from "@moyeorak/design-system";
-import { Plus } from "lucide-react";
+import { Plus, Trash2 } from "lucide-react";
import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form";
import { AdminTableSection } from "@/components/admin/admin-table-section";
@@ -15,6 +16,7 @@ import { CrudFormDialog } from "@/components/admin/crud-form-dialog";
import { DeleteConfirmDialog } from "@/components/common/delete-confirm-dialog";
import { RowActions } from "@/components/common/row-actions";
import { AdminPageShell } from "@/components/layout/admin-page-shell";
+import { useOrganizationQuery } from "@/features/organization/queries/organization-queries";
import {
useCreateMinimumStaffingRuleMutation,
useDeleteMinimumStaffingRuleMutation,
@@ -24,6 +26,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getApiErrorMessage } from "@/lib/api-error-message";
+import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
@@ -33,6 +36,7 @@ import {
} from "@/components/ui/select";
type DayOfWeek = MinimumStaffingRule["dayOfWeek"];
+type BusinessHour = OrganizationDetail["businessHours"][number];
type StaffingRuleFormValues = {
dayOfWeek: DayOfWeek | "";
@@ -41,6 +45,23 @@ type StaffingRuleFormValues = {
requiredCount: string;
};
+type BatchStaffingRuleFormValues = {
+ dayOfWeeks: DayOfWeek[];
+ startTime: string;
+ endTime: string;
+ requiredCount: string;
+};
+
+type DraftStaffingRule = CreateMinimumStaffingRuleRequest & {
+ id: string;
+};
+
+type DraftStaffingRuleGroup = {
+ dayOfWeek: DayOfWeek;
+ id: string;
+ rules: DraftStaffingRule[];
+};
+
const DAY_LABELS: Record = {
MON: "월요일",
TUE: "화요일",
@@ -51,6 +72,8 @@ const DAY_LABELS: Record = {
SUN: "일요일",
};
+const DAY_ORDER: DayOfWeek[] = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"];
+
const EMPTY_FORM_VALUES: StaffingRuleFormValues = {
dayOfWeek: "",
startTime: "",
@@ -58,6 +81,16 @@ const EMPTY_FORM_VALUES: StaffingRuleFormValues = {
requiredCount: "",
};
+const EMPTY_BATCH_FORM_VALUES: BatchStaffingRuleFormValues = {
+ dayOfWeeks: [],
+ startTime: "",
+ endTime: "",
+ requiredCount: "",
+};
+
+const MINUTES_IN_DAY = 24 * 60;
+const TIME_OPTION_STEP_MINUTES = 30;
+
function createFormValuesFromRule(rule: MinimumStaffingRule): StaffingRuleFormValues {
return {
dayOfWeek: rule.dayOfWeek,
@@ -67,6 +100,253 @@ function createFormValuesFromRule(rule: MinimumStaffingRule): StaffingRuleFormVa
};
}
+function parseTimeToMinutes(time: string) {
+ const [hour, minute] = time.split(":").map(Number);
+
+ return hour * 60 + minute;
+}
+
+function minutesToTime(minutes: number) {
+ const minutesInDay = minutes % MINUTES_IN_DAY;
+ const hour = Math.floor(minutesInDay / 60);
+ const minute = minutesInDay % 60;
+
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
+}
+
+function getBusinessHourForDay(businessHours: BusinessHour[], dayOfWeek: DayOfWeek | "") {
+ if (!dayOfWeek) {
+ return undefined;
+ }
+
+ return businessHours.find((businessHour) => businessHour.dayOfWeek === dayOfWeek);
+}
+
+function isSelectableBusinessHour(businessHour: BusinessHour | undefined) {
+ return Boolean(
+ businessHour && !businessHour.isClosed && businessHour.openTime && businessHour.closeTime,
+ );
+}
+
+function getBusinessHourEndMinutes(businessHour: BusinessHour) {
+ const closeMinutes = parseTimeToMinutes(businessHour.closeTime ?? "00:00");
+
+ return businessHour.closesNextDay ? closeMinutes + MINUTES_IN_DAY : closeMinutes;
+}
+
+function getTimeMinutesWithinRange(time: string, rangeStartMinutes: number) {
+ const rawMinutes = parseTimeToMinutes(time);
+
+ return rawMinutes < rangeStartMinutes ? rawMinutes + MINUTES_IN_DAY : rawMinutes;
+}
+
+function createTimeOptions(
+ businessHour: BusinessHour | undefined,
+ boundary: "start" | "end",
+ selectedStartTime?: string,
+): { label: string; value: string }[] {
+ if (!isSelectableBusinessHour(businessHour) || !businessHour?.openTime) {
+ return [];
+ }
+
+ const startMinutes = parseTimeToMinutes(businessHour.openTime);
+ const endMinutes = getBusinessHourEndMinutes(businessHour);
+ const selectedStartMinutes = selectedStartTime
+ ? getTimeMinutesWithinRange(selectedStartTime, startMinutes)
+ : null;
+ const firstMinutes =
+ boundary === "start"
+ ? startMinutes
+ : Math.max(
+ startMinutes + TIME_OPTION_STEP_MINUTES,
+ selectedStartMinutes !== null
+ ? selectedStartMinutes + TIME_OPTION_STEP_MINUTES
+ : startMinutes + TIME_OPTION_STEP_MINUTES,
+ );
+ const lastMinutes =
+ boundary === "start"
+ ? Math.min(endMinutes - TIME_OPTION_STEP_MINUTES, MINUTES_IN_DAY - TIME_OPTION_STEP_MINUTES)
+ : endMinutes;
+ const options: { label: string; value: string }[] = [];
+
+ for (let minutes = firstMinutes; minutes <= lastMinutes; minutes += TIME_OPTION_STEP_MINUTES) {
+ const value = minutesToTime(minutes);
+ const label = minutes >= MINUTES_IN_DAY ? `${value} 다음날` : value;
+
+ options.push({ label, value });
+ }
+
+ return options;
+}
+
+function createTimeOptionsFromRange(
+ range: { endMinutes: number; startMinutes: number } | null,
+ boundary: "start" | "end",
+ selectedStartTime?: string,
+): { label: string; value: string }[] {
+ if (!range) {
+ return [];
+ }
+
+ const selectedStartMinutes = selectedStartTime
+ ? getTimeMinutesWithinRange(selectedStartTime, range.startMinutes)
+ : null;
+ const firstMinutes =
+ boundary === "start"
+ ? range.startMinutes
+ : Math.max(
+ range.startMinutes + TIME_OPTION_STEP_MINUTES,
+ selectedStartMinutes !== null
+ ? selectedStartMinutes + TIME_OPTION_STEP_MINUTES
+ : range.startMinutes + TIME_OPTION_STEP_MINUTES,
+ );
+ const lastMinutes =
+ boundary === "start"
+ ? Math.min(
+ range.endMinutes - TIME_OPTION_STEP_MINUTES,
+ MINUTES_IN_DAY - TIME_OPTION_STEP_MINUTES,
+ )
+ : range.endMinutes;
+ const options: { label: string; value: string }[] = [];
+
+ for (let minutes = firstMinutes; minutes <= lastMinutes; minutes += TIME_OPTION_STEP_MINUTES) {
+ const value = minutesToTime(minutes);
+ const label = minutes >= MINUTES_IN_DAY ? `${value} 다음날` : value;
+
+ options.push({ label, value });
+ }
+
+ return options;
+}
+
+function getCommonBusinessHourRange(businessHours: BusinessHour[], dayOfWeeks: DayOfWeek[]) {
+ if (dayOfWeeks.length === 0) {
+ return null;
+ }
+
+ const ranges = dayOfWeeks.flatMap((dayOfWeek) => {
+ const businessHour = getBusinessHourForDay(businessHours, dayOfWeek);
+
+ if (!isSelectableBusinessHour(businessHour) || !businessHour?.openTime) {
+ return [];
+ }
+
+ return [
+ {
+ endMinutes: getBusinessHourEndMinutes(businessHour),
+ startMinutes: parseTimeToMinutes(businessHour.openTime),
+ },
+ ];
+ });
+
+ if (ranges.length !== dayOfWeeks.length) {
+ return null;
+ }
+
+ const startMinutes = Math.max(...ranges.map((range) => range.startMinutes));
+ const endMinutes = Math.min(...ranges.map((range) => range.endMinutes));
+
+ if (startMinutes >= endMinutes) {
+ return null;
+ }
+
+ return {
+ endMinutes,
+ startMinutes,
+ };
+}
+
+function getBusinessHourLabel(businessHour: BusinessHour | undefined) {
+ if (!businessHour) {
+ return "등록된 운영시간이 없습니다.";
+ }
+
+ if (!isSelectableBusinessHour(businessHour)) {
+ return "휴무일입니다.";
+ }
+
+ return `운영시간: ${businessHour.openTime}-${businessHour.closeTime}${
+ businessHour.closesNextDay ? " 다음날" : ""
+ }`;
+}
+
+function getBatchBusinessHourLabel(businessHours: BusinessHour[], dayOfWeeks: DayOfWeek[]) {
+ if (dayOfWeeks.length === 0) {
+ return "요일을 선택하면 선택한 요일들의 공통 운영시간 안에서 시간을 고를 수 있습니다.";
+ }
+
+ const commonRange = getCommonBusinessHourRange(businessHours, dayOfWeeks);
+
+ if (!commonRange) {
+ return "선택한 요일 사이에 공통으로 입력 가능한 운영시간이 없습니다.";
+ }
+
+ return `공통 입력 가능 시간: ${minutesToTime(commonRange.startMinutes)}-${minutesToTime(
+ commonRange.endMinutes,
+ )}${commonRange.endMinutes >= MINUTES_IN_DAY ? " 다음날" : ""}`;
+}
+
+function validateRuleBusinessHours(values: StaffingRuleFormValues, businessHours: BusinessHour[]) {
+ if (!values.dayOfWeek || !values.startTime || !values.endTime) {
+ return null;
+ }
+
+ const businessHour = getBusinessHourForDay(businessHours, values.dayOfWeek);
+
+ if (!isSelectableBusinessHour(businessHour) || !businessHour?.openTime) {
+ return {
+ field: "dayOfWeek" as const,
+ message: "휴무일에는 최소 인원 조건을 등록할 수 없습니다.",
+ };
+ }
+
+ const businessStartMinutes = parseTimeToMinutes(businessHour.openTime);
+ const businessEndMinutes = getBusinessHourEndMinutes(businessHour);
+ const ruleStartMinutes = parseTimeToMinutes(values.startTime);
+ const rawRuleEndMinutes = parseTimeToMinutes(values.endTime);
+ const ruleEndMinutes =
+ values.endTime < values.startTime ? rawRuleEndMinutes + MINUTES_IN_DAY : rawRuleEndMinutes;
+
+ if (ruleStartMinutes < businessStartMinutes || ruleEndMinutes > businessEndMinutes) {
+ return {
+ field: "startTime" as const,
+ message: "운영시간 안에서만 최소 인원 조건을 등록할 수 있습니다.",
+ };
+ }
+
+ return null;
+}
+
+function validateBatchRuleBusinessHours(
+ values: BatchStaffingRuleFormValues,
+ businessHours: BusinessHour[],
+) {
+ if (values.dayOfWeeks.length === 0 || !values.startTime || !values.endTime) {
+ return null;
+ }
+
+ for (const dayOfWeek of values.dayOfWeeks) {
+ const policyError = validateRuleBusinessHours(
+ {
+ dayOfWeek,
+ endTime: values.endTime,
+ requiredCount: values.requiredCount,
+ startTime: values.startTime,
+ },
+ businessHours,
+ );
+
+ if (policyError) {
+ return {
+ field: policyError.field === "dayOfWeek" ? ("dayOfWeeks" as const) : policyError.field,
+ message: `${DAY_LABELS[dayOfWeek]}: ${policyError.message}`,
+ };
+ }
+ }
+
+ return null;
+}
+
function createMinimumStaffingRuleRequestFromForm(
values: StaffingRuleFormValues,
): CreateMinimumStaffingRuleRequest {
@@ -79,6 +359,73 @@ function createMinimumStaffingRuleRequestFromForm(
};
}
+function createMinimumStaffingRuleRequestsFromBatchForm(
+ values: BatchStaffingRuleFormValues,
+): CreateMinimumStaffingRuleRequest[] {
+ return values.dayOfWeeks.map((dayOfWeek) => ({
+ dayOfWeek,
+ startTime: values.startTime,
+ endTime: values.endTime,
+ endsNextDay: values.endTime < values.startTime,
+ requiredCount: Number(values.requiredCount),
+ }));
+}
+
+function createDraftRuleId(rule: CreateMinimumStaffingRuleRequest) {
+ return `${rule.dayOfWeek}:${rule.startTime}:${rule.endTime}:${rule.requiredCount}`;
+}
+
+function hasSameDraftRule(
+ firstRule: CreateMinimumStaffingRuleRequest,
+ secondRule: CreateMinimumStaffingRuleRequest,
+) {
+ return (
+ firstRule.dayOfWeek === secondRule.dayOfWeek &&
+ firstRule.startTime === secondRule.startTime &&
+ firstRule.endTime === secondRule.endTime &&
+ firstRule.requiredCount === secondRule.requiredCount
+ );
+}
+
+function groupDraftRulesByDay(rules: DraftStaffingRule[]): DraftStaffingRuleGroup[] {
+ const groups = new Map();
+
+ rules.forEach((rule) => {
+ const group = groups.get(rule.dayOfWeek);
+
+ if (group) {
+ group.rules.push(rule);
+ return;
+ }
+
+ groups.set(rule.dayOfWeek, {
+ dayOfWeek: rule.dayOfWeek,
+ id: rule.dayOfWeek,
+ rules: [rule],
+ });
+ });
+
+ return Array.from(groups.values())
+ .sort(
+ (firstGroup, secondGroup) =>
+ DAY_ORDER.indexOf(firstGroup.dayOfWeek) - DAY_ORDER.indexOf(secondGroup.dayOfWeek),
+ )
+ .map((group) => ({
+ ...group,
+ rules: [...group.rules].sort((firstRule, secondRule) => {
+ if (firstRule.startTime !== secondRule.startTime) {
+ return firstRule.startTime.localeCompare(secondRule.startTime);
+ }
+
+ if (firstRule.endTime !== secondRule.endTime) {
+ return firstRule.endTime.localeCompare(secondRule.endTime);
+ }
+
+ return firstRule.requiredCount - secondRule.requiredCount;
+ }),
+ }));
+}
+
const staffingRuleFormResolver: Resolver = (values) => {
const request = createMinimumStaffingRuleRequestFromForm(values);
const result = createMinimumStaffingRuleRequestSchema.safeParse(request);
@@ -133,30 +480,151 @@ const staffingRuleFormResolver: Resolver = (values) => {
};
};
+const createBatchStaffingRuleFormResolver =
+ (businessHours: BusinessHour[]): Resolver =>
+ (values) => {
+ const errors: FieldErrors = {};
+
+ if (values.dayOfWeeks.length === 0) {
+ errors.dayOfWeeks = {
+ type: "manual",
+ message: "요일을 하나 이상 선택하세요.",
+ };
+ }
+
+ const requests = createMinimumStaffingRuleRequestsFromBatchForm(values);
+ const result =
+ requests.length > 0
+ ? createMinimumStaffingRuleRequestSchema.safeParse(requests[0])
+ : createMinimumStaffingRuleRequestSchema.safeParse({
+ dayOfWeek: "MON",
+ endTime: values.endTime,
+ endsNextDay: values.endTime < values.startTime,
+ requiredCount: Number(values.requiredCount),
+ startTime: values.startTime,
+ });
+
+ if (!result.success) {
+ result.error.issues.forEach((issue) => {
+ const [field] = issue.path;
+
+ if (field === "startTime") {
+ errors.startTime = {
+ type: "manual",
+ message: values.startTime ? issue.message : "시작 시간을 선택하세요.",
+ };
+ return;
+ }
+
+ if (field === "endTime") {
+ errors.endTime = {
+ type: "manual",
+ message: values.endTime ? issue.message : "종료 시간을 선택하세요.",
+ };
+ return;
+ }
+
+ if (field === "requiredCount") {
+ errors.requiredCount = {
+ type: "manual",
+ message: "최소 인원은 1 이상의 숫자로 입력하세요.",
+ };
+ }
+ });
+ }
+
+ const policyError = validateBatchRuleBusinessHours(values, businessHours);
+
+ if (policyError) {
+ errors[policyError.field] = {
+ type: "manual",
+ message: policyError.message,
+ };
+ }
+
+ if (Object.keys(errors).length === 0) {
+ return {
+ errors: {},
+ values,
+ };
+ }
+
+ return {
+ errors,
+ values: {},
+ };
+ };
+
export function MvpStaffingRulesPage() {
+ const organizationQuery = useOrganizationQuery();
const staffingRulesQuery = useMinimumStaffingRulesQuery();
const createMinimumStaffingRuleMutation = useCreateMinimumStaffingRuleMutation();
const updateMinimumStaffingRuleMutation = useUpdateMinimumStaffingRuleMutation();
const deleteMinimumStaffingRuleMutation = useDeleteMinimumStaffingRuleMutation();
+ const businessHours = useMemo(
+ () => organizationQuery.data?.businessHours ?? [],
+ [organizationQuery.data?.businessHours],
+ );
const {
- control,
- formState: { errors },
- handleSubmit,
- register,
- reset,
+ control: editControl,
+ formState: { errors: editErrors },
+ handleSubmit: handleEditSubmit,
+ register: registerEdit,
+ reset: resetEditForm,
+ setError: setEditError,
+ setValue: setEditValue,
+ watch: watchEditForm,
} = useForm({
defaultValues: EMPTY_FORM_VALUES,
resolver: staffingRuleFormResolver,
});
+ const {
+ control: batchControl,
+ formState: { errors: batchErrors },
+ getValues: getBatchValues,
+ handleSubmit: handleBatchSubmit,
+ register: registerBatch,
+ reset: resetBatchForm,
+ setError: setBatchError,
+ setValue: setBatchValue,
+ watch: watchBatchForm,
+ } = useForm({
+ defaultValues: EMPTY_BATCH_FORM_VALUES,
+ resolver: createBatchStaffingRuleFormResolver(businessHours),
+ });
const [formOpen, setFormOpen] = useState(false);
const [editingRuleId, setEditingRuleId] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
- const [formError, setFormError] = useState("");
+ const [draftRules, setDraftRules] = useState([]);
+ const [batchFormError, setBatchFormError] = useState("");
+ const [editFormError, setEditFormError] = useState("");
const [deleteError, setDeleteError] = useState("");
const rules = staffingRulesQuery.data?.items ?? [];
- const isEditing = editingRuleId !== null;
- const formTitle = isEditing ? "조건 수정" : "조건 추가";
+ const selectedDayOfWeek = watchEditForm("dayOfWeek");
+ const selectedBusinessHour = getBusinessHourForDay(businessHours, selectedDayOfWeek);
+ const selectedBusinessHourLabel = getBusinessHourLabel(selectedBusinessHour);
+ const selectedStartTime = watchEditForm("startTime");
+ const startTimeOptions = createTimeOptions(selectedBusinessHour, "start");
+ const endTimeOptions = createTimeOptions(selectedBusinessHour, "end", selectedStartTime);
+ const groupedDraftRules = useMemo(() => groupDraftRulesByDay(draftRules), [draftRules]);
+ const selectedBatchDayOfWeeks = watchBatchForm("dayOfWeeks");
+ const selectedBatchStartTime = watchBatchForm("startTime");
+ const batchCommonBusinessHourRange = getCommonBusinessHourRange(
+ businessHours,
+ selectedBatchDayOfWeeks,
+ );
+ const batchStartTimeOptions = createTimeOptionsFromRange(batchCommonBusinessHourRange, "start");
+ const batchEndTimeOptions = createTimeOptionsFromRange(
+ batchCommonBusinessHourRange,
+ "end",
+ selectedBatchStartTime,
+ );
+ const batchBusinessHourLabel = getBatchBusinessHourLabel(businessHours, selectedBatchDayOfWeeks);
+ const batchInputErrorMessage =
+ batchErrors.startTime?.message ??
+ batchErrors.endTime?.message ??
+ batchErrors.requiredCount?.message;
const isSaving =
createMinimumStaffingRuleMutation.isPending || updateMinimumStaffingRuleMutation.isPending;
const tableMessage = staffingRulesQuery.isPending
@@ -167,50 +635,186 @@ export function MvpStaffingRulesPage() {
"조건 목록을 불러오지 못했습니다. 다시 시도해 주세요.",
)
: "";
-
- function openCreateForm() {
- setEditingRuleId(null);
- reset(EMPTY_FORM_VALUES);
- setFormError("");
- setFormOpen(true);
- }
+ const isOrganizationUnavailable = organizationQuery.isPending || organizationQuery.isError;
function openEditForm(rule: MinimumStaffingRule) {
setEditingRuleId(rule.id);
- reset(createFormValuesFromRule(rule));
- setFormError("");
+ resetEditForm(createFormValuesFromRule(rule));
+ setEditFormError("");
setFormOpen(true);
}
function closeForm() {
setFormOpen(false);
setEditingRuleId(null);
- reset(EMPTY_FORM_VALUES);
- setFormError("");
+ resetEditForm(EMPTY_FORM_VALUES);
+ setEditFormError("");
}
- async function saveRule(values: StaffingRuleFormValues) {
- setFormError("");
+ function addDraftRules(values: BatchStaffingRuleFormValues) {
+ setBatchFormError("");
+
+ const requests = createMinimumStaffingRuleRequestsFromBatchForm(values);
+ const duplicatedRequest = requests.find((request) =>
+ draftRules.some((draftRule) => hasSameDraftRule(draftRule, request)),
+ );
+ const duplicatedExistingRule = requests.find((request) =>
+ rules.some((rule) => hasSameDraftRule(rule, request)),
+ );
+
+ if (duplicatedRequest) {
+ setBatchError("dayOfWeeks", {
+ type: "manual",
+ message: `${DAY_LABELS[duplicatedRequest.dayOfWeek]} ${duplicatedRequest.startTime}-${
+ duplicatedRequest.endTime
+ } ${duplicatedRequest.requiredCount}명 조건이 이미 추가되어 있습니다.`,
+ });
+ return;
+ }
+
+ if (duplicatedExistingRule) {
+ setBatchError("dayOfWeeks", {
+ type: "manual",
+ message: `${DAY_LABELS[duplicatedExistingRule.dayOfWeek]} ${
+ duplicatedExistingRule.startTime
+ }-${duplicatedExistingRule.endTime} ${
+ duplicatedExistingRule.requiredCount
+ }명 조건이 이미 조건 목록에 있습니다.`,
+ });
+ return;
+ }
+
+ setDraftRules((currentRules) => [
+ ...currentRules,
+ ...requests.map((request) => ({
+ ...request,
+ id: createDraftRuleId(request),
+ })),
+ ]);
+ resetBatchForm({
+ ...values,
+ dayOfWeeks: [],
+ endTime: "",
+ startTime: "",
+ });
+ }
+
+ function removeDraftRule(ruleId: string) {
+ setDraftRules((currentRules) => currentRules.filter((rule) => rule.id !== ruleId));
+ setBatchFormError("");
+ }
+
+ async function createRules(requests: CreateMinimumStaffingRuleRequest[]) {
+ for (const request of requests) {
+ await createMinimumStaffingRuleMutation.mutateAsync(request);
+ }
+ }
+
+ async function saveBatchRules() {
+ setBatchFormError("");
if (isSaving) {
return;
}
+ const values = getBatchValues();
+ const hasAnyCurrentInput =
+ values.dayOfWeeks.length > 0 || values.startTime || values.endTime || values.requiredCount;
+ const hasCompleteCurrentInput =
+ values.dayOfWeeks.length > 0 && values.startTime && values.endTime && values.requiredCount;
+
+ if (!hasAnyCurrentInput && draftRules.length === 0) {
+ setBatchError("dayOfWeeks", {
+ type: "manual",
+ message: "저장할 조건을 하나 이상 추가하세요.",
+ });
+ return;
+ }
+
+ if (hasCompleteCurrentInput || draftRules.length === 0) {
+ await handleBatchSubmit(async (batchValues) => {
+ const requests = createMinimumStaffingRuleRequestsFromBatchForm(batchValues);
+ const allRequests = [...draftRules, ...requests];
+ const duplicatedRequest = requests.find((request) =>
+ draftRules.some((draftRule) => hasSameDraftRule(draftRule, request)),
+ );
+ const duplicatedExistingRule = requests.find((request) =>
+ rules.some((rule) => hasSameDraftRule(rule, request)),
+ );
+
+ if (duplicatedRequest) {
+ setBatchError("dayOfWeeks", {
+ type: "manual",
+ message: `${DAY_LABELS[duplicatedRequest.dayOfWeek]} ${duplicatedRequest.startTime}-${
+ duplicatedRequest.endTime
+ } ${duplicatedRequest.requiredCount}명 조건이 이미 추가되어 있습니다.`,
+ });
+ return;
+ }
+
+ if (duplicatedExistingRule) {
+ setBatchError("dayOfWeeks", {
+ type: "manual",
+ message: `${DAY_LABELS[duplicatedExistingRule.dayOfWeek]} ${
+ duplicatedExistingRule.startTime
+ }-${duplicatedExistingRule.endTime} ${
+ duplicatedExistingRule.requiredCount
+ }명 조건이 이미 조건 목록에 있습니다.`,
+ });
+ return;
+ }
+
+ try {
+ await createRules(allRequests);
+ resetBatchForm(EMPTY_BATCH_FORM_VALUES);
+ setDraftRules([]);
+ } catch (error) {
+ setBatchFormError(
+ getApiErrorMessage(error, "최소 인원 조건을 저장하지 못했습니다. 다시 시도해 주세요."),
+ );
+ }
+ })();
+ return;
+ }
+
+ try {
+ await createRules(draftRules);
+ resetBatchForm(EMPTY_BATCH_FORM_VALUES);
+ setDraftRules([]);
+ } catch (error) {
+ setBatchFormError(
+ getApiErrorMessage(error, "최소 인원 조건을 저장하지 못했습니다. 다시 시도해 주세요."),
+ );
+ }
+ }
+
+ async function saveRule(values: StaffingRuleFormValues) {
+ setEditFormError("");
+
+ if (isSaving || !editingRuleId) {
+ return;
+ }
+
const request = createMinimumStaffingRuleRequestFromForm(values);
+ const policyError = validateRuleBusinessHours(values, businessHours);
+
+ if (policyError) {
+ setEditError(policyError.field, {
+ type: "manual",
+ message: policyError.message,
+ });
+ return;
+ }
try {
- if (editingRuleId) {
- await updateMinimumStaffingRuleMutation.mutateAsync({
- ruleId: editingRuleId,
- request,
- });
- } else {
- await createMinimumStaffingRuleMutation.mutateAsync(request);
- }
+ await updateMinimumStaffingRuleMutation.mutateAsync({
+ ruleId: editingRuleId,
+ request,
+ });
closeForm();
} catch (error) {
- setFormError(
+ setEditFormError(
getApiErrorMessage(error, "최소 인원 조건을 저장하지 못했습니다. 다시 시도해 주세요."),
);
}
@@ -219,26 +823,254 @@ export function MvpStaffingRulesPage() {
return (
-
- 조건 추가
-
- }
+ description="영업시간 중 특정 요일과 시간대에 더 필요한 최소 근무 인원을 관리합니다."
containerClassName="max-w-none"
contentClassName="space-y-6"
>
+
+
+
조건 추가
+
+ 요일과 시간대, 최소 인원을 한 세트로 추가하고 여러 조건을 한 번에 저장하세요.
+
+
+
+
+
+
요일
+
(
+
+ {DAY_ORDER.map((dayOfWeek) => {
+ const businessHour = getBusinessHourForDay(businessHours, dayOfWeek);
+ const isDisabled = !isSelectableBusinessHour(businessHour);
+ const checked = field.value.includes(dayOfWeek);
+
+ return (
+ {
+ const nextValue = checked
+ ? field.value.filter((item) => item !== dayOfWeek)
+ : [...field.value, dayOfWeek];
+
+ field.onChange(nextValue);
+ setBatchValue("startTime", "");
+ setBatchValue("endTime", "");
+ }}
+ >
+ {isDisabled ? `${DAY_LABELS[dayOfWeek]} · 휴무` : DAY_LABELS[dayOfWeek]}
+
+ );
+ })}
+
+ )}
+ />
+ {batchErrors.dayOfWeeks ? (
+ {batchErrors.dayOfWeeks.message}
+ ) : null}
+ {batchBusinessHourLabel}
+
+
+
+
+ 시작 시간
+ (
+ {
+ field.onChange(value);
+ setBatchValue("endTime", "");
+ }}
+ disabled={!batchCommonBusinessHourRange || isOrganizationUnavailable}
+ >
+
+
+
+
+ {batchStartTimeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+ />
+
+
+
+ 종료 시간
+ (
+
+
+
+
+
+ {batchEndTimeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+ />
+
+
+
+ 최소 인원
+
+
+
+
+
+ 목록에 추가
+
+ {batchInputErrorMessage ? (
+
{batchInputErrorMessage}
+ ) : null}
+
+
+
+
+
저장할 조건
+
+ {draftRules.length > 0 ? (
+
+
+
+
+
+
+
+
+ 요일
+ 조건
+
+
+
+ {groupedDraftRules.map((group) => (
+
+
+ {DAY_LABELS[group.dayOfWeek]}
+
+
+
+ {group.rules.map((rule) => (
+
+
+
+ {rule.startTime}-{rule.endTime}
+ {rule.endsNextDay ? " 다음날" : ""}
+
+ |
+ {rule.requiredCount}명
+
+ removeDraftRule(rule.id)}
+ >
+
+
+
+ ))}
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ 아직 추가된 조건이 없습니다. 요일과 시간대, 최소 인원을 입력한 뒤 목록에 추가하세요.
+
+ )}
+
+
+ {batchFormError ?
{batchFormError}
: null}
+
+
+ {
+ resetBatchForm(EMPTY_BATCH_FORM_VALUES);
+ setDraftRules([]);
+ setBatchFormError("");
+ }}
+ disabled={isSaving}
+ >
+ 초기화
+
+
+ {createMinimumStaffingRuleMutation.isPending ? "저장 중..." : "조건 저장"}
+
+
+
+
+
{tableMessage ? (
{tableMessage}
@@ -289,61 +1121,117 @@ export function MvpStaffingRulesPage() {
요일
(
-
-
+ {
+ field.onChange(value);
+ setEditValue("startTime", "");
+ setEditValue("endTime", "");
+ }}
+ >
+
- {Object.entries(DAY_LABELS).map(([value, label]) => (
-
- {label}
-
- ))}
+ {Object.entries(DAY_LABELS).map(([value, label]) => {
+ const businessHour = getBusinessHourForDay(businessHours, value as DayOfWeek);
+ const isDisabled = !isSelectableBusinessHour(businessHour);
+
+ return (
+
+ {isDisabled ? `${label} · 휴무` : label}
+
+ );
+ })}
)}
/>
- {errors.dayOfWeek ? (
- {errors.dayOfWeek.message}
+ {editErrors.dayOfWeek ? (
+ {editErrors.dayOfWeek.message}
+ ) : null}
+ {selectedDayOfWeek ? (
+ {selectedBusinessHourLabel}
) : null}
시작 시간
-
(
+
{
+ field.onChange(value);
+ setEditValue("endTime", "");
+ }}
+ disabled={!isSelectableBusinessHour(selectedBusinessHour)}
+ >
+
+
+
+
+ {startTimeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
/>
- {errors.startTime ? (
-
{errors.startTime.message}
+ {editErrors.startTime ? (
+
{editErrors.startTime.message}
) : null}
종료 시간
-
(
+
+
+
+
+
+ {endTimeOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
/>
- {errors.endTime ? (
-
{errors.endTime.message}
+ {editErrors.endTime ? (
+
{editErrors.endTime.message}
) : null}
@@ -355,14 +1243,15 @@ export function MvpStaffingRulesPage() {
type="number"
min={1}
placeholder="예: 2"
- aria-invalid={Boolean(errors.requiredCount)}
- {...register("requiredCount")}
+ aria-invalid={Boolean(editErrors.requiredCount)}
+ {...registerEdit("requiredCount")}
/>
- {errors.requiredCount ? (
- {errors.requiredCount.message}
+ {editErrors.requiredCount ? (
+ {editErrors.requiredCount.message}
) : null}
- {formError ? {formError}
: null}
+
+ {editFormError ? {editFormError}
: null}
) {
+ void queryClient.invalidateQueries({ queryKey: staffingRulesQueryKey });
+ void queryClient.invalidateQueries({ queryKey: availabilityQueryKeys.all });
+ void queryClient.invalidateQueries({ queryKey: schedulesQueryKeys.all });
+}
+
export function useMinimumStaffingRulesQuery() {
return useQuery({
queryFn: getMinimumStaffingRules,
@@ -31,7 +39,7 @@ export function useCreateMinimumStaffingRuleMutation() {
return useMutation({
mutationFn: createMinimumStaffingRule,
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: staffingRulesQueryKey });
+ invalidatePolicyDependentQueries(queryClient);
},
});
}
@@ -43,7 +51,7 @@ export function useUpdateMinimumStaffingRuleMutation() {
mutationFn: ({ request, ruleId }: UpdateMinimumStaffingRuleVariables) =>
updateMinimumStaffingRule(ruleId, request),
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: staffingRulesQueryKey });
+ invalidatePolicyDependentQueries(queryClient);
},
});
}
@@ -54,7 +62,7 @@ export function useDeleteMinimumStaffingRuleMutation() {
return useMutation({
mutationFn: deleteMinimumStaffingRule,
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: staffingRulesQueryKey });
+ invalidatePolicyDependentQueries(queryClient);
},
});
}
diff --git a/docs/API.md b/docs/API.md
index f9d1627..a10187a 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -331,6 +331,8 @@ Response `204`: body 없음
**Auth:** Bearer token
+특정 근무자의 선택 기간 가능 시간을 조회합니다. 정책상 가능 시간은 최소 인원 조건이 있는 시간대에만 입력하고 추천 생성에 사용합니다. 현재 최소 인원 조건 밖에 있는 기존 가능 시간은 유효하지 않은 데이터로 보고 조회 또는 저장 과정에서 정리하며, 조건과 겹치는 구간만 남깁니다.
+
Query:
```txt
@@ -357,7 +359,7 @@ Response `200`:
**Auth:** Bearer token
-선택 범위의 특정 인력 가능 시간을 전체 교체합니다.
+선택 범위의 특정 인력 가능 시간을 전체 교체합니다. 저장 가능한 시간은 조직 영업시간 안에 있으면서 최소 인원 조건이 있는 시간대로 제한합니다. 현재 최소 인원 조건과 겹치는 구간만 저장하고, 전혀 겹치지 않는 구간은 삭제합니다.
Request:
@@ -411,6 +413,8 @@ Response `200`:
**Auth:** Bearer token
+최소 인원 조건은 해당 요일의 조직 운영시간 안에서만 등록할 수 있습니다. 휴무일에는 등록할 수 없고, 운영시간 밖 요청은 `INVALID_TIME_RANGE`, 휴무일 요청은 `CLOSED_DAY`로 거절합니다.
+
Request:
```json
@@ -429,6 +433,8 @@ Response `201`: `MinimumStaffingRule`
**Auth:** Bearer token
+수정 후의 조건도 해당 요일의 조직 운영시간 안에 있어야 하며, 휴무일로 이동하거나 운영시간 밖으로 변경할 수 없습니다.
+
Request:
```json
@@ -453,7 +459,7 @@ Response `204`: body 없음
**Auth:** Bearer token
-현재 active planning period, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. 같은 입력 조건의 DRAFT가 있으면 `DRAFT_ALREADY_EXISTS`를 반환합니다.
+현재 active planning period, 조직 영업시간, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. 최소 인원 조건은 추천 대상 시간대와 최소 필요 인원을 정의하며, 조건이 있는 시간대에서만 가능 후보 산출, 추천 배정, 미충족 판단을 수행합니다. 같은 입력 조건의 DRAFT가 있으면 `DRAFT_ALREADY_EXISTS`를 반환합니다.
Request:
@@ -466,6 +472,8 @@ Request:
Response `201`: `ScheduleDetail`
+`ScheduleDetail.candidates`는 추천 생성 당시 해당 시간대에 일할 수 있었던 전체 가능 후보입니다. 각 후보의 `isRecommended` 값이 `true`이면 같은 시간대의 추천 배정에 포함된 근무자입니다.
+
### GET /schedules/draft
**Auth:** Bearer token
@@ -484,7 +492,7 @@ Response `200`:
}
```
-스케줄이 있으면 `schedule`은 `ScheduleDetail`입니다.
+스케줄이 있으면 `schedule`은 `ScheduleDetail`입니다. `candidates`는 가능 후보 전체, `assignments`는 추천 또는 확정 배정 목록입니다.
### POST /schedules/:scheduleId/assignments
diff --git a/docs/ERD.md b/docs/ERD.md
index fed2a56..5131f9c 100644
--- a/docs/ERD.md
+++ b/docs/ERD.md
@@ -18,8 +18,10 @@ erDiagram
WORKERS ||--o{ WORKER_AVAILABLE_TIMES : has
WORKERS ||--o{ SCHEDULE_ASSIGNMENTS : assigned
+ WORKERS ||--o{ SCHEDULE_CANDIDATES : candidate
SCHEDULES ||--o{ SCHEDULE_ASSIGNMENTS : contains
+ SCHEDULES ||--o{ SCHEDULE_CANDIDATES : contains
SCHEDULES ||--o{ SCHEDULE_WORKER_SHORTAGES : has
USERS {
@@ -129,6 +131,19 @@ erDiagram
datetime updated_at
}
+ SCHEDULE_CANDIDATES {
+ bigint id PK
+ bigint schedule_id FK
+ bigint worker_id FK "nullable"
+ date work_date
+ datetime starts_at
+ datetime ends_at
+ varchar worker_name_snapshot
+ varchar employee_code_snapshot
+ datetime created_at
+ datetime updated_at
+ }
+
SCHEDULE_WORKER_SHORTAGES {
bigint id PK
bigint schedule_id FK
@@ -190,7 +205,9 @@ erDiagram
- 운영 시간과 최소 인원 조건에서 종료 시간이 다음 날로 넘어가면 `closes_next_day` 또는 `ends_next_day`가 `true`입니다.
- `is_closed = true`인 운영 요일은 `open_time`, `close_time`을 비워둘 수 있습니다.
- 휴무일에는 가능 시간을 저장할 수 없습니다.
-- 가능 시간은 조직 운영 시간 기준 30분 단위여야 합니다.
+- 가능 시간은 조직 운영 시간 안에서 최소 인원 조건이 있는 시간대 기준 30분 단위여야 합니다.
+- 최소 인원 조건이 없는 시간대는 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외합니다.
+- 최소 인원 조건 변경으로 기존 가능 시간이 현재 조건 밖에 놓이면 조건과 겹치는 구간만 남기고, 전혀 겹치지 않는 구간은 삭제합니다.
- 삭제된 인력은 추천 생성 기준에서 제외합니다.
### Application Read / Export Rules
diff --git a/docs/PRD.md b/docs/PRD.md
index 1f27613..090782b 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -2,7 +2,7 @@
## 1. 제품 개요
-프래그먼트는 단일 조직의 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 이를 조정·확정·보관할 수 있게 하는 근무 스케줄 관리 도구 MVP입니다.
+프래그먼트는 단일 조직의 영업시간, 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 이를 조정·확정·보관할 수 있게 하는 근무 스케줄 관리 도구 MVP입니다.
MVP에서는 운영 웹이 전체 사용자 화면을 담당합니다. 따라서 회원가입, 로그인, 조직 생성, 운영 설정, 스케줄 추천, 확정 스케줄 조회까지 모두 반응형 웹에서 사용할 수 있어야 합니다.
@@ -23,9 +23,9 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보
- 조직의 운영 시간과 휴무일을 설정할 수 있습니다.
- 인력 정보를 등록하고 관리할 수 있습니다.
-- 인력별 가능 시간을 날짜와 시간 단위로 입력할 수 있습니다.
+- 최소 인원 조건이 있는 날짜와 시간 단위로 인력별 가능 시간을 입력할 수 있습니다.
- 요일과 시간대별 최소 인원 조건을 등록할 수 있습니다.
-- 입력된 조건을 기준으로 스케줄 초안을 추천받을 수 있습니다.
+- 최소 인원 조건이 있는 시간대에 대해 가능한 후보와 시스템 추천 배정이 구분된 스케줄 초안을 받을 수 있습니다.
- 추천된 DRAFT 스케줄을 직접 추가·수정·삭제할 수 있습니다.
- 스케줄을 확정하고, 확정 이력을 보관함에서 조회할 수 있습니다.
- 확정 스케줄을 CSV로 export할 수 있습니다.
@@ -89,7 +89,7 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보
- 선택된 가능 시간 저장
- 기존 가능 시간 수정 및 삭제
-가능 시간은 조직 운영 시간 기준 30분 단위 타임테이블로 입력합니다. 조직 휴무일에 해당하는 날짜는 선택할 수 없습니다.
+가능 시간은 조직 운영 시간 안에서 최소 인원 조건이 있는 시간대에만 30분 단위 타임테이블로 입력합니다. 최소 인원 조건이 없는 시간대와 조직 휴무일에 해당하는 날짜는 선택할 수 없습니다.
### 5.5 최소 인원 조건
@@ -99,7 +99,7 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보
- 필요한 인원 수 입력
- 조건 생성, 수정, 삭제
-최소 인원 조건은 스케줄 추천 시 시간대별 필요 인원을 판단하는 기준으로 사용합니다.
+최소 인원 조건은 스케줄 추천 대상 시간대와 해당 시간대의 최소 필요 인원을 정의합니다. 최소 인원 조건이 없는 시간대는 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외합니다.
### 5.6 스케줄
@@ -111,7 +111,7 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보
- 날짜별 스케줄 삭제
- 스케줄 확정
-스케줄 추천은 인력, 가능 시간, 최소 인원 조건, 근무 시작 날짜, 근무 종료 날짜를 기준으로 실행합니다. 추천 결과는 DRAFT 상태로 생성되며, 사용자가 직접 조정한 뒤 CONFIRMED 상태로 확정합니다.
+스케줄 추천은 영업시간, 인력, 가능 시간, 최소 인원 조건, 근무 시작 날짜, 근무 종료 날짜를 기준으로 실행합니다. 최소 인원 조건이 있는 시간대에서 가능한 후보와 시스템 추천 배정을 구분해 보여주고, 최소 필요 인원 대비 추천 배정이 부족한 구간을 미충족 조건으로 기록합니다. 추천 결과는 DRAFT 상태로 생성되며, 사용자가 직접 조정한 뒤 CONFIRMED 상태로 확정합니다. 상세 정책은 `docs/SCHEDULING_POLICY.md`를 기준으로 합니다.
### 5.7 보관함
diff --git a/docs/SCHEDULING_POLICY.md b/docs/SCHEDULING_POLICY.md
new file mode 100644
index 0000000..b7e92aa
--- /dev/null
+++ b/docs/SCHEDULING_POLICY.md
@@ -0,0 +1,258 @@
+# 스케줄 추천 정책
+
+이 문서는 프래그먼트의 스케줄 추천이 어떤 입력을 사용하고, 어떤 결과를 만들어야 하는지 정의합니다. 코드 구현보다 우선하는 제품 정책 문서입니다.
+
+## 1. 목적
+
+스케줄 추천은 운영자가 직접 모든 가능 시간을 대조하지 않아도, 특정 근무 기간에 대해 다음을 빠르게 판단할 수 있게 해야 합니다.
+
+- 최소 인원 조건이 있는 시간대에 일할 수 있는 사람이 누구인지
+- 시스템이 실제 근무자로 추천하는 사람이 누구인지
+- 최소 인원 조건을 충족하지 못하는 시간대가 어디인지
+- 근무자별 주간 계약 시간에 비해 추천 배정이 과하거나 부족한지
+
+## 2. 핵심 원칙
+
+최소 인원 조건은 스케줄 추천 대상 시간대를 정의합니다.
+
+- 최소 인원 조건이 있는 시간대만 가능 시간 입력 대상입니다.
+- 최소 인원 조건이 있는 시간대만 가능 후보 산출 대상입니다.
+- 최소 인원 조건이 있는 시간대만 추천 배정 대상입니다.
+- 최소 인원 조건이 있는 시간대만 미충족 판단 대상입니다.
+- 최소 인원 조건이 없는 영업시간은 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외합니다.
+
+조직 영업시간은 운영 가능한 전체 범위이고, 최소 인원 조건은 그 안에서 실제로 스케줄 추천이 필요한 시간대입니다.
+
+## 3. 핵심 용어
+
+### 가능 후보
+
+가능 후보는 최소 인원 조건이 있는 특정 날짜와 시간대에 일할 수 있는 모든 근무자입니다.
+
+- 해당 시간대가 최소 인원 조건 안에 포함되어야 합니다.
+- 근무자의 가능 시간 안에 포함되어야 합니다.
+- 조직 영업시간 안에 포함되어야 합니다.
+- 후보는 최종 근무 배정이 아닙니다.
+- 같은 근무자는 서로 다른 추천 대상 시간대의 후보에 각각 포함될 수 있습니다.
+- 단, 같은 시간대의 후보 목록에 같은 근무자가 중복 표시되면 안 됩니다.
+
+### 추천 배정
+
+추천 배정은 시스템이 가능 후보 중 실제 근무자로 선택한 사람입니다.
+
+- 최소 인원 조건을 최대한 충족하는 것을 1순위 목표로 합니다.
+- 근무자별 주간 계약 시간에 가깝게 배분하는 것을 2순위 목표로 합니다.
+- 같은 근무자가 겹치는 시간대에 동시에 추천 배정되면 안 됩니다.
+- 추천 배정은 사용자가 수정할 수 있는 DRAFT입니다.
+
+### 확정 배정
+
+확정 배정은 사용자가 DRAFT를 검토하고 확정한 최종 근무 스케줄입니다.
+
+- 확정 이후에는 일반 편집 화면에서 수정하지 않습니다.
+- 보관함과 export의 기준 데이터입니다.
+
+### 미충족 조건
+
+미충족 조건은 최소 인원 조건이 있는 시간대에서 추천 배정 가능한 인원이 필요한 인원보다 부족한 상태입니다.
+
+- 미충족 인원은 `최소 필요 인원 - 추천 배정 인원`입니다.
+- 추천 배정 인원은 하드 제약을 통과해 실제 배정 가능한 사람만 포함합니다.
+- 가능 후보가 최소 필요 인원보다 적으면 가능한 후보만 추천하고 부족한 인원을 미충족으로 기록합니다.
+- 계약 시간 균형 때문에 일부러 최소 인원을 채우지 않는 것은 허용하지 않습니다.
+
+## 4. 입력 데이터
+
+스케줄 추천은 다음 입력을 사용합니다.
+
+| 입력 | 역할 |
+| --- | --- |
+| 조직 영업시간 | 최소 인원 조건과 가능 시간 입력이 허용되는 운영 범위 |
+| 휴무일 | 가능 시간 입력과 자동 추천에서 제외할 날짜 |
+| 근무 기간 | 추천할 날짜 범위 |
+| 근무자 | 추천 대상 인력 |
+| 주간 계약 시간 | 추천 배정 균형 기준 |
+| 근무자별 가능 시간 | 가능 후보 산출 기준 |
+| 최소 인원 조건 | 추천 대상 시간대와 시간대별 최소 필요 인원 |
+
+## 5. 기본 정책
+
+### 영업시간
+
+- 영업시간 밖은 가능 시간 입력 대상이 아닙니다.
+- 영업시간 밖은 최소 인원 조건을 등록할 수 없습니다.
+- 휴무일은 가능 시간 입력과 자동 추천 대상이 아닙니다.
+- 영업시간이 다음 날로 넘어갈 수 있습니다.
+
+### 최소 인원 조건
+
+- 최소 인원 조건은 스케줄 추천 대상 시간대입니다.
+- 최소 인원 조건은 해당 시간대에 반드시 충족해야 하는 최소 필요 인원을 의미합니다.
+- 최소 인원 조건이 없는 시간대는 추천 대상 구간으로 만들지 않습니다.
+- 최소 인원 조건이 없는 시간대는 미충족 인원을 계산하지 않습니다.
+
+예시:
+
+- 영업시간: `09:00-18:00`
+- 최소 인원 조건: `12:00-14:00 3명`
+- 추천 대상 구간: `12:00-14:00`
+- 제외 구간: `09:00-12:00`, `14:00-18:00`
+
+### 가능 시간
+
+- 가능 시간은 최소 인원 조건이 있는 시간대에서만 입력할 수 있습니다.
+- 최소 인원 조건이 없는 시간대는 UI에서 선택할 수 없게 막습니다.
+- 저장된 가능 시간이 현재 최소 인원 조건 밖에 있게 되면 유효하지 않은 가능 시간으로 보고 정리합니다.
+- 기존 가능 시간이 최소 인원 조건과 일부 겹치면 겹치는 구간만 잘라서 남깁니다.
+- 기존 가능 시간이 어떤 최소 인원 조건과도 겹치지 않으면 삭제합니다.
+- 최소 인원 조건 생성, 수정, 삭제로 가능 시간의 유효 범위가 바뀌면 활성 근무 기간 안의 기존 가능 시간을 다시 검증하고, 현재 조건 기준으로 잘라서 저장합니다.
+- 가능 시간 일괄 저장 시에도 현재 최소 인원 조건 밖의 시간은 저장하지 않고, 겹치는 구간만 저장합니다.
+
+### 가능 후보
+
+- 해당 구간 전체를 근무자의 가능 시간이 포함하면 가능 후보입니다.
+- 가능 후보는 모두 화면에 보여야 합니다.
+- 가능 후보가 0명인 최소 인원 조건 구간은 미충족 조건으로 보여야 합니다.
+
+### 추천 배정
+
+- 추천 배정은 가능 후보 중에서 선택합니다.
+- 추천 배정은 최소 인원 조건을 최대한 충족해야 합니다.
+- 가능 후보가 최소 필요 인원보다 적으면 가능한 후보만 추천하고 미충족 조건을 기록합니다.
+- 같은 근무자는 겹치는 시간대에 동시에 추천 배정하지 않습니다.
+- 같은 근무자의 인접한 추천 배정은 가능한 한 하나의 연속 근무로 합칩니다.
+
+### 주간 계약 시간
+
+- 추천 배정은 근무자별 주간 계약 시간에 가까워지도록 배분합니다.
+- 주간 계약 시간은 MVP에서 하드 제한이 아니라 우선순위와 경고 기준입니다.
+- 추천 배정 시간이 계약 시간을 초과하거나 크게 부족하면 화면에서 확인할 수 있어야 합니다.
+
+## 6. 미충족 판단 기준
+
+미충족 판단은 최소 인원 조건이 있는 시간대에서만 수행합니다.
+
+1. 최소 인원 조건의 필요 인원을 확인합니다.
+2. 해당 시간대의 가능 후보를 산출합니다.
+3. 하드 제약을 통과한 후보 중 추천 배정을 선택합니다.
+4. `최소 필요 인원 - 추천 배정 인원`이 1명 이상이면 미충족 조건으로 기록합니다.
+
+하드 제약은 MVP에서 다음 기준을 사용합니다.
+
+- 해당 시간대가 최소 인원 조건 안에 있어야 합니다.
+- 근무자가 해당 시간대에 가능 시간을 등록해야 합니다.
+- 같은 근무자가 겹치는 시간대에 중복 추천 배정되면 안 됩니다.
+- 삭제되었거나 비활성 처리된 근무자는 추천 대상에서 제외합니다.
+
+## 7. 추천 배정 균형 기준
+
+추천 배정은 최소 인원 충족을 먼저 처리한 뒤, 가능한 후보 중 누구를 선택할지 균형 기준으로 결정합니다.
+
+우선순위는 다음과 같습니다.
+
+1. 최소 인원 조건을 최대한 충족합니다.
+2. 주간 계약 시간 대비 현재 추천 배정 시간이 적은 근무자를 우선합니다.
+3. 모든 가능 후보의 계약 시간 사용률이 100% 이상이면 초과 폭이 가장 작은 근무자를 우선합니다.
+4. 같은 수준이면 해당 날짜의 추천 배정 시간이 적은 근무자를 우선합니다.
+5. 그래도 같으면 기존 추천 배정을 유지하거나 사번 같은 안정적인 기준으로 정렬합니다.
+
+추천 배정 균형의 기본 지표는 다음과 같습니다.
+
+```txt
+계약 시간 사용률 = 이번 주 추천 배정 시간 / 주간 계약 시간
+```
+
+계약 시간 사용률이 낮은 근무자를 우선 추천합니다. 단, 이 기준은 최소 인원 조건을 일부러 미충족으로 만들기 위해 사용하지 않습니다.
+
+모든 가능 후보의 계약 시간 사용률이 100% 이상이어도 최소 인원 조건을 충족하기 위해 추천 배정은 생성합니다. 이때 계약 시간 초과 폭이 가장 작은 근무자를 우선 선택하고, 해당 배정은 계약 시간 초과 경고로 표시합니다.
+
+## 8. MVP 결정
+
+MVP에서는 다음 범위까지만 구현합니다.
+
+### 포함
+
+- 최소 인원 조건 기준 추천 구간 생성
+- 최소 인원 조건이 있는 시간대에서만 가능 시간 입력 허용
+- 가능 후보 전체 표시
+- 최소 인원 조건 기준 미충족 조건 표시
+- 추천 배정과 가능 후보의 개념 분리
+- 사용자가 DRAFT에서 추천 배정을 추가, 수정, 삭제
+- 확정 시 추천 배정을 확정 배정으로 저장
+
+### 보류
+
+- 휴게 시간 자동 삽입
+- 하루 최대 근무 시간
+- 한 번 근무의 최소/최대 길이 설정
+- 연속 근무일 제한
+- 선호 근무 시간
+- 근무자별 숙련도 또는 역할 조건
+- 공정성 점수 상세 표시
+
+## 9. 화면 정책
+
+스케줄 화면은 후보와 배정을 혼동하지 않게 표시해야 합니다.
+
+- 가능 후보: 최소 인원 조건이 있는 시간대에 일할 수 있는 모든 사람
+- 추천 배정: 시스템이 선택한 사람, 체크 또는 강조 표시
+- 미충족 조건: 최소 필요 인원 대비 추천 배정이 부족한 구간
+
+날짜 상세 패널에서는 최소한 다음 정보를 보여야 합니다.
+
+- 시간대
+- 최소 필요 인원
+- 가능 후보 수
+- 추천 배정 수
+- 가능 후보 목록
+- 추천 여부
+
+## 10. 데이터 모델
+
+스케줄 추천 결과는 다음 개념을 분리해 저장하고 응답해야 합니다.
+
+- `ScheduleCandidate`: 가능 후보
+- `ScheduleAssignment`: 추천 또는 확정 배정
+- `ScheduleWorkerShortage`: 미충족 조건
+
+`ScheduleCandidate`는 추천 생성 당시 해당 시간대에 일할 수 있었던 모든 후보를 저장합니다. `ScheduleAssignment`는 그중 시스템이 실제 추천 배정으로 선택했거나 사용자가 DRAFT에서 조정한 배정을 저장합니다. API 응답과 화면은 후보 전체를 보여주되, 추천 배정된 후보는 체크 또는 강조 표시해야 합니다.
+
+## 11. 예시
+
+### 예시 1: 최소 인원 조건 없음
+
+- 영업시간: `09:00-18:00`
+- 근무자 A 가능 시간: 입력 불가
+- 근무자 B 가능 시간: 입력 불가
+- 최소 인원 조건: 없음
+
+결과:
+
+- 추천 대상 시간대 없음
+- 가능 후보 산출 없음
+- 추천 배정 없음
+- 미충족 조건 없음
+- 운영자는 먼저 최소 인원 조건을 등록해야 가능 시간을 입력하고 추천을 생성할 수 있습니다.
+
+### 예시 2: 피크 시간 최소 인원 조건
+
+- 영업시간: `09:00-18:00`
+- 최소 인원 조건: `12:00-14:00 3명`
+- 가능 후보: A/B
+
+결과:
+
+- `09:00-12:00`: 추천 대상 아님
+- `12:00-14:00`: 최소 필요 인원 3명, 가능 후보 2명, 추천 배정 A/B, 미충족 1명
+- `14:00-18:00`: 추천 대상 아님
+
+### 예시 3: 가능 후보는 많고 추천 배정은 일부
+
+- 최소 인원 조건: `12:00-14:00 2명`
+- 가능 후보: A/B/C/D
+
+결과:
+
+- 가능 후보는 A/B/C/D 모두 표시합니다.
+- 추천 배정은 계약 시간 사용률과 기존 추천 배정을 고려해 2명을 선택합니다.
+- 추천 배정이 2명이면 미충족 조건은 없습니다.
diff --git a/docs/SPEC.md b/docs/SPEC.md
index a38ca39..1926676 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -1,6 +1,6 @@
# 기능 명세서 — 프래그먼트
-이 문서는 PRD의 MVP 범위를 개발 실행 가능한 수준으로 정리합니다. MVP는 단일 조직의 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 조정·확정·보관하는 흐름만 포함합니다.
+이 문서는 PRD의 MVP 범위를 개발 실행 가능한 수준으로 정리합니다. MVP는 단일 조직의 영업시간, 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 조정·확정·보관하는 흐름만 포함합니다.
## 1. 공통 원칙
@@ -185,11 +185,12 @@
| 항목 | 규칙 |
| --- | --- |
| 날짜 범위 | 근무 시작 날짜부터 근무 종료 날짜까지 |
-| 시간 범위 | 조직의 요일별 운영 시간 기준 |
+| 시간 범위 | 조직의 요일별 운영 시간 안에서 최소 인원 조건이 있는 시간대 |
| 시간 단위 | 30분 |
| 휴무일 | 가능 시간 입력 불가 |
운영 종료 시간이 익일인 경우에도 같은 영업일 기준으로 처리하며, 저장 시에는 실제 날짜가 반영된 시작/종료 datetime으로 저장합니다.
+최소 인원 조건이 없는 시간대는 가능 시간 선택을 차단합니다.
#### 입력 및 저장
@@ -199,12 +200,14 @@
4. 선택된 시간이 연속되면 하나의 구간으로 관리합니다.
- 예: `10:00~10:30`, `10:30~11:00`, `11:00~11:30` → `10:00~11:30`
5. 저장 시 선택된 전체 가능 시간을 반영합니다.
+6. 저장 시 현재 최소 인원 조건과 겹치는 구간만 가능 시간으로 저장합니다.
#### 수정 및 삭제
- 기존 가능 시간은 조회 후 수정할 수 있습니다.
- 수정은 가능 시간 구간을 추가하거나 제거한 뒤 저장합니다.
- 삭제는 기존 가능 시간 구간을 제거한 뒤 저장합니다.
+- 최소 인원 조건 변경으로 기존 가능 시간이 조건 밖에 놓이면 조건과 겹치는 구간만 남기고, 전혀 겹치지 않는 구간은 삭제합니다.
#### 검증
@@ -215,13 +218,14 @@
| 종료 날짜가 시작 날짜보다 빠름 | 저장 차단 |
| 인력 미선택 | 저장 차단 |
| 휴무일에 해당하는 시간 선택 | 저장 차단 |
+| 최소 인원 조건이 없는 시간 선택 | 저장 차단 |
### FR-6. 최소 인원 조건
| 항목 | 내용 |
| --- | --- |
| 경로 | `/staffing-rules` |
-| 목적 | 스케줄 추천 시 요일과 시간대별 필요한 인원 수 정의 |
+| 목적 | 스케줄 추천 대상 요일과 시간대, 필요한 최소 인원 수 정의 |
#### 조회 데이터
@@ -237,8 +241,8 @@
| 필드 | 조건 |
| --- | --- |
| 요일 | 필수 |
-| 시작 시간 | 필수 |
-| 종료 시간 | 필수, 시작 시간과 같을 수 없음 |
+| 시작 시간 | 필수, 조직 운영시간 안이어야 함 |
+| 종료 시간 | 필수, 시작 시간과 같을 수 없음, 조직 운영시간 안이어야 함 |
| 필요 인원 | 필수, 1 이상의 정수 |
#### 동작
@@ -248,6 +252,10 @@
3. 사용자는 기존 조건을 수정할 수 있습니다.
4. 사용자는 기존 조건을 삭제할 수 있습니다.
5. 종료 시간이 시작 시간보다 빠르면 다음 날 종료되는 조건으로 저장합니다.
+6. 최소 인원 조건이 없는 시간대는 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외됩니다.
+7. 조건 생성, 수정, 삭제 후 활성 근무 기간 안의 기존 가능 시간을 다시 검증하고, 현재 조건과 겹치는 구간만 남깁니다.
+8. 휴무일에는 최소 인원 조건을 생성하거나 수정할 수 없습니다.
+9. 최소 인원 조건은 해당 요일의 조직 운영시간을 벗어날 수 없습니다.
### FR-7. 스케줄
@@ -258,18 +266,25 @@
#### 추천 생성 조건
+정책 세부 기준은 `docs/SCHEDULING_POLICY.md`를 따릅니다.
+
- 조직이 있어야 합니다.
- 인력이 1명 이상 있어야 합니다.
-- 선택 기간에 저장된 가능 시간이 1개 이상 있어야 합니다.
+- 조직 영업시간이 설정되어 있어야 합니다.
- 최소 인원 조건이 1개 이상 있어야 합니다.
+- 선택 기간에 저장된 가능 시간이 없으면 최소 인원 조건이 있는 전체 구간을 미충족 조건으로 기록할 수 있습니다.
- 같은 입력 조건으로 이미 생성된 DRAFT가 있으면 중복 생성하지 않습니다.
#### 추천 생성 동작
1. 사용자가 추천 생성을 요청합니다.
-2. 서버는 인력, 가능 시간, 최소 인원 조건, 선택 기간을 기준으로 DRAFT 스케줄을 생성합니다.
-3. 생성된 스케줄은 `DRAFT` 상태로 저장합니다.
-4. 최소 인원 조건을 충족하지 못한 날짜와 시간대가 있으면 미충족 조건으로 기록합니다.
+2. 서버는 조직 영업시간, 인력, 가능 시간, 최소 인원 조건, 선택 기간을 기준으로 DRAFT 스케줄을 생성합니다.
+3. 최소 인원 조건이 있는 시간대에서만 가능한 후보를 산출합니다.
+4. 가능 후보 중 추천 배정을 산출합니다.
+5. 추천 배정은 최소 인원 조건을 최대한 충족한 뒤 주간 계약 시간 대비 배정 균형을 기준으로 선택합니다.
+6. 모든 가능 후보의 계약 시간 사용률이 100% 이상이면 초과 폭이 가장 작은 근무자를 우선 선택하고 계약 시간 초과 경고 대상으로 표시합니다.
+7. 생성된 스케줄은 `DRAFT` 상태로 저장합니다.
+8. 최소 필요 인원 대비 추천 배정이 부족한 날짜와 시간대가 있으면 미충족 조건으로 기록합니다.
#### 스케줄 추가/수정 입력값
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 1814092..1b1959c 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -283,7 +283,7 @@ paths:
tags:
- Availability
summary: 가능 시간 조회
- description: 특정 근무자의 선택 기간 가능 시간을 조회합니다.
+ description: 특정 근무자의 선택 기간 가능 시간을 조회합니다. 정책상 가능 시간은 최소 인원 조건이 있는 시간대에만 입력하고 추천 생성에 사용합니다. 현재 최소 인원 조건 밖에 있는 기존 가능 시간은 유효하지 않은 데이터로 보고 조회 또는 저장 과정에서 정리하며, 조건과 겹치는 구간만 남깁니다.
operationId: listAvailability
security:
- bearerAuth: []
@@ -312,7 +312,7 @@ paths:
tags:
- Availability
summary: 가능 시간 일괄 저장
- description: 선택 범위의 특정 근무자 가능 시간을 요청값으로 전체 교체합니다.
+ description: 선택 범위의 특정 근무자 가능 시간을 요청값으로 전체 교체합니다. 저장 가능한 시간은 조직 영업시간 안에 있으면서 최소 인원 조건이 있는 시간대로 제한합니다. 현재 최소 인원 조건과 겹치는 구간만 저장하고, 전혀 겹치지 않는 구간은 삭제합니다.
operationId: replaceAvailability
security:
- bearerAuth: []
@@ -446,7 +446,7 @@ paths:
tags:
- Schedules
summary: 스케줄 추천 생성
- description: 현재 active planning period, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다.
+ description: 현재 active planning period, 조직 영업시간, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. 최소 인원 조건은 추천 대상 시간대와 최소 필요 인원을 정의하며, 조건이 있는 시간대에서만 가능 후보 산출, 추천 배정, 미충족 판단을 수행합니다.
operationId: recommendSchedule
security:
- bearerAuth: []
@@ -1272,6 +1272,55 @@ components:
type: string
example: W-0001
+ ScheduleCandidate:
+ type: object
+ required:
+ - id
+ - scheduleId
+ - workerId
+ - workDate
+ - startsAt
+ - endsAt
+ - workerNameSnapshot
+ - employeeCodeSnapshot
+ - isRecommended
+ properties:
+ id:
+ type: string
+ pattern: "^\\d+$"
+ example: "20"
+ scheduleId:
+ type: string
+ pattern: "^\\d+$"
+ example: "1"
+ workerId:
+ type:
+ - string
+ - "null"
+ pattern: "^\\d+$"
+ example: "1"
+ workDate:
+ type: string
+ format: date
+ example: "2026-07-01"
+ startsAt:
+ type: string
+ format: date-time
+ example: "2026-07-01T10:00:00.000Z"
+ endsAt:
+ type: string
+ format: date-time
+ example: "2026-07-01T14:00:00.000Z"
+ workerNameSnapshot:
+ type: string
+ example: 김민수
+ employeeCodeSnapshot:
+ type: string
+ example: W-0001
+ isRecommended:
+ type: boolean
+ example: true
+
ScheduleWorkerShortage:
type: object
required:
@@ -1327,6 +1376,7 @@ components:
- generatedAt
- confirmedAt
- assignments
+ - candidates
- workerShortages
properties:
id:
@@ -1356,6 +1406,10 @@ components:
type: array
items:
$ref: "#/components/schemas/ScheduleAssignment"
+ candidates:
+ type: array
+ items:
+ $ref: "#/components/schemas/ScheduleCandidate"
workerShortages:
type: array
items:
diff --git a/packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql b/packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql
new file mode 100644
index 0000000..83a0bb2
--- /dev/null
+++ b/packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql
@@ -0,0 +1,21 @@
+CREATE TABLE "schedule_candidates" (
+ "id" BIGSERIAL NOT NULL,
+ "schedule_id" BIGINT NOT NULL,
+ "worker_id" BIGINT,
+ "work_date" DATE NOT NULL,
+ "starts_at" TIMESTAMP(3) NOT NULL,
+ "ends_at" TIMESTAMP(3) NOT NULL,
+ "worker_name_snapshot" TEXT NOT NULL,
+ "employee_code_snapshot" TEXT NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "schedule_candidates_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "schedule_candidates_schedule_id_work_date_idx" ON "schedule_candidates"("schedule_id", "work_date");
+CREATE INDEX "schedule_candidates_worker_id_idx" ON "schedule_candidates"("worker_id");
+CREATE INDEX "schedule_candidates_starts_at_ends_at_idx" ON "schedule_candidates"("starts_at", "ends_at");
+
+ALTER TABLE "schedule_candidates" ADD CONSTRAINT "schedule_candidates_schedule_id_fkey" FOREIGN KEY ("schedule_id") REFERENCES "schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "schedule_candidates" ADD CONSTRAINT "schedule_candidates_worker_id_fkey" FOREIGN KEY ("worker_id") REFERENCES "workers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 4c2fb2e..1d319fd 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -93,6 +93,7 @@ model Worker {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
availableTimes WorkerAvailableTime[]
scheduleAssignments ScheduleAssignment[]
+ scheduleCandidates ScheduleCandidate[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -155,6 +156,7 @@ model Schedule {
confirmedAt DateTime? @map("confirmed_at")
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
assignments ScheduleAssignment[]
+ candidates ScheduleCandidate[]
workerShortages ScheduleWorkerShortage[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -184,6 +186,26 @@ model ScheduleAssignment {
@@map("schedule_assignments")
}
+model ScheduleCandidate {
+ id BigInt @id @default(autoincrement())
+ scheduleId BigInt @map("schedule_id")
+ workerId BigInt? @map("worker_id")
+ workDate DateTime @map("work_date") @db.Date
+ startsAt DateTime @map("starts_at")
+ endsAt DateTime @map("ends_at")
+ workerNameSnapshot String @map("worker_name_snapshot")
+ employeeCodeSnapshot String @map("employee_code_snapshot")
+ schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
+ worker Worker? @relation(fields: [workerId], references: [id], onDelete: SetNull)
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@index([scheduleId, workDate])
+ @@index([workerId])
+ @@index([startsAt, endsAt])
+ @@map("schedule_candidates")
+}
+
model ScheduleWorkerShortage {
id BigInt @id @default(autoincrement())
scheduleId BigInt @map("schedule_id")
diff --git a/packages/shared/src/schemas/schedules.ts b/packages/shared/src/schemas/schedules.ts
index 6bd2e70..56cd4d3 100644
--- a/packages/shared/src/schemas/schedules.ts
+++ b/packages/shared/src/schemas/schedules.ts
@@ -21,6 +21,18 @@ export const scheduleAssignmentSchema = z.object({
employeeCodeSnapshot: z.string(),
});
+export const scheduleCandidateSchema = z.object({
+ id: idSchema,
+ scheduleId: idSchema,
+ workerId: idSchema.nullable(),
+ workDate: dateSchema,
+ startsAt: dateTimeSchema,
+ endsAt: dateTimeSchema,
+ workerNameSnapshot: z.string(),
+ employeeCodeSnapshot: z.string(),
+ isRecommended: z.boolean(),
+});
+
const scheduleAssignmentInputBaseSchema = z.object({
workerId: idSchema,
workDate: dateSchema,
@@ -74,6 +86,7 @@ export const scheduleSummarySchema = z.object({
export const scheduleDetailSchema = scheduleSummarySchema.extend({
assignments: z.array(scheduleAssignmentSchema),
+ candidates: z.array(scheduleCandidateSchema),
workerShortages: z.array(scheduleWorkerShortageSchema),
});
diff --git a/packages/shared/src/types/api/index.ts b/packages/shared/src/types/api/index.ts
index e6125fb..29fa58f 100644
--- a/packages/shared/src/types/api/index.ts
+++ b/packages/shared/src/types/api/index.ts
@@ -25,6 +25,7 @@ import {
replaceAvailabilityRequestSchema,
scheduleAssignmentInputSchema,
scheduleAssignmentSchema,
+ scheduleCandidateSchema,
scheduleDetailSchema,
scheduleHistoryDetailResponseSchema,
scheduleHistoryItemSchema,
@@ -88,6 +89,7 @@ export type UpdateMinimumStaffingRuleRequest = z.infer<
export type ScheduleSummary = z.infer;
export type ScheduleDetail = z.infer;
export type ScheduleAssignment = z.infer;
+export type ScheduleCandidate = z.infer;
export type ScheduleWorkerShortage = z.infer;
export type RecommendScheduleRequest = z.infer;
export type DraftScheduleQuery = z.infer;