From ea0565fb4522dc07d4adda96b328c42d564b02d4 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 03:19:47 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs(api):=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=20=EC=A1=B0=ED=9A=8C=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API.md | 45 +++++++++++++++++++----- packages/shared/src/schemas/dashboard.ts | 4 +-- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/API.md b/docs/API.md index a10187a..0bae983 100644 --- a/docs/API.md +++ b/docs/API.md @@ -545,7 +545,11 @@ Query: year=2026&month=7 ``` -`month`가 없으면 해당 연도의 확정 이력을 조회합니다. +`month`가 없으면 해당 연도와 스케줄 기간이 겹치는 확정 이력을 조회합니다. +`month`가 있으면 해당 연월과 스케줄 기간이 겹치는 확정 이력을 조회합니다. + +예를 들어 `2026-06-25 ~ 2026-07-05` 확정 스케줄은 2026년 6월 필터와 2026년 7월 필터에 모두 포함됩니다. +목록은 `confirmedAt desc, id desc` 순서로 정렬합니다. Response `200`: @@ -562,17 +566,32 @@ Response `200`: } ``` +조건에 맞는 확정 이력이 없으면 `items`는 빈 배열입니다. + ### GET /schedule-history/:scheduleId **Auth:** Bearer token +현재 사용자의 조직에 속한 `CONFIRMED` 스케줄만 조회합니다. + Response `200`: `ScheduleHistoryDetailResponse` ### GET /schedule-history/:scheduleId/export.csv **Auth:** Bearer token -Response `200`: `text/csv` +현재 사용자의 조직에 속한 `CONFIRMED` 스케줄을 CSV로 내보냅니다. +CSV는 스케줄 배정 기록의 snapshot 값을 사용합니다. + +Response `200`: `text/csv; charset=utf-8` + +CSV columns: + +```txt +날짜,요일,시작시간,종료시간,사번,근무자명 +``` + +조회 가능한 확정 스케줄이 없으면 `404 SCHEDULE_NOT_FOUND`를 반환합니다. ## Dashboard @@ -580,20 +599,30 @@ Response `200`: `text/csv` **Auth:** Bearer token +현재 조직 정보와 가장 최근 확정 스케줄 1개를 조회합니다. +가장 최근 확정 스케줄은 `confirmedAt desc, id desc` 기준으로 선택합니다. + Response `200`: ```json { "organization": { "id": "1", - "name": "프래그먼트 카페" + "name": "프래그먼트 카페", + "businessHours": [ + { + "id": "1", + "dayOfWeek": "MON", + "isClosed": false, + "openTime": "09:00", + "closeTime": "18:00", + "closesNextDay": false + } + ] }, "latestConfirmedSchedule": null } ``` -### GET /dashboard/latest-confirmed-schedule/export.csv - -**Auth:** Bearer token - -Response `200`: `text/csv` +확정 스케줄이 없으면 `latestConfirmedSchedule`은 `null`입니다. +대시보드에서 CSV export가 필요하면 `latestConfirmedSchedule.id`로 `GET /schedule-history/:scheduleId/export.csv`를 호출합니다. diff --git a/packages/shared/src/schemas/dashboard.ts b/packages/shared/src/schemas/dashboard.ts index 6ff903a..93f22b4 100644 --- a/packages/shared/src/schemas/dashboard.ts +++ b/packages/shared/src/schemas/dashboard.ts @@ -1,8 +1,8 @@ import { z } from "zod"; -import { organizationSummarySchema } from "./organization"; +import { organizationDetailSchema } from "./organization"; import { scheduleDetailSchema } from "./schedules"; export const dashboardResponseSchema = z.object({ - organization: organizationSummarySchema, + organization: organizationDetailSchema, latestConfirmedSchedule: scheduleDetailSchema.nullable(), }); From a099f091845e761f3fd1d8f80406c8893bdfc6ef Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 03:28:28 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(api):=20=EB=B3=B4=EA=B4=80=ED=95=A8?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule-history.handlers.ts | 79 ++++++ .../schedule-history.service.ts | 112 ++++++++ apps/api/src/routes/index.ts | 2 + .../api/src/routes/schedule-history.routes.ts | 60 ++++ apps/api/test/helpers/fake-prisma.ts | 62 ++++- apps/api/test/schedule-history.routes.test.ts | 258 ++++++++++++++++++ 6 files changed, 566 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/modules/schedule-history/schedule-history.handlers.ts create mode 100644 apps/api/src/modules/schedule-history/schedule-history.service.ts create mode 100644 apps/api/src/routes/schedule-history.routes.ts create mode 100644 apps/api/test/schedule-history.routes.test.ts diff --git a/apps/api/src/modules/schedule-history/schedule-history.handlers.ts b/apps/api/src/modules/schedule-history/schedule-history.handlers.ts new file mode 100644 index 0000000..c4cbbb6 --- /dev/null +++ b/apps/api/src/modules/schedule-history/schedule-history.handlers.ts @@ -0,0 +1,79 @@ +import type { PrismaClient } from "@fragment/database"; +import type { ScheduleHistoryQuery } from "@fragment/shared"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { + exportScheduleHistoryCsv, + getScheduleHistoryDetail, + listScheduleHistory, +} from "./schedule-history.service"; + +const getRequestOrganizationId = (req: Request) => { + if (!req.organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return req.organization.id; +}; + +const getScheduleIdParam = (req: Request) => { + const scheduleId = req.params.scheduleId; + + if (typeof scheduleId !== "string") { + throw new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다."); + } + + return scheduleId; +}; + +export const listScheduleHistoryHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await listScheduleHistory( + prisma, + getRequestOrganizationId(req), + req.query as unknown as ScheduleHistoryQuery, + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const getScheduleHistoryDetailHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await getScheduleHistoryDetail( + prisma, + getRequestOrganizationId(req), + getScheduleIdParam(req), + ); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + +export const exportScheduleHistoryCsvHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const scheduleId = getScheduleIdParam(req); + const result = await exportScheduleHistoryCsv( + prisma, + getRequestOrganizationId(req), + scheduleId, + ); + + res + .status(200) + .type("text/csv; charset=utf-8") + .set("Content-Disposition", `attachment; filename="schedule-${scheduleId}.csv"`) + .send(result); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/schedule-history/schedule-history.service.ts b/apps/api/src/modules/schedule-history/schedule-history.service.ts new file mode 100644 index 0000000..0d2de27 --- /dev/null +++ b/apps/api/src/modules/schedule-history/schedule-history.service.ts @@ -0,0 +1,112 @@ +import type { PrismaClient } from "@fragment/database"; +import type { + ScheduleHistoryDetailResponse, + ScheduleHistoryListResponse, + ScheduleHistoryQuery, +} from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { dateStringToDate, dateToDateString, dateToTimeString } from "@/utils/date-time"; +import { toApiId, toPrismaId } from "@/utils/mapper"; +import { scheduleDetailInclude, toScheduleDetail } from "@/modules/schedules/schedules.mapper"; + +const createScheduleNotFoundError = () => + new HttpError(404, ERROR_CODES.SCHEDULE_NOT_FOUND, "스케줄을 찾을 수 없습니다."); + +const WEEKDAY_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const; + +const createDateRangeFilter = ({ year, month }: ScheduleHistoryQuery) => { + const startDate = dateStringToDate(`${year}-${String(month ?? 1).padStart(2, "0")}-01`); + const endDate = + month === undefined ? dateStringToDate(`${year}-12-31`) : new Date(Date.UTC(year, month, 0)); + + return { + endDate: { + gte: startDate, + }, + startDate: { + lte: endDate, + }, + }; +}; + +export async function listScheduleHistory( + prisma: PrismaClient, + organizationId: string, + query: ScheduleHistoryQuery, +): Promise { + const schedules = await prisma.schedule.findMany({ + where: { + organizationId: toPrismaId(organizationId), + status: "CONFIRMED", + ...createDateRangeFilter(query), + }, + orderBy: [{ confirmedAt: "desc" }, { id: "desc" }], + }); + + return { + items: schedules.map((schedule) => ({ + id: toApiId(schedule.id), + startDate: dateToDateString(schedule.startDate), + endDate: dateToDateString(schedule.endDate), + confirmedAt: schedule.confirmedAt!.toISOString(), + })), + }; +} + +export async function getScheduleHistoryDetail( + prisma: PrismaClient, + organizationId: string, + scheduleId: string, +): Promise { + const schedule = await prisma.schedule.findFirst({ + where: { + id: toPrismaId(scheduleId), + organizationId: toPrismaId(organizationId), + status: "CONFIRMED", + }, + include: scheduleDetailInclude, + }); + + if (!schedule) { + throw createScheduleNotFoundError(); + } + + return toScheduleDetail(schedule) as ScheduleHistoryDetailResponse; +} + +const escapeCsvCell = (value: string) => { + if (!/[",\n\r]/.test(value)) { + return value; + } + + return `"${value.replace(/"/g, '""')}"`; +}; + +const createCsvRow = (values: string[]) => values.map(escapeCsvCell).join(","); + +export async function exportScheduleHistoryCsv( + prisma: PrismaClient, + organizationId: string, + scheduleId: string, +): Promise { + const schedule = await getScheduleHistoryDetail(prisma, organizationId, scheduleId); + const rows = [ + createCsvRow(["날짜", "요일", "시작시간", "종료시간", "사번", "근무자명"]), + ...schedule.assignments.map((assignment) => { + const workDate = dateStringToDate(assignment.workDate); + + return createCsvRow([ + assignment.workDate, + WEEKDAY_LABELS[workDate.getUTCDay()], + dateToTimeString(new Date(assignment.startsAt)), + dateToTimeString(new Date(assignment.endsAt)), + assignment.employeeCodeSnapshot, + assignment.workerNameSnapshot, + ]); + }), + ]; + + return rows.join("\n"); +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 3d64588..f2fc778 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -3,6 +3,7 @@ import { Router } from "express"; import { availabilityRoutes } from "@/routes/availability.routes"; import { authRoutes } from "@/routes/auth.routes"; import { organizationRoutes } from "@/routes/organization.routes"; +import { scheduleHistoryRoutes } from "@/routes/schedule-history.routes"; import { schedulesRoutes } from "@/routes/schedules.routes"; import { staffingRulesRoutes } from "@/routes/staffing-rules.routes"; import { workersRoutes } from "@/routes/workers.routes"; @@ -12,6 +13,7 @@ export const apiRoutes = Router(); apiRoutes.use("/availability", availabilityRoutes); apiRoutes.use("/auth", authRoutes); apiRoutes.use("/organization", organizationRoutes); +apiRoutes.use("/schedule-history", scheduleHistoryRoutes); apiRoutes.use("/schedules", schedulesRoutes); apiRoutes.use("/staffing-rules", staffingRulesRoutes); apiRoutes.use("/workers", workersRoutes); diff --git a/apps/api/src/routes/schedule-history.routes.ts b/apps/api/src/routes/schedule-history.routes.ts new file mode 100644 index 0000000..f12793f --- /dev/null +++ b/apps/api/src/routes/schedule-history.routes.ts @@ -0,0 +1,60 @@ +import { idSchema, scheduleHistoryQuerySchema } from "@fragment/shared"; +import type { PrismaClient } from "@fragment/database"; +import { Router } from "express"; +import { z } from "zod"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { createRequireOrganization } from "@/middlewares/require-organization"; +import { validate } from "@/middlewares/validate"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { + exportScheduleHistoryCsvHandler, + getScheduleHistoryDetailHandler, + listScheduleHistoryHandler, +} from "@/modules/schedule-history/schedule-history.handlers"; + +const scheduleIdParamsSchema = z.object({ + scheduleId: idSchema, +}); + +export const scheduleHistoryRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +scheduleHistoryRoutes.use(requireAuth); +scheduleHistoryRoutes.use((req, res, next) => { + const requireOrganization = createRequireOrganization({ + prisma: req.app.locals.prisma as PrismaClient, + }); + + return requireOrganization(req, res, next); +}); + +scheduleHistoryRoutes.get( + "/", + validate( + z.object({ + query: scheduleHistoryQuerySchema, + }), + ), + listScheduleHistoryHandler, +); + +scheduleHistoryRoutes.get( + "/:scheduleId/export.csv", + validate( + z.object({ + params: scheduleIdParamsSchema, + }), + ), + exportScheduleHistoryCsvHandler, +); + +scheduleHistoryRoutes.get( + "/:scheduleId", + validate( + z.object({ + params: scheduleIdParamsSchema, + }), + ), + getScheduleHistoryDetailHandler, +); diff --git a/apps/api/test/helpers/fake-prisma.ts b/apps/api/test/helpers/fake-prisma.ts index 5001de0..c013ba6 100644 --- a/apps/api/test/helpers/fake-prisma.ts +++ b/apps/api/test/helpers/fake-prisma.ts @@ -408,15 +408,35 @@ export function createFakePrisma({ return false; } - if ( - where.startDate !== undefined && - schedule.startDate.getTime() !== where.startDate.getTime() - ) { - return false; + if (where.startDate !== undefined) { + if ( + where.startDate instanceof Date && + schedule.startDate.getTime() !== where.startDate.getTime() + ) { + return false; + } + + if (where.startDate.lte !== undefined && schedule.startDate > where.startDate.lte) { + return false; + } + + if (where.startDate.gte !== undefined && schedule.startDate < where.startDate.gte) { + return false; + } } - if (where.endDate !== undefined && schedule.endDate.getTime() !== where.endDate.getTime()) { - return false; + if (where.endDate !== undefined) { + if (where.endDate instanceof Date && schedule.endDate.getTime() !== where.endDate.getTime()) { + return false; + } + + if (where.endDate.lte !== undefined && schedule.endDate > where.endDate.lte) { + return false; + } + + if (where.endDate.gte !== undefined && schedule.endDate < where.endDate.gte) { + return false; + } } return true; @@ -878,6 +898,34 @@ export function createFakePrisma({ return toScheduleResult(schedule ?? null, options); }, + findMany: async ({ orderBy, where, ...options }: any) => { + let schedules = state.schedules.filter((currentSchedule) => + matchesScheduleWhere(currentSchedule, where), + ); + + if (Array.isArray(orderBy)) { + schedules = [...schedules].sort((first, second) => { + for (const order of orderBy) { + if (order.confirmedAt === "desc") { + const firstTime = first.confirmedAt?.getTime() ?? 0; + const secondTime = second.confirmedAt?.getTime() ?? 0; + + if (firstTime !== secondTime) { + return secondTime - firstTime; + } + } + + if (order.id === "desc" && first.id !== second.id) { + return first.id < second.id ? 1 : -1; + } + } + + return 0; + }); + } + + return schedules.map((schedule) => toScheduleResult(schedule, options)); + }, update: async ({ data, where, ...options }: any) => { const schedule = state.schedules.find((currentSchedule) => currentSchedule.id === where.id); diff --git a/apps/api/test/schedule-history.routes.test.ts b/apps/api/test/schedule-history.routes.test.ts new file mode 100644 index 0000000..7d275ca --- /dev/null +++ b/apps/api/test/schedule-history.routes.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +import { createFakePrisma } 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 createOrganization = () => ({ + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [], +}); + +const createSchedule = ({ + confirmedAt, + endDate, + id, + startDate, + status = "CONFIRMED", +}: { + confirmedAt: Date | null; + endDate: string; + id: bigint; + startDate: string; + status?: "DRAFT" | "CONFIRMED"; +}) => ({ + id, + organizationId: BigInt(1), + status, + inputHash: `hash-${id.toString()}`, + startDate: dateOnly(startDate), + endDate: dateOnly(endDate), + generatedAt: timestamp, + confirmedAt, + createdAt: timestamp, + updatedAt: timestamp, +}); + +const createAssignment = () => ({ + id: BigInt(10), + scheduleId: BigInt(1), + workerId: BigInt(1), + workDate: dateOnly("2026-07-01"), + startsAt: new Date("2026-07-01T10:00:00.000Z"), + endsAt: new Date("2026-07-01T14:00:00.000Z"), + workerNameSnapshot: "김민수", + employeeCodeSnapshot: "W-0001", + createdAt: timestamp, + updatedAt: timestamp, +}); + +describe("schedule history routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + it("requires authentication", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/schedule-history?year=2026"); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("requires an organization", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history?year=2026") + .set("Authorization", authHeader()); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + errorCode: "ORGANIZATION_REQUIRED", + statusCode: 403, + }); + }); + + it("lists confirmed schedules overlapping the requested month", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2026-06-25", + endDate: "2026-07-05", + confirmedAt: new Date("2026-06-20T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(2), + startDate: "2026-08-01", + endDate: "2026-08-31", + confirmedAt: new Date("2026-07-01T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(3), + startDate: "2026-07-10", + endDate: "2026-07-20", + confirmedAt: new Date("2026-06-21T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(4), + startDate: "2026-07-01", + endDate: "2026-07-01", + confirmedAt: null, + status: "DRAFT", + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history?year=2026&month=7") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [ + { + id: "3", + startDate: "2026-07-10", + endDate: "2026-07-20", + confirmedAt: "2026-06-21T00:00:00.000Z", + }, + { + id: "1", + startDate: "2026-06-25", + endDate: "2026-07-05", + confirmedAt: "2026-06-20T00:00:00.000Z", + }, + ], + }); + }); + + it("returns an empty list when there is no matching confirmed schedule", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2026-06-01", + endDate: "2026-06-30", + confirmedAt: new Date("2026-06-20T00:00:00.000Z"), + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history?year=2026&month=7") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ items: [] }); + }); + + it("returns confirmed schedule detail", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + scheduleAssignments: [createAssignment()], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-25T00:00:00.000Z"), + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history/1") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: "1", + status: "CONFIRMED", + confirmedAt: "2026-06-25T00:00:00.000Z", + assignments: [ + { + id: "10", + employeeCodeSnapshot: "W-0001", + workerNameSnapshot: "김민수", + }, + ], + }); + }); + + it("exports confirmed schedule assignments as csv", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + scheduleAssignments: [createAssignment()], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-25T00:00:00.000Z"), + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history/1/export.csv") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toContain("text/csv"); + expect(response.text).toBe( + ["날짜,요일,시작시간,종료시간,사번,근무자명", "2026-07-01,수,10:00,14:00,W-0001,김민수"].join( + "\n", + ), + ); + }); + + it("returns 404 when exporting a non-confirmed schedule", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: null, + status: "DRAFT", + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history/1/export.csv") + .set("Authorization", authHeader()); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + errorCode: "SCHEDULE_NOT_FOUND", + statusCode: 404, + }); + }); +}); From 458f0ff4c60446b1735207d851f1cda01558b713 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 04:08:35 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(api):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/dashboard/dashboard.handlers.ts | 25 +++ .../modules/dashboard/dashboard.service.ts | 43 +++++ .../organization/organization.service.ts | 6 +- apps/api/src/routes/dashboard.routes.ts | 21 +++ apps/api/src/routes/index.ts | 2 + apps/api/test/dashboard.routes.test.ts | 152 ++++++++++++++++++ apps/api/test/helpers/fake-prisma.ts | 58 ++++--- 7 files changed, 280 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/modules/dashboard/dashboard.handlers.ts create mode 100644 apps/api/src/modules/dashboard/dashboard.service.ts create mode 100644 apps/api/src/routes/dashboard.routes.ts create mode 100644 apps/api/test/dashboard.routes.test.ts diff --git a/apps/api/src/modules/dashboard/dashboard.handlers.ts b/apps/api/src/modules/dashboard/dashboard.handlers.ts new file mode 100644 index 0000000..4709dab --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.handlers.ts @@ -0,0 +1,25 @@ +import type { PrismaClient } from "@fragment/database"; +import type { Request, RequestHandler } from "express"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { getDashboard } from "./dashboard.service"; + +const getRequestOrganizationId = (req: Request) => { + if (!req.organization) { + throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); + } + + return req.organization.id; +}; + +export const getDashboardHandler: RequestHandler = async (req, res, next) => { + try { + const prisma = req.app.locals.prisma as PrismaClient; + const result = await getDashboard(prisma, getRequestOrganizationId(req)); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/modules/dashboard/dashboard.service.ts b/apps/api/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..2e3d85e --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,43 @@ +import type { PrismaClient } from "@fragment/database"; +import type { DashboardResponse } from "@fragment/shared"; + +import { ERROR_CODES } from "@/common/constants/error-codes"; +import { HttpError } from "@/errors/http-error"; +import { toOrganizationDetail } from "@/modules/organization/organization.service"; +import { scheduleDetailInclude, toScheduleDetail } from "@/modules/schedules/schedules.mapper"; +import { toPrismaId } from "@/utils/mapper"; + +export async function getDashboard( + prisma: PrismaClient, + organizationId: string, +): Promise { + const organizationDatabaseId = toPrismaId(organizationId); + const organization = await prisma.organization.findUnique({ + where: { + id: organizationDatabaseId, + }, + include: { + businessHours: true, + }, + }); + + if (!organization) { + throw new HttpError(404, ERROR_CODES.NOT_FOUND, "조직을 찾을 수 없습니다."); + } + + const latestConfirmedSchedule = await prisma.schedule.findFirst({ + where: { + organizationId: organizationDatabaseId, + status: "CONFIRMED", + }, + orderBy: [{ confirmedAt: "desc" }, { id: "desc" }], + include: scheduleDetailInclude, + }); + + return { + organization: toOrganizationDetail(organization), + latestConfirmedSchedule: latestConfirmedSchedule + ? toScheduleDetail(latestConfirmedSchedule) + : null, + }; +} diff --git a/apps/api/src/modules/organization/organization.service.ts b/apps/api/src/modules/organization/organization.service.ts index 6094e30..e4c5ca6 100644 --- a/apps/api/src/modules/organization/organization.service.ts +++ b/apps/api/src/modules/organization/organization.service.ts @@ -13,7 +13,7 @@ import { toApiId, toPrismaId } from "@/utils/mapper"; type OrganizationDatabaseClient = PrismaClient | Prisma.TransactionClient; -type OrganizationWithBusinessHours = Prisma.OrganizationGetPayload<{ +export type OrganizationWithBusinessHours = Prisma.OrganizationGetPayload<{ include: { businessHours: true; }; @@ -28,7 +28,9 @@ const getClosesNextDay = ({ }: CreateOrganizationRequest["businessHours"][number]) => !isClosed && openTime !== null && closeTime !== null && openTime > closeTime; -const toOrganizationDetail = (organization: OrganizationWithBusinessHours): OrganizationDetail => ({ +export const toOrganizationDetail = ( + organization: OrganizationWithBusinessHours, +): OrganizationDetail => ({ id: toApiId(organization.id), name: organization.name, businessHours: [...organization.businessHours] diff --git a/apps/api/src/routes/dashboard.routes.ts b/apps/api/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..500797b --- /dev/null +++ b/apps/api/src/routes/dashboard.routes.ts @@ -0,0 +1,21 @@ +import type { PrismaClient } from "@fragment/database"; +import { Router } from "express"; + +import { createRequireAuth } from "@/middlewares/require-auth"; +import { createRequireOrganization } from "@/middlewares/require-organization"; +import { verifyAccessToken } from "@/modules/auth/auth.tokens"; +import { getDashboardHandler } from "@/modules/dashboard/dashboard.handlers"; + +export const dashboardRoutes = Router(); +const requireAuth = createRequireAuth({ verifyAccessToken }); + +dashboardRoutes.use(requireAuth); +dashboardRoutes.use((req, res, next) => { + const requireOrganization = createRequireOrganization({ + prisma: req.app.locals.prisma as PrismaClient, + }); + + return requireOrganization(req, res, next); +}); + +dashboardRoutes.get("/", getDashboardHandler); diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index f2fc778..4d51d2f 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { availabilityRoutes } from "@/routes/availability.routes"; import { authRoutes } from "@/routes/auth.routes"; +import { dashboardRoutes } from "@/routes/dashboard.routes"; import { organizationRoutes } from "@/routes/organization.routes"; import { scheduleHistoryRoutes } from "@/routes/schedule-history.routes"; import { schedulesRoutes } from "@/routes/schedules.routes"; @@ -12,6 +13,7 @@ export const apiRoutes = Router(); apiRoutes.use("/availability", availabilityRoutes); apiRoutes.use("/auth", authRoutes); +apiRoutes.use("/dashboard", dashboardRoutes); apiRoutes.use("/organization", organizationRoutes); apiRoutes.use("/schedule-history", scheduleHistoryRoutes); apiRoutes.use("/schedules", schedulesRoutes); diff --git a/apps/api/test/dashboard.routes.test.ts b/apps/api/test/dashboard.routes.test.ts new file mode 100644 index 0000000..1bc0b23 --- /dev/null +++ b/apps/api/test/dashboard.routes.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createAccessToken } from "@/modules/auth/auth.tokens"; +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 createOrganization = () => ({ + id: BigInt(1), + userId: BigInt(1), + name: "프래그먼트 카페", + businessHours: [ + { + id: BigInt(1), + organizationId: BigInt(1), + dayOfWeek: "MON" as const, + isClosed: false, + openTime: createTimeDate("09:00"), + closeTime: createTimeDate("18:00"), + closesNextDay: false, + }, + ], +}); + +const createSchedule = ({ + confirmedAt, + id, + status = "CONFIRMED", +}: { + confirmedAt: Date | null; + id: bigint; + status?: "DRAFT" | "CONFIRMED"; +}) => ({ + id, + organizationId: BigInt(1), + status, + inputHash: `hash-${id.toString()}`, + startDate: dateOnly("2026-07-01"), + endDate: dateOnly("2026-07-31"), + generatedAt: timestamp, + confirmedAt, + createdAt: timestamp, + updatedAt: timestamp, +}); + +describe("dashboard routes", () => { + beforeEach(() => { + process.env.JWT_ACCESS_SECRET = "test-access-secret"; + delete process.env.JWT_ACCESS_EXPIRES_IN; + delete process.env.WEB_APP_ORIGIN; + }); + + const authHeader = () => `Bearer ${createAccessToken(BigInt(1))}`; + + it("requires authentication", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/dashboard"); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + errorCode: "UNAUTHORIZED", + statusCode: 401, + }); + }); + + it("requires an organization", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/dashboard").set("Authorization", authHeader()); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + errorCode: "ORGANIZATION_REQUIRED", + statusCode: 403, + }); + }); + + it("returns organization detail and the latest confirmed schedule", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [ + createSchedule({ + id: BigInt(1), + confirmedAt: new Date("2026-06-20T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(2), + confirmedAt: new Date("2026-06-21T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(3), + confirmedAt: new Date("2026-06-21T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(4), + confirmedAt: null, + status: "DRAFT", + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/dashboard").set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + organization: { + id: "1", + name: "프래그먼트 카페", + businessHours: [ + { + dayOfWeek: "MON", + isClosed: false, + openTime: "09:00", + closeTime: "18:00", + closesNextDay: false, + }, + ], + }, + latestConfirmedSchedule: { + id: "3", + status: "CONFIRMED", + confirmedAt: "2026-06-21T00:00:00.000Z", + }, + }); + }); + + it("returns null when there is no confirmed schedule", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [ + createSchedule({ + id: BigInt(1), + confirmedAt: null, + status: "DRAFT", + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/dashboard").set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body.latestConfirmedSchedule).toBeNull(); + }); +}); diff --git a/apps/api/test/helpers/fake-prisma.ts b/apps/api/test/helpers/fake-prisma.ts index c013ba6..d942192 100644 --- a/apps/api/test/helpers/fake-prisma.ts +++ b/apps/api/test/helpers/fake-prisma.ts @@ -454,6 +454,37 @@ export function createFakePrisma({ return true; }; + const sortSchedules = (schedules: FakeSchedule[], orderBy: any) => { + if (Array.isArray(orderBy)) { + return [...schedules].sort((first, second) => { + for (const order of orderBy) { + if (order.confirmedAt === "desc") { + const firstTime = first.confirmedAt?.getTime() ?? 0; + const secondTime = second.confirmedAt?.getTime() ?? 0; + + if (firstTime !== secondTime) { + return secondTime - firstTime; + } + } + + if (order.id === "desc" && first.id !== second.id) { + return first.id < second.id ? 1 : -1; + } + } + + return 0; + }); + } + + if (orderBy?.id === "desc") { + return [...schedules].sort((first, second) => + first.id < second.id ? 1 : first.id > second.id ? -1 : 0, + ); + } + + return schedules; + }; + const prisma = { $transaction: async (callback: (transaction: PrismaClient) => T | Promise) => callback(prisma as unknown as PrismaClient), @@ -888,11 +919,7 @@ export function createFakePrisma({ matchesScheduleWhere(currentSchedule, where), ); - if (orderBy?.id === "desc") { - schedules = [...schedules].sort((first, second) => - first.id < second.id ? 1 : first.id > second.id ? -1 : 0, - ); - } + schedules = sortSchedules(schedules, orderBy); const schedule = schedules[0]; @@ -903,26 +930,7 @@ export function createFakePrisma({ matchesScheduleWhere(currentSchedule, where), ); - if (Array.isArray(orderBy)) { - schedules = [...schedules].sort((first, second) => { - for (const order of orderBy) { - if (order.confirmedAt === "desc") { - const firstTime = first.confirmedAt?.getTime() ?? 0; - const secondTime = second.confirmedAt?.getTime() ?? 0; - - if (firstTime !== secondTime) { - return secondTime - firstTime; - } - } - - if (order.id === "desc" && first.id !== second.id) { - return first.id < second.id ? 1 : -1; - } - } - - return 0; - }); - } + schedules = sortSchedules(schedules, orderBy); return schedules.map((schedule) => toScheduleResult(schedule, options)); }, From 28ba5e6ecf87ab8e835c54956019271d5cd98113 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 04:24:40 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(web):=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=20API=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/dashboard/api/dashboard-api.ts | 9 ++++ .../dashboard/queries/dashboard-queries.ts | 15 +++++++ .../dashboard/queries/dashboard-query-keys.ts | 1 + .../api/schedule-history-api.ts | 44 +++++++++++++++++++ .../queries/schedule-history-queries.ts | 34 ++++++++++++++ .../queries/schedule-history-query-keys.ts | 7 +++ apps/web/src/lib/api-client.ts | 13 +++++- 7 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/features/dashboard/api/dashboard-api.ts create mode 100644 apps/web/src/features/dashboard/queries/dashboard-queries.ts create mode 100644 apps/web/src/features/dashboard/queries/dashboard-query-keys.ts create mode 100644 apps/web/src/features/schedule-history/api/schedule-history-api.ts create mode 100644 apps/web/src/features/schedule-history/queries/schedule-history-queries.ts create mode 100644 apps/web/src/features/schedule-history/queries/schedule-history-query-keys.ts diff --git a/apps/web/src/features/dashboard/api/dashboard-api.ts b/apps/web/src/features/dashboard/api/dashboard-api.ts new file mode 100644 index 0000000..96cf7f7 --- /dev/null +++ b/apps/web/src/features/dashboard/api/dashboard-api.ts @@ -0,0 +1,9 @@ +import type { DashboardResponse } from "@fragment/shared"; + +import { apiClient } from "@/lib/api-client"; + +export function getDashboard() { + return apiClient("/dashboard", { + method: "GET", + }); +} diff --git a/apps/web/src/features/dashboard/queries/dashboard-queries.ts b/apps/web/src/features/dashboard/queries/dashboard-queries.ts new file mode 100644 index 0000000..04c44d5 --- /dev/null +++ b/apps/web/src/features/dashboard/queries/dashboard-queries.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { getDashboard } from "@/features/dashboard/api/dashboard-api"; +import { dashboardQueryKey } from "./dashboard-query-keys"; + +export { dashboardQueryKey } from "./dashboard-query-keys"; + +export function useDashboardQuery() { + return useQuery({ + queryFn: getDashboard, + queryKey: dashboardQueryKey, + }); +} diff --git a/apps/web/src/features/dashboard/queries/dashboard-query-keys.ts b/apps/web/src/features/dashboard/queries/dashboard-query-keys.ts new file mode 100644 index 0000000..55e5063 --- /dev/null +++ b/apps/web/src/features/dashboard/queries/dashboard-query-keys.ts @@ -0,0 +1 @@ +export const dashboardQueryKey = ["dashboard"] as const; diff --git a/apps/web/src/features/schedule-history/api/schedule-history-api.ts b/apps/web/src/features/schedule-history/api/schedule-history-api.ts new file mode 100644 index 0000000..afecdbc --- /dev/null +++ b/apps/web/src/features/schedule-history/api/schedule-history-api.ts @@ -0,0 +1,44 @@ +import type { + ScheduleHistoryDetailResponse, + ScheduleHistoryListResponse, + ScheduleHistoryQuery, +} from "@fragment/shared"; + +import { apiClient } from "@/lib/api-client"; + +function createScheduleHistorySearchParams(query: ScheduleHistoryQuery) { + const searchParams = new URLSearchParams({ + year: String(query.year), + }); + + if (query.month !== undefined) { + searchParams.set("month", String(query.month)); + } + + return searchParams.toString(); +} + +export function getScheduleHistory(query: ScheduleHistoryQuery) { + return apiClient( + `/schedule-history?${createScheduleHistorySearchParams(query)}`, + { + method: "GET", + }, + ); +} + +export function getScheduleHistoryDetail(scheduleId: string) { + return apiClient(`/schedule-history/${scheduleId}`, { + method: "GET", + }); +} + +export function exportScheduleHistoryCsv(scheduleId: string) { + return apiClient(`/schedule-history/${scheduleId}/export.csv`, { + headers: { + accept: "text/csv", + }, + method: "GET", + responseType: "blob", + }); +} diff --git a/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts b/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts new file mode 100644 index 0000000..4dbaddf --- /dev/null +++ b/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts @@ -0,0 +1,34 @@ +"use client"; + +import type { ScheduleHistoryQuery } from "@fragment/shared"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { + exportScheduleHistoryCsv, + getScheduleHistory, + getScheduleHistoryDetail, +} from "@/features/schedule-history/api/schedule-history-api"; +import { scheduleHistoryQueryKeys } from "./schedule-history-query-keys"; + +export { scheduleHistoryQueryKeys } from "./schedule-history-query-keys"; + +export function useScheduleHistoryQuery(query: ScheduleHistoryQuery) { + return useQuery({ + queryFn: () => getScheduleHistory(query), + queryKey: scheduleHistoryQueryKeys.list(query), + }); +} + +export function useScheduleHistoryDetailQuery(scheduleId: string, enabled = true) { + return useQuery({ + enabled, + queryFn: () => getScheduleHistoryDetail(scheduleId), + queryKey: scheduleHistoryQueryKeys.detail(scheduleId), + }); +} + +export function useExportScheduleHistoryCsvMutation() { + return useMutation({ + mutationFn: exportScheduleHistoryCsv, + }); +} diff --git a/apps/web/src/features/schedule-history/queries/schedule-history-query-keys.ts b/apps/web/src/features/schedule-history/queries/schedule-history-query-keys.ts new file mode 100644 index 0000000..5b4f77c --- /dev/null +++ b/apps/web/src/features/schedule-history/queries/schedule-history-query-keys.ts @@ -0,0 +1,7 @@ +import type { ScheduleHistoryQuery } from "@fragment/shared"; + +export const scheduleHistoryQueryKeys = { + all: ["schedule-history"] as const, + detail: (scheduleId: string) => [...scheduleHistoryQueryKeys.all, "detail", scheduleId] as const, + list: (query: ScheduleHistoryQuery) => [...scheduleHistoryQueryKeys.all, "list", query] as const, +}; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index a0fcccc..8ee0cc3 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -16,6 +16,10 @@ type ApiVoidRequestOptions = ApiRequestOptions & { responseType: "void"; }; +type ApiBlobRequestOptions = ApiRequestOptions & { + responseType: "blob"; +}; + type ApiErrorResponse = Partial; type ApiClientAuthHandlers = { @@ -97,6 +101,7 @@ async function parseErrorResponse(response: Response) { } export function apiClient(path: string, options: ApiVoidRequestOptions): Promise; +export function apiClient(path: string, options: ApiBlobRequestOptions): Promise; export function apiClient( path: string, options?: ApiJsonRequestOptions, @@ -110,8 +115,8 @@ export async function apiClient( redirectOnUnauthorized = true, responseType = "json", ...init - }: ApiJsonRequestOptions | ApiVoidRequestOptions = {}, -): Promise { + }: ApiBlobRequestOptions | ApiJsonRequestOptions | ApiVoidRequestOptions = {}, +): Promise { const hasBody = body !== undefined; async function sendRequest() { @@ -181,6 +186,10 @@ export async function apiClient( return; } + if (responseType === "blob") { + return response.blob(); + } + if (response.status === 204) { throw new ApiError(response.status, { message: "응답 본문이 비어 있습니다.", From fc75b9fadc61b0bfb7127bb40af48154ccd051bb Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 04:35:01 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(web):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-dashboard-page.tsx | 281 ++++++++++-------- 1 file changed, 159 insertions(+), 122 deletions(-) diff --git a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx index cc14055..fec5031 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -2,26 +2,15 @@ import Link from "next/link"; import { useState } from "react"; -import type { OrganizationDetail } from "@fragment/shared"; +import type { OrganizationDetail, ScheduleAssignment } from "@fragment/shared"; import { Button } from "@moyeorak/design-system"; import { Building2, CalendarX, Clock, Download, Pencil } from "lucide-react"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { Badge } from "@/components/ui/badge"; -import { useOrganizationQuery } from "@/features/organization/queries/organization-queries"; - -type ConfirmedSchedule = { - id: string; - date: string; - startTime: string; - endTime: string; - workerName: string; -}; - -const scheduleSummary = { - scheduleRange: "2026-06-20 ~ 2026-07-05", - confirmedScheduleCount: 20, -}; +import { useDashboardQuery } from "@/features/dashboard/queries/dashboard-queries"; +import { useExportScheduleHistoryCsvMutation } from "@/features/schedule-history/queries/schedule-history-queries"; +import { getApiErrorMessage } from "@/lib/api-error-message"; const DAY_LABELS = { MON: "월", @@ -33,57 +22,6 @@ const DAY_LABELS = { SUN: "일", } as const; const WEEKDAY_LABELS = Object.values(DAY_LABELS); -const CONFIRMED_START_DATE = "2026-06-20"; -const CONFIRMED_END_DATE = "2026-07-05"; -const CONFIRMED_WORKER_NAMES = [ - "김민지", - "박준호", - "이서연", - "최유나", - "정도윤", - "한서준", - "오하린", - "강지우", - "윤태오", - "임서아", - "조민규", - "배수빈", - "문지훈", - "신예린", - "남현우", - "서다은", - "권도현", - "백지민", - "유시우", - "홍나연", -]; -const CONFIRMED_TIME_RANGES = [ - ["09:00", "13:00"], - ["10:00", "14:00"], - ["13:00", "17:00"], - ["14:00", "18:00"], - ["18:00", "22:00"], -]; -const CONFIRMED_SCHEDULE_DATES = [ - "2026-06-26", - "2026-06-27", - "2026-06-29", - "2026-07-01", - "2026-07-03", -]; -const CONFIRMED_SCHEDULES: ConfirmedSchedule[] = CONFIRMED_WORKER_NAMES.map((workerName, index) => { - const [startTime, endTime] = CONFIRMED_TIME_RANGES[index % CONFIRMED_TIME_RANGES.length]; - const date = - index < 17 ? "2026-06-26" : CONFIRMED_SCHEDULE_DATES[index % CONFIRMED_SCHEDULE_DATES.length]; - - return { - id: `confirmed-${index + 1}`, - date, - startTime, - endTime, - workerName, - }; -}); function addDays(date: string, days: number) { const [year, month, day] = date.split("-").map(Number); @@ -116,6 +54,30 @@ function createCalendarDates(startDate: string, endDate: string) { return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); } +function formatScheduleRange(startDate: string, endDate: string) { + return `${startDate} ~ ${endDate}`; +} + +function formatDateTimeToTime(dateTime: string) { + return dateTime.slice(11, 16); +} + +function createCsvFileName(startDate: string, endDate: string) { + return `schedule-${startDate}-${endDate}.csv`; +} + +function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = fileName; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + function formatBusinessHourRange(businessHour: OrganizationDetail["businessHours"][number]) { if (!businessHour.openTime || !businessHour.closeTime) { return "운영 시간 미설정"; @@ -163,25 +125,51 @@ function getClosedDayLabels(organization: OrganizationDetail) { } export function MvpDashboardPage() { - const organizationQuery = useOrganizationQuery(); + const dashboardQuery = useDashboardQuery(); + const exportScheduleHistoryCsvMutation = useExportScheduleHistoryCsvMutation(); const [exportMessage, setExportMessage] = useState(""); - const calendarDates = createCalendarDates(CONFIRMED_START_DATE, CONFIRMED_END_DATE); - const organization = organizationQuery.data; - const organizationName = organizationQuery.isPending + const organization = dashboardQuery.data?.organization; + const latestConfirmedSchedule = dashboardQuery.data?.latestConfirmedSchedule ?? null; + const calendarDates = latestConfirmedSchedule + ? createCalendarDates(latestConfirmedSchedule.startDate, latestConfirmedSchedule.endDate) + : []; + const organizationName = dashboardQuery.isPending ? "조직 정보를 불러오는 중입니다" - : organizationQuery.isError + : dashboardQuery.isError ? "조직 정보를 불러오지 못했습니다" : (organization?.name ?? "-"); - const organizationStatus = organizationQuery.isPending + const organizationStatus = dashboardQuery.isPending ? "조회 중" - : organizationQuery.isError + : dashboardQuery.isError ? "확인 필요" : "운영 정보"; - const fallbackLabel = organizationQuery.isPending ? "조회 중" : "-"; + const fallbackLabel = dashboardQuery.isPending ? "조회 중" : "-"; const operationHourGroups = organization ? getOperationHourGroups(organization) : [{ dayLabels: fallbackLabel, range: "-" }]; const closedDayLabels = organization ? getClosedDayLabels(organization) : [fallbackLabel]; + const canExportSchedule = + latestConfirmedSchedule !== null && !exportScheduleHistoryCsvMutation.isPending; + + const handleExportSchedule = async () => { + if (!latestConfirmedSchedule) { + return; + } + + setExportMessage(""); + + try { + const blob = await exportScheduleHistoryCsvMutation.mutateAsync(latestConfirmedSchedule.id); + + downloadBlob( + blob, + createCsvFileName(latestConfirmedSchedule.startDate, latestConfirmedSchedule.endDate), + ); + setExportMessage("CSV 파일을 다운로드했습니다."); + } catch (error) { + setExportMessage(getApiErrorMessage(error, "CSV 다운로드에 실패했습니다.")); + } + }; return ( {organizationName} - + {organizationStatus} @@ -272,65 +260,114 @@ export function MvpDashboardPage() {

