From f591396b66f6286e513849600d664205b5ad0cf0 Mon Sep 17 00:00:00 2001 From: Yooseong Nam <102887277+meteorqz6@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:03:10 +0900 Subject: [PATCH 1/6] feat(api): serve swagger docs (#102) * feat(api): serve swagger docs * fix(api): address swagger docs review * fix(api): document error response examples * test(api): assert openapi response refs resolve --- .env.example | 1 + apps/api/package.json | 7 +- apps/api/src/app.ts | 15 ++ apps/api/src/openapi.ts | 40 ++++ apps/api/test/docs.routes.test.ts | 115 ++++++++++ docs/SWAGGER.md | 39 +++- docs/openapi.yaml | 360 +++++++++++++++++++++++++++--- package.json | 2 +- pnpm-lock.yaml | 62 ++++- 9 files changed, 594 insertions(+), 47 deletions(-) create mode 100644 apps/api/src/openapi.ts create mode 100644 apps/api/test/docs.routes.test.ts diff --git a/.env.example b/.env.example index 3a344ad..2620176 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DB_NAME" WEB_APP_ORIGIN="http://localhost:3000" +API_PUBLIC_BASE_URL="http://localhost:3001/api" JWT_ACCESS_SECRET="replace-with-local-access-secret" JWT_ACCESS_EXPIRES_IN="15m" REFRESH_TOKEN_EXPIRES_DAYS="7" diff --git a/apps/api/package.json b/apps/api/package.json index fb47d07..1f38613 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,12 +5,12 @@ "scripts": { "start:dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts", "dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts", - "build": "pnpm --filter @fragment/shared build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "start": "node dist/main", "lint": "eslint \"{src,test}/**/*.ts\"", "test": "jest --runInBand", "test:watch": "jest --watch", - "typecheck": "pnpm --filter @fragment/shared build && tsc --noEmit" + "typecheck": "tsc --noEmit" }, "dependencies": { "@fragment/database": "workspace:*", @@ -20,6 +20,8 @@ "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.3", + "swagger-ui-express": "^5.0.1", + "yaml": "^2.9.0", "zod": "^3.25.76" }, "devDependencies": { @@ -30,6 +32,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/supertest": "^6.0.3", + "@types/swagger-ui-express": "^4.1.8", "eslint": "^9", "jest": "^29.7.0", "supertest": "^7.2.2", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f2cb9ef..2968949 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,11 +1,13 @@ import cors from "cors"; import cookieParser from "cookie-parser"; import express from "express"; +import swaggerUi from "swagger-ui-express"; import type { PrismaClient } from "@fragment/database"; import { ERROR_CODES } from "@/common/constants/error-codes"; import { HttpError } from "@/errors/http-error"; import { errorHandler } from "@/middlewares/error-handler"; +import { getOpenApiDocument } from "@/openapi"; import { apiRoutes } from "@/routes"; type AppDependencies = { @@ -31,6 +33,19 @@ export const createApp = ({ prisma }: AppDependencies) => { app.use(express.json()); app.use(cookieParser()); + const openApiDocument = getOpenApiDocument(); + + app.get("/api/openapi.json", (_req, res) => { + res.json(openApiDocument); + }); + app.use( + "/api/docs", + swaggerUi.serve, + swaggerUi.setup(openApiDocument, { + customSiteTitle: "프래그먼트 API Docs", + }), + ); + app.use("/api", apiRoutes); app.use((_req, _res, next) => { diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000..5180060 --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; + +type OpenApiServer = { + description?: string; + url: string; +}; + +export type OpenApiDocument = { + servers?: OpenApiServer[]; + [key: string]: unknown; +}; + +const OPENAPI_DOCUMENT_PATH = resolve(__dirname, "../../../docs/openapi.yaml"); + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ""); + +export const getOpenApiDocument = (): OpenApiDocument => { + const document = parse(readFileSync(OPENAPI_DOCUMENT_PATH, "utf8")) as OpenApiDocument; + const publicBaseUrl = process.env.API_PUBLIC_BASE_URL?.trim(); + + if (!publicBaseUrl) { + return document; + } + + const normalizedPublicBaseUrl = trimTrailingSlash(publicBaseUrl); + const existingServers = document.servers ?? []; + + return { + ...document, + servers: [ + { + url: normalizedPublicBaseUrl, + description: "Configured deployment", + }, + ...existingServers.filter((server) => server.url !== normalizedPublicBaseUrl), + ], + }; +}; diff --git a/apps/api/test/docs.routes.test.ts b/apps/api/test/docs.routes.test.ts new file mode 100644 index 0000000..5371c4a --- /dev/null +++ b/apps/api/test/docs.routes.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createFakePrisma } from "./helpers/fake-prisma"; + +const resolveOpenApiRef = (document: any, ref: string) => + ref + .slice(2) + .split("/") + .reduce((value: any, key) => value?.[key], document); + +const expectResponseExamplesToMatchStatusCodes = (document: any) => { + for (const pathItem of Object.values(document.paths)) { + for (const operation of Object.values(pathItem)) { + const responses = operation.responses; + + if (!responses) { + continue; + } + + for (const [statusCode, responseDefinition] of Object.entries(responses)) { + const response = + typeof responseDefinition.$ref === "string" + ? resolveOpenApiRef(document, responseDefinition.$ref) + : responseDefinition; + + if (typeof responseDefinition.$ref === "string") { + expect(response).toBeDefined(); + } + + const jsonContent = response?.content?.["application/json"]; + const expectedStatusCode = Number(statusCode); + + if (!Number.isInteger(expectedStatusCode) || !jsonContent) { + continue; + } + + if (jsonContent.example?.statusCode !== undefined) { + expect(jsonContent.example.statusCode).toBe(expectedStatusCode); + } + + for (const example of Object.values(jsonContent.examples ?? {})) { + if (example.value?.statusCode !== undefined) { + expect(example.value.statusCode).toBe(expectedStatusCode); + } + } + } + } + } +}; + +describe("docs routes", () => { + beforeEach(() => { + delete process.env.API_PUBLIC_BASE_URL; + delete process.env.WEB_APP_ORIGIN; + }); + + it("serves Swagger UI", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/docs/"); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + expect(response.text).toContain("swagger-ui"); + }); + + it("does not persist Swagger UI authorization in browser storage", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/docs/swagger-ui-init.js"); + + expect(response.status).toBe(200); + expect(response.text).not.toContain("persistAuthorization"); + }); + + it("serves OpenAPI JSON with the configured public server first", async () => { + process.env.API_PUBLIC_BASE_URL = "https://api.example.com/api"; + + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/openapi.json"); + + expect(response.status).toBe(200); + expect(response.body.openapi).toBe("3.1.0"); + expect(response.body.servers[0]).toEqual({ + url: "https://api.example.com/api", + description: "Configured deployment", + }); + expect(response.body.paths["/staffing-rules"]).toBeDefined(); + expect( + response.body.components.responses.MinimumStaffingRuleBadRequest.content["application/json"] + .examples.closedDay.value.errorCode, + ).toBe("CLOSED_DAY"); + expect( + response.body.components.responses.MinimumStaffingRuleBadRequest.content["application/json"] + .examples.invalidTimeRange.value.errorCode, + ).toBe("INVALID_TIME_RANGE"); + expect( + response.body.components.responses.DuplicateEmail.content["application/json"].example, + ).toEqual({ + statusCode: 409, + errorCode: "DUPLICATE_EMAIL", + message: "이미 사용 중인 이메일입니다.", + }); + expect(response.body.paths["/auth/signup"].post.responses["409"].$ref).toBe( + "#/components/responses/DuplicateEmail", + ); + expectResponseExamplesToMatchStatusCodes(response.body); + }); +}); diff --git a/docs/SWAGGER.md b/docs/SWAGGER.md index c0174d6..00b8a3e 100644 --- a/docs/SWAGGER.md +++ b/docs/SWAGGER.md @@ -10,7 +10,7 @@ ## 현재 OpenAPI 문서화 범위 -현재 `docs/openapi.yaml`은 아래 범위를 문서화합니다. +현재 `docs/openapi.yaml`은 구현된 API 중 아래 범위를 문서화합니다. - 공통 metadata - 공통 security scheme @@ -30,6 +30,30 @@ - `POST /organization` - `GET /organization` - `PATCH /organization` +- Active schedule planning period endpoint + - `GET /organization/active-schedule-planning-period` + - `PUT /organization/active-schedule-planning-period` + - `DELETE /organization/active-schedule-planning-period` +- Availability endpoint + - `GET /availability` + - `PUT /availability/bulk` +- Workers endpoint + - `GET /workers` + - `POST /workers` + - `PATCH /workers/{workerId}` + - `DELETE /workers/{workerId}` +- Staffing Rules endpoint + - `GET /staffing-rules` + - `POST /staffing-rules` + - `PATCH /staffing-rules/{ruleId}` + - `DELETE /staffing-rules/{ruleId}` +- Schedules endpoint + - `POST /schedules/recommend` + - `GET /schedules/draft` + - `POST /schedules/{scheduleId}/assignments` + - `PATCH /schedules/{scheduleId}/assignments/{assignmentId}` + - `DELETE /schedules/{scheduleId}/assignments/{assignmentId}` + - `POST /schedules/{scheduleId}/confirm` ## 변경 기준 @@ -44,11 +68,22 @@ Swagger UI를 API 서버에서 제공하는 작업을 진행할 경우 아래 기준을 따릅니다. - Swagger UI 라우트는 `/api/docs`로 제공합니다. +- 원본 OpenAPI JSON은 `/api/openapi.json`로 제공합니다. - OpenAPI `servers[0].url`은 local 개발 기준 `http://localhost:3001/api`로 시작합니다. -- 운영 배포 후 production server URL을 추가합니다. +- 운영 배포에서는 `API_PUBLIC_BASE_URL=https:///api`를 설정해 production server URL을 첫 번째 server로 제공합니다. - `docs/openapi.yaml`이 YAML로 파싱되어야 합니다. - Swagger UI에 `docs/openapi.yaml`의 endpoint와 공통 schema가 표시되어야 합니다. +## 외부 공유 기준 + +외부 공유용 Swagger는 아래 상태를 만족한 후 공유합니다. + +- 공유 URL은 `https:///api/docs`입니다. +- `https:///api/openapi.json`에서 원본 OpenAPI 문서를 내려받을 수 있어야 합니다. +- Swagger UI의 server URL이 `localhost`가 아니라 운영 API URL이어야 합니다. +- 실제 구현되지 않은 endpoint는 OpenAPI에 포함하지 않습니다. +- 인증이 필요한 API는 `Authorize` 버튼에 로그인 API에서 받은 bearer token을 넣어 테스트합니다. + ## Swagger UI 도입 시 추가 패키지 Swagger UI를 API 서버에서 제공하는 작업을 진행할 때 `apps/api`에 아래 패키지를 추가합니다. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1b1959c..4cb8574 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -51,11 +51,7 @@ paths: "400": $ref: "#/components/responses/ValidationError" "409": - description: 이미 가입된 이메일 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/DuplicateEmail" /auth/login: post: @@ -162,11 +158,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "409": - description: 이미 조직이 존재함 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/OrganizationAlreadyExists" get: tags: @@ -441,6 +433,108 @@ paths: "404": $ref: "#/components/responses/NotFound" + /staffing-rules: + get: + tags: + - StaffingRules + summary: 최소 인원 조건 목록 조회 + operationId: listMinimumStaffingRules + security: + - bearerAuth: [] + responses: + "200": + description: 최소 인원 조건 목록 조회 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/MinimumStaffingRulesListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + + post: + tags: + - StaffingRules + summary: 최소 인원 조건 생성 + description: 최소 인원 조건은 해당 요일의 조직 운영시간 안에서만 등록할 수 있습니다. + operationId: createMinimumStaffingRule + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMinimumStaffingRuleRequest" + responses: + "201": + description: 최소 인원 조건 생성 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/MinimumStaffingRule" + "400": + $ref: "#/components/responses/MinimumStaffingRuleBadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + + /staffing-rules/{ruleId}: + patch: + tags: + - StaffingRules + summary: 최소 인원 조건 수정 + description: 수정 후의 조건도 해당 요일의 조직 운영시간 안에 있어야 합니다. + operationId: updateMinimumStaffingRule + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/RuleId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMinimumStaffingRuleRequest" + responses: + "200": + description: 최소 인원 조건 수정 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/MinimumStaffingRule" + "400": + $ref: "#/components/responses/MinimumStaffingRuleBadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + "404": + $ref: "#/components/responses/NotFound" + + delete: + tags: + - StaffingRules + summary: 최소 인원 조건 삭제 + operationId: deleteMinimumStaffingRule + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/RuleId" + responses: + "204": + description: 최소 인원 조건 삭제 성공 + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + "404": + $ref: "#/components/responses/NotFound" + /schedules/recommend: post: tags: @@ -470,11 +564,7 @@ paths: "403": $ref: "#/components/responses/OrganizationRequired" "409": - description: 같은 입력 조건의 DRAFT가 이미 존재함 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/DraftAlreadyExists" /schedules/draft: get: @@ -533,11 +623,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" /schedules/{scheduleId}/assignments/{assignmentId}: patch: @@ -572,11 +658,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" delete: tags: @@ -600,11 +682,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" /schedules/{scheduleId}/confirm: post: @@ -632,11 +710,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" components: securitySchemes: @@ -656,24 +730,141 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 400 + errorCode: VALIDATION_ERROR + message: 요청 형식이 올바르지 않습니다. + MinimumStaffingRuleBadRequest: + description: 요청 값 검증 실패 또는 최소 인원 조건 도메인 규칙 위반 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + validationError: + summary: 요청 형식 오류 + value: + statusCode: 400 + errorCode: VALIDATION_ERROR + message: 요청 형식이 올바르지 않습니다. + closedDay: + summary: 휴무일 요청 + value: + statusCode: 400 + errorCode: CLOSED_DAY + message: 휴무일에는 최소 인원 조건을 등록할 수 없습니다. + invalidTimeRange: + summary: 운영시간 밖 요청 + value: + statusCode: 400 + errorCode: INVALID_TIME_RANGE + message: 최소 인원 조건은 조직 운영시간 안에서만 등록할 수 있습니다. Unauthorized: description: 인증 실패 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + examples: + invalidCredentials: + summary: 로그인 인증 실패 + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: 이메일 또는 비밀번호가 올바르지 않습니다. + authRequired: + summary: 인증 필요 + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: 인증이 필요합니다. + invalidToken: + summary: 유효하지 않은 인증 토큰 + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: 유효하지 않은 인증 토큰입니다. + invalidRefreshToken: + summary: 유효하지 않은 refresh token + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: Refresh token이 유효하지 않습니다. NotFound: description: 리소스를 찾을 수 없음 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + examples: + notFound: + summary: 일반 리소스 없음 + value: + statusCode: 404 + errorCode: NOT_FOUND + message: 요청한 API를 찾을 수 없습니다. + workerNotFound: + summary: 근무자 없음 + value: + statusCode: 404 + errorCode: WORKER_NOT_FOUND + message: 근무자를 찾을 수 없습니다. + scheduleNotFound: + summary: 스케줄 없음 + value: + statusCode: 404 + errorCode: SCHEDULE_NOT_FOUND + message: 스케줄을 찾을 수 없습니다. OrganizationRequired: description: 조직 생성 필요 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 403 + errorCode: ORGANIZATION_REQUIRED + message: 조직 생성 후 이용할 수 있습니다. + DuplicateEmail: + description: 이미 가입된 이메일 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: DUPLICATE_EMAIL + message: 이미 사용 중인 이메일입니다. + OrganizationAlreadyExists: + description: 이미 조직이 존재함 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: ORGANIZATION_ALREADY_EXISTS + message: 이미 조직이 존재합니다. + DraftAlreadyExists: + description: 같은 입력 조건의 DRAFT가 이미 존재함 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: DRAFT_ALREADY_EXISTS + message: 이미 생성된 DRAFT 스케줄입니다. + ScheduleConflict: + description: DRAFT 상태가 아닌 스케줄 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: CONFLICT + message: DRAFT 상태의 스케줄만 변경할 수 있습니다. parameters: WorkerId: @@ -684,6 +875,14 @@ components: type: string pattern: "^\\d+$" example: "1" + RuleId: + name: ruleId + in: path + required: true + schema: + type: string + pattern: "^\\d+$" + example: "1" AvailabilityWorkerId: name: workerId in: query @@ -1159,6 +1358,97 @@ components: minimum: 1 example: 32 + MinimumStaffingRule: + type: object + required: + - id + - dayOfWeek + - startTime + - endTime + - endsNextDay + - requiredCount + properties: + id: + type: string + pattern: "^\\d+$" + example: "1" + dayOfWeek: + $ref: "#/components/schemas/DayOfWeek" + startTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "10:00" + endTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "14:00" + endsNextDay: + type: boolean + example: false + requiredCount: + type: integer + minimum: 1 + example: 2 + + MinimumStaffingRulesListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/MinimumStaffingRule" + + CreateMinimumStaffingRuleRequest: + type: object + required: + - dayOfWeek + - startTime + - endTime + - endsNextDay + - requiredCount + properties: + dayOfWeek: + $ref: "#/components/schemas/DayOfWeek" + startTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "10:00" + endTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "14:00" + endsNextDay: + type: boolean + example: false + requiredCount: + type: integer + minimum: 1 + example: 2 + + UpdateMinimumStaffingRuleRequest: + type: object + minProperties: 1 + properties: + dayOfWeek: + $ref: "#/components/schemas/DayOfWeek" + startTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "10:00" + endTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "14:00" + endsNextDay: + type: boolean + example: false + requiredCount: + type: integer + minimum: 1 + example: 3 + DateRangeRequest: type: object required: diff --git a/package.json b/package.json index be3bca6..aec3b77 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "dev:api": "pnpm --filter @fragment/database build && pnpm --filter @fragment/api dev", "dev:all": "pnpm --filter @fragment/database build && pnpm --parallel --filter @fragment/web --filter @fragment/api dev", "build": "pnpm --filter @fragment/web build", - "build:api": "pnpm --filter @fragment/api build", + "build:api": "corepack pnpm --filter @fragment/api... build", "db:generate": "pnpm --filter @fragment/database db:generate", "db:migrate": "pnpm --filter @fragment/database db:migrate", "db:push": "pnpm --filter @fragment/database db:push", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d20cab..c183621 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) + yaml: + specifier: ^2.9.0 + version: 2.9.0 zod: specifier: ^3.25.76 version: 3.25.76 @@ -69,6 +75,9 @@ importers: '@types/supertest': specifier: ^6.0.3 version: 6.0.3 + '@types/swagger-ui-express': + specifier: ^4.1.8 + version: 4.1.8 eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) @@ -198,7 +207,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)) + version: 4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) packages/database: dependencies: @@ -1597,6 +1606,9 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} @@ -1885,6 +1897,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/swagger-ui-express@4.1.8': + resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4527,6 +4542,15 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5008,6 +5032,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6431,6 +6460,8 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@scarf/scarf@1.4.0': {} + '@simple-libs/child-process-utils@1.0.2': dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -6728,6 +6759,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/swagger-ui-express@4.1.8': + dependencies: + '@types/express': 5.0.6 + '@types/serve-static': 2.2.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -6904,13 +6940,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0) + vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9806,6 +9842,15 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.8 + symbol-tree@3.2.4: {} tailwind-merge@3.6.0: {} @@ -10089,7 +10134,7 @@ snapshots: vary@1.1.2: {} - vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0): + vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -10101,11 +10146,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 + yaml: 2.9.0 - vitest@4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)): + vitest@4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -10122,7 +10168,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0) + vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.41 @@ -10279,6 +10325,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} From 687f3f1785f416f9761ab7d38057a410402ec128 Mon Sep 17 00:00:00 2001 From: Kang Yeryeong Date: Tue, 30 Jun 2026 10:35:25 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=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=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(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(api): 확정 스케줄 조회 계약 정리 * feat(api): 보관함 API 구현 * feat(api): 대시보드 API 구현 * feat(web): 확정 스케줄 API 연결 함수 추가 * feat(web): 대시보드 API 연결 * feat(web): 보관함 API 연결 * feat(web): 보관함 CSV 근무표 형식 적용 * fix: 보관함·대시보드 리뷰 반영 * docs(api): 보관함·대시보드 OpenAPI 추가 * refactor(web): 스케줄 시간 표시 util 분리 --- .../modules/dashboard/dashboard.handlers.ts | 25 + .../modules/dashboard/dashboard.service.ts | 43 ++ .../organization/organization.service.ts | 6 +- .../schedule-history.handlers.ts | 79 +++ .../schedule-history.service.ts | 156 ++++++ apps/api/src/routes/dashboard.routes.ts | 21 + apps/api/src/routes/index.ts | 4 + .../api/src/routes/schedule-history.routes.ts | 60 ++ apps/api/test/dashboard.routes.test.ts | 174 ++++++ apps/api/test/docs.routes.test.ts | 9 + apps/api/test/helpers/fake-prisma.ts | 80 ++- apps/api/test/schedule-history.routes.test.ts | 475 ++++++++++++++++ .../features/dashboard/api/dashboard-api.ts | 9 + .../components/mvp-dashboard-page.tsx | 278 +++++---- .../dashboard/queries/dashboard-queries.ts | 15 + .../dashboard/queries/dashboard-query-keys.ts | 1 + .../api/schedule-history-api.ts | 48 ++ .../components/mvp-schedule-history-page.tsx | 526 ++++++++++-------- .../queries/schedule-history-queries.ts | 34 ++ .../queries/schedule-history-query-keys.ts | 7 + apps/web/src/lib/api-client.ts | 13 +- apps/web/src/lib/schedule-display.ts | 10 + docs/API.md | 57 +- docs/SWAGGER.md | 6 + docs/openapi.yaml | 184 ++++++ packages/shared/src/schemas/dashboard.ts | 4 +- .../shared/src/schemas/schedule-history.ts | 12 +- 27 files changed, 1946 insertions(+), 390 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/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/dashboard.routes.ts create mode 100644 apps/api/src/routes/schedule-history.routes.ts create mode 100644 apps/api/test/dashboard.routes.test.ts create mode 100644 apps/api/test/schedule-history.routes.test.ts 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 create mode 100644 apps/web/src/lib/schedule-display.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/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..03597c9 --- /dev/null +++ b/apps/api/src/modules/schedule-history/schedule-history.service.ts @@ -0,0 +1,156 @@ +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) => { + 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)); + + 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 FORMULA_INJECTION_PREFIX_PATTERN = /^\s*[=+\-@]/; + +const escapeCsvCell = (value: string) => { + const safeValue = FORMULA_INJECTION_PREFIX_PATTERN.test(value) ? `'${value}` : value; + + if (!/[",\n\r]/.test(safeValue)) { + return safeValue; + } + + return `"${safeValue.replace(/"/g, '""')}"`; +}; + +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, + organizationId: string, + 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(["날짜", "요일", ...timeRanges]), + ...dates.map((date) => { + const workDate = dateStringToDate(date); + + return createCsvRow([ + date, + WEEKDAY_LABELS[workDate.getUTCDay()], + ...timeRanges.map((timeRange) => { + return schedule.assignments + .filter( + (assignment) => + assignment.workDate === date && getAssignmentTimeRange(assignment) === timeRange, + ) + .map((assignment) => assignment.workerNameSnapshot) + .join(" / "); + }), + ]); + }), + ]; + + return `${UTF8_BOM}${rows.join("\n")}`; +} 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 3d64588..4d51d2f 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -2,7 +2,9 @@ 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"; import { staffingRulesRoutes } from "@/routes/staffing-rules.routes"; import { workersRoutes } from "@/routes/workers.routes"; @@ -11,7 +13,9 @@ 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); 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/dashboard.routes.test.ts b/apps/api/test/dashboard.routes.test.ts new file mode 100644 index 0000000..43e1db5 --- /dev/null +++ b/apps/api/test/dashboard.routes.test.ts @@ -0,0 +1,174 @@ +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), + name = "프래그먼트 카페", + userId = BigInt(1), +}: { + id?: bigint; + name?: string; + userId?: bigint; +} = {}) => ({ + id, + userId, + name, + businessHours: [ + { + id: BigInt(1), + organizationId: id, + dayOfWeek: "MON" as const, + isClosed: false, + openTime: createTimeDate("09:00"), + closeTime: createTimeDate("18:00"), + closesNextDay: false, + }, + ], +}); + +const createSchedule = ({ + confirmedAt, + id, + organizationId = BigInt(1), + status = "CONFIRMED", +}: { + confirmedAt: Date | null; + id: bigint; + organizationId?: bigint; + status?: "DRAFT" | "CONFIRMED"; +}) => ({ + id, + organizationId, + 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(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], + 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", + }), + createSchedule({ + id: BigInt(5), + organizationId: BigInt(2), + confirmedAt: new Date("2026-06-30T00:00:00.000Z"), + }), + ], + }); + 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/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/apps/api/test/helpers/fake-prisma.ts b/apps/api/test/helpers/fake-prisma.ts index 5001de0..d942192 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; @@ -434,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), @@ -868,16 +919,21 @@ 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]; return toScheduleResult(schedule ?? null, options); }, + findMany: async ({ orderBy, where, ...options }: any) => { + let schedules = state.schedules.filter((currentSchedule) => + matchesScheduleWhere(currentSchedule, where), + ); + + schedules = sortSchedules(schedules, orderBy); + + 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..fd6d02d --- /dev/null +++ b/apps/api/test/schedule-history.routes.test.ts @@ -0,0 +1,475 @@ +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), + name = "프래그먼트 카페", + userId = BigInt(1), +}: { + id?: bigint; + name?: string; + userId?: bigint; +} = {}) => ({ + id, + userId, + name, + businessHours: [], +}); + +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, + status, + inputHash: `hash-${id.toString()}`, + startDate: dateOnly(startDate), + endDate: dateOnly(endDate), + generatedAt: timestamp, + confirmedAt, + createdAt: timestamp, + updatedAt: timestamp, +}); + +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 = "김민수", + workDate = "2026-07-01", +}: { + employeeCodeSnapshot?: string; + endsAt?: string; + id?: bigint; + scheduleId?: bigint; + startsAt?: string; + workerId?: bigint; + workerNameSnapshot?: string; + workDate?: string; +} = {}) => ({ + id, + scheduleId, + workerId, + workDate: dateOnly(workDate), + startsAt: new Date(startsAt), + endsAt: new Date(endsAt), + workerNameSnapshot, + employeeCodeSnapshot, + 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("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), + 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", + }), + 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 }); + + 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(), + createOrganization({ + id: BigInt(2), + name: "다른 매장", + userId: BigInt(2), + }), + ], + scheduleAssignments: [ + createAssignment(), + createAssignment({ + id: BigInt(11), + scheduleId: BigInt(2), + workerNameSnapshot: "외부 근무자", + }), + ], + schedules: [ + createSchedule({ + id: BigInt(1), + startDate: "2026-07-01", + 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 }); + + 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("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()], + 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", + }), + 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({ + id: BigInt(1), + startDate: "2026-07-01", + endDate: "2026-07-03", + 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( + `\uFEFF${[ + "날짜,요일,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")}`, + ); + }); + + 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, + }); + }); + + 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/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/components/mvp-dashboard-page.tsx b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx index cc14055..363ed13 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,16 @@ 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"; +import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; const DAY_LABELS = { MON: "월", @@ -33,57 +23,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 +55,26 @@ function createCalendarDates(startDate: string, endDate: string) { return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); } +function formatScheduleRange(startDate: string, endDate: string) { + return `${startDate} ~ ${endDate}`; +} + +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 +122,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 +257,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) => ( +

+ {formatScheduleAssignmentTimeRange(assignment)}{" "} + {assignment.workerNameSnapshot} +

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

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

+

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

+ +
+ )} ); 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..dbe57a7 --- /dev/null +++ b/apps/web/src/features/schedule-history/api/schedule-history-api.ts @@ -0,0 +1,48 @@ +import type { + ScheduleHistoryDetailResponse, + ScheduleHistoryListResponse, + ScheduleHistoryQuery, +} from "@fragment/shared"; + +import { apiClient } from "@/lib/api-client"; + +function createScheduleHistorySearchParams(query: ScheduleHistoryQuery) { + const searchParams = new URLSearchParams(); + + if (query.year !== undefined) { + searchParams.set("year", String(query.year)); + } + + if (query.month !== undefined) { + searchParams.set("month", String(query.month)); + } + + return searchParams.toString(); +} + +export function getScheduleHistory(query: ScheduleHistoryQuery) { + const searchParams = createScheduleHistorySearchParams(query); + + return apiClient( + `/schedule-history${searchParams ? `?${searchParams}` : ""}`, + { + 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/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 153eef4..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 @@ -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,16 @@ 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"; +import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; 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 INITIAL_HISTORY_YEAR = new Date().getFullYear(); function addDays(date: string, days: number) { const [year, month, day] = date.split("-").map(Number); @@ -93,92 +55,137 @@ 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}`; +} - 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 formatConfirmedAt(confirmedAt: string) { + return confirmedAt.slice(0, 10); +} - return { - id: `${startDate}-confirmed-${index + 1}`, - date, - startTime, - endTime, - workerName, - }; - }); +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 findSelectedHistory(items: ScheduleHistoryItem[], selectedHistoryId: string) { + 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 isHistoryInYear(history: ScheduleHistoryItem, year: string) { + const yearStart = `${year}-01-01`; + const yearEnd = `${year}-12-31`; + + return history.startDate <= yearEnd && history.endDate >= yearStart; } -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 getHistoryYear(history: ScheduleHistory) { - return history.startDate.slice(0, 4); +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 getHistoryMonth(history: ScheduleHistory) { - return history.startDate.slice(5, 7); +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(SCHEDULE_HISTORIES[0]?.id ?? ""); - const [selectedYear, setSelectedYear] = useState(getHistoryYear(SCHEDULE_HISTORIES[0])); - const [selectedMonth, setSelectedMonth] = useState("ALL"); + const [selectedHistoryId, setSelectedHistoryId] = useState(""); + const [selectedYear, setSelectedYear] = useState(String(INITIAL_HISTORY_YEAR)); + const [selectedMonth, setSelectedMonth] = useState(""); const [exportMessage, setExportMessage] = useState(""); - const historyYears = Array.from(new Set(SCHEDULE_HISTORIES.map(getHistoryYear))); - const selectedYearHistories = SCHEDULE_HISTORIES.filter( - (history) => getHistoryYear(history) === selectedYear, + 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 = allHistoryItems.filter((history) => + isHistoryInYear(history, effectiveSelectedYear), ); - const historyMonths = Array.from(new Set(selectedYearHistories.map(getHistoryMonth))); - const filteredHistories = selectedYearHistories.filter( - (history) => selectedMonth === "ALL" || getHistoryMonth(history) === selectedMonth, + const availableMonths = getHistoryMonthOptions(selectedYearItems, effectiveSelectedYear); + const effectiveSelectedMonth = availableMonths.includes(selectedMonth) + ? selectedMonth + : (availableMonths[0] ?? ""); + 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 ?? ""; + 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 isScheduleDetailLoading = selectedScheduleId !== "" && scheduleHistoryDetailQuery.isPending; + const isScheduleHistoryLoading = scheduleHistoryQuery.isPending || isScheduleDetailLoading; 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}개 배정 + {isScheduleHistoryLoading ? ( +

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

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

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

+ ) : selectedHistory ? ( +

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

+ ) : ( +

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

+ )} + + {hasAvailableYears ? ( +
+ + + + + {hasHistoryItems ? ( + <> + + + + + ) : null} +
+ ) : null} + + + {isScheduleHistoryLoading ? ( +
+

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

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

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

+

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

-
-
-
- {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 ( + ) : selectedHistory ? ( +
+
+ {WEEKDAY_LABELS.map((weekday) => (
-
-

{date.slice(8, 10)}

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

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

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

{date.slice(8, 10)}

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

+ {formatScheduleAssignmentTimeRange(assignment)}{" "} + {assignment.workerNameSnapshot} +

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

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

+

다른 연도나 월을 선택해 주세요.

+
+ )} diff --git a/apps/web/src/features/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: "응답 본문이 비어 있습니다.", 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}`; +} diff --git a/docs/API.md b/docs/API.md index a10187a..24b49bf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -545,7 +545,13 @@ Query: year=2026&month=7 ``` -`month`가 없으면 해당 연도의 확정 이력을 조회합니다. +`year`와 `month`가 모두 없으면 현재 조직의 모든 확정 이력을 조회합니다. +`month`가 없으면 해당 연도와 스케줄 기간이 겹치는 확정 이력을 조회합니다. +`month`가 있으면 해당 연월과 스케줄 기간이 겹치는 확정 이력을 조회합니다. +`month`는 `year`와 함께 전달해야 합니다. + +예를 들어 `2026-06-25 ~ 2026-07-05` 확정 스케줄은 2026년 6월 필터와 2026년 7월 필터에 모두 포함됩니다. +목록은 `confirmedAt desc, id desc` 순서로 정렬합니다. Response `200`: @@ -562,17 +568,42 @@ 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` + +응답 본문은 Excel 호환을 위해 UTF-8 BOM으로 시작합니다. + +CSV format: + +- 첫 행은 `날짜`, `요일`, `시간대...` 헤더입니다. +- 시간대 컬럼은 해당 스케줄의 배정 시간 범위를 오름차순으로 생성합니다. +- 이후 행은 스케줄 기간의 날짜별 근무표입니다. +- 같은 날짜/시간대에 여러 근무자가 있으면 ` / `로 구분합니다. + +```txt +날짜,요일,10:00-14:00,15:00-18:00 +2026-07-01,수,김민수 / 이서연, +2026-07-02,목,,박준호 +``` + +조회 가능한 확정 스케줄이 없으면 `404 SCHEDULE_NOT_FOUND`를 반환합니다. ## Dashboard @@ -580,20 +611,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/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" 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(), }); 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 da0f6b1191f9a1f6b48af85ff2110cd941e7b54f Mon Sep 17 00:00:00 2001 From: Kang Yeryeong Date: Tue, 30 Jun 2026 15:03:31 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(web):=20=EA=B0=80=EB=8A=A5=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EA=B4=80=EB=A6=AC=20UI=20=EA=B0=9C=EC=84=A0=20(#10?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): 가능 시간 관리 UI 개선 * feat(web): 가능 시간 드래그 선택 개선 * fix(web): 가능 시간 UI 리뷰 반영 --- .../components/mvp-availability-page.tsx | 912 ++++++++++++------ 1 file changed, 637 insertions(+), 275 deletions(-) diff --git a/apps/web/src/features/availability/components/mvp-availability-page.tsx b/apps/web/src/features/availability/components/mvp-availability-page.tsx index 65c6136..d7f8764 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -9,9 +9,9 @@ import { ReplaceAvailabilityRequest, Worker, } from "@fragment/shared"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Badge, Button } from "@moyeorak/design-system"; -import { Check } from "lucide-react"; +import { CalendarDays, Check, ChevronLeft, ChevronRight } from "lucide-react"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { @@ -25,15 +25,17 @@ import { } from "@/features/organization/queries/organization-queries"; import { useMinimumStaffingRulesQuery } from "@/features/staffing-rules/queries/staffing-rules-queries"; import { useWorkersQuery } from "@/features/workers/queries/workers-queries"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; import { getApiErrorMessage } from "@/lib/api-error-message"; import { cn } from "@/lib/utils"; @@ -57,6 +59,7 @@ const DEFAULT_TIMETABLE_END_MINUTES = 22 * 60; const EMPTY_WORKERS: Worker[] = []; const EMPTY_BUSINESS_HOURS: BusinessHour[] = []; const EMPTY_STAFFING_RULES: MinimumStaffingRule[] = []; +const CALENDAR_WEEKDAYS = ["월", "화", "수", "목", "금", "토", "일"]; const DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", { month: "2-digit", @@ -69,6 +72,19 @@ const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", }); +const MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", { + month: "long", + timeZone: "Asia/Seoul", + year: "numeric", +}); + +const TODAY_FORMATTER = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", + timeZone: "Asia/Seoul", + year: "numeric", +}); + function timeToMinutes(time: string) { const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes; @@ -95,6 +111,30 @@ function addDays(date: string, days: number) { return nextDate.toISOString().slice(0, 10); } +function addMonths(date: string, months: number) { + const [year, month] = date.split("-").map(Number); + const nextDate = new Date(Date.UTC(year, month - 1 + months, 1)); + return nextDate.toISOString().slice(0, 10); +} + +function getMonthStartDate(date: string) { + return `${date.slice(0, 7)}-01`; +} + +function createCalendarMonthDates(monthDate: string) { + const [year, month] = monthDate.split("-").map(Number); + const firstDate = new Date(Date.UTC(year, month - 1, 1)); + const day = firstDate.getUTCDay(); + const mondayOffset = day === 0 ? -6 : 1 - day; + const firstCalendarDate = addDays(monthDate, mondayOffset); + + return Array.from({ length: 42 }, (_, index) => addDays(firstCalendarDate, index)); +} + +function getTodayDate() { + return TODAY_FORMATTER.format(new Date()); +} + function createDateRange(startDate: string, endDate: string) { if (!startDate || !endDate || endDate < startDate) { return []; @@ -228,6 +268,14 @@ function formatWeekday(date: string) { return WEEKDAY_FORMATTER.format(new Date(`${date}T00:00:00+09:00`)); } +function formatDateWithWeekday(date: string) { + return `${formatDateLabel(date)}(${formatWeekday(date)})`; +} + +function formatMonthLabel(date: string) { + return MONTH_FORMATTER.format(new Date(`${date}T00:00:00+09:00`)); +} + function createLocalDateTime(date: string, minutes: number) { const [year, month, day] = date.split("-").map(Number); @@ -235,7 +283,7 @@ function createLocalDateTime(date: string, minutes: number) { } function formatSlotTime(minutes: number) { - return minutes >= 24 * 60 ? `${minutesToTime(minutes)}+1` : minutesToTime(minutes); + return minutes >= 24 * 60 ? `익일 ${minutesToTime(minutes)}` : minutesToTime(minutes); } function getSlotKey(workerId: string, date: string, startMinutes: number) { @@ -341,11 +389,22 @@ export function MvpAvailabilityPage() { const activePlanningPeriod = planningPeriodQuery.data?.period ?? null; const [selectedWorkerId, setSelectedWorkerId] = useState(""); const [selectedAvailabilityDate, setSelectedAvailabilityDate] = useState(""); + const [workPeriodDialogOpen, setWorkPeriodDialogOpen] = useState(false); + const [calendarMonthDate, setCalendarMonthDate] = useState(() => + getMonthStartDate(getTodayDate()), + ); const [workStartDate, setWorkStartDate] = useState(""); const [workEndDate, setWorkEndDate] = useState(""); const [savedAvailability, setSavedAvailability] = useState([]); const [draftAvailability, setDraftAvailability] = useState([]); const [saveMessage, setSaveMessage] = useState(""); + const dragSelectionRef = useRef<{ + lastColumnIndex: number; + lastRowIndex: number; + shouldSelect: boolean; + visitedKeys: Set; + } | null>(null); + const suppressNextCellClickRef = useRef(false); const availabilityQuery: AvailabilityQuery = useMemo( () => ({ endDate: workEndDate, @@ -354,14 +413,15 @@ export function MvpAvailabilityPage() { }), [selectedWorkerId, workEndDate, workStartDate], ); - const canFetchAvailability = - Boolean(selectedWorkerId && workStartDate && workEndDate) && workStartDate <= workEndDate; - const availabilityResult = useAvailabilityQuery(availabilityQuery, canFetchAvailability); const isPageLoading = organizationQuery.isLoading || planningPeriodQuery.isLoading || staffingRulesQuery.isLoading || workersQuery.isLoading; + const hasValidDateRange = Boolean(workStartDate && workEndDate) && workStartDate <= workEndDate; + const hasSavedPlanningPeriod = Boolean(activePlanningPeriod && hasValidDateRange); + const canFetchAvailability = hasSavedPlanningPeriod && Boolean(selectedWorkerId); + const availabilityResult = useAvailabilityQuery(availabilityQuery, canFetchAvailability); const queryError = organizationQuery.error ?? planningPeriodQuery.error ?? @@ -371,11 +431,30 @@ export function MvpAvailabilityPage() { const queryErrorMessage = queryError ? getApiErrorMessage(queryError, "가능 시간 정보를 불러오지 못했습니다.") : ""; - const hasValidDateRange = Boolean(workStartDate && workEndDate) && workStartDate <= workEndDate; const dateRange = useMemo( () => createDateRange(workStartDate, workEndDate), [workEndDate, workStartDate], ); + const closedDates = useMemo( + () => dateRange.filter((date) => isClosedDate(businessHours, date)), + [businessHours, dateRange], + ); + const datesWithoutStaffingRules = useMemo( + () => + dateRange.filter( + (date) => + !isClosedDate(businessHours, date) && + getSchedulableRanges(businessHours, staffingRules, date).length === 0, + ), + [businessHours, dateRange, staffingRules], + ); + const visibleTimetableDates = useMemo( + () => + dateRange.filter( + (date) => getSchedulableRanges(businessHours, staffingRules, date).length > 0, + ), + [businessHours, dateRange, staffingRules], + ); const timetableRange = useMemo( () => getTimetableRange(businessHours, staffingRules, dateRange), [businessHours, dateRange, staffingRules], @@ -384,6 +463,10 @@ export function MvpAvailabilityPage() { () => createTimeSlots(timetableRange.startMinutes, timetableRange.endMinutes), [timetableRange], ); + const calendarDates = useMemo( + () => createCalendarMonthDates(calendarMonthDate), + [calendarMonthDate], + ); useEffect(() => { if (!planningPeriodQuery.data) { @@ -398,6 +481,7 @@ export function MvpAvailabilityPage() { setWorkStartDate(activePlanningPeriod.startDate); setWorkEndDate(activePlanningPeriod.endDate); + setCalendarMonthDate(getMonthStartDate(activePlanningPeriod.startDate)); }, [activePlanningPeriod, planningPeriodQuery.data]); useEffect(() => { @@ -422,7 +506,23 @@ export function MvpAvailabilityPage() { setSaveMessage(""); }, [availabilityResult.data]); - const selectedWorker = workers.find((worker) => worker.id === selectedWorkerId); + useEffect(() => { + function stopDragSelection() { + dragSelectionRef.current = null; + window.setTimeout(() => { + suppressNextCellClickRef.current = false; + }, 0); + } + + window.addEventListener("pointerup", stopDragSelection); + window.addEventListener("pointercancel", stopDragSelection); + + return () => { + window.removeEventListener("pointerup", stopDragSelection); + window.removeEventListener("pointercancel", stopDragSelection); + }; + }, []); + const selectedDraftAvailability = draftAvailability.filter( (slot) => slot.workerId === selectedWorkerId && @@ -437,10 +537,6 @@ export function MvpAvailabilityPage() { ""; const activeAvailabilityRanges = selectedAvailabilityByDate.find((group) => group.date === activeAvailabilityDate)?.ranges ?? []; - const selectedAvailabilityRangeCount = selectedAvailabilityByDate.reduce( - (total, group) => total + group.ranges.length, - 0, - ); const draftSlotKeys = new Set( selectedDraftAvailability.map((slot) => getSlotKey(slot.workerId, slot.date, slot.startMinutes), @@ -449,40 +545,159 @@ export function MvpAvailabilityPage() { const hasUnsavedChanges = !hasSameSlots(savedAvailability, draftAvailability); const canSaveAvailability = hasUnsavedChanges && + hasSavedPlanningPeriod && hasValidDateRange && Boolean(selectedWorkerId) && !availabilityResult.isFetching && !queryErrorMessage; - function toggleSlot(date: string, startMinutes: number) { + function updateSlotSelections( + slots: { date: string; startMinutes: number }[], + shouldSelect: boolean, + ) { if (!selectedWorkerId || queryErrorMessage || availabilityResult.isFetching) { return; } - if (!isSlotSchedulable(businessHours, staffingRules, date, startMinutes)) { + const schedulableSlots = slots.filter((slot) => + isSlotSchedulable(businessHours, staffingRules, slot.date, slot.startMinutes), + ); + + if (schedulableSlots.length === 0) { return; } - const key = getSlotKey(selectedWorkerId, date, startMinutes); - const selected = draftSlotKeys.has(key); - - setDraftAvailability((currentAvailability) => - selected - ? currentAvailability.filter( - (slot) => getSlotKey(slot.workerId, slot.date, slot.startMinutes) !== key, - ) - : [ - ...currentAvailability, - { - workerId: selectedWorkerId, - date, - startMinutes, - }, - ], + const targetKeys = new Set( + schedulableSlots.map((slot) => getSlotKey(selectedWorkerId, slot.date, slot.startMinutes)), ); + + setDraftAvailability((currentAvailability) => { + const currentKeys = new Set( + currentAvailability.map((slot) => getSlotKey(slot.workerId, slot.date, slot.startMinutes)), + ); + + return shouldSelect + ? [ + ...currentAvailability, + ...schedulableSlots + .filter( + (slot) => + !currentKeys.has(getSlotKey(selectedWorkerId, slot.date, slot.startMinutes)), + ) + .map((slot) => ({ + date: slot.date, + startMinutes: slot.startMinutes, + workerId: selectedWorkerId, + })), + ] + : currentAvailability.filter( + (slot) => !targetKeys.has(getSlotKey(slot.workerId, slot.date, slot.startMinutes)), + ); + }); setSaveMessage(""); } + function toggleSlot(date: string, startMinutes: number) { + const key = getSlotKey(selectedWorkerId, date, startMinutes); + updateSlotSelections([{ date, startMinutes }], !draftSlotKeys.has(key)); + } + + function applySlotDragRange( + fromRowIndex: number, + fromColumnIndex: number, + toRowIndex: number, + toColumnIndex: number, + ) { + const dragSelection = dragSelectionRef.current; + + if (!dragSelection) { + return; + } + + const rowStart = Math.min(fromRowIndex, toRowIndex); + const rowEnd = Math.max(fromRowIndex, toRowIndex); + const columnStart = Math.min(fromColumnIndex, toColumnIndex); + const columnEnd = Math.max(fromColumnIndex, toColumnIndex); + const slots: { date: string; startMinutes: number }[] = []; + + for (let rowIndex = rowStart; rowIndex <= rowEnd; rowIndex += 1) { + const startMinutes = timeSlots[rowIndex]; + + if (startMinutes === undefined) { + continue; + } + + for (let columnIndex = columnStart; columnIndex <= columnEnd; columnIndex += 1) { + const date = visibleTimetableDates[columnIndex]; + + if (!date) { + continue; + } + + const key = getSlotKey(selectedWorkerId, date, startMinutes); + + if (dragSelection.visitedKeys.has(key)) { + continue; + } + + dragSelection.visitedKeys.add(key); + slots.push({ date, startMinutes }); + } + } + + updateSlotSelections(slots, dragSelection.shouldSelect); + } + + function startSlotDrag( + date: string, + startMinutes: number, + rowIndex: number, + columnIndex: number, + ) { + const key = getSlotKey(selectedWorkerId, date, startMinutes); + const shouldSelect = !draftSlotKeys.has(key); + + dragSelectionRef.current = { + lastColumnIndex: columnIndex, + lastRowIndex: rowIndex, + shouldSelect, + visitedKeys: new Set(), + }; + suppressNextCellClickRef.current = true; + applySlotDragRange(rowIndex, columnIndex, rowIndex, columnIndex); + } + + function moveSlotDrag(clientX: number, clientY: number) { + const dragSelection = dragSelectionRef.current; + + if (!dragSelection) { + return; + } + + const element = document.elementFromPoint(clientX, clientY); + const cell = element?.closest("[data-availability-cell='true']"); + + if (!cell) { + return; + } + + const rowIndex = Number(cell.dataset.rowIndex); + const columnIndex = Number(cell.dataset.columnIndex); + + if (!Number.isInteger(rowIndex) || !Number.isInteger(columnIndex)) { + return; + } + + applySlotDragRange( + dragSelection.lastRowIndex, + dragSelection.lastColumnIndex, + rowIndex, + columnIndex, + ); + dragSelection.lastRowIndex = rowIndex; + dragSelection.lastColumnIndex = columnIndex; + } + async function saveAvailability() { if (!canSaveAvailability) { return; @@ -532,35 +747,30 @@ export function MvpAvailabilityPage() { ); } + function selectWorkDate(date: string) { + if (isPageLoading) { + return; + } + + if (!workStartDate || (workStartDate && workEndDate) || date < workStartDate) { + setWorkStartDate(date); + setWorkEndDate(""); + setSelectedAvailabilityDate(""); + setSaveMessage(""); + return; + } + + setWorkEndDate(date); + setSelectedAvailabilityDate(""); + setSaveMessage(""); + setWorkPeriodDialogOpen(false); + savePlanningPeriod(workStartDate, date); + } + return ( - - -
- } containerClassName="max-w-none" contentClassName="space-y-6" > @@ -577,256 +787,408 @@ export function MvpAvailabilityPage() { ) : null}
-
+

근무 기간 설정

모든 근무자의 가능 시간 입력에 공통으로 적용되는 기간입니다.

-
-
- - { - const nextStartDate = event.target.value; - - setWorkStartDate(nextStartDate); - setSelectedAvailabilityDate(""); - setSaveMessage(""); - savePlanningPeriod(nextStartDate, workEndDate); - }} - /> -
-
- - { - const nextEndDate = event.target.value; - - setWorkEndDate(nextEndDate); - setSelectedAvailabilityDate(""); - setSaveMessage(""); - savePlanningPeriod(workStartDate, nextEndDate); - }} - /> + +
+
+
-
-
-
+ + + + + + + 근무 기간 선택 + + 시작일을 먼저 선택하고 종료일을 선택하면 근무 기간이 저장됩니다. + + + +
+
+ +

+ {formatMonthLabel(calendarMonthDate)} +

+ +
-
-
-
- - -
+
+ {CALENDAR_WEEKDAYS.map((weekday) => ( +
+ {weekday} +
+ ))} +
+
+ {calendarDates.map((date) => { + const inCurrentMonth = date.slice(0, 7) === calendarMonthDate.slice(0, 7); + const rangeStart = date === workStartDate; + const rangeEnd = date === workEndDate; + const inRange = Boolean(workStartDate && workEndDate) + ? date > workStartDate && date < workEndDate + : false; + const selected = rangeStart || rangeEnd; -
- {selectedWorker ? ( - <> -

{selectedWorker.name}

-

- 주간 계약 시간: 주 {selectedWorker.weeklyContractHours}시간 · 운영 시간은 요일별 - 조직 설정과 최소 인원 조건을 따릅니다. -

- - ) : ( -

등록된 근무자가 없습니다.

- )} -
-
-
+ return ( + + ); + })} +
+ -
-
-
-

선택된 가능 시간

-

- 날짜를 선택하면 해당 날짜의 가능 시간이 표시됩니다. -

+ + + + + + +
- {selectedAvailabilityRangeCount}개 구간 - {selectedAvailabilityByDate.length > 0 ? ( - <> -
-
- {selectedAvailabilityByDate.map((group) => { - const active = group.date === activeAvailabilityDate; - - return ( - - ); - })} + {hasValidDateRange ? ( +
+

입력 제외 날짜

+
+
+ 휴무일 + {closedDates.length > 0 ? ( +
+ {closedDates.map((date) => ( + + {formatDateWithWeekday(date)} + + ))} +
+ ) : ( + 없음 + )}
-
-
-

- {formatDateLabel(activeAvailabilityDate)}({formatWeekday(activeAvailabilityDate)}) -

-
- {activeAvailabilityRanges.map((range) => ( - - {formatSlotTime(range.startMinutes)}~{formatSlotTime(range.endMinutes)} - - ))} +
+ 최소 인원 조건 미설정일 + {datesWithoutStaffingRules.length > 0 ? ( +
+ {datesWithoutStaffingRules.map((date) => ( + + {formatDateWithWeekday(date)} + + ))} +
+ ) : ( + 없음 + )}
- - ) : ( -

선택된 가능 시간이 없습니다.

- )} - - - {saveMessage ? ( -
- {saveMessage} -
- ) : null} - -
-
-
-
-

가능 시간 타임테이블

-

- 최소 인원 조건이 있는 셀만 선택할 수 있습니다. 선택된 셀을 다시 누르면 삭제됩니다. -

-
- {hasUnsavedChanges ? 저장 전 변경 있음 : null} - {availabilityResult.isFetching ? ( - 불러오는 중 - ) : null}
-
+ ) : null} +
-
-
-
-
- {dateRange.map((date) => ( -
-

- {formatWeekday(date)} + {hasSavedPlanningPeriod ? ( +

+
+
+
+
+

가능 시간 입력

+

+ 근무자를 선택한 뒤 최소 인원 조건이 있는 셀만 가능 시간으로 지정합니다.

-

{formatDateLabel(date)}

- {isClosedDate(businessHours, date) ? ( - - 휴무 - - ) : getSchedulableRanges(businessHours, staffingRules, date).length === 0 ? ( - - 조건 없음 - +
+
+ {availabilityResult.isFetching ? ( + 불러오는 중 + ) : null} + {hasUnsavedChanges ? ( + <> + + + ) : null}
- ))} +
- {timeSlots.map((startMinutes) => { - const showHourLabel = startMinutes % 60 === 0; - const timeLabel = formatSlotTime(startMinutes); +
+ + {workers.length > 0 ? ( +
+ {workers.map((worker) => { + const selected = worker.id === selectedWorkerId; - return ( -
-
- {showHourLabel ? timeLabel : ""} -
- {dateRange.map((date) => { - const disabled = !isSlotSchedulable( - businessHours, - staffingRules, - date, - startMinutes, - ); - const selected = draftSlotKeys.has( - getSlotKey(selectedWorkerId, date, startMinutes), + return ( + ); - const disabledByState = - disabled || - !selectedWorkerId || - !hasValidDateRange || - Boolean(queryErrorMessage) || - availabilityResult.isFetching; + })} +
+ ) : ( +

등록된 근무자가 없습니다.

+ )} +
+
+
+ + {saveMessage ? ( +
+ {saveMessage} +
+ ) : null} + +
+

선택된 가능 시간

+ + {selectedAvailabilityByDate.length > 0 ? ( +
+
+
+ {selectedAvailabilityByDate.map((group) => { + const selected = group.date === activeAvailabilityDate; return ( ); })}
- ); - })} -
+
+ +
+
+ {activeAvailabilityRanges.map((range) => ( + + {formatSlotTime(range.startMinutes)}~{formatSlotTime(range.endMinutes)} + + ))} +
+
+
+ ) : ( +

선택된 가능 시간이 없습니다.

+ )}
-
- + +
+

가능 시간 타임테이블

+

+ 최소 인원 조건이 있는 셀만 선택할 수 있습니다. 선택된 셀을 다시 누르면 삭제됩니다. +

+
+ + {visibleTimetableDates.length > 0 ? ( +
+
+
moveSlotDrag(event.clientX, event.clientY)} + > +
+ {visibleTimetableDates.map((date) => ( +
+

+ {formatWeekday(date)} +

+

+ {formatDateLabel(date)} +

+
+ ))} + + {timeSlots.map((startMinutes, rowIndex) => { + const showHourLabel = startMinutes % 60 === 0; + const timeLabel = formatSlotTime(startMinutes); + + return ( +
+
+ {showHourLabel ? timeLabel : ""} +
+ {visibleTimetableDates.map((date, columnIndex) => { + const disabled = !isSlotSchedulable( + businessHours, + staffingRules, + date, + startMinutes, + ); + const selected = draftSlotKeys.has( + getSlotKey(selectedWorkerId, date, startMinutes), + ); + const disabledByState = + disabled || + !selectedWorkerId || + !hasValidDateRange || + Boolean(queryErrorMessage) || + availabilityResult.isFetching; + + return ( + + ); + })} +
+ ); + })} +
+
+
+ ) : ( +
+

입력 가능한 날짜가 없습니다.

+

+ 근무 기간 설정에서 휴무일과 최소 인원 조건 미설정일을 확인해 주세요. +

+
+ )} + + ) : null} ); } From 8edc214a935c9a3abc887d3dd54a439281660a83 Mon Sep 17 00:00:00 2001 From: Yooseong Nam <102887277+meteorqz6@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:23:46 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(web):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=EC=99=80=20=EB=B3=B4=EA=B4=80=ED=95=A8=EC=9D=98=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=EB=8B=AC?= =?UTF-8?q?=EB=A0=A5=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): improve schedule calendar and time inputs * fix: address schedule review feedback * fix: address remaining schedule review comments --- apps/api/test/organization.routes.test.ts | 23 ++ .../components/mvp-dashboard-page.tsx | 122 ++---- .../components/mvp-organization-edit-page.tsx | 2 +- .../components/organization-form.tsx | 86 ++++- .../components/mvp-schedule-history-page.tsx | 126 ++----- .../confirmed-schedule-calendar.tsx | 255 +++++++++++++ .../components/mvp-schedules-page.tsx | 355 ++++++++++++++---- 7 files changed, 688 insertions(+), 281 deletions(-) create mode 100644 apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx diff --git a/apps/api/test/organization.routes.test.ts b/apps/api/test/organization.routes.test.ts index 2314715..2dd72a5 100644 --- a/apps/api/test/organization.routes.test.ts +++ b/apps/api/test/organization.routes.test.ts @@ -50,6 +50,29 @@ describe("organization routes", () => { }); }); + it("rejects organization business hours outside the 30-minute boundary", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app) + .post("/api/organization") + .set("Authorization", authHeader()) + .send({ + name: "프래그먼트 카페", + businessHours: createBusinessHoursInput({ + MON: { + openTime: "09:15", + }, + }), + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + errorCode: "VALIDATION_ERROR", + statusCode: 400, + }); + }); + it("creates an organization and derives overnight business hours", async () => { const { prisma } = createFakePrisma(); const app = createApp({ prisma }); diff --git a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx index 363ed13..9bf63aa 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { OrganizationDetail, ScheduleAssignment } from "@fragment/shared"; import { Button } from "@moyeorak/design-system"; import { Building2, CalendarX, Clock, Download, Pencil } from "lucide-react"; @@ -10,8 +10,11 @@ import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { Badge } from "@/components/ui/badge"; import { useDashboardQuery } from "@/features/dashboard/queries/dashboard-queries"; import { useExportScheduleHistoryCsvMutation } from "@/features/schedule-history/queries/schedule-history-queries"; +import { + ConfirmedScheduleCalendar, + type ConfirmedScheduleCalendarItem, +} from "@/features/schedules/components/confirmed-schedule-calendar"; import { getApiErrorMessage } from "@/lib/api-error-message"; -import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; const DAY_LABELS = { MON: "월", @@ -22,38 +25,6 @@ const DAY_LABELS = { SAT: "토", SUN: "일", } as const; -const WEEKDAY_LABELS = Object.values(DAY_LABELS); - -function addDays(date: string, days: number) { - const [year, month, day] = date.split("-").map(Number); - const nextDate = new Date(Date.UTC(year, month - 1, day + days)); - return nextDate.toISOString().slice(0, 10); -} - -function createDateRange(startDate: string, endDate: string) { - const dates: string[] = []; - let currentDate = startDate; - - while (currentDate <= endDate) { - dates.push(currentDate); - currentDate = addDays(currentDate, 1); - } - - return dates; -} - -function getMonday(date: string) { - const [year, month, day] = date.split("-").map(Number); - const targetDate = new Date(Date.UTC(year, month - 1, day)); - const dayIndex = targetDate.getUTCDay(); - const diff = dayIndex === 0 ? -6 : 1 - dayIndex; - - return addDays(date, diff); -} - -function createCalendarDates(startDate: string, endDate: string) { - return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); -} function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; @@ -75,6 +46,24 @@ function downloadBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } +function formatAssignmentTime(workDate: string, dateTime: string) { + const time = dateTime.slice(11, 16); + + return dateTime.slice(0, 10) > workDate ? `${time}+1` : time; +} + +function scheduleAssignmentToCalendarItem( + assignment: ScheduleAssignment, +): ConfirmedScheduleCalendarItem { + return { + date: assignment.workDate, + endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt), + id: assignment.id, + startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt), + workerName: assignment.workerNameSnapshot, + }; +} + function formatBusinessHourRange(businessHour: OrganizationDetail["businessHours"][number]) { if (!businessHour.openTime || !businessHour.closeTime) { return "운영 시간 미설정"; @@ -127,9 +116,10 @@ export function MvpDashboardPage() { const [exportMessage, setExportMessage] = useState(""); const organization = dashboardQuery.data?.organization; const latestConfirmedSchedule = dashboardQuery.data?.latestConfirmedSchedule ?? null; - const calendarDates = latestConfirmedSchedule - ? createCalendarDates(latestConfirmedSchedule.startDate, latestConfirmedSchedule.endDate) - : []; + const latestConfirmedSchedules = useMemo( + () => latestConfirmedSchedule?.assignments.map(scheduleAssignmentToCalendarItem) ?? [], + [latestConfirmedSchedule], + ); const organizationName = dashboardQuery.isPending ? "조직 정보를 불러오는 중입니다" : dashboardQuery.isError @@ -255,7 +245,7 @@ export function MvpDashboardPage() {
-

확정 스케줄 달력

+

최근 확정 스케줄

{dashboardQuery.isPending ? (

@@ -303,57 +293,11 @@ export function MvpDashboardPage() {

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

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

{date.slice(8, 10)}

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

- {formatScheduleAssignmentTimeRange(assignment)}{" "} - {assignment.workerNameSnapshot} -

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

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

diff --git a/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx b/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx index d72c51f..9463c81 100644 --- a/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx +++ b/apps/web/src/features/organization/components/mvp-organization-edit-page.tsx @@ -47,7 +47,7 @@ export function MvpOrganizationEditPage() { cancelHref="/dashboard" initialValues={formValues} submitError={submitError} - submitLabel="변경사항 저장" + submitLabel="저장" submittingLabel="저장 중" onSubmit={async (request) => { setSubmitError(null); diff --git a/apps/web/src/features/organization/components/organization-form.tsx b/apps/web/src/features/organization/components/organization-form.tsx index 19b59cc..2bc7e34 100644 --- a/apps/web/src/features/organization/components/organization-form.tsx +++ b/apps/web/src/features/organization/components/organization-form.tsx @@ -3,10 +3,17 @@ import Link from "next/link"; import { createOrganizationRequestSchema } from "@fragment/shared"; import { Button } from "@moyeorak/design-system"; -import { type FieldErrors, type Resolver, useForm } from "react-hook-form"; +import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { createOrganizationRequestFromForm, @@ -24,6 +31,21 @@ type OrganizationFormProps = { submittingLabel: string; }; +const TIME_OPTION_STEP_MINUTES = 30; +const MINUTES_IN_DAY = 24 * 60; + +const TIME_OPTIONS = Array.from( + { length: MINUTES_IN_DAY / TIME_OPTION_STEP_MINUTES }, + (_, index) => { + const minutes = index * TIME_OPTION_STEP_MINUTES; + const hour = Math.floor(minutes / 60); + const minute = minutes % 60; + const value = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + + return { label: value, value }; + }, +); + const organizationFormResolver: Resolver = (values) => { const request = createOrganizationRequestFromForm(values); const result = createOrganizationRequestSchema.safeParse(request); @@ -85,6 +107,7 @@ export function OrganizationForm({ submittingLabel, }: OrganizationFormProps) { const { + control, formState: { errors, isSubmitting }, handleSubmit, register, @@ -124,13 +147,6 @@ export function OrganizationForm({ className="rounded-xl border border-border bg-card p-6 shadow-card" onSubmit={handleSubmit(submitForm)} > -
-

조직 정보

-

- 휴무일로 지정한 요일은 운영 시간 입력이 비활성화됩니다. -

-
-
@@ -202,19 +218,49 @@ export function OrganizationForm({
- ( + + )} /> - ( + + )} />
{dayError ?

{dayError}

: null} diff --git a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx index 8f96df3..509e510 100644 --- a/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx +++ b/apps/web/src/features/schedule-history/components/mvp-schedule-history-page.tsx @@ -18,43 +18,14 @@ import { useScheduleHistoryDetailQuery, useScheduleHistoryQuery, } from "@/features/schedule-history/queries/schedule-history-queries"; +import { + ConfirmedScheduleCalendar, + type ConfirmedScheduleCalendarItem, +} from "@/features/schedules/components/confirmed-schedule-calendar"; import { getApiErrorMessage } from "@/lib/api-error-message"; -import { formatScheduleAssignmentTimeRange } from "@/lib/schedule-display"; -const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const INITIAL_HISTORY_YEAR = new Date().getFullYear(); -function addDays(date: string, days: number) { - const [year, month, day] = date.split("-").map(Number); - const nextDate = new Date(Date.UTC(year, month - 1, day + days)); - return nextDate.toISOString().slice(0, 10); -} - -function createDateRange(startDate: string, endDate: string) { - const dates: string[] = []; - let currentDate = startDate; - - while (currentDate <= endDate) { - dates.push(currentDate); - currentDate = addDays(currentDate, 1); - } - - return dates; -} - -function getMonday(date: string) { - const [year, month, day] = date.split("-").map(Number); - const targetDate = new Date(Date.UTC(year, month - 1, day)); - const dayIndex = targetDate.getUTCDay(); - const diff = dayIndex === 0 ? -6 : 1 - dayIndex; - - return addDays(date, diff); -} - -function createCalendarDates(startDate: string, endDate: string) { - return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); -} - function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; } @@ -79,6 +50,24 @@ function downloadBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } +function formatAssignmentTime(workDate: string, dateTime: string) { + const time = dateTime.slice(11, 16); + + return dateTime.slice(0, 10) > workDate ? `${time}+1` : time; +} + +function scheduleAssignmentToCalendarItem( + assignment: ScheduleAssignment, +): ConfirmedScheduleCalendarItem { + return { + date: assignment.workDate, + endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt), + id: assignment.id, + startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt), + workerName: assignment.workerNameSnapshot, + }; +} + function findSelectedHistory(items: ScheduleHistoryItem[], selectedHistoryId: string) { return items.find((history) => history.id === selectedHistoryId) ?? items[0] ?? null; } @@ -159,15 +148,12 @@ export function MvpScheduleHistoryPage() { ); const exportScheduleHistoryCsvMutation = useExportScheduleHistoryCsvMutation(); const selectedHistory = scheduleHistoryDetailQuery.data ?? null; - const isScheduleDetailLoading = selectedScheduleId !== "" && scheduleHistoryDetailQuery.isPending; - const isScheduleHistoryLoading = scheduleHistoryQuery.isPending || isScheduleDetailLoading; - const calendarDates = useMemo( - () => - selectedHistory - ? createCalendarDates(selectedHistory.startDate, selectedHistory.endDate) - : [], + const selectedHistorySchedules = useMemo( + () => selectedHistory?.assignments.map(scheduleAssignmentToCalendarItem) ?? [], [selectedHistory], ); + const isScheduleDetailLoading = selectedScheduleId !== "" && scheduleHistoryDetailQuery.isPending; + const isScheduleHistoryLoading = scheduleHistoryQuery.isPending || isScheduleDetailLoading; const canExportSchedule = selectedHistory !== null && !exportScheduleHistoryCsvMutation.isPending; const handleExportSchedule = async () => { @@ -205,7 +191,7 @@ export function MvpScheduleHistoryPage() {
-

읽기 모드 달력

+

확정 스케줄 기록

{isScheduleHistoryLoading ? (

@@ -319,58 +305,12 @@ export function MvpScheduleHistoryPage() {

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

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

{date.slice(8, 10)}

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

- {formatScheduleAssignmentTimeRange(assignment)}{" "} - {assignment.workerNameSnapshot} -

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

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

diff --git a/apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx b/apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx new file mode 100644 index 0000000..623622d --- /dev/null +++ b/apps/web/src/features/schedules/components/confirmed-schedule-calendar.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { X } from "lucide-react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export type ConfirmedScheduleCalendarItem = { + id: string; + date: string; + startTime: string; + endTime: string; + workerName: string; +}; + +type ConfirmedScheduleCalendarProps = { + endDate: string; + schedules: ConfirmedScheduleCalendarItem[]; + startDate: string; +}; + +type ScheduleTimeGroup = { + endTime: string; + id: string; + startTime: string; + workerNames: string[]; +}; + +const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; +const MAX_VISIBLE_GROUPS = 3; +const MAX_VISIBLE_WORKER_NAMES = 2; + +function addDays(date: string, days: number) { + const [year, month, day] = date.split("-").map(Number); + const nextDate = new Date(Date.UTC(year, month - 1, day + days)); + return nextDate.toISOString().slice(0, 10); +} + +function createDateRange(startDate: string, endDate: string) { + const dates: string[] = []; + let currentDate = startDate; + + while (currentDate <= endDate) { + dates.push(currentDate); + currentDate = addDays(currentDate, 1); + } + + return dates; +} + +function getMonday(date: string) { + const [year, month, day] = date.split("-").map(Number); + const targetDate = new Date(Date.UTC(year, month - 1, day)); + const dayIndex = targetDate.getUTCDay(); + const diff = dayIndex === 0 ? -6 : 1 - dayIndex; + + return addDays(date, diff); +} + +function createCalendarDates(startDate: string, endDate: string) { + return createDateRange(getMonday(startDate), addDays(getMonday(endDate), 6)); +} + +function formatDateTitle(date: string) { + const [, month, day] = date.split("-"); + + return `${Number(month)}월 ${Number(day)}일`; +} + +function getTimeRangeLabel(group: ScheduleTimeGroup) { + return `${group.startTime}-${group.endTime}`; +} + +function getWorkerNamesPreview(workerNames: string[]) { + const visibleWorkerNames = workerNames.slice(0, MAX_VISIBLE_WORKER_NAMES); + const hiddenWorkerCount = workerNames.length - visibleWorkerNames.length; + const visibleLabel = visibleWorkerNames.join(", "); + + if (hiddenWorkerCount <= 0) { + return visibleLabel; + } + + return `${visibleLabel} 외 ${hiddenWorkerCount}명`; +} + +function groupSchedulesByTime(schedules: ConfirmedScheduleCalendarItem[]) { + const groupsByTime = new Map(); + + schedules.forEach((schedule) => { + const key = `${schedule.startTime}-${schedule.endTime}`; + const group = groupsByTime.get(key); + + if (group) { + group.workerNames.push(schedule.workerName); + return; + } + + groupsByTime.set(key, { + endTime: schedule.endTime, + id: key, + startTime: schedule.startTime, + workerNames: [schedule.workerName], + }); + }); + + return [...groupsByTime.values()].sort((left, right) => { + const startCompare = left.startTime.localeCompare(right.startTime); + + if (startCompare !== 0) { + return startCompare; + } + + return left.endTime.localeCompare(right.endTime); + }); +} + +export function ConfirmedScheduleCalendar({ + endDate, + schedules, + startDate, +}: ConfirmedScheduleCalendarProps) { + const [selectedDate, setSelectedDate] = useState(null); + const calendarDates = useMemo( + () => createCalendarDates(startDate, endDate), + [endDate, startDate], + ); + const schedulesByDate = useMemo(() => { + const nextSchedulesByDate = new Map(); + + schedules.forEach((schedule) => { + const dateSchedules = nextSchedulesByDate.get(schedule.date) ?? []; + nextSchedulesByDate.set(schedule.date, [...dateSchedules, schedule]); + }); + + return nextSchedulesByDate; + }, [schedules]); + const selectedDateSchedules = selectedDate ? (schedulesByDate.get(selectedDate) ?? []) : []; + const selectedDateGroups = groupSchedulesByTime(selectedDateSchedules); + + return ( + <> +
+
+ {WEEKDAY_LABELS.map((weekday) => ( +
+ {weekday} +
+ ))} + + {calendarDates.map((date) => { + const inRange = date >= startDate && date <= endDate; + const dateSchedules = schedulesByDate.get(date) ?? []; + const timeGroups = groupSchedulesByTime(dateSchedules); + const visibleGroups = timeGroups.slice(0, MAX_VISIBLE_GROUPS); + const hiddenGroupCount = timeGroups.length - visibleGroups.length; + const canOpenDetail = inRange && dateSchedules.length > 0; + const Cell = canOpenDetail ? "button" : "div"; + + return ( + setSelectedDate(date) : undefined} + className={ + inRange + ? [ + "flex min-h-36 w-full flex-col items-stretch border-b border-r border-border bg-card p-3 text-left", + canOpenDetail + ? "cursor-pointer transition-colors hover:bg-accent/60 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring" + : "", + ].join(" ") + : "flex min-h-36 w-full flex-col items-stretch border-b border-r border-border bg-surface-secondary p-3 text-left opacity-45" + } + > +
+

{date.slice(8, 10)}

+
+ +
+ {visibleGroups.map((group) => ( +
+ + {getTimeRangeLabel(group)} + + + {getWorkerNamesPreview(group.workerNames)} + +
+ ))} + + {hiddenGroupCount > 0 ? ( +

+ 더보기

+ ) : null} +
+
+ ); + })} +
+
+ + !open && setSelectedDate(null)}> + +
+ + + {selectedDate ? `${formatDateTitle(selectedDate)} 확정 스케줄` : "확정 스케줄"} + + + + 닫기 + +
+ +
+ {selectedDateGroups.map((group) => ( +
+
+

+ {getTimeRangeLabel(group)} +

+
+
+ {group.workerNames.map((workerName, index) => ( + + {workerName} + + ))} +
+
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx index cf7007f..f1495e7 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -11,7 +11,7 @@ import type { } from "@fragment/shared"; import { useEffect, useMemo, useState } from "react"; import { Badge, Button } from "@moyeorak/design-system"; -import { CalendarDays, Check, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react"; +import { CalendarDays, CircleAlert, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react"; import { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form"; import { z } from "zod"; @@ -69,6 +69,13 @@ type ScheduleCandidateItem = { workerName: string; }; +type ScheduleTimeGroup = { + endTime: string; + id: string; + items: ScheduleItem[]; + startTime: string; +}; + type ScheduleDraft = { date: string; endTime: string; @@ -87,6 +94,12 @@ type UnfilledCondition = { timeRange: string; }; +type UnfilledConditionGroup = { + conditions: UnfilledCondition[]; + date: string; + dayLabel: string; +}; + type WorkerOption = { id: string; name: string; @@ -99,6 +112,10 @@ type OperationMessage = { const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; const EMPTY_WORKERS: Worker[] = []; +const DEFAULT_VISIBLE_UNFILLED_GROUPS = 5; +const DEFAULT_VISIBLE_SCHEDULE_GROUPS = 3; +const MAX_VISIBLE_WORKER_NAMES = 2; +const MAX_PREVIEW_UNFILLED_CONDITIONS = 3; const EMPTY_DRAFT: ScheduleDraft = { date: "", @@ -316,6 +333,12 @@ function formatDateTitle(date: string) { return `${date} ${getDayLabel(date)}`; } +function formatShortDateTitle(date: string) { + const [, month, day] = date.split("-"); + + return `${Number(month)}월 ${Number(day)}일`; +} + function formatShortageTimeRange(shortage: ScheduleWorkerShortage) { return `${shortage.startTime}-${shortage.endTime}${shortage.endsNextDay ? "+1" : ""}`; } @@ -344,6 +367,53 @@ function candidateToScheduleCandidateItem(candidate: ScheduleCandidate): Schedul }; } +function getTimeRangeLabel(group: Pick) { + return `${group.startTime}-${group.endTime}`; +} + +function getWorkerNamesPreview(workerNames: string[]) { + const visibleWorkerNames = workerNames.slice(0, MAX_VISIBLE_WORKER_NAMES); + const hiddenWorkerCount = workerNames.length - visibleWorkerNames.length; + const visibleLabel = visibleWorkerNames.join(", "); + + if (hiddenWorkerCount <= 0) { + return visibleLabel; + } + + return `${visibleLabel} 외 ${hiddenWorkerCount}명`; +} + +function groupSchedulesByTime(schedules: ScheduleItem[]): ScheduleTimeGroup[] { + const groupsByTime = new Map(); + + schedules.forEach((schedule) => { + const key = `${schedule.startTime}-${schedule.endTime}`; + const group = groupsByTime.get(key); + + if (group) { + group.items.push(schedule); + return; + } + + groupsByTime.set(key, { + endTime: schedule.endTime, + id: key, + items: [schedule], + startTime: schedule.startTime, + }); + }); + + return [...groupsByTime.values()].sort((left, right) => { + const startCompare = left.startTime.localeCompare(right.startTime); + + if (startCompare !== 0) { + return startCompare; + } + + return left.endTime.localeCompare(right.endTime); + }); +} + function shortageToUnfilledCondition(shortage: ScheduleWorkerShortage): UnfilledCondition { return { assignedWorkers: shortage.assignedCount, @@ -355,6 +425,54 @@ function shortageToUnfilledCondition(shortage: ScheduleWorkerShortage): Unfilled }; } +function groupUnfilledConditionsByDate(conditions: UnfilledCondition[]): UnfilledConditionGroup[] { + const groupsByDate = new Map(); + + conditions.forEach((condition) => { + const group = groupsByDate.get(condition.date); + + if (group) { + group.conditions.push(condition); + return; + } + + groupsByDate.set(condition.date, { + conditions: [condition], + date: condition.date, + dayLabel: condition.dayLabel, + }); + }); + + return [...groupsByDate.values()] + .map((group) => ({ + ...group, + conditions: [...group.conditions].sort((left, right) => + left.timeRange.localeCompare(right.timeRange), + ), + })) + .sort((left, right) => left.date.localeCompare(right.date)); +} + +function getShortageCount(condition: UnfilledCondition) { + return Math.max(condition.requiredWorkers - condition.assignedWorkers, 0); +} + +function getUnfilledConditionLabel(condition: UnfilledCondition) { + return `${condition.timeRange} ${getShortageCount(condition)}명 부족`; +} + +function getUnfilledConditionPreview(conditions: UnfilledCondition[]) { + const visibleConditions = conditions.slice(0, MAX_PREVIEW_UNFILLED_CONDITIONS); + const hiddenConditionCount = conditions.length - visibleConditions.length; + const visibleLabel = visibleConditions.map(getUnfilledConditionLabel).join(" · "); + + if (hiddenConditionCount <= 0) { + return visibleLabel; + } + + return `${visibleLabel} · +${hiddenConditionCount}개`; +} + function createWorkerOptions(workers: Worker[], editingSchedule: ScheduleItem | null) { const options: WorkerOption[] = workers.map((worker) => ({ id: worker.id, @@ -411,6 +529,10 @@ export function MvpSchedulesPage() { () => schedule?.workerShortages.map(shortageToUnfilledCondition) ?? [], [schedule], ); + const unfilledConditionGroups = useMemo( + () => groupUnfilledConditionsByDate(unfilledConditions), + [unfilledConditions], + ); const scheduleTargetDates = useMemo( () => new Set([ @@ -442,6 +564,7 @@ export function MvpSchedulesPage() { const [confirmOpen, setConfirmOpen] = useState(false); const [selectedDate, setSelectedDate] = useState(""); const [dateDetailDismissed, setDateDetailDismissed] = useState(false); + const [showUnfilledDetails, setShowUnfilledDetails] = useState(false); const [operationMessage, setOperationMessage] = useState(null); const scheduleFormDate = watch("date"); const scheduleFormStartTime = watch("startTime"); @@ -486,10 +609,17 @@ export function MvpSchedulesPage() { !queryErrorMessage; const formTitle = formMode === "create" ? "스케줄 추가" : "스케줄 수정"; const selectedDateSchedules = schedules.filter((item) => item.date === selectedDate); - const selectedDateCandidates = candidates.filter((item) => item.date === selectedDate); - const selectedDateUnfilledConditions = unfilledConditions.filter( - (condition) => condition.date === selectedDate, + const selectedDateCandidates = candidates.filter( + (item) => item.date === selectedDate && !item.isRecommended, + ); + const selectedDateUnfilledConditions = + unfilledConditionGroups.find((group) => group.date === selectedDate)?.conditions ?? []; + const visibleUnfilledConditionGroups = unfilledConditionGroups.slice( + 0, + DEFAULT_VISIBLE_UNFILLED_GROUPS, ); + const hiddenUnfilledGroupCount = + unfilledConditionGroups.length - visibleUnfilledConditionGroups.length; const isSelectedDateScheduleTarget = scheduleTargetDates.has(selectedDate); const scheduleEndTimeOptions = useMemo( () => createScheduleEndTimeOptions(scheduleFormStartTime), @@ -520,7 +650,14 @@ export function MvpSchedulesPage() { } setSelectedDate(firstScheduleTargetDate); - }, [dateDetailDismissed, firstScheduleTargetDate, schedule, scheduleTargetDates, selectedDate]); + }, [ + dateDetailDismissed, + firstScheduleTargetDate, + reset, + schedule, + scheduleTargetDates, + selectedDate, + ]); function openCreateForm(date: string) { if (!isDraft) { @@ -781,36 +918,85 @@ export function MvpSchedulesPage() { ) : null} {unfilledConditions.length > 0 && isDraft ? ( -
-
-

미충족 조건

+
+
+
+
+ + 미충족 + +

미충족 조건

+
+

+ {unfilledConditions.length}개 조건이 {unfilledConditionGroups.length}일에 걸쳐 + 부족합니다. +

+
+
-
- - - - - - - - - - - {unfilledConditions.map((condition) => ( - - - - - - - ))} - -
요일시간대필요 인원추천 배정
{condition.dayLabel}{condition.timeRange} - {condition.requiredWorkers}명 - - {condition.assignedWorkers}명 -
+ +
+ {visibleUnfilledConditionGroups.map((group) => ( + + ))} + {hiddenUnfilledGroupCount > 0 ? ( + + +{hiddenUnfilledGroupCount}일 + + ) : null}
+ + {showUnfilledDetails ? ( +
+ {unfilledConditionGroups.map((group) => ( + + ))} +
+ ) : null}
) : null} @@ -846,11 +1032,16 @@ export function MvpSchedulesPage() { {calendarDates.map((date) => { const inWorkRange = date >= workStartDate && date <= workEndDate; const dateSchedules = schedules.filter((item) => item.date === date); + const dateScheduleGroups = groupSchedulesByTime(dateSchedules); + const visibleScheduleGroups = dateScheduleGroups.slice( + 0, + DEFAULT_VISIBLE_SCHEDULE_GROUPS, + ); + const hiddenScheduleGroupCount = + dateScheduleGroups.length - visibleScheduleGroups.length; const unfilled = unfilledConditions.some((condition) => condition.date === date); const hasScheduleTarget = scheduleTargetDates.has(date); const selected = selectedDate === date; - const visibleSchedules = dateSchedules.slice(0, 3); - const hiddenScheduleCount = dateSchedules.length - visibleSchedules.length; return ( @@ -959,14 +1146,37 @@ export function MvpSchedulesPage() {
{selectedDateUnfilledConditions.length > 0 ? ( -
-

미충족 조건

-
- {selectedDateUnfilledConditions.map((condition) => ( -

- {condition.timeRange} · 필요 {condition.requiredWorkers}명 / 추천{" "} - {condition.assignedWorkers}명 +

+
+
+
+
+

최소 인원 미달

+

+ 시간대별 기준에 미달한 항목만 표시합니다.

+
+
+
+ {selectedDateUnfilledConditions.map((condition) => ( +
+ + {condition.timeRange} + + + 배정 {condition.assignedWorkers}명 · 최소 {condition.requiredWorkers}명 + + + {getShortageCount(condition)}명 부족 + +
))}
@@ -991,11 +1201,7 @@ export function MvpSchedulesPage() { selectedDateCandidates.map((candidate) => (

@@ -1005,16 +1211,9 @@ export function MvpSchedulesPage() { {candidate.workerName}

- {candidate.isRecommended ? ( - - - ) : ( - - 후보 - - )} + + 후보 +
)) ) : ( From 68e3724af2e8e5b6df2a62961ca2e372f688f05d Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Wed, 1 Jul 2026 11:21:26 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20PR=20=EB=A6=AC=EB=B7=B0=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/package.json | 2 +- apps/api/test/schedule-history.routes.test.ts | 19 +- .../components/mvp-availability-page.tsx | 13 +- .../components/mvp-dashboard-page.tsx | 11 +- .../components/mvp-schedule-history-page.tsx | 22 +-- .../confirmed-schedule-calendar.tsx | 64 +------ .../components/mvp-schedules-page.test.ts | 65 +++++++ .../components/mvp-schedules-page.tsx | 169 ++++-------------- .../schedules/utils/unfilled-conditions.ts | 66 +++++++ apps/web/src/lib/schedule-display.test.ts | 55 ++++++ apps/web/src/lib/schedule-display.ts | 121 ++++++++++++- docs/openapi.yaml | 16 +- 12 files changed, 400 insertions(+), 223 deletions(-) create mode 100644 apps/web/src/features/schedules/components/mvp-schedules-page.test.ts create mode 100644 apps/web/src/features/schedules/utils/unfilled-conditions.ts create mode 100644 apps/web/src/lib/schedule-display.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 1f38613..72d1116 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,7 +10,7 @@ "lint": "eslint \"{src,test}/**/*.ts\"", "test": "jest --runInBand", "test:watch": "jest --watch", - "typecheck": "tsc --noEmit" + "typecheck": "corepack pnpm --filter @fragment/shared build && corepack pnpm --filter @fragment/database build && tsc --noEmit" }, "dependencies": { "@fragment/database": "workspace:*", diff --git a/apps/api/test/schedule-history.routes.test.ts b/apps/api/test/schedule-history.routes.test.ts index fd6d02d..0344063 100644 --- a/apps/api/test/schedule-history.routes.test.ts +++ b/apps/api/test/schedule-history.routes.test.ts @@ -135,6 +135,23 @@ describe("schedule history routes", () => { }); }); + it("rejects month without year", async () => { + const { prisma } = createFakePrisma({ + organizations: [createOrganization()], + }); + const app = createApp({ prisma }); + + const response = await request(app) + .get("/api/schedule-history?month=7") + .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()], @@ -403,7 +420,7 @@ describe("schedule history routes", () => { .set("Authorization", authHeader()); expect(response.status).toBe(200); - expect(response.headers["content-type"]).toContain("text/csv"); + expect(response.headers["content-type"]).toBe("text/csv; charset=utf-8"); expect(response.text).toBe( `\uFEFF${[ "날짜,요일,07:00-09:00,10:00-14:00,15:00-18:00", diff --git a/apps/web/src/features/availability/components/mvp-availability-page.tsx b/apps/web/src/features/availability/components/mvp-availability-page.tsx index d7f8764..204f0ea 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -419,8 +419,13 @@ export function MvpAvailabilityPage() { staffingRulesQuery.isLoading || workersQuery.isLoading; const hasValidDateRange = Boolean(workStartDate && workEndDate) && workStartDate <= workEndDate; - const hasSavedPlanningPeriod = Boolean(activePlanningPeriod && hasValidDateRange); - const canFetchAvailability = hasSavedPlanningPeriod && Boolean(selectedWorkerId); + const hasMatchingPlanningPeriod = Boolean( + activePlanningPeriod && + hasValidDateRange && + activePlanningPeriod.startDate === workStartDate && + activePlanningPeriod.endDate === workEndDate, + ); + const canFetchAvailability = hasMatchingPlanningPeriod && Boolean(selectedWorkerId); const availabilityResult = useAvailabilityQuery(availabilityQuery, canFetchAvailability); const queryError = organizationQuery.error ?? @@ -545,7 +550,7 @@ export function MvpAvailabilityPage() { const hasUnsavedChanges = !hasSameSlots(savedAvailability, draftAvailability); const canSaveAvailability = hasUnsavedChanges && - hasSavedPlanningPeriod && + hasMatchingPlanningPeriod && hasValidDateRange && Boolean(selectedWorkerId) && !availabilityResult.isFetching && @@ -941,7 +946,7 @@ export function MvpAvailabilityPage() { ) : null}
- {hasSavedPlanningPeriod ? ( + {hasMatchingPlanningPeriod ? (
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 9bf63aa..87e4ee0 100644 --- a/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx +++ b/apps/web/src/features/dashboard/components/mvp-dashboard-page.tsx @@ -15,6 +15,7 @@ import { type ConfirmedScheduleCalendarItem, } from "@/features/schedules/components/confirmed-schedule-calendar"; import { getApiErrorMessage } from "@/lib/api-error-message"; +import { formatScheduleAssignmentTime } from "@/lib/schedule-display"; const DAY_LABELS = { MON: "월", @@ -46,20 +47,14 @@ function downloadBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } -function formatAssignmentTime(workDate: string, dateTime: string) { - const time = dateTime.slice(11, 16); - - return dateTime.slice(0, 10) > workDate ? `${time}+1` : time; -} - function scheduleAssignmentToCalendarItem( assignment: ScheduleAssignment, ): ConfirmedScheduleCalendarItem { return { date: assignment.workDate, - endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt), + endTime: formatScheduleAssignmentTime(assignment.workDate, assignment.endsAt), id: assignment.id, - startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt), + startTime: formatScheduleAssignmentTime(assignment.workDate, assignment.startsAt), workerName: assignment.workerNameSnapshot, }; } 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 509e510..88a9697 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 @@ -23,8 +23,10 @@ import { type ConfirmedScheduleCalendarItem, } from "@/features/schedules/components/confirmed-schedule-calendar"; import { getApiErrorMessage } from "@/lib/api-error-message"; +import { formatScheduleAssignmentTime } from "@/lib/schedule-display"; const INITIAL_HISTORY_YEAR = new Date().getFullYear(); +const ALL_MONTH_VALUE = "all"; function formatScheduleRange(startDate: string, endDate: string) { return `${startDate} ~ ${endDate}`; @@ -50,20 +52,14 @@ function downloadBlob(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } -function formatAssignmentTime(workDate: string, dateTime: string) { - const time = dateTime.slice(11, 16); - - return dateTime.slice(0, 10) > workDate ? `${time}+1` : time; -} - function scheduleAssignmentToCalendarItem( assignment: ScheduleAssignment, ): ConfirmedScheduleCalendarItem { return { date: assignment.workDate, - endTime: formatAssignmentTime(assignment.workDate, assignment.endsAt), + endTime: formatScheduleAssignmentTime(assignment.workDate, assignment.endsAt), id: assignment.id, - startTime: formatAssignmentTime(assignment.workDate, assignment.startsAt), + startTime: formatScheduleAssignmentTime(assignment.workDate, assignment.startsAt), workerName: assignment.workerNameSnapshot, }; } @@ -130,9 +126,8 @@ export function MvpScheduleHistoryPage() { isHistoryInYear(history, effectiveSelectedYear), ); const availableMonths = getHistoryMonthOptions(selectedYearItems, effectiveSelectedYear); - const effectiveSelectedMonth = availableMonths.includes(selectedMonth) - ? selectedMonth - : (availableMonths[0] ?? ""); + const effectiveSelectedMonth = + selectedMonth === "" ? "" : availableMonths.includes(selectedMonth) ? selectedMonth : ""; const historyItems = effectiveSelectedMonth === "" ? selectedYearItems @@ -236,9 +231,9 @@ export function MvpScheduleHistoryPage() {