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}개 배정
+
+ ) : (
+
+ 선택한 조건에 해당하는 확정 스케줄이 없습니다.
+
+ )}
-
-
- {WEEKDAY_LABELS.map((weekday) => (
-
- {weekday}
-
- ))}
- {calendarDates.map((date) => {
- const inRange =
- date >= selectedHistory.startDate && date <= selectedHistory.endDate;
- const dateSchedules = selectedHistory.schedules.filter(
- (schedule) => schedule.date === date,
- );
-
- return (
+ {scheduleHistoryQuery.isPending || scheduleHistoryDetailQuery.isPending ? (
+
+
+ 확정 스케줄을 불러오는 중입니다.
+
+
+ ) : scheduleHistoryQuery.isError || scheduleHistoryDetailQuery.isError ? (
+
+
+ 확정 스케줄 보관함을 불러오지 못했습니다.
+
+
잠시 후 다시 시도해 주세요.
+
+ ) : selectedHistory ? (
+
+
+ {WEEKDAY_LABELS.map((weekday) => (
-
-
{date.slice(8, 10)}
- {inRange ? (
-
- {dateSchedules.length}명
-
- ) : null}
-
+ {weekday}
+
+ ))}
+
+ {calendarDates.map((date) => {
+ const inRange =
+ date >= selectedHistory.startDate && date <= selectedHistory.endDate;
+ const dateSchedules = selectedHistory.assignments.filter(
+ (assignment) => assignment.workDate === date,
+ );
-
- {dateSchedules.map((schedule) => (
-
- {schedule.startTime}-{schedule.endTime} {schedule.workerName}
-
- ))}
+ return (
+
+
+
{date.slice(8, 10)}
+ {inRange ? (
+
+ {dateSchedules.length}명
+
+ ) : null}
+
+
+
+ {dateSchedules.map((assignment: ScheduleAssignment) => (
+
+ {formatDateTimeToTime(assignment.startsAt)}-
+ {formatDateTimeToTime(assignment.endsAt)}{" "}
+ {assignment.workerNameSnapshot}
+
+ ))}
+
-
- );
- })}
+ );
+ })}
+
-
+ ) : (
+
+
확정된 스케줄이 없습니다.
+
다른 연도나 월을 선택해 주세요.
+
+ )}
From 355ea341473c9fcb0203b6483968b2ad8caded5d Mon Sep 17 00:00:00 2001
From: Yeryeong Kang
Date: Tue, 30 Jun 2026 04:55:48 +0900
Subject: [PATCH 07/10] =?UTF-8?q?feat(web):=20=EB=B3=B4=EA=B4=80=ED=95=A8?=
=?UTF-8?q?=20CSV=20=EA=B7=BC=EB=AC=B4=ED=91=9C=20=ED=98=95=EC=8B=9D=20?=
=?UTF-8?q?=EC=A0=81=EC=9A=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../schedule-history.service.ts | 54 ++++-
apps/api/test/schedule-history.routes.test.ts | 60 +++--
.../components/mvp-schedule-history-page.tsx | 206 +++++++++++-------
.../queries/schedule-history-queries.ts | 11 +-
docs/API.md | 14 +-
5 files changed, 235 insertions(+), 110 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 0d2de27..e923400 100644
--- a/apps/api/src/modules/schedule-history/schedule-history.service.ts
+++ b/apps/api/src/modules/schedule-history/schedule-history.service.ts
@@ -85,6 +85,35 @@ const escapeCsvCell = (value: string) => {
};
const createCsvRow = (values: string[]) => values.map(escapeCsvCell).join(",");
+const UTF8_BOM = "\uFEFF";
+
+const createScheduleDateRange = (startDate: string, endDate: string) => {
+ const dates: string[] = [];
+ let currentDate = startDate;
+
+ while (currentDate <= endDate) {
+ dates.push(currentDate);
+ const nextDate = dateStringToDate(currentDate);
+ nextDate.setUTCDate(nextDate.getUTCDate() + 1);
+ currentDate = dateToDateString(nextDate);
+ }
+
+ return dates;
+};
+
+const getAssignmentTimeRange = (
+ assignment: ScheduleHistoryDetailResponse["assignments"][number],
+) => {
+ return `${dateToTimeString(new Date(assignment.startsAt))}-${dateToTimeString(
+ new Date(assignment.endsAt),
+ )}`;
+};
+
+const getScheduleTimeRanges = (assignments: ScheduleHistoryDetailResponse["assignments"]) => {
+ return Array.from(new Set(assignments.map(getAssignmentTimeRange))).sort((a, b) =>
+ a.localeCompare(b),
+ );
+};
export async function exportScheduleHistoryCsv(
prisma: PrismaClient,
@@ -92,21 +121,28 @@ export async function exportScheduleHistoryCsv(
scheduleId: string,
): Promise {
const schedule = await getScheduleHistoryDetail(prisma, organizationId, scheduleId);
+ const dates = createScheduleDateRange(schedule.startDate, schedule.endDate);
+ const timeRanges = getScheduleTimeRanges(schedule.assignments);
const rows = [
- createCsvRow(["날짜", "요일", "시작시간", "종료시간", "사번", "근무자명"]),
- ...schedule.assignments.map((assignment) => {
- const workDate = dateStringToDate(assignment.workDate);
+ createCsvRow(["날짜", "요일", ...timeRanges]),
+ ...dates.map((date) => {
+ const workDate = dateStringToDate(date);
return createCsvRow([
- assignment.workDate,
+ date,
WEEKDAY_LABELS[workDate.getUTCDay()],
- dateToTimeString(new Date(assignment.startsAt)),
- dateToTimeString(new Date(assignment.endsAt)),
- assignment.employeeCodeSnapshot,
- assignment.workerNameSnapshot,
+ ...timeRanges.map((timeRange) => {
+ return schedule.assignments
+ .filter(
+ (assignment) =>
+ assignment.workDate === date && getAssignmentTimeRange(assignment) === timeRange,
+ )
+ .map((assignment) => assignment.workerNameSnapshot)
+ .join(" / ");
+ }),
]);
}),
];
- return rows.join("\n");
+ return `${UTF8_BOM}${rows.join("\n")}`;
}
diff --git a/apps/api/test/schedule-history.routes.test.ts b/apps/api/test/schedule-history.routes.test.ts
index 7d275ca..d7328c8 100644
--- a/apps/api/test/schedule-history.routes.test.ts
+++ b/apps/api/test/schedule-history.routes.test.ts
@@ -40,15 +40,31 @@ const createSchedule = ({
updatedAt: timestamp,
});
-const createAssignment = () => ({
- id: BigInt(10),
+const createAssignment = ({
+ employeeCodeSnapshot = "W-0001",
+ endsAt = "2026-07-01T14:00:00.000Z",
+ id = BigInt(10),
+ startsAt = "2026-07-01T10:00:00.000Z",
+ workerId = BigInt(1),
+ workerNameSnapshot = "김민수",
+ workDate = "2026-07-01",
+}: {
+ employeeCodeSnapshot?: string;
+ endsAt?: string;
+ id?: bigint;
+ startsAt?: string;
+ workerId?: bigint;
+ workerNameSnapshot?: string;
+ workDate?: string;
+} = {}) => ({
+ id,
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",
+ workerId,
+ workDate: dateOnly(workDate),
+ startsAt: new Date(startsAt),
+ endsAt: new Date(endsAt),
+ workerNameSnapshot,
+ employeeCodeSnapshot,
createdAt: timestamp,
updatedAt: timestamp,
});
@@ -205,12 +221,27 @@ describe("schedule history routes", () => {
it("exports confirmed schedule assignments as csv", async () => {
const { prisma } = createFakePrisma({
organizations: [createOrganization()],
- scheduleAssignments: [createAssignment()],
+ scheduleAssignments: [
+ createAssignment(),
+ createAssignment({
+ id: BigInt(11),
+ workerId: BigInt(2),
+ workerNameSnapshot: "이서연",
+ }),
+ createAssignment({
+ id: BigInt(12),
+ startsAt: "2026-07-02T15:00:00.000Z",
+ endsAt: "2026-07-02T18:00:00.000Z",
+ workerId: BigInt(3),
+ workerNameSnapshot: "박준호",
+ workDate: "2026-07-02",
+ }),
+ ],
schedules: [
createSchedule({
id: BigInt(1),
startDate: "2026-07-01",
- endDate: "2026-07-31",
+ endDate: "2026-07-03",
confirmedAt: new Date("2026-06-25T00:00:00.000Z"),
}),
],
@@ -224,9 +255,12 @@ describe("schedule history routes", () => {
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",
- ),
+ `\uFEFF${[
+ "날짜,요일,10:00-14:00,15:00-18:00",
+ "2026-07-01,수,김민수 / 이서연,",
+ "2026-07-02,목,,박준호",
+ "2026-07-03,금,,",
+ ].join("\n")}`,
);
});
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 a0df39f..6071629 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,15 +17,13 @@ import {
useExportScheduleHistoryCsvMutation,
useScheduleHistoryDetailQuery,
useScheduleHistoryQuery,
+ useScheduleHistoryYearQueries,
} from "@/features/schedule-history/queries/schedule-history-queries";
import { getApiErrorMessage } from "@/lib/api-error-message";
const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"];
-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),
-);
+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);
@@ -90,16 +88,56 @@ function findSelectedHistory(items: ScheduleHistoryItem[], selectedHistoryId: st
return items.find((history) => history.id === selectedHistoryId) ?? items[0] ?? null;
}
+function getMonthRange(year: string, month: string) {
+ const monthNumber = Number(month);
+ const monthStart = `${year}-${month.padStart(2, "0")}-01`;
+ const monthEnd = new Date(Date.UTC(Number(year), monthNumber, 0)).toISOString().slice(0, 10);
+
+ return { monthEnd, monthStart };
+}
+
+function isHistoryInMonth(history: ScheduleHistoryItem, year: string, month: string) {
+ const { monthEnd, monthStart } = getMonthRange(year, month);
+
+ return history.startDate <= monthEnd && history.endDate >= monthStart;
+}
+
+function getHistoryMonthOptions(items: ScheduleHistoryItem[], year: string) {
+ return Array.from({ length: 12 }, (_, index) => String(index + 1)).filter((month) =>
+ items.some((history) => isHistoryInMonth(history, year, month)),
+ );
+}
+
export function MvpScheduleHistoryPage() {
const [selectedHistoryId, setSelectedHistoryId] = useState("");
const [selectedYear, setSelectedYear] = useState(String(INITIAL_HISTORY_YEAR));
- const [selectedMonth, setSelectedMonth] = useState("ALL");
+ 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 hasAvailableYears = availableYears.length > 0;
+ const effectiveSelectedYear = availableYears.includes(selectedYear)
+ ? selectedYear
+ : (availableYears[0] ?? selectedYear);
+ const selectedYearItems =
+ historyYearItems.find((historyYearItem) => historyYearItem.year === effectiveSelectedYear)
+ ?.items ?? [];
+ const availableMonths = getHistoryMonthOptions(selectedYearItems, effectiveSelectedYear);
+ const effectiveSelectedMonth = availableMonths.includes(selectedMonth)
+ ? selectedMonth
+ : (availableMonths[0] ?? "");
const scheduleHistoryQuery = useScheduleHistoryQuery({
- year: Number(selectedYear),
- ...(selectedMonth === "ALL" ? {} : { month: Number(selectedMonth) }),
+ year: Number(effectiveSelectedYear),
+ ...(effectiveSelectedMonth === "" ? {} : { month: Number(effectiveSelectedMonth) }),
});
const historyItems = scheduleHistoryQuery.data?.items ?? [];
+ const hasHistoryItems = historyItems.length > 0;
const selectedHistoryItem = findSelectedHistory(historyItems, selectedHistoryId);
const selectedScheduleId = selectedHistoryItem?.id ?? "";
const scheduleHistoryDetailQuery = useScheduleHistoryDetailQuery(
@@ -173,83 +211,83 @@ export function MvpScheduleHistoryPage() {
)}
-
-
-
-
-
-
-
-
-
+ {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}`;
+}