확정 스케줄 달력

-

{scheduleSummary.scheduleRange}

-

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

+ {dashboardQuery.isPending ? ( +

+ 확정 스케줄 정보를 불러오는 중입니다. +

+ ) : dashboardQuery.isError ? ( +

+ 확정 스케줄 정보를 불러오지 못했습니다. +

+ ) : latestConfirmedSchedule ? ( + <> +

+ {formatScheduleRange( + latestConfirmedSchedule.startDate, + latestConfirmedSchedule.endDate, + )} +

+

+ 총 {latestConfirmedSchedule.assignments.length}개 배정이 확정되었습니다. +

+ + ) : ( +

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

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

확정 스케줄을 불러오는 중입니다.

+
+ ) : dashboardQuery.isError ? ( +
+

+ 확정 스케줄 정보를 불러오지 못했습니다. +

+

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

+
+ ) : latestConfirmedSchedule ? ( +
+
+ {WEEKDAY_LABELS.map((weekday) => (
-
-

{date.slice(8, 10)}

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

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

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

{date.slice(8, 10)}

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

+ {formatDateTimeToTime(assignment.startsAt)}- + {formatDateTimeToTime(assignment.endsAt)} {assignment.workerNameSnapshot} +

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

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

+

+ 스케줄을 확정하면 이곳에서 확인할 수 있습니다. +

+ +
+ )} ); From 0f891029a6570dd46422f5f84c24a5a3f654f492 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 04:40:29 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat(web):=20=EB=B3=B4=EA=B4=80=ED=95=A8?= =?UTF-8?q?=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-schedule-history-page.tsx | 352 +++++++++--------- 1 file changed, 175 insertions(+), 177 deletions(-) diff --git a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 153eef4..a0df39f 100644 --- a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx +++ b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import type { ScheduleAssignment, ScheduleHistoryItem } from "@fragment/shared"; import { Button } from "@moyeorak/design-system"; import { Download } from "lucide-react"; @@ -12,55 +13,19 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; - -type ConfirmedSchedule = { - id: string; - date: string; - startTime: string; - endTime: string; - workerName: string; -}; - -type ScheduleHistory = { - id: string; - period: string; - startDate: string; - endDate: string; - confirmedAt: string; - scheduleCount: number; - schedules: ConfirmedSchedule[]; -}; +import { + useExportScheduleHistoryCsvMutation, + useScheduleHistoryDetailQuery, + useScheduleHistoryQuery, +} from "@/features/schedule-history/queries/schedule-history-queries"; +import { getApiErrorMessage } from "@/lib/api-error-message"; const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; -const WORKER_NAMES = [ - "김민지", - "박준호", - "이서연", - "최유나", - "정도윤", - "한서준", - "오하린", - "강지우", - "윤태오", - "임서아", - "조민규", - "배수빈", - "문지훈", - "신예린", - "남현우", - "서다은", - "권도현", - "백지민", - "유시우", - "홍나연", -]; -const TIME_RANGES = [ - ["09:00", "13:00"], - ["10:00", "14:00"], - ["13:00", "17:00"], - ["14:00", "18:00"], - ["18:00", "22:00"], -]; +const MONTH_OPTIONS = Array.from({ length: 12 }, (_, index) => index + 1); +const INITIAL_HISTORY_YEAR = new Date().getFullYear(); +const HISTORY_YEAR_OPTIONS = Array.from({ length: 6 }, (_, index) => + String(INITIAL_HISTORY_YEAR - index), +); function addDays(date: string, days: number) { const [year, month, day] = date.split("-").map(Number); @@ -93,92 +58,81 @@ function createCalendarDates(startDate: string, endDate: string) { return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); } -function createConfirmedSchedules(startDate: string, endDate: string) { - const dates = createDateRange(startDate, endDate); +function formatScheduleRange(startDate: string, endDate: string) { + return `${startDate} ~ ${endDate}`; +} + +function formatConfirmedAt(confirmedAt: string) { + return confirmedAt.slice(0, 10); +} - return WORKER_NAMES.map((workerName, index) => { - const [startTime, endTime] = TIME_RANGES[index % TIME_RANGES.length]; - const date = index < 17 ? dates[6] : (dates[(index + 2) % dates.length] ?? endDate); +function formatDateTimeToTime(dateTime: string) { + return dateTime.slice(11, 16); +} - return { - id: `${startDate}-confirmed-${index + 1}`, - date, - startTime, - endTime, - workerName, - }; - }); +function createCsvFileName(startDate: string, endDate: string) { + return `schedule-${startDate}-${endDate}.csv`; } -const SCHEDULE_HISTORIES: ScheduleHistory[] = [ - { - id: "history-1", - period: "2026-06-20 ~ 2026-07-05", - startDate: "2026-06-20", - endDate: "2026-07-05", - confirmedAt: "2026-06-20", - scheduleCount: 20, - schedules: createConfirmedSchedules("2026-06-20", "2026-07-05"), - }, - { - id: "history-2", - period: "2026-06-01 ~ 2026-06-15", - startDate: "2026-06-01", - endDate: "2026-06-15", - confirmedAt: "2026-05-30", - scheduleCount: 18, - schedules: createConfirmedSchedules("2026-06-01", "2026-06-15").slice(0, 18), - }, - { - id: "history-3", - period: "2026-05-16 ~ 2026-05-31", - startDate: "2026-05-16", - endDate: "2026-05-31", - confirmedAt: "2026-05-14", - scheduleCount: 16, - schedules: createConfirmedSchedules("2026-05-16", "2026-05-31").slice(0, 16), - }, - { - id: "history-4", - period: "2025-12-16 ~ 2025-12-31", - startDate: "2025-12-16", - endDate: "2025-12-31", - confirmedAt: "2025-12-14", - scheduleCount: 15, - schedules: createConfirmedSchedules("2025-12-16", "2025-12-31").slice(0, 15), - }, -]; +function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); -function getHistoryYear(history: ScheduleHistory) { - return history.startDate.slice(0, 4); + link.href = url; + link.download = fileName; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); } -function getHistoryMonth(history: ScheduleHistory) { - return history.startDate.slice(5, 7); +function findSelectedHistory(items: ScheduleHistoryItem[], selectedHistoryId: string) { + return items.find((history) => history.id === selectedHistoryId) ?? items[0] ?? null; } export function MvpScheduleHistoryPage() { - const [selectedHistoryId, setSelectedHistoryId] = useState(SCHEDULE_HISTORIES[0]?.id ?? ""); - const [selectedYear, setSelectedYear] = useState(getHistoryYear(SCHEDULE_HISTORIES[0])); + const [selectedHistoryId, setSelectedHistoryId] = useState(""); + const [selectedYear, setSelectedYear] = useState(String(INITIAL_HISTORY_YEAR)); const [selectedMonth, setSelectedMonth] = useState("ALL"); const [exportMessage, setExportMessage] = useState(""); - const historyYears = Array.from(new Set(SCHEDULE_HISTORIES.map(getHistoryYear))); - const selectedYearHistories = SCHEDULE_HISTORIES.filter( - (history) => getHistoryYear(history) === selectedYear, - ); - const historyMonths = Array.from(new Set(selectedYearHistories.map(getHistoryMonth))); - const filteredHistories = selectedYearHistories.filter( - (history) => selectedMonth === "ALL" || getHistoryMonth(history) === selectedMonth, + const scheduleHistoryQuery = useScheduleHistoryQuery({ + year: Number(selectedYear), + ...(selectedMonth === "ALL" ? {} : { month: Number(selectedMonth) }), + }); + const historyItems = scheduleHistoryQuery.data?.items ?? []; + const selectedHistoryItem = findSelectedHistory(historyItems, selectedHistoryId); + const selectedScheduleId = selectedHistoryItem?.id ?? ""; + const scheduleHistoryDetailQuery = useScheduleHistoryDetailQuery( + selectedScheduleId, + selectedScheduleId !== "", ); - const selectedHistory = - filteredHistories.find((history) => history.id === selectedHistoryId) ?? - filteredHistories[0] ?? - selectedYearHistories[0] ?? - SCHEDULE_HISTORIES[0]; + const exportScheduleHistoryCsvMutation = useExportScheduleHistoryCsvMutation(); + const selectedHistory = scheduleHistoryDetailQuery.data ?? null; const calendarDates = useMemo( - () => createCalendarDates(selectedHistory.startDate, selectedHistory.endDate), - [selectedHistory.endDate, selectedHistory.startDate], + () => + selectedHistory + ? createCalendarDates(selectedHistory.startDate, selectedHistory.endDate) + : [], + [selectedHistory], ); + const canExportSchedule = selectedHistory !== null && !exportScheduleHistoryCsvMutation.isPending; + + const handleExportSchedule = async () => { + if (!selectedHistory) { + return; + } + + setExportMessage(""); + + try { + const blob = await exportScheduleHistoryCsvMutation.mutateAsync(selectedHistory.id); + + downloadBlob(blob, createCsvFileName(selectedHistory.startDate, selectedHistory.endDate)); + setExportMessage("CSV 파일을 다운로드했습니다."); + } catch (error) { + setExportMessage(getApiErrorMessage(error, "CSV 다운로드에 실패했습니다.")); + } + }; return (

읽기 모드 달력

-

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

+ {scheduleHistoryQuery.isPending ? ( +

+ 확정 스케줄 보관함을 불러오는 중입니다. +

+ ) : scheduleHistoryQuery.isError || scheduleHistoryDetailQuery.isError ? ( +

+ 확정 스케줄 보관함을 불러오지 못했습니다. +

+ ) : selectedHistory ? ( +

+ 확정일 {formatConfirmedAt(selectedHistory.confirmedAt)} · 총{" "} + {selectedHistory.assignments.length}개 배정 +

+ ) : ( +

+ 선택한 조건에 해당하는 확정 스케줄이 없습니다. +

+ )}
{ - setSelectedYear(value); - setSelectedMonth("ALL"); - setSelectedHistoryId(""); - setExportMessage(""); - }} - > - - - - - {HISTORY_YEAR_OPTIONS.map((year) => ( - - {year}년 - - ))} - - - - - - - - -
+ {hasAvailableYears ? ( +
+ + + + + {hasHistoryItems ? ( + <> + + + + + ) : null} +
+ ) : null} {scheduleHistoryQuery.isPending || scheduleHistoryDetailQuery.isPending ? ( diff --git a/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts b/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts index 4dbaddf..8203f4e 100644 --- a/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts +++ b/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts @@ -1,7 +1,7 @@ "use client"; import type { ScheduleHistoryQuery } from "@fragment/shared"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQueries, useQuery } from "@tanstack/react-query"; import { exportScheduleHistoryCsv, @@ -19,6 +19,15 @@ export function useScheduleHistoryQuery(query: ScheduleHistoryQuery) { }); } +export function useScheduleHistoryYearQueries(years: number[]) { + return useQueries({ + queries: years.map((year) => ({ + queryFn: () => getScheduleHistory({ year }), + queryKey: scheduleHistoryQueryKeys.list({ year }), + })), + }); +} + export function useScheduleHistoryDetailQuery(scheduleId: string, enabled = true) { return useQuery({ enabled, diff --git a/docs/API.md b/docs/API.md index 0bae983..e69ebc7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -581,14 +581,22 @@ Response `200`: `ScheduleHistoryDetailResponse` **Auth:** Bearer token 현재 사용자의 조직에 속한 `CONFIRMED` 스케줄을 CSV로 내보냅니다. -CSV는 스케줄 배정 기록의 snapshot 값을 사용합니다. +CSV는 인쇄/공유용 근무표 형태로 내려줍니다. +근무자명은 스케줄 배정 기록의 snapshot 값을 사용합니다. Response `200`: `text/csv; charset=utf-8` -CSV columns: +CSV format: + +- 첫 행은 `날짜`, `요일`, `시간대...` 헤더입니다. +- 시간대 컬럼은 해당 스케줄의 배정 시간 범위를 오름차순으로 생성합니다. +- 이후 행은 스케줄 기간의 날짜별 근무표입니다. +- 같은 날짜/시간대에 여러 근무자가 있으면 ` / `로 구분합니다. ```txt -날짜,요일,시작시간,종료시간,사번,근무자명 +날짜,요일,10:00-14:00,15:00-18:00 +2026-07-01,수,김민수 / 이서연, +2026-07-02,목,,박준호 ``` 조회 가능한 확정 스케줄이 없으면 `404 SCHEDULE_NOT_FOUND`를 반환합니다. From 829b6fd697a4fd6dfb21e3d08677762f1e7ac63a Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 10:14:07 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=EB=B3=B4=EA=B4=80=ED=95=A8=C2=B7?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule-history.service.ts | 14 +- apps/api/test/dashboard.routes.test.ts | 36 ++- apps/api/test/schedule-history.routes.test.ts | 209 ++++++++++++++++-- .../components/mvp-dashboard-page.tsx | 13 +- .../api/schedule-history-api.ts | 12 +- .../components/mvp-schedule-history-page.tsx | 70 ++++-- .../queries/schedule-history-queries.ts | 11 +- docs/API.md | 4 + .../shared/src/schemas/schedule-history.ts | 12 +- 9 files changed, 312 insertions(+), 69 deletions(-) diff --git a/apps/api/src/modules/schedule-history/schedule-history.service.ts b/apps/api/src/modules/schedule-history/schedule-history.service.ts index e923400..03597c9 100644 --- a/apps/api/src/modules/schedule-history/schedule-history.service.ts +++ b/apps/api/src/modules/schedule-history/schedule-history.service.ts @@ -17,6 +17,10 @@ const createScheduleNotFoundError = () => const WEEKDAY_LABELS = ["일", "월", "화", "수", "목", "금", "토"] as const; const createDateRangeFilter = ({ year, month }: ScheduleHistoryQuery) => { + if (year === undefined) { + return {}; + } + const startDate = dateStringToDate(`${year}-${String(month ?? 1).padStart(2, "0")}-01`); const endDate = month === undefined ? dateStringToDate(`${year}-12-31`) : new Date(Date.UTC(year, month, 0)); @@ -76,12 +80,16 @@ export async function getScheduleHistoryDetail( return toScheduleDetail(schedule) as ScheduleHistoryDetailResponse; } +const FORMULA_INJECTION_PREFIX_PATTERN = /^\s*[=+\-@]/; + const escapeCsvCell = (value: string) => { - if (!/[",\n\r]/.test(value)) { - return value; + const safeValue = FORMULA_INJECTION_PREFIX_PATTERN.test(value) ? `'${value}` : value; + + if (!/[",\n\r]/.test(safeValue)) { + return safeValue; } - return `"${value.replace(/"/g, '""')}"`; + return `"${safeValue.replace(/"/g, '""')}"`; }; const createCsvRow = (values: string[]) => values.map(escapeCsvCell).join(","); diff --git a/apps/api/test/dashboard.routes.test.ts b/apps/api/test/dashboard.routes.test.ts index 1bc0b23..43e1db5 100644 --- a/apps/api/test/dashboard.routes.test.ts +++ b/apps/api/test/dashboard.routes.test.ts @@ -8,14 +8,22 @@ 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 createOrganization = () => ({ - id: BigInt(1), - userId: BigInt(1), - name: "프래그먼트 카페", +const createOrganization = ({ + id = BigInt(1), + name = "프래그먼트 카페", + userId = BigInt(1), +}: { + id?: bigint; + name?: string; + userId?: bigint; +} = {}) => ({ + id, + userId, + name, businessHours: [ { id: BigInt(1), - organizationId: BigInt(1), + organizationId: id, dayOfWeek: "MON" as const, isClosed: false, openTime: createTimeDate("09:00"), @@ -28,14 +36,16 @@ const createOrganization = () => ({ const createSchedule = ({ confirmedAt, id, + organizationId = BigInt(1), status = "CONFIRMED", }: { confirmedAt: Date | null; id: bigint; + organizationId?: bigint; status?: "DRAFT" | "CONFIRMED"; }) => ({ id, - organizationId: BigInt(1), + organizationId, status, inputHash: `hash-${id.toString()}`, startDate: dateOnly("2026-07-01"), @@ -83,7 +93,14 @@ describe("dashboard routes", () => { it("returns organization detail and the latest confirmed schedule", async () => { const { prisma } = createFakePrisma({ - organizations: [createOrganization()], + organizations: [ + createOrganization(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], schedules: [ createSchedule({ id: BigInt(1), @@ -102,6 +119,11 @@ describe("dashboard routes", () => { confirmedAt: null, status: "DRAFT", }), + createSchedule({ + id: BigInt(5), + organizationId: BigInt(2), + confirmedAt: new Date("2026-06-30T00:00:00.000Z"), + }), ], }); const app = createApp({ prisma }); diff --git a/apps/api/test/schedule-history.routes.test.ts b/apps/api/test/schedule-history.routes.test.ts index d7328c8..fd6d02d 100644 --- a/apps/api/test/schedule-history.routes.test.ts +++ b/apps/api/test/schedule-history.routes.test.ts @@ -8,10 +8,18 @@ import { createFakePrisma } 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 createOrganization = () => ({ - id: BigInt(1), - userId: BigInt(1), - name: "프래그먼트 카페", +const createOrganization = ({ + id = BigInt(1), + name = "프래그먼트 카페", + userId = BigInt(1), +}: { + id?: bigint; + name?: string; + userId?: bigint; +} = {}) => ({ + id, + userId, + name, businessHours: [], }); @@ -19,17 +27,19 @@ const createSchedule = ({ confirmedAt, endDate, id, + organizationId = BigInt(1), startDate, status = "CONFIRMED", }: { confirmedAt: Date | null; endDate: string; id: bigint; + organizationId?: bigint; startDate: string; status?: "DRAFT" | "CONFIRMED"; }) => ({ id, - organizationId: BigInt(1), + organizationId, status, inputHash: `hash-${id.toString()}`, startDate: dateOnly(startDate), @@ -44,6 +54,7 @@ const createAssignment = ({ employeeCodeSnapshot = "W-0001", endsAt = "2026-07-01T14:00:00.000Z", id = BigInt(10), + scheduleId = BigInt(1), startsAt = "2026-07-01T10:00:00.000Z", workerId = BigInt(1), workerNameSnapshot = "김민수", @@ -52,13 +63,14 @@ const createAssignment = ({ employeeCodeSnapshot?: string; endsAt?: string; id?: bigint; + scheduleId?: bigint; startsAt?: string; workerId?: bigint; workerNameSnapshot?: string; workDate?: string; } = {}) => ({ id, - scheduleId: BigInt(1), + scheduleId, workerId, workDate: dateOnly(workDate), startsAt: new Date(startsAt), @@ -106,9 +118,78 @@ describe("schedule history routes", () => { }); }); - it("lists confirmed schedules overlapping the requested month", async () => { + it("rejects invalid history query", async () => { const { prisma } = createFakePrisma({ organizations: [createOrganization()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history?year=2026&month=13") + .set("Authorization", authHeader()); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("rejects invalid history detail id", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history/not-a-number") + .set("Authorization", authHeader()); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + + it("lists all confirmed schedules when year is omitted", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2025-12-20", + endDate: "2026-01-05", + confirmedAt: new Date("2025-12-15T00:00:00.000Z"), + }), + createSchedule({ + id: BigInt(2), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-20T00:00:00.000Z"), + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history") + .set("Authorization", authHeader()); + + expect(response.status).toBe(200); + expect(response.body.items.map((item: { id: string }) => item.id)).toEqual(["2", "1"]); + }); + + it("lists confirmed schedules overlapping the requested month", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + createOrganization(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], schedules: [ createSchedule({ id: BigInt(1), @@ -135,6 +216,13 @@ describe("schedule history routes", () => { confirmedAt: null, status: "DRAFT", }), + createSchedule({ + id: BigInt(5), + organizationId: BigInt(2), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-30T00:00:00.000Z"), + }), ], }); const app = createApp({ prisma }); @@ -186,8 +274,22 @@ describe("schedule history routes", () => { it("returns confirmed schedule detail", async () => { const { prisma } = createFakePrisma({ - organizations: [createOrganization()], - scheduleAssignments: [createAssignment()], + organizations: [ + createOrganization(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], + scheduleAssignments: [ + createAssignment(), + createAssignment({ + id: BigInt(11), + scheduleId: BigInt(2), + workerNameSnapshot: "외부 근무자", + }), + ], schedules: [ createSchedule({ id: BigInt(1), @@ -195,6 +297,13 @@ describe("schedule history routes", () => { endDate: "2026-07-31", confirmedAt: new Date("2026-06-25T00:00:00.000Z"), }), + createSchedule({ + id: BigInt(2), + organizationId: BigInt(2), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-30T00:00:00.000Z"), + }), ], }); const app = createApp({ prisma }); @@ -218,6 +327,39 @@ describe("schedule history routes", () => { }); }); + it("returns 404 when requesting another organization's schedule detail", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + createOrganization(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], + schedules: [ + createSchedule({ + id: BigInt(2), + organizationId: BigInt(2), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-30T00:00:00.000Z"), + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history/2") + .set("Authorization", authHeader()); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + errorCode: "SCHEDULE_NOT_FOUND", + statusCode: 404, + }); + }); + it("exports confirmed schedule assignments as csv", async () => { const { prisma } = createFakePrisma({ organizations: [createOrganization()], @@ -236,6 +378,14 @@ describe("schedule history routes", () => { workerNameSnapshot: "박준호", workDate: "2026-07-02", }), + createAssignment({ + id: BigInt(13), + startsAt: "2026-07-03T07:00:00.000Z", + endsAt: "2026-07-03T09:00:00.000Z", + workerId: BigInt(4), + workerNameSnapshot: '=HYPERLINK("https://example.com")', + workDate: "2026-07-03", + }), ], schedules: [ createSchedule({ @@ -256,10 +406,10 @@ describe("schedule history routes", () => { expect(response.headers["content-type"]).toContain("text/csv"); expect(response.text).toBe( `\uFEFF${[ - "날짜,요일,10:00-14:00,15:00-18:00", - "2026-07-01,수,김민수 / 이서연,", - "2026-07-02,목,,박준호", - "2026-07-03,금,,", + "날짜,요일,07:00-09:00,10:00-14:00,15:00-18:00", + "2026-07-01,수,,김민수 / 이서연,", + "2026-07-02,목,,,박준호", + '2026-07-03,금,"\'=HYPERLINK(""https://example.com"")",,', ].join("\n")}`, ); }); @@ -289,4 +439,37 @@ describe("schedule history routes", () => { statusCode: 404, }); }); + + it("returns 404 when exporting another organization's schedule", async () => { + const { prisma } = createFakePrisma({ + organizations: [ + createOrganization(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], + schedules: [ + createSchedule({ + id: BigInt(2), + organizationId: BigInt(2), + startDate: "2026-07-01", + endDate: "2026-07-31", + confirmedAt: new Date("2026-06-30T00:00:00.000Z"), + }), + ], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history/2/export.csv") + .set("Authorization", authHeader()); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + errorCode: "SCHEDULE_NOT_FOUND", + statusCode: 404, + }); + }); }); diff --git a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx index fec5031..f9375f8 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -58,8 +58,13 @@ function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; } -function formatDateTimeToTime(dateTime: string) { - return dateTime.slice(11, 16); +function formatScheduleAssignmentTimeRange(assignment: ScheduleAssignment) { + const startTime = assignment.startsAt.slice(11, 16); + const endTime = assignment.endsAt.slice(11, 16); + const endPrefix = + assignment.endsAt.slice(0, 10) > assignment.startsAt.slice(0, 10) ? "익일 " : ""; + + return `${startTime}-${endPrefix}${endTime}`; } function createCsvFileName(startDate: string, endDate: string) { @@ -347,8 +352,8 @@ export function MvpDashboardPage() { key={assignment.id} className="truncate text-xs font-medium text-foreground" > - {formatDateTimeToTime(assignment.startsAt)}- - {formatDateTimeToTime(assignment.endsAt)} {assignment.workerNameSnapshot} + {formatScheduleAssignmentTimeRange(assignment)}{" "} + {assignment.workerNameSnapshot}

))} diff --git a/apps/web/src/features/schedule-history/api/schedule-history-api.ts b/apps/web/src/features/schedule-history/api/schedule-history-api.ts index afecdbc..dbe57a7 100644 --- a/apps/web/src/features/schedule-history/api/schedule-history-api.ts +++ b/apps/web/src/features/schedule-history/api/schedule-history-api.ts @@ -7,9 +7,11 @@ import type { import { apiClient } from "@/lib/api-client"; function createScheduleHistorySearchParams(query: ScheduleHistoryQuery) { - const searchParams = new URLSearchParams({ - year: String(query.year), - }); + const searchParams = new URLSearchParams(); + + if (query.year !== undefined) { + searchParams.set("year", String(query.year)); + } if (query.month !== undefined) { searchParams.set("month", String(query.month)); @@ -19,8 +21,10 @@ function createScheduleHistorySearchParams(query: ScheduleHistoryQuery) { } export function getScheduleHistory(query: ScheduleHistoryQuery) { + const searchParams = createScheduleHistorySearchParams(query); + return apiClient( - `/schedule-history?${createScheduleHistorySearchParams(query)}`, + `/schedule-history${searchParams ? `?${searchParams}` : ""}`, { method: "GET", }, diff --git a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 6071629..9d93754 100644 --- a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx +++ b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx @@ -17,13 +17,11 @@ import { useExportScheduleHistoryCsvMutation, useScheduleHistoryDetailQuery, useScheduleHistoryQuery, - useScheduleHistoryYearQueries, } from "@/features/schedule-history/queries/schedule-history-queries"; import { getApiErrorMessage } from "@/lib/api-error-message"; const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const INITIAL_HISTORY_YEAR = new Date().getFullYear(); -const HISTORY_YEAR_OPTIONS = Array.from({ length: 6 }, (_, index) => INITIAL_HISTORY_YEAR - index); function addDays(date: string, days: number) { const [year, month, day] = date.split("-").map(Number); @@ -64,8 +62,13 @@ function formatConfirmedAt(confirmedAt: string) { return confirmedAt.slice(0, 10); } -function formatDateTimeToTime(dateTime: string) { - return dateTime.slice(11, 16); +function formatScheduleAssignmentTimeRange(assignment: ScheduleAssignment) { + const startTime = assignment.startsAt.slice(11, 16); + const endTime = assignment.endsAt.slice(11, 16); + const endPrefix = + assignment.endsAt.slice(0, 10) > assignment.startsAt.slice(0, 10) ? "익일 " : ""; + + return `${startTime}-${endPrefix}${endTime}`; } function createCsvFileName(startDate: string, endDate: string) { @@ -102,6 +105,28 @@ function isHistoryInMonth(history: ScheduleHistoryItem, year: string, month: str return history.startDate <= monthEnd && history.endDate >= monthStart; } +function isHistoryInYear(history: ScheduleHistoryItem, year: string) { + const yearStart = `${year}-01-01`; + const yearEnd = `${year}-12-31`; + + return history.startDate <= yearEnd && history.endDate >= yearStart; +} + +function getHistoryYearOptions(items: ScheduleHistoryItem[]) { + const years = new Set(); + + items.forEach((history) => { + const startYear = Number(history.startDate.slice(0, 4)); + const endYear = Number(history.endDate.slice(0, 4)); + + for (let year = startYear; year <= endYear; year += 1) { + years.add(String(year)); + } + }); + + return [...years].sort((first, second) => Number(second) - Number(first)); +} + function getHistoryMonthOptions(items: ScheduleHistoryItem[], year: string) { return Array.from({ length: 12 }, (_, index) => String(index + 1)).filter((month) => items.some((history) => isHistoryInMonth(history, year, month)), @@ -113,30 +138,26 @@ export function MvpScheduleHistoryPage() { const [selectedYear, setSelectedYear] = useState(String(INITIAL_HISTORY_YEAR)); const [selectedMonth, setSelectedMonth] = useState(""); const [exportMessage, setExportMessage] = useState(""); - const scheduleHistoryYearQueries = useScheduleHistoryYearQueries(HISTORY_YEAR_OPTIONS); - const historyYearItems = scheduleHistoryYearQueries.map((query, index) => ({ - items: query.data?.items ?? [], - year: String(HISTORY_YEAR_OPTIONS[index]), - })); - const availableYears = historyYearItems - .filter((historyYearItem) => historyYearItem.items.length > 0) - .map((historyYearItem) => historyYearItem.year); + const scheduleHistoryQuery = useScheduleHistoryQuery({}); + const allHistoryItems = scheduleHistoryQuery.data?.items ?? []; + const availableYears = getHistoryYearOptions(allHistoryItems); const hasAvailableYears = availableYears.length > 0; const effectiveSelectedYear = availableYears.includes(selectedYear) ? selectedYear : (availableYears[0] ?? selectedYear); - const selectedYearItems = - historyYearItems.find((historyYearItem) => historyYearItem.year === effectiveSelectedYear) - ?.items ?? []; + const selectedYearItems = allHistoryItems.filter((history) => + isHistoryInYear(history, effectiveSelectedYear), + ); const availableMonths = getHistoryMonthOptions(selectedYearItems, effectiveSelectedYear); const effectiveSelectedMonth = availableMonths.includes(selectedMonth) ? selectedMonth : (availableMonths[0] ?? ""); - const scheduleHistoryQuery = useScheduleHistoryQuery({ - year: Number(effectiveSelectedYear), - ...(effectiveSelectedMonth === "" ? {} : { month: Number(effectiveSelectedMonth) }), - }); - const historyItems = scheduleHistoryQuery.data?.items ?? []; + const historyItems = + effectiveSelectedMonth === "" + ? selectedYearItems + : selectedYearItems.filter((history) => + isHistoryInMonth(history, effectiveSelectedYear, effectiveSelectedMonth), + ); const hasHistoryItems = historyItems.length > 0; const selectedHistoryItem = findSelectedHistory(historyItems, selectedHistoryId); const selectedScheduleId = selectedHistoryItem?.id ?? ""; @@ -146,6 +167,8 @@ export function MvpScheduleHistoryPage() { ); const exportScheduleHistoryCsvMutation = useExportScheduleHistoryCsvMutation(); const selectedHistory = scheduleHistoryDetailQuery.data ?? null; + const isScheduleDetailLoading = selectedScheduleId !== "" && scheduleHistoryDetailQuery.isPending; + const isScheduleHistoryLoading = scheduleHistoryQuery.isPending || isScheduleDetailLoading; const calendarDates = useMemo( () => selectedHistory @@ -192,7 +215,7 @@ export function MvpScheduleHistoryPage() {

읽기 모드 달력

- {scheduleHistoryQuery.isPending ? ( + {isScheduleHistoryLoading ? (

확정 스케줄 보관함을 불러오는 중입니다.

@@ -290,7 +313,7 @@ export function MvpScheduleHistoryPage() { ) : null} - {scheduleHistoryQuery.isPending || scheduleHistoryDetailQuery.isPending ? ( + {isScheduleHistoryLoading ? (

확정 스케줄을 불러오는 중입니다. @@ -346,8 +369,7 @@ export function MvpScheduleHistoryPage() { key={assignment.id} className="truncate text-xs font-medium text-foreground" > - {formatDateTimeToTime(assignment.startsAt)}- - {formatDateTimeToTime(assignment.endsAt)}{" "} + {formatScheduleAssignmentTimeRange(assignment)}{" "} {assignment.workerNameSnapshot}

))} diff --git a/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts b/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts index 8203f4e..4dbaddf 100644 --- a/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts +++ b/apps/web/src/features/schedule-history/queries/schedule-history-queries.ts @@ -1,7 +1,7 @@ "use client"; import type { ScheduleHistoryQuery } from "@fragment/shared"; -import { useMutation, useQueries, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { exportScheduleHistoryCsv, @@ -19,15 +19,6 @@ export function useScheduleHistoryQuery(query: ScheduleHistoryQuery) { }); } -export function useScheduleHistoryYearQueries(years: number[]) { - return useQueries({ - queries: years.map((year) => ({ - queryFn: () => getScheduleHistory({ year }), - queryKey: scheduleHistoryQueryKeys.list({ year }), - })), - }); -} - export function useScheduleHistoryDetailQuery(scheduleId: string, enabled = true) { return useQuery({ enabled, diff --git a/docs/API.md b/docs/API.md index e69ebc7..24b49bf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -545,8 +545,10 @@ Query: year=2026&month=7 ``` +`year`와 `month`가 모두 없으면 현재 조직의 모든 확정 이력을 조회합니다. `month`가 없으면 해당 연도와 스케줄 기간이 겹치는 확정 이력을 조회합니다. `month`가 있으면 해당 연월과 스케줄 기간이 겹치는 확정 이력을 조회합니다. +`month`는 `year`와 함께 전달해야 합니다. 예를 들어 `2026-06-25 ~ 2026-07-05` 확정 스케줄은 2026년 6월 필터와 2026년 7월 필터에 모두 포함됩니다. 목록은 `confirmedAt desc, id desc` 순서로 정렬합니다. @@ -586,6 +588,8 @@ CSV는 인쇄/공유용 근무표 형태로 내려줍니다. Response `200`: `text/csv; charset=utf-8` +응답 본문은 Excel 호환을 위해 UTF-8 BOM으로 시작합니다. + CSV format: - 첫 행은 `날짜`, `요일`, `시간대...` 헤더입니다. diff --git a/packages/shared/src/schemas/schedule-history.ts b/packages/shared/src/schemas/schedule-history.ts index b67438e..0d47d7e 100644 --- a/packages/shared/src/schemas/schedule-history.ts +++ b/packages/shared/src/schemas/schedule-history.ts @@ -2,10 +2,14 @@ import { z } from "zod"; import { dateSchema, dateTimeSchema, idSchema } from "./common"; import { scheduleDetailSchema } from "./schedules"; -export const scheduleHistoryQuerySchema = z.object({ - year: z.coerce.number().int().min(1900), - month: z.coerce.number().int().min(1).max(12).optional(), -}); +export const scheduleHistoryQuerySchema = z + .object({ + year: z.coerce.number().int().min(1900).optional(), + month: z.coerce.number().int().min(1).max(12).optional(), + }) + .refine((query) => query.year !== undefined || query.month === undefined, { + path: ["month"], + }); export const scheduleHistoryItemSchema = z.object({ id: idSchema, From 3b8f22931e88faf1d1c1a8f081166ae266dd5651 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 10:22:28 +0900 Subject: [PATCH 09/10] =?UTF-8?q?docs(api):=20=EB=B3=B4=EA=B4=80=ED=95=A8?= =?UTF-8?q?=C2=B7=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20OpenAPI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/test/docs.routes.test.ts | 9 ++ docs/SWAGGER.md | 6 + docs/openapi.yaml | 184 ++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) diff --git a/apps/api/test/docs.routes.test.ts b/apps/api/test/docs.routes.test.ts index 5371c4a..e816f57 100644 --- a/apps/api/test/docs.routes.test.ts +++ b/apps/api/test/docs.routes.test.ts @@ -91,7 +91,16 @@ describe("docs routes", () => { url: "https://api.example.com/api", description: "Configured deployment", }); + expect(response.body.paths["/dashboard"]).toBeDefined(); + expect(response.body.paths["/schedule-history"]).toBeDefined(); + expect(response.body.paths["/schedule-history/{scheduleId}"]).toBeDefined(); + expect(response.body.paths["/schedule-history/{scheduleId}/export.csv"]).toBeDefined(); expect(response.body.paths["/staffing-rules"]).toBeDefined(); + expect( + response.body.paths["/schedule-history/{scheduleId}/export.csv"].get.responses["200"].content[ + "text/csv; charset=utf-8" + ], + ).toBeDefined(); expect( response.body.components.responses.MinimumStaffingRuleBadRequest.content["application/json"] .examples.closedDay.value.errorCode, diff --git a/docs/SWAGGER.md b/docs/SWAGGER.md index 00b8a3e..3323afe 100644 --- a/docs/SWAGGER.md +++ b/docs/SWAGGER.md @@ -54,6 +54,12 @@ - `PATCH /schedules/{scheduleId}/assignments/{assignmentId}` - `DELETE /schedules/{scheduleId}/assignments/{assignmentId}` - `POST /schedules/{scheduleId}/confirm` +- Schedule History endpoint + - `GET /schedule-history` + - `GET /schedule-history/{scheduleId}` + - `GET /schedule-history/{scheduleId}/export.csv` +- Dashboard endpoint + - `GET /dashboard` ## 변경 기준 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4cb8574..f9af442 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -712,6 +712,125 @@ paths: "409": $ref: "#/components/responses/ScheduleConflict" + /schedule-history: + get: + tags: + - ScheduleHistory + summary: 확정 스케줄 이력 목록 조회 + description: 현재 조직의 CONFIRMED 스케줄 이력을 조회합니다. year와 month가 모두 없으면 모든 확정 이력을 조회합니다. month는 year와 함께 전달해야 합니다. + operationId: listScheduleHistory + security: + - bearerAuth: [] + parameters: + - name: year + in: query + required: false + schema: + type: integer + minimum: 1900 + example: 2026 + - name: month + in: query + required: false + description: 1월부터 12월까지의 월입니다. month를 전달할 때는 year도 함께 전달해야 합니다. + schema: + type: integer + minimum: 1 + maximum: 12 + example: 7 + responses: + "200": + description: 확정 스케줄 이력 목록 조회 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduleHistoryListResponse" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + + /schedule-history/{scheduleId}: + get: + tags: + - ScheduleHistory + summary: 확정 스케줄 상세 조회 + description: 현재 조직에 속한 CONFIRMED 스케줄 상세를 조회합니다. + operationId: getScheduleHistoryDetail + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/ScheduleId" + responses: + "200": + description: 확정 스케줄 상세 조회 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduleHistoryDetailResponse" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + "404": + $ref: "#/components/responses/NotFound" + + /schedule-history/{scheduleId}/export.csv: + get: + tags: + - ScheduleHistory + summary: 확정 스케줄 CSV 내보내기 + description: 현재 조직에 속한 CONFIRMED 스케줄을 인쇄/공유용 근무표 CSV로 내려줍니다. 응답 본문은 Excel 호환을 위해 UTF-8 BOM으로 시작합니다. + operationId: exportScheduleHistoryCsv + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/ScheduleId" + responses: + "200": + description: CSV export 성공 + content: + text/csv; charset=utf-8: + schema: + type: string + example: | + 날짜,요일,10:00-14:00,15:00-18:00 + 2026-07-01,수,김민수 / 이서연, + 2026-07-02,목,,박준호 + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + "404": + $ref: "#/components/responses/NotFound" + + /dashboard: + get: + tags: + - Dashboard + summary: 대시보드 조회 + description: 현재 조직 상세와 가장 최근 확정 스케줄 1개를 조회합니다. 가장 최근 확정 스케줄은 confirmedAt desc, id desc 기준입니다. + operationId: getDashboard + security: + - bearerAuth: [] + responses: + "200": + description: 대시보드 조회 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/DashboardResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + components: securitySchemes: bearerAuth: @@ -1714,3 +1833,68 @@ components: oneOf: - $ref: "#/components/schemas/ScheduleDetail" - type: "null" + + ScheduleHistoryItem: + type: object + required: + - id + - startDate + - endDate + - confirmedAt + properties: + id: + type: string + pattern: "^\\d+$" + example: "1" + startDate: + type: string + format: date + example: "2026-07-01" + endDate: + type: string + format: date + example: "2026-07-31" + confirmedAt: + type: string + format: date-time + example: "2026-06-22T08:00:00.000Z" + + ScheduleHistoryListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/ScheduleHistoryItem" + + ScheduleHistoryDetailResponse: + allOf: + - $ref: "#/components/schemas/ScheduleDetail" + - type: object + required: + - status + - confirmedAt + properties: + status: + type: string + enum: + - CONFIRMED + confirmedAt: + type: string + format: date-time + example: "2026-06-22T08:00:00.000Z" + + DashboardResponse: + type: object + required: + - organization + - latestConfirmedSchedule + properties: + organization: + $ref: "#/components/schemas/OrganizationDetail" + latestConfirmedSchedule: + oneOf: + - $ref: "#/components/schemas/ScheduleHistoryDetailResponse" + - type: "null" From 4951c8eec4e2b82bcc4d31ee5f42690d3f49c950 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 10:32:36 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor(web):=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=20=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C=20util=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/components/mvp-dashboard-page.tsx | 10 +--------- .../components/mvp-schedule-history-page.tsx | 10 +--------- apps/web/src/lib/schedule-display.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/lib/schedule-display.ts diff --git a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx index f9375f8..363ed13 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge"; import { useDashboardQuery } from "@/features/dashboard/queries/dashboard-queries"; import { useExportScheduleHistoryCsvMutation } from "@/features/schedule-history/queries/schedule-history-queries"; import { getApiErrorMessage } from "@/lib/api-error-message"; +import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; const DAY_LABELS = { MON: "월", @@ -58,15 +59,6 @@ function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; } -function formatScheduleAssignmentTimeRange(assignment: ScheduleAssignment) { - const startTime = assignment.startsAt.slice(11, 16); - const endTime = assignment.endsAt.slice(11, 16); - const endPrefix = - assignment.endsAt.slice(0, 10) > assignment.startsAt.slice(0, 10) ? "익일 " : ""; - - return `${startTime}-${endPrefix}${endTime}`; -} - function createCsvFileName(startDate: string, endDate: string) { return `schedule-${startDate}-${endDate}.csv`; } diff --git a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 9d93754..8f96df3 100644 --- a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx +++ b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx @@ -19,6 +19,7 @@ import { useScheduleHistoryQuery, } from "@/features/schedule-history/queries/schedule-history-queries"; import { getApiErrorMessage } from "@/lib/api-error-message"; +import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const INITIAL_HISTORY_YEAR = new Date().getFullYear(); @@ -62,15 +63,6 @@ function formatConfirmedAt(confirmedAt: string) { return confirmedAt.slice(0, 10); } -function formatScheduleAssignmentTimeRange(assignment: ScheduleAssignment) { - const startTime = assignment.startsAt.slice(11, 16); - const endTime = assignment.endsAt.slice(11, 16); - const endPrefix = - assignment.endsAt.slice(0, 10) > assignment.startsAt.slice(0, 10) ? "익일 " : ""; - - return `${startTime}-${endPrefix}${endTime}`; -} - function createCsvFileName(startDate: string, endDate: string) { return `schedule-${startDate}-${endDate}.csv`; } diff --git a/apps/web/src/lib/schedule-display.ts b/apps/web/src/lib/schedule-display.ts new file mode 100644 index 0000000..9247bff --- /dev/null +++ b/apps/web/src/lib/schedule-display.ts @@ -0,0 +1,10 @@ +import type { ScheduleAssignment } from "@fragment/shared"; + +export function formatScheduleAssignmentTimeRange(assignment: ScheduleAssignment) { + const startTime = assignment.startsAt.slice(11, 16); + const endTime = assignment.endsAt.slice(11, 16); + const endPrefix = + assignment.endsAt.slice(0, 10) > assignment.startsAt.slice(0, 10) ? "익일 " : ""; + + return `${startTime}-${endPrefix}${endTime}`; +}