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 ( ) : 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() {
) : null} - {selectedDate && scheduleStatus !== null ? ( + {selectedDate && schedule && isSelectedDateScheduleTarget ? ( @@ -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 ( -