From bd3070f57956386f3bff76acc7deca9ab212b460 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 20:15:02 +0900 Subject: [PATCH 01/16] =?UTF-8?q?docs(repo):=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=EC=A0=95=EC=B1=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API.md | 14 ++- docs/ERD.md | 19 ++- docs/PRD.md | 12 +- docs/SCHEDULING_POLICY.md | 258 ++++++++++++++++++++++++++++++++++++++ docs/SPEC.md | 33 +++-- docs/openapi.yaml | 58 ++++++++- 6 files changed, 372 insertions(+), 22 deletions(-) create mode 100644 docs/SCHEDULING_POLICY.md diff --git a/docs/API.md b/docs/API.md index f9d1627..a10187a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -331,6 +331,8 @@ Response `204`: body 없음 **Auth:** Bearer token +특정 근무자의 선택 기간 가능 시간을 조회합니다. 정책상 가능 시간은 최소 인원 조건이 있는 시간대에만 입력하고 추천 생성에 사용합니다. 현재 최소 인원 조건 밖에 있는 기존 가능 시간은 유효하지 않은 데이터로 보고 조회 또는 저장 과정에서 정리하며, 조건과 겹치는 구간만 남깁니다. + Query: ```txt @@ -357,7 +359,7 @@ Response `200`: **Auth:** Bearer token -선택 범위의 특정 인력 가능 시간을 전체 교체합니다. +선택 범위의 특정 인력 가능 시간을 전체 교체합니다. 저장 가능한 시간은 조직 영업시간 안에 있으면서 최소 인원 조건이 있는 시간대로 제한합니다. 현재 최소 인원 조건과 겹치는 구간만 저장하고, 전혀 겹치지 않는 구간은 삭제합니다. Request: @@ -411,6 +413,8 @@ Response `200`: **Auth:** Bearer token +최소 인원 조건은 해당 요일의 조직 운영시간 안에서만 등록할 수 있습니다. 휴무일에는 등록할 수 없고, 운영시간 밖 요청은 `INVALID_TIME_RANGE`, 휴무일 요청은 `CLOSED_DAY`로 거절합니다. + Request: ```json @@ -429,6 +433,8 @@ Response `201`: `MinimumStaffingRule` **Auth:** Bearer token +수정 후의 조건도 해당 요일의 조직 운영시간 안에 있어야 하며, 휴무일로 이동하거나 운영시간 밖으로 변경할 수 없습니다. + Request: ```json @@ -453,7 +459,7 @@ Response `204`: body 없음 **Auth:** Bearer token -현재 active planning period, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. 같은 입력 조건의 DRAFT가 있으면 `DRAFT_ALREADY_EXISTS`를 반환합니다. +현재 active planning period, 조직 영업시간, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. 최소 인원 조건은 추천 대상 시간대와 최소 필요 인원을 정의하며, 조건이 있는 시간대에서만 가능 후보 산출, 추천 배정, 미충족 판단을 수행합니다. 같은 입력 조건의 DRAFT가 있으면 `DRAFT_ALREADY_EXISTS`를 반환합니다. Request: @@ -466,6 +472,8 @@ Request: Response `201`: `ScheduleDetail` +`ScheduleDetail.candidates`는 추천 생성 당시 해당 시간대에 일할 수 있었던 전체 가능 후보입니다. 각 후보의 `isRecommended` 값이 `true`이면 같은 시간대의 추천 배정에 포함된 근무자입니다. + ### GET /schedules/draft **Auth:** Bearer token @@ -484,7 +492,7 @@ Response `200`: } ``` -스케줄이 있으면 `schedule`은 `ScheduleDetail`입니다. +스케줄이 있으면 `schedule`은 `ScheduleDetail`입니다. `candidates`는 가능 후보 전체, `assignments`는 추천 또는 확정 배정 목록입니다. ### POST /schedules/:scheduleId/assignments diff --git a/docs/ERD.md b/docs/ERD.md index fed2a56..5131f9c 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -18,8 +18,10 @@ erDiagram WORKERS ||--o{ WORKER_AVAILABLE_TIMES : has WORKERS ||--o{ SCHEDULE_ASSIGNMENTS : assigned + WORKERS ||--o{ SCHEDULE_CANDIDATES : candidate SCHEDULES ||--o{ SCHEDULE_ASSIGNMENTS : contains + SCHEDULES ||--o{ SCHEDULE_CANDIDATES : contains SCHEDULES ||--o{ SCHEDULE_WORKER_SHORTAGES : has USERS { @@ -129,6 +131,19 @@ erDiagram datetime updated_at } + SCHEDULE_CANDIDATES { + bigint id PK + bigint schedule_id FK + bigint worker_id FK "nullable" + date work_date + datetime starts_at + datetime ends_at + varchar worker_name_snapshot + varchar employee_code_snapshot + datetime created_at + datetime updated_at + } + SCHEDULE_WORKER_SHORTAGES { bigint id PK bigint schedule_id FK @@ -190,7 +205,9 @@ erDiagram - 운영 시간과 최소 인원 조건에서 종료 시간이 다음 날로 넘어가면 `closes_next_day` 또는 `ends_next_day`가 `true`입니다. - `is_closed = true`인 운영 요일은 `open_time`, `close_time`을 비워둘 수 있습니다. - 휴무일에는 가능 시간을 저장할 수 없습니다. -- 가능 시간은 조직 운영 시간 기준 30분 단위여야 합니다. +- 가능 시간은 조직 운영 시간 안에서 최소 인원 조건이 있는 시간대 기준 30분 단위여야 합니다. +- 최소 인원 조건이 없는 시간대는 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외합니다. +- 최소 인원 조건 변경으로 기존 가능 시간이 현재 조건 밖에 놓이면 조건과 겹치는 구간만 남기고, 전혀 겹치지 않는 구간은 삭제합니다. - 삭제된 인력은 추천 생성 기준에서 제외합니다. ### Application Read / Export Rules diff --git a/docs/PRD.md b/docs/PRD.md index 1f27613..090782b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -2,7 +2,7 @@ ## 1. 제품 개요 -프래그먼트는 단일 조직의 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 이를 조정·확정·보관할 수 있게 하는 근무 스케줄 관리 도구 MVP입니다. +프래그먼트는 단일 조직의 영업시간, 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 이를 조정·확정·보관할 수 있게 하는 근무 스케줄 관리 도구 MVP입니다. MVP에서는 운영 웹이 전체 사용자 화면을 담당합니다. 따라서 회원가입, 로그인, 조직 생성, 운영 설정, 스케줄 추천, 확정 스케줄 조회까지 모두 반응형 웹에서 사용할 수 있어야 합니다. @@ -23,9 +23,9 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보 - 조직의 운영 시간과 휴무일을 설정할 수 있습니다. - 인력 정보를 등록하고 관리할 수 있습니다. -- 인력별 가능 시간을 날짜와 시간 단위로 입력할 수 있습니다. +- 최소 인원 조건이 있는 날짜와 시간 단위로 인력별 가능 시간을 입력할 수 있습니다. - 요일과 시간대별 최소 인원 조건을 등록할 수 있습니다. -- 입력된 조건을 기준으로 스케줄 초안을 추천받을 수 있습니다. +- 최소 인원 조건이 있는 시간대에 대해 가능한 후보와 시스템 추천 배정이 구분된 스케줄 초안을 받을 수 있습니다. - 추천된 DRAFT 스케줄을 직접 추가·수정·삭제할 수 있습니다. - 스케줄을 확정하고, 확정 이력을 보관함에서 조회할 수 있습니다. - 확정 스케줄을 CSV로 export할 수 있습니다. @@ -89,7 +89,7 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보 - 선택된 가능 시간 저장 - 기존 가능 시간 수정 및 삭제 -가능 시간은 조직 운영 시간 기준 30분 단위 타임테이블로 입력합니다. 조직 휴무일에 해당하는 날짜는 선택할 수 없습니다. +가능 시간은 조직 운영 시간 안에서 최소 인원 조건이 있는 시간대에만 30분 단위 타임테이블로 입력합니다. 최소 인원 조건이 없는 시간대와 조직 휴무일에 해당하는 날짜는 선택할 수 없습니다. ### 5.5 최소 인원 조건 @@ -99,7 +99,7 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보 - 필요한 인원 수 입력 - 조건 생성, 수정, 삭제 -최소 인원 조건은 스케줄 추천 시 시간대별 필요 인원을 판단하는 기준으로 사용합니다. +최소 인원 조건은 스케줄 추천 대상 시간대와 해당 시간대의 최소 필요 인원을 정의합니다. 최소 인원 조건이 없는 시간대는 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외합니다. ### 5.6 스케줄 @@ -111,7 +111,7 @@ MVP의 목표는 사용자가 근무 스케줄 관리에 필요한 기본 정보 - 날짜별 스케줄 삭제 - 스케줄 확정 -스케줄 추천은 인력, 가능 시간, 최소 인원 조건, 근무 시작 날짜, 근무 종료 날짜를 기준으로 실행합니다. 추천 결과는 DRAFT 상태로 생성되며, 사용자가 직접 조정한 뒤 CONFIRMED 상태로 확정합니다. +스케줄 추천은 영업시간, 인력, 가능 시간, 최소 인원 조건, 근무 시작 날짜, 근무 종료 날짜를 기준으로 실행합니다. 최소 인원 조건이 있는 시간대에서 가능한 후보와 시스템 추천 배정을 구분해 보여주고, 최소 필요 인원 대비 추천 배정이 부족한 구간을 미충족 조건으로 기록합니다. 추천 결과는 DRAFT 상태로 생성되며, 사용자가 직접 조정한 뒤 CONFIRMED 상태로 확정합니다. 상세 정책은 `docs/SCHEDULING_POLICY.md`를 기준으로 합니다. ### 5.7 보관함 diff --git a/docs/SCHEDULING_POLICY.md b/docs/SCHEDULING_POLICY.md new file mode 100644 index 0000000..b7e92aa --- /dev/null +++ b/docs/SCHEDULING_POLICY.md @@ -0,0 +1,258 @@ +# 스케줄 추천 정책 + +이 문서는 프래그먼트의 스케줄 추천이 어떤 입력을 사용하고, 어떤 결과를 만들어야 하는지 정의합니다. 코드 구현보다 우선하는 제품 정책 문서입니다. + +## 1. 목적 + +스케줄 추천은 운영자가 직접 모든 가능 시간을 대조하지 않아도, 특정 근무 기간에 대해 다음을 빠르게 판단할 수 있게 해야 합니다. + +- 최소 인원 조건이 있는 시간대에 일할 수 있는 사람이 누구인지 +- 시스템이 실제 근무자로 추천하는 사람이 누구인지 +- 최소 인원 조건을 충족하지 못하는 시간대가 어디인지 +- 근무자별 주간 계약 시간에 비해 추천 배정이 과하거나 부족한지 + +## 2. 핵심 원칙 + +최소 인원 조건은 스케줄 추천 대상 시간대를 정의합니다. + +- 최소 인원 조건이 있는 시간대만 가능 시간 입력 대상입니다. +- 최소 인원 조건이 있는 시간대만 가능 후보 산출 대상입니다. +- 최소 인원 조건이 있는 시간대만 추천 배정 대상입니다. +- 최소 인원 조건이 있는 시간대만 미충족 판단 대상입니다. +- 최소 인원 조건이 없는 영업시간은 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외합니다. + +조직 영업시간은 운영 가능한 전체 범위이고, 최소 인원 조건은 그 안에서 실제로 스케줄 추천이 필요한 시간대입니다. + +## 3. 핵심 용어 + +### 가능 후보 + +가능 후보는 최소 인원 조건이 있는 특정 날짜와 시간대에 일할 수 있는 모든 근무자입니다. + +- 해당 시간대가 최소 인원 조건 안에 포함되어야 합니다. +- 근무자의 가능 시간 안에 포함되어야 합니다. +- 조직 영업시간 안에 포함되어야 합니다. +- 후보는 최종 근무 배정이 아닙니다. +- 같은 근무자는 서로 다른 추천 대상 시간대의 후보에 각각 포함될 수 있습니다. +- 단, 같은 시간대의 후보 목록에 같은 근무자가 중복 표시되면 안 됩니다. + +### 추천 배정 + +추천 배정은 시스템이 가능 후보 중 실제 근무자로 선택한 사람입니다. + +- 최소 인원 조건을 최대한 충족하는 것을 1순위 목표로 합니다. +- 근무자별 주간 계약 시간에 가깝게 배분하는 것을 2순위 목표로 합니다. +- 같은 근무자가 겹치는 시간대에 동시에 추천 배정되면 안 됩니다. +- 추천 배정은 사용자가 수정할 수 있는 DRAFT입니다. + +### 확정 배정 + +확정 배정은 사용자가 DRAFT를 검토하고 확정한 최종 근무 스케줄입니다. + +- 확정 이후에는 일반 편집 화면에서 수정하지 않습니다. +- 보관함과 export의 기준 데이터입니다. + +### 미충족 조건 + +미충족 조건은 최소 인원 조건이 있는 시간대에서 추천 배정 가능한 인원이 필요한 인원보다 부족한 상태입니다. + +- 미충족 인원은 `최소 필요 인원 - 추천 배정 인원`입니다. +- 추천 배정 인원은 하드 제약을 통과해 실제 배정 가능한 사람만 포함합니다. +- 가능 후보가 최소 필요 인원보다 적으면 가능한 후보만 추천하고 부족한 인원을 미충족으로 기록합니다. +- 계약 시간 균형 때문에 일부러 최소 인원을 채우지 않는 것은 허용하지 않습니다. + +## 4. 입력 데이터 + +스케줄 추천은 다음 입력을 사용합니다. + +| 입력 | 역할 | +| --- | --- | +| 조직 영업시간 | 최소 인원 조건과 가능 시간 입력이 허용되는 운영 범위 | +| 휴무일 | 가능 시간 입력과 자동 추천에서 제외할 날짜 | +| 근무 기간 | 추천할 날짜 범위 | +| 근무자 | 추천 대상 인력 | +| 주간 계약 시간 | 추천 배정 균형 기준 | +| 근무자별 가능 시간 | 가능 후보 산출 기준 | +| 최소 인원 조건 | 추천 대상 시간대와 시간대별 최소 필요 인원 | + +## 5. 기본 정책 + +### 영업시간 + +- 영업시간 밖은 가능 시간 입력 대상이 아닙니다. +- 영업시간 밖은 최소 인원 조건을 등록할 수 없습니다. +- 휴무일은 가능 시간 입력과 자동 추천 대상이 아닙니다. +- 영업시간이 다음 날로 넘어갈 수 있습니다. + +### 최소 인원 조건 + +- 최소 인원 조건은 스케줄 추천 대상 시간대입니다. +- 최소 인원 조건은 해당 시간대에 반드시 충족해야 하는 최소 필요 인원을 의미합니다. +- 최소 인원 조건이 없는 시간대는 추천 대상 구간으로 만들지 않습니다. +- 최소 인원 조건이 없는 시간대는 미충족 인원을 계산하지 않습니다. + +예시: + +- 영업시간: `09:00-18:00` +- 최소 인원 조건: `12:00-14:00 3명` +- 추천 대상 구간: `12:00-14:00` +- 제외 구간: `09:00-12:00`, `14:00-18:00` + +### 가능 시간 + +- 가능 시간은 최소 인원 조건이 있는 시간대에서만 입력할 수 있습니다. +- 최소 인원 조건이 없는 시간대는 UI에서 선택할 수 없게 막습니다. +- 저장된 가능 시간이 현재 최소 인원 조건 밖에 있게 되면 유효하지 않은 가능 시간으로 보고 정리합니다. +- 기존 가능 시간이 최소 인원 조건과 일부 겹치면 겹치는 구간만 잘라서 남깁니다. +- 기존 가능 시간이 어떤 최소 인원 조건과도 겹치지 않으면 삭제합니다. +- 최소 인원 조건 생성, 수정, 삭제로 가능 시간의 유효 범위가 바뀌면 활성 근무 기간 안의 기존 가능 시간을 다시 검증하고, 현재 조건 기준으로 잘라서 저장합니다. +- 가능 시간 일괄 저장 시에도 현재 최소 인원 조건 밖의 시간은 저장하지 않고, 겹치는 구간만 저장합니다. + +### 가능 후보 + +- 해당 구간 전체를 근무자의 가능 시간이 포함하면 가능 후보입니다. +- 가능 후보는 모두 화면에 보여야 합니다. +- 가능 후보가 0명인 최소 인원 조건 구간은 미충족 조건으로 보여야 합니다. + +### 추천 배정 + +- 추천 배정은 가능 후보 중에서 선택합니다. +- 추천 배정은 최소 인원 조건을 최대한 충족해야 합니다. +- 가능 후보가 최소 필요 인원보다 적으면 가능한 후보만 추천하고 미충족 조건을 기록합니다. +- 같은 근무자는 겹치는 시간대에 동시에 추천 배정하지 않습니다. +- 같은 근무자의 인접한 추천 배정은 가능한 한 하나의 연속 근무로 합칩니다. + +### 주간 계약 시간 + +- 추천 배정은 근무자별 주간 계약 시간에 가까워지도록 배분합니다. +- 주간 계약 시간은 MVP에서 하드 제한이 아니라 우선순위와 경고 기준입니다. +- 추천 배정 시간이 계약 시간을 초과하거나 크게 부족하면 화면에서 확인할 수 있어야 합니다. + +## 6. 미충족 판단 기준 + +미충족 판단은 최소 인원 조건이 있는 시간대에서만 수행합니다. + +1. 최소 인원 조건의 필요 인원을 확인합니다. +2. 해당 시간대의 가능 후보를 산출합니다. +3. 하드 제약을 통과한 후보 중 추천 배정을 선택합니다. +4. `최소 필요 인원 - 추천 배정 인원`이 1명 이상이면 미충족 조건으로 기록합니다. + +하드 제약은 MVP에서 다음 기준을 사용합니다. + +- 해당 시간대가 최소 인원 조건 안에 있어야 합니다. +- 근무자가 해당 시간대에 가능 시간을 등록해야 합니다. +- 같은 근무자가 겹치는 시간대에 중복 추천 배정되면 안 됩니다. +- 삭제되었거나 비활성 처리된 근무자는 추천 대상에서 제외합니다. + +## 7. 추천 배정 균형 기준 + +추천 배정은 최소 인원 충족을 먼저 처리한 뒤, 가능한 후보 중 누구를 선택할지 균형 기준으로 결정합니다. + +우선순위는 다음과 같습니다. + +1. 최소 인원 조건을 최대한 충족합니다. +2. 주간 계약 시간 대비 현재 추천 배정 시간이 적은 근무자를 우선합니다. +3. 모든 가능 후보의 계약 시간 사용률이 100% 이상이면 초과 폭이 가장 작은 근무자를 우선합니다. +4. 같은 수준이면 해당 날짜의 추천 배정 시간이 적은 근무자를 우선합니다. +5. 그래도 같으면 기존 추천 배정을 유지하거나 사번 같은 안정적인 기준으로 정렬합니다. + +추천 배정 균형의 기본 지표는 다음과 같습니다. + +```txt +계약 시간 사용률 = 이번 주 추천 배정 시간 / 주간 계약 시간 +``` + +계약 시간 사용률이 낮은 근무자를 우선 추천합니다. 단, 이 기준은 최소 인원 조건을 일부러 미충족으로 만들기 위해 사용하지 않습니다. + +모든 가능 후보의 계약 시간 사용률이 100% 이상이어도 최소 인원 조건을 충족하기 위해 추천 배정은 생성합니다. 이때 계약 시간 초과 폭이 가장 작은 근무자를 우선 선택하고, 해당 배정은 계약 시간 초과 경고로 표시합니다. + +## 8. MVP 결정 + +MVP에서는 다음 범위까지만 구현합니다. + +### 포함 + +- 최소 인원 조건 기준 추천 구간 생성 +- 최소 인원 조건이 있는 시간대에서만 가능 시간 입력 허용 +- 가능 후보 전체 표시 +- 최소 인원 조건 기준 미충족 조건 표시 +- 추천 배정과 가능 후보의 개념 분리 +- 사용자가 DRAFT에서 추천 배정을 추가, 수정, 삭제 +- 확정 시 추천 배정을 확정 배정으로 저장 + +### 보류 + +- 휴게 시간 자동 삽입 +- 하루 최대 근무 시간 +- 한 번 근무의 최소/최대 길이 설정 +- 연속 근무일 제한 +- 선호 근무 시간 +- 근무자별 숙련도 또는 역할 조건 +- 공정성 점수 상세 표시 + +## 9. 화면 정책 + +스케줄 화면은 후보와 배정을 혼동하지 않게 표시해야 합니다. + +- 가능 후보: 최소 인원 조건이 있는 시간대에 일할 수 있는 모든 사람 +- 추천 배정: 시스템이 선택한 사람, 체크 또는 강조 표시 +- 미충족 조건: 최소 필요 인원 대비 추천 배정이 부족한 구간 + +날짜 상세 패널에서는 최소한 다음 정보를 보여야 합니다. + +- 시간대 +- 최소 필요 인원 +- 가능 후보 수 +- 추천 배정 수 +- 가능 후보 목록 +- 추천 여부 + +## 10. 데이터 모델 + +스케줄 추천 결과는 다음 개념을 분리해 저장하고 응답해야 합니다. + +- `ScheduleCandidate`: 가능 후보 +- `ScheduleAssignment`: 추천 또는 확정 배정 +- `ScheduleWorkerShortage`: 미충족 조건 + +`ScheduleCandidate`는 추천 생성 당시 해당 시간대에 일할 수 있었던 모든 후보를 저장합니다. `ScheduleAssignment`는 그중 시스템이 실제 추천 배정으로 선택했거나 사용자가 DRAFT에서 조정한 배정을 저장합니다. API 응답과 화면은 후보 전체를 보여주되, 추천 배정된 후보는 체크 또는 강조 표시해야 합니다. + +## 11. 예시 + +### 예시 1: 최소 인원 조건 없음 + +- 영업시간: `09:00-18:00` +- 근무자 A 가능 시간: 입력 불가 +- 근무자 B 가능 시간: 입력 불가 +- 최소 인원 조건: 없음 + +결과: + +- 추천 대상 시간대 없음 +- 가능 후보 산출 없음 +- 추천 배정 없음 +- 미충족 조건 없음 +- 운영자는 먼저 최소 인원 조건을 등록해야 가능 시간을 입력하고 추천을 생성할 수 있습니다. + +### 예시 2: 피크 시간 최소 인원 조건 + +- 영업시간: `09:00-18:00` +- 최소 인원 조건: `12:00-14:00 3명` +- 가능 후보: A/B + +결과: + +- `09:00-12:00`: 추천 대상 아님 +- `12:00-14:00`: 최소 필요 인원 3명, 가능 후보 2명, 추천 배정 A/B, 미충족 1명 +- `14:00-18:00`: 추천 대상 아님 + +### 예시 3: 가능 후보는 많고 추천 배정은 일부 + +- 최소 인원 조건: `12:00-14:00 2명` +- 가능 후보: A/B/C/D + +결과: + +- 가능 후보는 A/B/C/D 모두 표시합니다. +- 추천 배정은 계약 시간 사용률과 기존 추천 배정을 고려해 2명을 선택합니다. +- 추천 배정이 2명이면 미충족 조건은 없습니다. diff --git a/docs/SPEC.md b/docs/SPEC.md index a38ca39..1926676 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,6 +1,6 @@ # 기능 명세서 — 프래그먼트 -이 문서는 PRD의 MVP 범위를 개발 실행 가능한 수준으로 정리합니다. MVP는 단일 조직의 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 조정·확정·보관하는 흐름만 포함합니다. +이 문서는 PRD의 MVP 범위를 개발 실행 가능한 수준으로 정리합니다. MVP는 단일 조직의 영업시간, 인력 정보, 가능 시간, 최소 인원 조건을 기반으로 스케줄 초안을 추천하고, 사용자가 조정·확정·보관하는 흐름만 포함합니다. ## 1. 공통 원칙 @@ -185,11 +185,12 @@ | 항목 | 규칙 | | --- | --- | | 날짜 범위 | 근무 시작 날짜부터 근무 종료 날짜까지 | -| 시간 범위 | 조직의 요일별 운영 시간 기준 | +| 시간 범위 | 조직의 요일별 운영 시간 안에서 최소 인원 조건이 있는 시간대 | | 시간 단위 | 30분 | | 휴무일 | 가능 시간 입력 불가 | 운영 종료 시간이 익일인 경우에도 같은 영업일 기준으로 처리하며, 저장 시에는 실제 날짜가 반영된 시작/종료 datetime으로 저장합니다. +최소 인원 조건이 없는 시간대는 가능 시간 선택을 차단합니다. #### 입력 및 저장 @@ -199,12 +200,14 @@ 4. 선택된 시간이 연속되면 하나의 구간으로 관리합니다. - 예: `10:00~10:30`, `10:30~11:00`, `11:00~11:30` → `10:00~11:30` 5. 저장 시 선택된 전체 가능 시간을 반영합니다. +6. 저장 시 현재 최소 인원 조건과 겹치는 구간만 가능 시간으로 저장합니다. #### 수정 및 삭제 - 기존 가능 시간은 조회 후 수정할 수 있습니다. - 수정은 가능 시간 구간을 추가하거나 제거한 뒤 저장합니다. - 삭제는 기존 가능 시간 구간을 제거한 뒤 저장합니다. +- 최소 인원 조건 변경으로 기존 가능 시간이 조건 밖에 놓이면 조건과 겹치는 구간만 남기고, 전혀 겹치지 않는 구간은 삭제합니다. #### 검증 @@ -215,13 +218,14 @@ | 종료 날짜가 시작 날짜보다 빠름 | 저장 차단 | | 인력 미선택 | 저장 차단 | | 휴무일에 해당하는 시간 선택 | 저장 차단 | +| 최소 인원 조건이 없는 시간 선택 | 저장 차단 | ### FR-6. 최소 인원 조건 | 항목 | 내용 | | --- | --- | | 경로 | `/staffing-rules` | -| 목적 | 스케줄 추천 시 요일과 시간대별 필요한 인원 수 정의 | +| 목적 | 스케줄 추천 대상 요일과 시간대, 필요한 최소 인원 수 정의 | #### 조회 데이터 @@ -237,8 +241,8 @@ | 필드 | 조건 | | --- | --- | | 요일 | 필수 | -| 시작 시간 | 필수 | -| 종료 시간 | 필수, 시작 시간과 같을 수 없음 | +| 시작 시간 | 필수, 조직 운영시간 안이어야 함 | +| 종료 시간 | 필수, 시작 시간과 같을 수 없음, 조직 운영시간 안이어야 함 | | 필요 인원 | 필수, 1 이상의 정수 | #### 동작 @@ -248,6 +252,10 @@ 3. 사용자는 기존 조건을 수정할 수 있습니다. 4. 사용자는 기존 조건을 삭제할 수 있습니다. 5. 종료 시간이 시작 시간보다 빠르면 다음 날 종료되는 조건으로 저장합니다. +6. 최소 인원 조건이 없는 시간대는 가능 시간 입력, 가능 후보 산출, 추천 배정, 미충족 판단 대상에서 제외됩니다. +7. 조건 생성, 수정, 삭제 후 활성 근무 기간 안의 기존 가능 시간을 다시 검증하고, 현재 조건과 겹치는 구간만 남깁니다. +8. 휴무일에는 최소 인원 조건을 생성하거나 수정할 수 없습니다. +9. 최소 인원 조건은 해당 요일의 조직 운영시간을 벗어날 수 없습니다. ### FR-7. 스케줄 @@ -258,18 +266,25 @@ #### 추천 생성 조건 +정책 세부 기준은 `docs/SCHEDULING_POLICY.md`를 따릅니다. + - 조직이 있어야 합니다. - 인력이 1명 이상 있어야 합니다. -- 선택 기간에 저장된 가능 시간이 1개 이상 있어야 합니다. +- 조직 영업시간이 설정되어 있어야 합니다. - 최소 인원 조건이 1개 이상 있어야 합니다. +- 선택 기간에 저장된 가능 시간이 없으면 최소 인원 조건이 있는 전체 구간을 미충족 조건으로 기록할 수 있습니다. - 같은 입력 조건으로 이미 생성된 DRAFT가 있으면 중복 생성하지 않습니다. #### 추천 생성 동작 1. 사용자가 추천 생성을 요청합니다. -2. 서버는 인력, 가능 시간, 최소 인원 조건, 선택 기간을 기준으로 DRAFT 스케줄을 생성합니다. -3. 생성된 스케줄은 `DRAFT` 상태로 저장합니다. -4. 최소 인원 조건을 충족하지 못한 날짜와 시간대가 있으면 미충족 조건으로 기록합니다. +2. 서버는 조직 영업시간, 인력, 가능 시간, 최소 인원 조건, 선택 기간을 기준으로 DRAFT 스케줄을 생성합니다. +3. 최소 인원 조건이 있는 시간대에서만 가능한 후보를 산출합니다. +4. 가능 후보 중 추천 배정을 산출합니다. +5. 추천 배정은 최소 인원 조건을 최대한 충족한 뒤 주간 계약 시간 대비 배정 균형을 기준으로 선택합니다. +6. 모든 가능 후보의 계약 시간 사용률이 100% 이상이면 초과 폭이 가장 작은 근무자를 우선 선택하고 계약 시간 초과 경고 대상으로 표시합니다. +7. 생성된 스케줄은 `DRAFT` 상태로 저장합니다. +8. 최소 필요 인원 대비 추천 배정이 부족한 날짜와 시간대가 있으면 미충족 조건으로 기록합니다. #### 스케줄 추가/수정 입력값 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1814092..d15c5de 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -283,7 +283,7 @@ paths: tags: - Availability summary: 가능 시간 조회 - description: 특정 근무자의 선택 기간 가능 시간을 조회합니다. + description: 특정 근무자의 선택 기간 가능 시간을 조회합니다. 정책상 가능 시간은 최소 인원 조건이 있는 시간대에만 입력하고 추천 생성에 사용합니다. 현재 최소 인원 조건 밖에 있는 기존 가능 시간은 유효하지 않은 데이터로 보고 조회 또는 저장 과정에서 정리하며, 조건과 겹치는 구간만 남깁니다. operationId: listAvailability security: - bearerAuth: [] @@ -312,7 +312,7 @@ paths: tags: - Availability summary: 가능 시간 일괄 저장 - description: 선택 범위의 특정 근무자 가능 시간을 요청값으로 전체 교체합니다. + description: 선택 범위의 특정 근무자 가능 시간을 요청값으로 전체 교체합니다. 저장 가능한 시간은 조직 영업시간 안에 있으면서 최소 인원 조건이 있는 시간대로 제한합니다. 현재 최소 인원 조건과 겹치는 구간만 저장하고, 전혀 겹치지 않는 구간은 삭제합니다. operationId: replaceAvailability security: - bearerAuth: [] @@ -446,7 +446,7 @@ paths: tags: - Schedules summary: 스케줄 추천 생성 - description: 현재 active planning period, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. + description: 현재 active planning period, 조직 영업시간, 인력, 가능 시간, 최소 인원 조건을 기준으로 DRAFT 스케줄을 생성합니다. 최소 인원 조건은 추천 대상 시간대와 최소 필요 인원을 정의하며, 조건이 있는 시간대에서만 가능 후보 산출, 추천 배정, 미충족 판단을 수행합니다. operationId: recommendSchedule security: - bearerAuth: [] @@ -1272,6 +1272,53 @@ components: type: string example: W-0001 + ScheduleCandidate: + type: object + required: + - id + - scheduleId + - workerId + - workDate + - startsAt + - endsAt + - workerNameSnapshot + - employeeCodeSnapshot + - isRecommended + properties: + id: + type: string + example: "20" + scheduleId: + type: string + example: "1" + workerId: + type: + - string + - "null" + pattern: "^\\d+$" + example: "1" + workDate: + type: string + format: date + example: "2026-07-01" + startsAt: + type: string + format: date-time + example: "2026-07-01T10:00:00.000Z" + endsAt: + type: string + format: date-time + example: "2026-07-01T14:00:00.000Z" + workerNameSnapshot: + type: string + example: 김민수 + employeeCodeSnapshot: + type: string + example: W-0001 + isRecommended: + type: boolean + example: true + ScheduleWorkerShortage: type: object required: @@ -1327,6 +1374,7 @@ components: - generatedAt - confirmedAt - assignments + - candidates - workerShortages properties: id: @@ -1356,6 +1404,10 @@ components: type: array items: $ref: "#/components/schemas/ScheduleAssignment" + candidates: + type: array + items: + $ref: "#/components/schemas/ScheduleCandidate" workerShortages: type: array items: From 67140c0af0f3eb11249f0eb7f4f91a015f0e55ba Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 20:15:18 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat(db):=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=20=ED=9B=84=EB=B3=B4=20=EC=A0=80=EC=9E=A5=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 21 ++++++++++++++++++ packages/database/prisma/schema.prisma | 22 +++++++++++++++++++ packages/shared/src/schemas/schedules.ts | 13 +++++++++++ packages/shared/src/types/api/index.ts | 2 ++ 4 files changed, 58 insertions(+) create mode 100644 packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql diff --git a/packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql b/packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql new file mode 100644 index 0000000..83a0bb2 --- /dev/null +++ b/packages/database/prisma/migrations/20260629000000_add_schedule_candidates/migration.sql @@ -0,0 +1,21 @@ +CREATE TABLE "schedule_candidates" ( + "id" BIGSERIAL NOT NULL, + "schedule_id" BIGINT NOT NULL, + "worker_id" BIGINT, + "work_date" DATE NOT NULL, + "starts_at" TIMESTAMP(3) NOT NULL, + "ends_at" TIMESTAMP(3) NOT NULL, + "worker_name_snapshot" TEXT NOT NULL, + "employee_code_snapshot" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "schedule_candidates_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "schedule_candidates_schedule_id_work_date_idx" ON "schedule_candidates"("schedule_id", "work_date"); +CREATE INDEX "schedule_candidates_worker_id_idx" ON "schedule_candidates"("worker_id"); +CREATE INDEX "schedule_candidates_starts_at_ends_at_idx" ON "schedule_candidates"("starts_at", "ends_at"); + +ALTER TABLE "schedule_candidates" ADD CONSTRAINT "schedule_candidates_schedule_id_fkey" FOREIGN KEY ("schedule_id") REFERENCES "schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "schedule_candidates" ADD CONSTRAINT "schedule_candidates_worker_id_fkey" FOREIGN KEY ("worker_id") REFERENCES "workers"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 4c2fb2e..1d319fd 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -93,6 +93,7 @@ model Worker { organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) availableTimes WorkerAvailableTime[] scheduleAssignments ScheduleAssignment[] + scheduleCandidates ScheduleCandidate[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -155,6 +156,7 @@ model Schedule { confirmedAt DateTime? @map("confirmed_at") organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) assignments ScheduleAssignment[] + candidates ScheduleCandidate[] workerShortages ScheduleWorkerShortage[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -184,6 +186,26 @@ model ScheduleAssignment { @@map("schedule_assignments") } +model ScheduleCandidate { + id BigInt @id @default(autoincrement()) + scheduleId BigInt @map("schedule_id") + workerId BigInt? @map("worker_id") + workDate DateTime @map("work_date") @db.Date + startsAt DateTime @map("starts_at") + endsAt DateTime @map("ends_at") + workerNameSnapshot String @map("worker_name_snapshot") + employeeCodeSnapshot String @map("employee_code_snapshot") + schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + worker Worker? @relation(fields: [workerId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([scheduleId, workDate]) + @@index([workerId]) + @@index([startsAt, endsAt]) + @@map("schedule_candidates") +} + model ScheduleWorkerShortage { id BigInt @id @default(autoincrement()) scheduleId BigInt @map("schedule_id") diff --git a/packages/shared/src/schemas/schedules.ts b/packages/shared/src/schemas/schedules.ts index 6bd2e70..56cd4d3 100644 --- a/packages/shared/src/schemas/schedules.ts +++ b/packages/shared/src/schemas/schedules.ts @@ -21,6 +21,18 @@ export const scheduleAssignmentSchema = z.object({ employeeCodeSnapshot: z.string(), }); +export const scheduleCandidateSchema = z.object({ + id: idSchema, + scheduleId: idSchema, + workerId: idSchema.nullable(), + workDate: dateSchema, + startsAt: dateTimeSchema, + endsAt: dateTimeSchema, + workerNameSnapshot: z.string(), + employeeCodeSnapshot: z.string(), + isRecommended: z.boolean(), +}); + const scheduleAssignmentInputBaseSchema = z.object({ workerId: idSchema, workDate: dateSchema, @@ -74,6 +86,7 @@ export const scheduleSummarySchema = z.object({ export const scheduleDetailSchema = scheduleSummarySchema.extend({ assignments: z.array(scheduleAssignmentSchema), + candidates: z.array(scheduleCandidateSchema), workerShortages: z.array(scheduleWorkerShortageSchema), }); diff --git a/packages/shared/src/types/api/index.ts b/packages/shared/src/types/api/index.ts index e6125fb..29fa58f 100644 --- a/packages/shared/src/types/api/index.ts +++ b/packages/shared/src/types/api/index.ts @@ -25,6 +25,7 @@ import { replaceAvailabilityRequestSchema, scheduleAssignmentInputSchema, scheduleAssignmentSchema, + scheduleCandidateSchema, scheduleDetailSchema, scheduleHistoryDetailResponseSchema, scheduleHistoryItemSchema, @@ -88,6 +89,7 @@ export type UpdateMinimumStaffingRuleRequest = z.infer< export type ScheduleSummary = z.infer; export type ScheduleDetail = z.infer; export type ScheduleAssignment = z.infer; +export type ScheduleCandidate = z.infer; export type ScheduleWorkerShortage = z.infer; export type RecommendScheduleRequest = z.infer; export type DraftScheduleQuery = z.infer; From b50d0527b8a0b2007bb4d381511a518be8e76107 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 20:15:35 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat(api):=20=EA=B0=80=EB=8A=A5=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EA=B3=BC=20=EC=B5=9C=EC=86=8C=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=20=EC=A1=B0=EA=B1=B4=20=EC=A0=95=EC=B1=85=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../availability/availability.service.ts | 135 +++++++---- .../api/src/modules/scheduling-time-policy.ts | 221 ++++++++++++++++++ .../staffing-rules/staffing-rules.service.ts | 109 ++++++++- 3 files changed, 413 insertions(+), 52 deletions(-) create mode 100644 apps/api/src/modules/scheduling-time-policy.ts diff --git a/apps/api/src/modules/availability/availability.service.ts b/apps/api/src/modules/availability/availability.service.ts index a9ad590..a3c1b56 100644 --- a/apps/api/src/modules/availability/availability.service.ts +++ b/apps/api/src/modules/availability/availability.service.ts @@ -13,27 +13,25 @@ import type { import { ERROR_CODES } from "@/common/constants/error-codes"; import { HttpError } from "@/errors/http-error"; import { - addDays, - createDateTimeOnDate, + pruneAvailabilityTimesToSchedulableRanges, + type PrunedAvailabilityTime, +} from "@/modules/scheduling-time-policy"; +import { dateStringToDate, dateTimeStringToDate, dateToDateString, } from "@/utils/date-time"; -import { getDayOfWeek } from "@/utils/day-of-week"; import { toApiId, toPrismaId } from "@/utils/mapper"; type AvailabilityDatabaseClient = PrismaClient | Prisma.TransactionClient; type OrganizationForAvailability = Prisma.OrganizationGetPayload<{ - include: { businessHours: true }; + include: { businessHours: true; minimumStaffingRules: true }; }>; const createInvalidTimeRangeError = () => new HttpError(400, ERROR_CODES.INVALID_TIME_RANGE, "가능 시간이 조직 운영시간을 벗어났습니다."); -const createClosedDayError = () => - new HttpError(400, ERROR_CODES.CLOSED_DAY, "휴무일에는 가능 시간을 저장할 수 없습니다."); - const findOrganizationByUserId = async ( prisma: PrismaClient, userId: string, @@ -44,6 +42,7 @@ const findOrganizationByUserId = async ( }, include: { businessHours: true, + minimumStaffingRules: true, }, }); @@ -77,41 +76,31 @@ const toAvailability = (availability: PrismaWorkerAvailableTime): Availability = endsAt: availability.endsAt.toISOString(), }); -const assertAvailabilityItemInBusinessHours = ( - organization: OrganizationForAvailability, - input: ReplaceAvailabilityRequest, -) => { +const assertAvailabilityItemsInDateRange = (input: ReplaceAvailabilityRequest) => { for (const item of input.items) { if (item.availableDate < input.startDate || item.availableDate > input.endDate) { throw createInvalidTimeRangeError(); } - const availableDate = dateStringToDate(item.availableDate); - const businessHour = organization.businessHours.find( - (currentBusinessHour) => currentBusinessHour.dayOfWeek === getDayOfWeek(availableDate), - ); - - if ( - !businessHour || - businessHour.isClosed || - !businessHour.openTime || - !businessHour.closeTime - ) { - throw createClosedDayError(); - } - - const startsAt = dateTimeStringToDate(item.startsAt); - const endsAt = dateTimeStringToDate(item.endsAt); - const openAt = createDateTimeOnDate(availableDate, businessHour.openTime); - const closeDate = businessHour.closesNextDay ? addDays(availableDate, 1) : availableDate; - const closeAt = createDateTimeOnDate(closeDate, businessHour.closeTime); - - if (startsAt >= endsAt || startsAt < openAt || endsAt > closeAt) { + if (dateTimeStringToDate(item.startsAt) >= dateTimeStringToDate(item.endsAt)) { throw createInvalidTimeRangeError(); } } }; +const createPrunedAvailabilityItems = ( + organization: OrganizationForAvailability, + availableTimes: Pick< + PrismaWorkerAvailableTime, + "availableDate" | "endsAt" | "startsAt" | "workerId" + >[], +): PrunedAvailabilityTime[] => + pruneAvailabilityTimesToSchedulableRanges({ + availableTimes, + businessHours: organization.businessHours, + rules: organization.minimumStaffingRules, + }); + export async function listAvailability( prisma: PrismaClient, userId: string, @@ -126,6 +115,8 @@ export async function listAvailability( throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); } + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationId); + const items = await prisma.workerAvailableTime.findMany({ where: { workerId, @@ -156,7 +147,17 @@ export async function replaceAvailability( throw new HttpError(404, ERROR_CODES.WORKER_NOT_FOUND, "근무자를 찾을 수 없습니다."); } - assertAvailabilityItemInBusinessHours(organization, input); + assertAvailabilityItemsInDateRange(input); + + const prunedItems = createPrunedAvailabilityItems( + organization, + input.items.map((item) => ({ + workerId, + availableDate: dateStringToDate(item.availableDate), + startsAt: dateTimeStringToDate(item.startsAt), + endsAt: dateTimeStringToDate(item.endsAt), + })), + ); return prisma.$transaction(async (transaction) => { const dateRange = { @@ -171,14 +172,9 @@ export async function replaceAvailability( }, }); - if (input.items.length > 0) { + if (prunedItems.length > 0) { await transaction.workerAvailableTime.createMany({ - data: input.items.map((item) => ({ - workerId, - availableDate: dateStringToDate(item.availableDate), - startsAt: dateTimeStringToDate(item.startsAt), - endsAt: dateTimeStringToDate(item.endsAt), - })), + data: prunedItems, }); } @@ -195,3 +191,62 @@ export async function replaceAvailability( }; }); } + +export async function pruneOrganizationAvailabilityForActivePlanningPeriod( + client: AvailabilityDatabaseClient, + organizationId: bigint, +): Promise { + const activePeriod = await client.activeSchedulePlanningPeriod.findUnique({ + where: { + organizationId, + }, + }); + + if (!activePeriod) { + return; + } + + const organization = await client.organization.findUnique({ + where: { + id: organizationId, + }, + include: { + businessHours: true, + minimumStaffingRules: true, + }, + }); + + if (!organization) { + return; + } + + const dateRange = { + gte: activePeriod.startDate, + lte: activePeriod.endDate, + }; + const existingItems = await client.workerAvailableTime.findMany({ + where: { + worker: { + organizationId, + }, + availableDate: dateRange, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + const prunedItems = createPrunedAvailabilityItems(organization, existingItems); + + await client.workerAvailableTime.deleteMany({ + where: { + worker: { + organizationId, + }, + availableDate: dateRange, + }, + }); + + if (prunedItems.length > 0) { + await client.workerAvailableTime.createMany({ + data: prunedItems, + }); + } +} diff --git a/apps/api/src/modules/scheduling-time-policy.ts b/apps/api/src/modules/scheduling-time-policy.ts new file mode 100644 index 0000000..ef03f56 --- /dev/null +++ b/apps/api/src/modules/scheduling-time-policy.ts @@ -0,0 +1,221 @@ +import type { + MinimumStaffingRule, + OrganizationBusinessHour, + WorkerAvailableTime, +} from "@fragment/database"; + +import { + addDays, + dateToTimeString, + parseTimeToMinutes, + timeStringToDate, +} from "@/utils/date-time"; +import { getDayOfWeek } from "@/utils/day-of-week"; + +type TimeRange = { + startMinutes: number; + endMinutes: number; +}; + +export type SchedulableRange = TimeRange & { + requiredCount: number; +}; + +export type PrunedAvailabilityTime = { + workerId: bigint; + availableDate: Date; + startsAt: Date; + endsAt: Date; +}; + +const MINUTES_IN_DAY = 24 * 60; + +const timeDateToMinutes = (time: Date) => parseTimeToMinutes(dateToTimeString(time)); + +export const createDateTimeFromMinutes = (date: Date, minutes: number) => { + const dayOffset = Math.floor(minutes / MINUTES_IN_DAY); + const minutesInDay = minutes % MINUTES_IN_DAY; + const hour = Math.floor(minutesInDay / 60); + const minute = minutesInDay % 60; + const targetDate = addDays(date, dayOffset); + + return new Date( + Date.UTC( + targetDate.getUTCFullYear(), + targetDate.getUTCMonth(), + targetDate.getUTCDate(), + hour, + minute, + 0, + ), + ); +}; + +export const minutesToTimeDate = (minutes: number) => { + const minutesInDay = minutes % MINUTES_IN_DAY; + const hour = Math.floor(minutesInDay / 60); + const minute = minutesInDay % 60; + + return timeStringToDate(`${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`); +}; + +const getBusinessHourRange = ( + businessHours: OrganizationBusinessHour[], + workDate: Date, +): TimeRange | null => { + const dayOfWeek = getDayOfWeek(workDate); + const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek); + + if ( + !businessHour || + businessHour.isClosed || + !businessHour.openTime || + !businessHour.closeTime + ) { + return null; + } + + const startMinutes = timeDateToMinutes(businessHour.openTime); + const rawEndMinutes = timeDateToMinutes(businessHour.closeTime); + + return { + endMinutes: businessHour.closesNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes, + startMinutes, + }; +}; + +const getRuleRange = (rule: MinimumStaffingRule): SchedulableRange => { + const startMinutes = timeDateToMinutes(rule.startTime); + const rawEndMinutes = timeDateToMinutes(rule.endTime); + + return { + endMinutes: rule.endsNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes, + requiredCount: rule.requiredCount, + startMinutes, + }; +}; + +const mergeRanges = (ranges: TimeRange[]): TimeRange[] => { + const sortedRanges = [...ranges] + .filter((range) => range.startMinutes < range.endMinutes) + .sort((first, second) => first.startMinutes - second.startMinutes); + const mergedRanges: TimeRange[] = []; + + for (const range of sortedRanges) { + const previous = mergedRanges.at(-1); + + if (previous && range.startMinutes <= previous.endMinutes) { + previous.endMinutes = Math.max(previous.endMinutes, range.endMinutes); + continue; + } + + mergedRanges.push({ ...range }); + } + + return mergedRanges; +}; + +const dateTimeToMinutesFromWorkDate = (workDate: Date, dateTime: Date) => + Math.round((dateTime.getTime() - workDate.getTime()) / 60000); + +export const getSchedulableRangesForDate = ({ + businessHours, + rules, + workDate, +}: { + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; + workDate: Date; +}): SchedulableRange[] => { + const businessHourRange = getBusinessHourRange(businessHours, workDate); + + if (!businessHourRange) { + return []; + } + + return rules + .filter((rule) => rule.dayOfWeek === getDayOfWeek(workDate)) + .map(getRuleRange) + .map((ruleRange) => ({ + endMinutes: Math.min(ruleRange.endMinutes, businessHourRange.endMinutes), + requiredCount: ruleRange.requiredCount, + startMinutes: Math.max(ruleRange.startMinutes, businessHourRange.startMinutes), + })) + .filter((range) => range.startMinutes < range.endMinutes); +}; + +export const getMergedSchedulableRangesForDate = ({ + businessHours, + rules, + workDate, +}: { + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; + workDate: Date; +}): TimeRange[] => + mergeRanges( + getSchedulableRangesForDate({ + businessHours, + rules, + workDate, + }), + ); + +export const pruneAvailabilityTimeToSchedulableRanges = ({ + availableTime, + businessHours, + rules, +}: { + availableTime: Pick; + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; +}): PrunedAvailabilityTime[] => { + const ranges = getMergedSchedulableRangesForDate({ + businessHours, + rules, + workDate: availableTime.availableDate, + }); + const availabilityStartMinutes = dateTimeToMinutesFromWorkDate( + availableTime.availableDate, + availableTime.startsAt, + ); + const availabilityEndMinutes = dateTimeToMinutesFromWorkDate( + availableTime.availableDate, + availableTime.endsAt, + ); + + return ranges.flatMap((range) => { + const startMinutes = Math.max(availabilityStartMinutes, range.startMinutes); + const endMinutes = Math.min(availabilityEndMinutes, range.endMinutes); + + if (startMinutes >= endMinutes) { + return []; + } + + return [ + { + availableDate: availableTime.availableDate, + endsAt: createDateTimeFromMinutes(availableTime.availableDate, endMinutes), + startsAt: createDateTimeFromMinutes(availableTime.availableDate, startMinutes), + workerId: availableTime.workerId, + }, + ]; + }); +}; + +export const pruneAvailabilityTimesToSchedulableRanges = ({ + availableTimes, + businessHours, + rules, +}: { + availableTimes: Pick[]; + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; +}): PrunedAvailabilityTime[] => + availableTimes.flatMap((availableTime) => + pruneAvailabilityTimeToSchedulableRanges({ + availableTime, + businessHours, + rules, + }), + ); diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts index 0ef48d0..a3e2502 100644 --- a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts +++ b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts @@ -1,5 +1,6 @@ import type { MinimumStaffingRule as PrismaMinimumStaffingRule, + OrganizationBusinessHour, Prisma, PrismaClient, } from "@fragment/database"; @@ -12,15 +13,33 @@ import type { import { ERROR_CODES } from "@/common/constants/error-codes"; import { HttpError } from "@/errors/http-error"; -import { dateToTimeString, timeStringToDate } from "@/utils/date-time"; +import { pruneOrganizationAvailabilityForActivePlanningPeriod } from "@/modules/availability/availability.service"; +import { dateToTimeString, parseTimeToMinutes, timeStringToDate } from "@/utils/date-time"; import { toApiId, toPrismaId } from "@/utils/mapper"; +const MINUTES_IN_DAY = 24 * 60; + +type StaffingRulesOrganization = { + businessHours: OrganizationBusinessHour[]; + id: bigint; +}; + const createMinimumStaffingRuleNotFoundError = () => new HttpError(404, ERROR_CODES.NOT_FOUND, "최소 인원 조건을 찾을 수 없습니다."); const createMinimumStaffingRuleValidationError = () => new HttpError(400, ERROR_CODES.VALIDATION_ERROR, "요청 형식이 올바르지 않습니다."); +const createMinimumStaffingRuleClosedDayError = () => + new HttpError(400, ERROR_CODES.CLOSED_DAY, "휴무일에는 최소 인원 조건을 등록할 수 없습니다."); + +const createMinimumStaffingRuleOutsideBusinessHoursError = () => + new HttpError( + 400, + ERROR_CODES.INVALID_TIME_RANGE, + "최소 인원 조건은 조직 운영시간 안에서만 등록할 수 있습니다.", + ); + const assertMinimumStaffingRuleTimeRange = ({ endTime, endsNextDay, @@ -39,12 +58,57 @@ const assertMinimumStaffingRuleTimeRange = ({ } }; -const findOrganizationIdByUserId = async (prisma: PrismaClient, userId: string) => { +const dateToMinutes = (time: Date) => parseTimeToMinutes(dateToTimeString(time)); + +const getRangeEndMinutes = (endTime: string, endsNextDay: boolean) => { + const rawEndMinutes = parseTimeToMinutes(endTime); + + return endsNextDay ? rawEndMinutes + MINUTES_IN_DAY : rawEndMinutes; +}; + +const assertMinimumStaffingRuleInsideBusinessHours = ({ + businessHours, + dayOfWeek, + endTime, + endsNextDay, + startTime, +}: Pick & { + businessHours: OrganizationBusinessHour[]; +}) => { + const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek); + + if ( + !businessHour || + businessHour.isClosed || + !businessHour.openTime || + !businessHour.closeTime + ) { + throw createMinimumStaffingRuleClosedDayError(); + } + + const businessStartMinutes = dateToMinutes(businessHour.openTime); + const rawBusinessEndMinutes = dateToMinutes(businessHour.closeTime); + const businessEndMinutes = businessHour.closesNextDay + ? rawBusinessEndMinutes + MINUTES_IN_DAY + : rawBusinessEndMinutes; + const ruleStartMinutes = parseTimeToMinutes(startTime); + const ruleEndMinutes = getRangeEndMinutes(endTime, endsNextDay); + + if (ruleStartMinutes < businessStartMinutes || ruleEndMinutes > businessEndMinutes) { + throw createMinimumStaffingRuleOutsideBusinessHoursError(); + } +}; + +const findOrganizationByUserId = async ( + prisma: PrismaClient, + userId: string, +): Promise => { const organization = await prisma.organization.findUnique({ where: { userId: toPrismaId(userId), }, select: { + businessHours: true, id: true, }, }); @@ -53,7 +117,7 @@ const findOrganizationIdByUserId = async (prisma: PrismaClient, userId: string) throw new HttpError(403, ERROR_CODES.ORGANIZATION_REQUIRED, "조직 생성 후 이용할 수 있습니다."); } - return organization.id; + return organization; }; export const toMinimumStaffingRule = (rule: PrismaMinimumStaffingRule): MinimumStaffingRule => ({ @@ -91,11 +155,11 @@ export async function getMinimumStaffingRules( prisma: PrismaClient, userId: string, ): Promise { - const organizationId = await findOrganizationIdByUserId(prisma, userId); + const organization = await findOrganizationByUserId(prisma, userId); const rules = await prisma.minimumStaffingRule.findMany({ where: { - organizationId, + organizationId: organization.id, }, orderBy: [{ dayOfWeek: "asc" }, { startTime: "asc" }], }); @@ -110,11 +174,20 @@ export async function createMinimumStaffingRule( userId: string, input: CreateMinimumStaffingRuleRequest, ): Promise { - const organizationId = await findOrganizationIdByUserId(prisma, userId); + const organization = await findOrganizationByUserId(prisma, userId); + + assertMinimumStaffingRuleTimeRange(input); + assertMinimumStaffingRuleInsideBusinessHours({ + businessHours: organization.businessHours, + ...input, + }); + const rule = await prisma.minimumStaffingRule.create({ - data: createMinimumStaffingRuleData(organizationId, input), + data: createMinimumStaffingRuleData(organization.id, input), }); + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id); + return toMinimumStaffingRule(rule); } @@ -124,15 +197,16 @@ export async function updateMinimumStaffingRule( ruleId: string, input: UpdateMinimumStaffingRuleRequest, ): Promise { - const organizationId = await findOrganizationIdByUserId(prisma, userId); + const organization = await findOrganizationByUserId(prisma, userId); const ruleDatabaseId = toPrismaId(ruleId); const existingRule = await prisma.minimumStaffingRule.findFirst({ where: { id: ruleDatabaseId, - organizationId, + organizationId: organization.id, }, select: { + dayOfWeek: true, endTime: true, endsNextDay: true, id: true, @@ -144,10 +218,17 @@ export async function updateMinimumStaffingRule( throw createMinimumStaffingRuleNotFoundError(); } - assertMinimumStaffingRuleTimeRange({ + const mergedRule = { + dayOfWeek: input.dayOfWeek ?? existingRule.dayOfWeek, startTime: input.startTime ?? dateToTimeString(existingRule.startTime), endTime: input.endTime ?? dateToTimeString(existingRule.endTime), endsNextDay: input.endsNextDay ?? existingRule.endsNextDay, + }; + + assertMinimumStaffingRuleTimeRange(mergedRule); + assertMinimumStaffingRuleInsideBusinessHours({ + businessHours: organization.businessHours, + ...mergedRule, }); const updatedRule = await prisma.minimumStaffingRule.update({ @@ -157,6 +238,8 @@ export async function updateMinimumStaffingRule( data: createMinimumStaffingRuleUpdateData(input), }); + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id); + return toMinimumStaffingRule(updatedRule); } @@ -165,15 +248,17 @@ export async function deleteMinimumStaffingRule( userId: string, ruleId: string, ): Promise { - const organizationId = await findOrganizationIdByUserId(prisma, userId); + const organization = await findOrganizationByUserId(prisma, userId); const result = await prisma.minimumStaffingRule.deleteMany({ where: { id: toPrismaId(ruleId), - organizationId, + organizationId: organization.id, }, }); if (result.count === 0) { throw createMinimumStaffingRuleNotFoundError(); } + + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organization.id); } From f66934c5520955f337f7c4ae3d986d3cf352c16f Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 20:15:50 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(api):=20=EA=B0=80=EB=8A=A5=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=EC=99=80=20=EC=B6=94=EC=B2=9C=20=EB=B0=B0?= =?UTF-8?q?=EC=A0=95=20=EB=B6=84=EB=A6=AC=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/modules/schedules/schedules.mapper.ts | 43 ++- .../schedules/schedules.recommendation.ts | 316 ++++++++++++++++-- .../modules/schedules/schedules.service.ts | 63 +++- 3 files changed, 370 insertions(+), 52 deletions(-) diff --git a/apps/api/src/modules/schedules/schedules.mapper.ts b/apps/api/src/modules/schedules/schedules.mapper.ts index 1a3ff0f..4cde601 100644 --- a/apps/api/src/modules/schedules/schedules.mapper.ts +++ b/apps/api/src/modules/schedules/schedules.mapper.ts @@ -2,15 +2,22 @@ import type { Prisma, Schedule, ScheduleAssignment as PrismaScheduleAssignment, + ScheduleCandidate as PrismaScheduleCandidate, ScheduleWorkerShortage as PrismaScheduleWorkerShortage, } from "@fragment/database"; -import type { ScheduleAssignment, ScheduleDetail, ScheduleWorkerShortage } from "@fragment/shared"; +import type { + ScheduleAssignment, + ScheduleCandidate, + ScheduleDetail, + ScheduleWorkerShortage, +} from "@fragment/shared"; import { dateToDateString, dateToTimeString } from "@/utils/date-time"; import { toApiId } from "@/utils/mapper"; type ScheduleWithDetails = Schedule & { assignments: PrismaScheduleAssignment[]; + candidates: PrismaScheduleCandidate[]; workerShortages: PrismaScheduleWorkerShortage[]; }; @@ -18,6 +25,9 @@ export const scheduleDetailInclude = { assignments: { orderBy: [{ workDate: "asc" }, { startsAt: "asc" }, { id: "asc" }], }, + candidates: { + orderBy: [{ workDate: "asc" }, { startsAt: "asc" }, { employeeCodeSnapshot: "asc" }], + }, workerShortages: { orderBy: [{ workDate: "asc" }, { startTime: "asc" }, { id: "asc" }], }, @@ -34,6 +44,34 @@ export const toScheduleAssignment = (assignment: PrismaScheduleAssignment): Sche employeeCodeSnapshot: assignment.employeeCodeSnapshot, }); +const isCandidateRecommended = ( + candidate: PrismaScheduleCandidate, + assignments: PrismaScheduleAssignment[], +) => + candidate.workerId !== null && + assignments.some( + (assignment) => + assignment.workerId === candidate.workerId && + assignment.workDate.getTime() === candidate.workDate.getTime() && + assignment.startsAt.getTime() === candidate.startsAt.getTime() && + assignment.endsAt.getTime() === candidate.endsAt.getTime(), + ); + +export const toScheduleCandidate = ( + candidate: PrismaScheduleCandidate, + assignments: PrismaScheduleAssignment[], +): ScheduleCandidate => ({ + id: toApiId(candidate.id), + scheduleId: toApiId(candidate.scheduleId), + workerId: candidate.workerId === null ? null : toApiId(candidate.workerId), + workDate: dateToDateString(candidate.workDate), + startsAt: candidate.startsAt.toISOString(), + endsAt: candidate.endsAt.toISOString(), + workerNameSnapshot: candidate.workerNameSnapshot, + employeeCodeSnapshot: candidate.employeeCodeSnapshot, + isRecommended: isCandidateRecommended(candidate, assignments), +}); + export const toScheduleWorkerShortage = ( shortage: PrismaScheduleWorkerShortage, ): ScheduleWorkerShortage => ({ @@ -56,5 +94,8 @@ export const toScheduleDetail = (schedule: ScheduleWithDetails): ScheduleDetail generatedAt: schedule.generatedAt.toISOString(), confirmedAt: schedule.confirmedAt ? schedule.confirmedAt.toISOString() : null, assignments: schedule.assignments.map(toScheduleAssignment), + candidates: schedule.candidates.map((candidate) => + toScheduleCandidate(candidate, schedule.assignments), + ), workerShortages: schedule.workerShortages.map(toScheduleWorkerShortage), }); diff --git a/apps/api/src/modules/schedules/schedules.recommendation.ts b/apps/api/src/modules/schedules/schedules.recommendation.ts index da85416..0ecef9b 100644 --- a/apps/api/src/modules/schedules/schedules.recommendation.ts +++ b/apps/api/src/modules/schedules/schedules.recommendation.ts @@ -2,19 +2,20 @@ import { createHash } from "node:crypto"; import type { DayOfWeek, MinimumStaffingRule, + OrganizationBusinessHour, Worker, WorkerAvailableTime, } from "@fragment/database"; -import { - addDays, - createDateTimeOnDate, - dateToDateString, - dateToTimeString, -} from "@/utils/date-time"; +import { dateToDateString, dateToTimeString } from "@/utils/date-time"; import { eachDateInRange } from "@/utils/date-range"; import { getDayOfWeek } from "@/utils/day-of-week"; import { toApiId } from "@/utils/mapper"; +import { + createDateTimeFromMinutes, + getSchedulableRangesForDate, + minutesToTimeDate, +} from "@/modules/scheduling-time-policy"; type RecommendationAssignment = { workerId: bigint; @@ -25,6 +26,8 @@ type RecommendationAssignment = { employeeCodeSnapshot: string; }; +type RecommendationCandidate = RecommendationAssignment; + type RecommendationShortage = { workDate: Date; dayOfWeek: DayOfWeek; @@ -35,12 +38,21 @@ type RecommendationShortage = { assignedCount: number; }; -const createScheduleDateTimeOnDate = (date: Date, time: Date, addDay = false) => { - const targetDate = addDay ? addDays(date, 1) : date; - - return createDateTimeOnDate(targetDate, time); +type DemandWindow = { + workDate: Date; + dayOfWeek: DayOfWeek; + startMinutes: number; + endMinutes: number; + startsAt: Date; + endsAt: Date; + startTime: Date; + endTime: Date; + endsNextDay: boolean; + requiredCount: number; }; +const MINUTES_IN_DAY = 24 * 60; + const hasOverlap = ( assignments: RecommendationAssignment[], workerId: bigint, @@ -54,14 +66,209 @@ const hasOverlap = ( startsAt < assignment.endsAt, ); +const mergeAdjacentAssignments = (assignments: RecommendationAssignment[]) => { + const sortedAssignments = [...assignments].sort((a, b) => { + if (a.workerId !== b.workerId) { + return Number(a.workerId - b.workerId); + } + + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + return a.startsAt.getTime() - b.startsAt.getTime(); + }); + const mergedAssignments: RecommendationAssignment[] = []; + + for (const assignment of sortedAssignments) { + const previous = mergedAssignments.at(-1); + + if ( + previous && + previous.workerId === assignment.workerId && + previous.workDate.getTime() === assignment.workDate.getTime() && + previous.endsAt.getTime() === assignment.startsAt.getTime() + ) { + previous.endsAt = assignment.endsAt; + continue; + } + + mergedAssignments.push({ ...assignment }); + } + + return mergedAssignments.sort((a, b) => { + if (a.workDate.getTime() !== b.workDate.getTime()) { + return a.workDate.getTime() - b.workDate.getTime(); + } + + if (a.startsAt.getTime() !== b.startsAt.getTime()) { + return a.startsAt.getTime() - b.startsAt.getTime(); + } + + return Number(a.workerId - b.workerId); + }); +}; + +const getAssignmentDurationMinutes = (assignment: Pick) => + Math.round((assignment.endsAt.getTime() - assignment.startsAt.getTime()) / 60000); + +const getWeekStartDate = (date: Date) => { + const day = date.getUTCDay(); + const mondayFirstOffset = day === 0 ? -6 : 1 - day; + + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + mondayFirstOffset), + ); +}; + +const getAssignedMinutes = ( + assignments: RecommendationAssignment[], + workerId: bigint, + predicate: (assignment: RecommendationAssignment) => boolean, +) => + assignments + .filter((assignment) => assignment.workerId === workerId && predicate(assignment)) + .reduce((total, assignment) => total + getAssignmentDurationMinutes(assignment), 0); + +const dateTimeToMinutesFromWorkDate = (workDate: Date, dateTime: Date) => + Math.round((dateTime.getTime() - workDate.getTime()) / 60000); + +const compareCandidatesByBalance = ({ + assignments, + firstWorker, + secondWorker, + workDate, +}: { + assignments: RecommendationAssignment[]; + firstWorker: Worker; + secondWorker: Worker; + workDate: Date; +}) => { + const weekStartDate = getWeekStartDate(workDate); + const weekEndDate = new Date( + Date.UTC(weekStartDate.getUTCFullYear(), weekStartDate.getUTCMonth(), weekStartDate.getUTCDate() + 7), + ); + const isSameWeek = (assignment: RecommendationAssignment) => + assignment.workDate >= weekStartDate && assignment.workDate < weekEndDate; + const isSameDay = (assignment: RecommendationAssignment) => + assignment.workDate.getTime() === workDate.getTime(); + const firstWeeklyMinutes = getAssignedMinutes(assignments, firstWorker.id, isSameWeek); + const secondWeeklyMinutes = getAssignedMinutes(assignments, secondWorker.id, isSameWeek); + const firstContractMinutes = firstWorker.weeklyContractHours * 60; + const secondContractMinutes = secondWorker.weeklyContractHours * 60; + const firstUsage = firstWeeklyMinutes / firstContractMinutes; + const secondUsage = secondWeeklyMinutes / secondContractMinutes; + const allCandidatesOverContract = firstUsage >= 1 && secondUsage >= 1; + + if (allCandidatesOverContract) { + const firstOverage = Math.max(0, firstWeeklyMinutes - firstContractMinutes); + const secondOverage = Math.max(0, secondWeeklyMinutes - secondContractMinutes); + + if (firstOverage !== secondOverage) { + return firstOverage - secondOverage; + } + } + + if (firstUsage !== secondUsage) { + return firstUsage - secondUsage; + } + + const firstDailyMinutes = getAssignedMinutes(assignments, firstWorker.id, isSameDay); + const secondDailyMinutes = getAssignedMinutes(assignments, secondWorker.id, isSameDay); + + if (firstDailyMinutes !== secondDailyMinutes) { + return firstDailyMinutes - secondDailyMinutes; + } + + return firstWorker.employeeCode.localeCompare(secondWorker.employeeCode); +}; + +const createDemandWindowsForDate = ({ + availableTimes, + businessHours, + rules, + workDate, +}: { + availableTimes: WorkerAvailableTime[]; + businessHours: OrganizationBusinessHour[]; + rules: MinimumStaffingRule[]; + workDate: Date; +}): DemandWindow[] => { + const dayOfWeek = getDayOfWeek(workDate); + const demandSources = getSchedulableRangesForDate({ + businessHours, + rules, + workDate, + }); + + const boundaries = [ + ...new Set([ + ...demandSources.flatMap((source) => [source.startMinutes, source.endMinutes]), + ...availableTimes + .filter((availableTime) => availableTime.availableDate.getTime() === workDate.getTime()) + .flatMap((availableTime) => { + const startMinutes = dateTimeToMinutesFromWorkDate(workDate, availableTime.startsAt); + const endMinutes = dateTimeToMinutesFromWorkDate(workDate, availableTime.endsAt); + + return demandSources.flatMap((source) => { + if (source.startMinutes >= endMinutes || startMinutes >= source.endMinutes) { + return []; + } + + return [ + Math.max(startMinutes, source.startMinutes), + Math.min(endMinutes, source.endMinutes), + ]; + }); + }), + ]), + ] + .filter((boundary) => Number.isFinite(boundary)) + .sort((a, b) => a - b); + const demandWindows: DemandWindow[] = []; + + for (let index = 0; index < boundaries.length - 1; index += 1) { + const startMinutes = boundaries[index]; + const endMinutes = boundaries[index + 1]; + const activeDemandSources = demandSources.filter( + (source) => source.startMinutes < endMinutes && startMinutes < source.endMinutes, + ); + + if (activeDemandSources.length === 0) { + continue; + } + + const requiredCount = Math.max(...activeDemandSources.map((source) => source.requiredCount)); + const startsAt = createDateTimeFromMinutes(workDate, startMinutes); + const endsAt = createDateTimeFromMinutes(workDate, endMinutes); + + demandWindows.push({ + dayOfWeek, + endMinutes, + endsAt, + endsNextDay: endMinutes >= MINUTES_IN_DAY, + endTime: minutesToTimeDate(endMinutes), + requiredCount, + startMinutes, + startsAt, + startTime: minutesToTimeDate(startMinutes), + workDate, + }); + } + + return demandWindows; +}; + export const createInputHash = ({ availableTimes, + businessHours, endDate, rules, startDate, workers, }: { availableTimes: WorkerAvailableTime[]; + businessHours: OrganizationBusinessHour[]; endDate: Date; rules: MinimumStaffingRule[]; startDate: Date; @@ -82,6 +289,13 @@ export const createInputHash = ({ startsAt: availableTime.startsAt.toISOString(), endsAt: availableTime.endsAt.toISOString(), })), + businessHours: businessHours.map((businessHour) => ({ + dayOfWeek: businessHour.dayOfWeek, + isClosed: businessHour.isClosed, + openTime: businessHour.openTime ? dateToTimeString(businessHour.openTime) : null, + closeTime: businessHour.closeTime ? dateToTimeString(businessHour.closeTime) : null, + closesNextDay: businessHour.closesNextDay, + })), rules: rules.map((rule) => ({ dayOfWeek: rule.dayOfWeek, startTime: dateToTimeString(rule.startTime), @@ -96,66 +310,98 @@ export const createInputHash = ({ export const createRecommendations = ({ availableTimes, + businessHours, endDate, rules, startDate, workers, }: { availableTimes: WorkerAvailableTime[]; + businessHours: OrganizationBusinessHour[]; endDate: Date; rules: MinimumStaffingRule[]; startDate: Date; workers: Worker[]; }): { assignments: RecommendationAssignment[]; + candidates: RecommendationCandidate[]; shortages: RecommendationShortage[]; } => { const assignments: RecommendationAssignment[] = []; + const candidates: RecommendationCandidate[] = []; const shortages: RecommendationShortage[] = []; for (const workDate of eachDateInRange(startDate, endDate)) { - const dayOfWeek = getDayOfWeek(workDate); - const dayRules = rules.filter((rule) => rule.dayOfWeek === dayOfWeek); - - for (const rule of dayRules) { - const startsAt = createScheduleDateTimeOnDate(workDate, rule.startTime); - const endsAt = createScheduleDateTimeOnDate(workDate, rule.endTime, rule.endsNextDay); - const candidates = workers.filter((worker) => - availableTimes.some( - (availableTime) => - availableTime.workerId === worker.id && - availableTime.availableDate.getTime() === workDate.getTime() && - availableTime.startsAt <= startsAt && - availableTime.endsAt >= endsAt && - !hasOverlap(assignments, worker.id, startsAt, endsAt), - ), + const demandWindows = createDemandWindowsForDate({ + availableTimes, + businessHours, + rules, + workDate, + }); + + for (const demandWindow of demandWindows) { + const availableCandidates = workers + .filter((worker) => + availableTimes.some( + (availableTime) => + availableTime.workerId === worker.id && + availableTime.availableDate.getTime() === workDate.getTime() && + availableTime.startsAt <= demandWindow.startsAt && + availableTime.endsAt >= demandWindow.endsAt, + ), + ) + .sort((firstWorker, secondWorker) => + firstWorker.employeeCode.localeCompare(secondWorker.employeeCode), + ); + const selectableCandidates = availableCandidates + .filter( + (worker) => !hasOverlap(assignments, worker.id, demandWindow.startsAt, demandWindow.endsAt), + ) + .sort((firstWorker, secondWorker) => + compareCandidatesByBalance({ + assignments, + firstWorker, + secondWorker, + workDate, + }), + ); + const selectedWorkers = selectableCandidates.slice(0, demandWindow.requiredCount); + + candidates.push( + ...availableCandidates.map((worker) => ({ + workerId: worker.id, + workDate, + startsAt: demandWindow.startsAt, + endsAt: demandWindow.endsAt, + workerNameSnapshot: worker.name, + employeeCodeSnapshot: worker.employeeCode, + })), ); - const selectedWorkers = candidates.slice(0, rule.requiredCount); assignments.push( ...selectedWorkers.map((worker) => ({ workerId: worker.id, workDate, - startsAt, - endsAt, + startsAt: demandWindow.startsAt, + endsAt: demandWindow.endsAt, workerNameSnapshot: worker.name, employeeCodeSnapshot: worker.employeeCode, })), ); - if (selectedWorkers.length < rule.requiredCount) { + if (selectedWorkers.length < demandWindow.requiredCount) { shortages.push({ workDate, - dayOfWeek, - startTime: rule.startTime, - endTime: rule.endTime, - endsNextDay: rule.endsNextDay, - requiredCount: rule.requiredCount, + dayOfWeek: demandWindow.dayOfWeek, + startTime: demandWindow.startTime, + endTime: demandWindow.endTime, + endsNextDay: demandWindow.endsNextDay, + requiredCount: demandWindow.requiredCount, assignedCount: selectedWorkers.length, }); } } } - return { assignments, shortages }; + return { assignments: mergeAdjacentAssignments(assignments), candidates, shortages }; }; diff --git a/apps/api/src/modules/schedules/schedules.service.ts b/apps/api/src/modules/schedules/schedules.service.ts index c1915fe..11fadf4 100644 --- a/apps/api/src/modules/schedules/schedules.service.ts +++ b/apps/api/src/modules/schedules/schedules.service.ts @@ -11,6 +11,7 @@ import type { import { ERROR_CODES } from "@/common/constants/error-codes"; import { HttpError } from "@/errors/http-error"; +import { pruneOrganizationAvailabilityForActivePlanningPeriod } from "@/modules/availability/availability.service"; import { dateStringToDate, dateTimeStringToDate } from "@/utils/date-time"; import { toPrismaId } from "@/utils/mapper"; import { scheduleDetailInclude, toScheduleAssignment, toScheduleDetail } from "./schedules.mapper"; @@ -118,7 +119,15 @@ export async function recommendSchedule( throw createValidationError("활성 스케줄 계획 기간과 요청 기간이 일치해야 합니다."); } - const [workers, availableTimes, rules] = await Promise.all([ + const [organization, workers, rules] = await Promise.all([ + prisma.organization.findUnique({ + where: { + id: organizationDatabaseId, + }, + include: { + businessHours: true, + }, + }), prisma.worker.findMany({ where: { organizationId: organizationDatabaseId, @@ -127,18 +136,6 @@ export async function recommendSchedule( employeeCode: "asc", }, }), - prisma.workerAvailableTime.findMany({ - where: { - worker: { - organizationId: organizationDatabaseId, - }, - availableDate: { - gte: startDate, - lte: endDate, - }, - }, - orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], - }), prisma.minimumStaffingRule.findMany({ where: { organizationId: organizationDatabaseId, @@ -147,21 +144,41 @@ export async function recommendSchedule( }), ]); - if (workers.length === 0 || availableTimes.length === 0 || rules.length === 0) { + const hasOpenBusinessHours = + organization?.businessHours.some((businessHour) => !businessHour.isClosed) ?? false; + + if (!organization || workers.length === 0 || !hasOpenBusinessHours || rules.length === 0) { throw createValidationError( - "스케줄 추천에 필요한 근무자, 가능 시간, 최소 인원 조건이 부족합니다.", + "스케줄 추천에 필요한 근무자, 영업시간 또는 최소 인원 조건이 부족합니다.", ); } + await pruneOrganizationAvailabilityForActivePlanningPeriod(prisma, organizationDatabaseId); + + const availableTimes = await prisma.workerAvailableTime.findMany({ + where: { + worker: { + organizationId: organizationDatabaseId, + }, + availableDate: { + gte: startDate, + lte: endDate, + }, + }, + orderBy: [{ availableDate: "asc" }, { startsAt: "asc" }], + }); + const inputHash = createInputHash({ availableTimes, + businessHours: organization.businessHours, endDate, rules, startDate, workers, }); - const { assignments, shortages } = createRecommendations({ + const { assignments, candidates, shortages } = createRecommendations({ availableTimes, + businessHours: organization.businessHours, endDate, rules, startDate, @@ -211,6 +228,20 @@ export async function recommendSchedule( employeeCodeSnapshot: assignment.employeeCodeSnapshot, })), }, + candidates: { + create: candidates.map((candidate) => ({ + worker: { + connect: { + id: candidate.workerId, + }, + }, + workDate: candidate.workDate, + startsAt: candidate.startsAt, + endsAt: candidate.endsAt, + workerNameSnapshot: candidate.workerNameSnapshot, + employeeCodeSnapshot: candidate.employeeCodeSnapshot, + })), + }, workerShortages: { create: shortages, }, From 24899290320620a2782f0ddf4bc871be60a479de Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 20:16:19 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(web):=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=20=EC=B6=94=EC=B2=9C=20=ED=99=94=EB=A9=B4=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/schedules/api/schedules-api.ts | 67 ++ .../components/mvp-schedules-page.tsx | 726 +++++++++++++----- .../schedules/queries/schedules-queries.ts | 129 ++++ .../schedules/queries/schedules-query-keys.ts | 6 + 4 files changed, 735 insertions(+), 193 deletions(-) create mode 100644 apps/web/src/features/schedules/api/schedules-api.ts create mode 100644 apps/web/src/features/schedules/queries/schedules-queries.ts create mode 100644 apps/web/src/features/schedules/queries/schedules-query-keys.ts diff --git a/apps/web/src/features/schedules/api/schedules-api.ts b/apps/web/src/features/schedules/api/schedules-api.ts new file mode 100644 index 0000000..6e92d52 --- /dev/null +++ b/apps/web/src/features/schedules/api/schedules-api.ts @@ -0,0 +1,67 @@ +import type { + DraftScheduleQuery, + DraftScheduleResponse, + RecommendScheduleRequest, + ScheduleAssignment, + ScheduleAssignmentInput, + ScheduleDetail, + UpdateScheduleAssignmentRequest, +} from "@fragment/shared"; + +import { apiClient } from "@/lib/api-client"; + +function createDraftScheduleSearchParams(query: DraftScheduleQuery) { + const searchParams = new URLSearchParams({ + endDate: query.endDate, + startDate: query.startDate, + }); + + return searchParams.toString(); +} + +export function getDraftSchedule(query: DraftScheduleQuery) { + return apiClient( + `/schedules/draft?${createDraftScheduleSearchParams(query)}`, + { + method: "GET", + }, + ); +} + +export function recommendSchedule(request: RecommendScheduleRequest) { + return apiClient("/schedules/recommend", { + method: "POST", + body: request, + }); +} + +export function createScheduleAssignment(scheduleId: string, request: ScheduleAssignmentInput) { + return apiClient(`/schedules/${scheduleId}/assignments`, { + method: "POST", + body: request, + }); +} + +export function updateScheduleAssignment( + scheduleId: string, + assignmentId: string, + request: UpdateScheduleAssignmentRequest, +) { + return apiClient(`/schedules/${scheduleId}/assignments/${assignmentId}`, { + method: "PATCH", + body: request, + }); +} + +export function deleteScheduleAssignment(scheduleId: string, assignmentId: string) { + return apiClient(`/schedules/${scheduleId}/assignments/${assignmentId}`, { + method: "DELETE", + responseType: "void", + }); +} + +export function confirmSchedule(scheduleId: string) { + return apiClient(`/schedules/${scheduleId}/confirm`, { + method: "POST", + }); +} diff --git a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx index cf896ee..b698e27 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -1,8 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; +import type { + DraftScheduleQuery, + ScheduleAssignment, + ScheduleAssignmentInput, + ScheduleCandidate, + ScheduleDetail, + ScheduleWorkerShortage, + Worker, +} from "@fragment/shared"; +import { useEffect, useMemo, useState } from "react"; import { Badge, Button } from "@moyeorak/design-system"; -import { CalendarDays, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react"; +import { CalendarDays, Check, Pencil, Plus, Sparkles, Trash2, X } from "lucide-react"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { @@ -24,27 +33,45 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; - -type ScheduleStatus = "DRAFT" | "CONFIRMED"; - -type Worker = { - id: string; - name: string; -}; +import { useActiveSchedulePlanningPeriodQuery } from "@/features/organization/queries/organization-queries"; +import { + useConfirmScheduleMutation, + useCreateScheduleAssignmentMutation, + useDeleteScheduleAssignmentMutation, + useDraftScheduleQuery, + useRecommendScheduleMutation, + useUpdateScheduleAssignmentMutation, +} from "@/features/schedules/queries/schedules-queries"; +import { useMinimumStaffingRulesQuery } from "@/features/staffing-rules/queries/staffing-rules-queries"; +import { useWorkersQuery } from "@/features/workers/queries/workers-queries"; +import { getApiErrorMessage } from "@/lib/api-error-message"; + +type ScheduleStatus = ScheduleDetail["status"]; type ScheduleItem = { id: string; date: string; dayLabel: string; + endTime: string; startTime: string; + workerId: string | null; + workerName: string; +}; + +type ScheduleCandidateItem = { + id: string; + date: string; endTime: string; - workerId: string; + isRecommended: boolean; + startTime: string; + workerId: string | null; + workerName: string; }; type ScheduleDraft = { date: string; - startTime: string; endTime: string; + startTime: string; workerId: string; }; @@ -52,55 +79,30 @@ type ScheduleFormMode = "create" | "edit"; type UnfilledCondition = { id: string; + assignedWorkers: number; date: string; dayLabel: string; - timeRange: string; requiredWorkers: number; - assignedWorkers: number; + timeRange: string; }; -const WORKERS: Worker[] = [ - { id: "worker-1", name: "김민지" }, - { id: "worker-2", name: "박준호" }, - { id: "worker-3", name: "이서연" }, - { id: "worker-4", name: "최유나" }, - { id: "worker-5", name: "정도윤" }, - { id: "worker-6", name: "한서준" }, - { id: "worker-7", name: "오하린" }, - { id: "worker-8", name: "강지우" }, - { id: "worker-9", name: "윤태오" }, - { id: "worker-10", name: "임서아" }, - { id: "worker-11", name: "조민규" }, - { id: "worker-12", name: "배수빈" }, - { id: "worker-13", name: "문지훈" }, - { id: "worker-14", name: "신예린" }, - { id: "worker-15", name: "남현우" }, - { id: "worker-16", name: "서다은" }, - { id: "worker-17", name: "권도현" }, - { id: "worker-18", name: "백지민" }, - { id: "worker-19", name: "유시우" }, - { id: "worker-20", name: "홍나연" }, -]; - -const INITIAL_UNFILLED_CONDITIONS: UnfilledCondition[] = [ - { - id: "unfilled-1", - date: "2026-06-26", - dayLabel: "금요일", - timeRange: "18:00-22:00", - requiredWorkers: 20, - assignedWorkers: 17, - }, -]; +type WorkerOption = { + id: string; + name: string; +}; + +type OperationMessage = { + tone: "success" | "error"; + text: string; +}; const WEEKDAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]; -const RECOMMENDATION_WORK_START_DATE = "2026-06-20"; -const RECOMMENDATION_WORK_END_DATE = "2026-07-05"; +const EMPTY_WORKERS: Worker[] = []; const EMPTY_DRAFT: ScheduleDraft = { date: "", - startTime: "", endTime: "", + startTime: "", workerId: "", }; @@ -109,10 +111,6 @@ const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", }); -function getWorkerName(workerId: string) { - return WORKERS.find((worker) => worker.id === workerId)?.name ?? "알 수 없음"; -} - function getDayLabel(date: string) { return KST_DATE_FORMATTER.format(new Date(`${date}T00:00:00+09:00`)); } @@ -120,6 +118,7 @@ function getDayLabel(date: string) { function addDays(date: string, days: number) { const [year, month, day] = date.split("-").map(Number); const nextDate = new Date(Date.UTC(year, month - 1, day + days)); + return nextDate.toISOString().slice(0, 10); } @@ -160,12 +159,48 @@ function createCalendarDates(startDate: string, endDate: string) { return createDateRange(getMonday(startDate), getSunday(endDate)); } +function minutesToTime(minutes: number) { + const minutesInDay = 24 * 60; + const normalizedMinutes = ((minutes % minutesInDay) + minutesInDay) % minutesInDay; + const hours = Math.floor(normalizedMinutes / 60); + const restMinutes = normalizedMinutes % 60; + + return `${String(hours).padStart(2, "0")}:${String(restMinutes).padStart(2, "0")}`; +} + +function dateTimeToMinutes(date: string, dateTime: string) { + const baseDate = new Date(`${date}T00:00:00.000Z`); + const targetDate = new Date(dateTime); + + return Math.round((targetDate.getTime() - baseDate.getTime()) / 60000); +} + +function dateTimeToTime(date: string, dateTime: string) { + return minutesToTime(dateTimeToMinutes(date, dateTime)); +} + +function createDateTime(date: string, time: string) { + const [year, month, day] = date.split("-").map(Number); + const [hours, minutes] = time.split(":").map(Number); + + return new Date(Date.UTC(year, month - 1, day, hours, minutes)).toISOString(); +} + function createDraftFromSchedule(schedule: ScheduleItem): ScheduleDraft { return { date: schedule.date, - startTime: schedule.startTime, endTime: schedule.endTime, - workerId: schedule.workerId, + startTime: schedule.startTime, + workerId: schedule.workerId ?? "", + }; +} + +function createScheduleAssignmentInput(draft: ScheduleDraft): ScheduleAssignmentInput { + return { + endsAt: createDateTime(draft.date, draft.endTime), + startsAt: createDateTime(draft.date, draft.startTime), + workDate: draft.date, + workerId: draft.workerId, }; } @@ -176,12 +211,12 @@ function validateScheduleDraft(draft: ScheduleDraft, workStartDate: string, work : draft.date < workStartDate || draft.date > workEndDate ? "근무 시작 날짜와 종료 날짜 사이에서만 선택할 수 있습니다." : "", - startTime: !draft.startTime ? "시작 시간을 선택하세요." : "", endTime: !draft.endTime ? "종료 시간을 선택하세요." : draft.startTime && draft.endTime <= draft.startTime ? "종료 시간은 시작 시간보다 늦어야 합니다." : "", + startTime: !draft.startTime ? "시작 시간을 선택하세요." : "", workerId: !draft.workerId ? "근무자를 선택하세요." : "", }; } @@ -202,81 +237,211 @@ function formatDateTitle(date: string) { return `${date} ${getDayLabel(date)}`; } -function createRecommendedSchedules(workStartDate: string, workEndDate: string) { - const availableDates = createDateRange(workStartDate, workEndDate); - const denseDate = "2026-06-26"; - const timeRanges = [ - ["09:00", "13:00"], - ["10:00", "14:00"], - ["13:00", "17:00"], - ["14:00", "18:00"], - ["18:00", "22:00"], - ]; - - return WORKERS.map((worker, index) => { - const [startTime, endTime] = timeRanges[index % timeRanges.length]; - const date = - index < 17 ? denseDate : (availableDates[(index + 2) % availableDates.length] ?? workEndDate); - - return { - id: `schedule-${index + 1}`, - date, - startTime, - endTime, - workerId: worker.id, - }; - }).map((schedule) => ({ - ...schedule, - dayLabel: getDayLabel(schedule.date), +function formatShortageTimeRange(shortage: ScheduleWorkerShortage) { + return `${shortage.startTime}-${shortage.endTime}${shortage.endsNextDay ? "+1" : ""}`; +} + +function assignmentToScheduleItem(assignment: ScheduleAssignment): ScheduleItem { + return { + date: assignment.workDate, + dayLabel: getDayLabel(assignment.workDate), + endTime: dateTimeToTime(assignment.workDate, assignment.endsAt), + id: assignment.id, + startTime: dateTimeToTime(assignment.workDate, assignment.startsAt), + workerId: assignment.workerId, + workerName: assignment.workerNameSnapshot, + }; +} + +function candidateToScheduleCandidateItem(candidate: ScheduleCandidate): ScheduleCandidateItem { + return { + date: candidate.workDate, + endTime: dateTimeToTime(candidate.workDate, candidate.endsAt), + id: candidate.id, + isRecommended: candidate.isRecommended, + startTime: dateTimeToTime(candidate.workDate, candidate.startsAt), + workerId: candidate.workerId, + workerName: candidate.workerNameSnapshot, + }; +} + +function shortageToUnfilledCondition(shortage: ScheduleWorkerShortage): UnfilledCondition { + return { + assignedWorkers: shortage.assignedCount, + date: shortage.workDate, + dayLabel: getDayLabel(shortage.workDate), + id: shortage.id, + requiredWorkers: shortage.requiredCount, + timeRange: formatShortageTimeRange(shortage), + }; +} + +function createWorkerOptions(workers: Worker[], editingSchedule: ScheduleItem | null) { + const options: WorkerOption[] = workers.map((worker) => ({ + id: worker.id, + name: worker.name, })); + + if ( + editingSchedule?.workerId && + !options.some((option) => option.id === editingSchedule.workerId) + ) { + options.push({ + id: editingSchedule.workerId, + name: editingSchedule.workerName, + }); + } + + return options; } export function MvpSchedulesPage() { - const [scheduleStatus, setScheduleStatus] = useState(null); - const [schedules, setSchedules] = useState([]); - const [unfilledConditions, setUnfilledConditions] = useState([]); + const planningPeriodQuery = useActiveSchedulePlanningPeriodQuery(); + const workersQuery = useWorkersQuery(); + const staffingRulesQuery = useMinimumStaffingRulesQuery(); + const recommendScheduleMutation = useRecommendScheduleMutation(); + const createScheduleAssignmentMutation = useCreateScheduleAssignmentMutation(); + const updateScheduleAssignmentMutation = useUpdateScheduleAssignmentMutation(); + const deleteScheduleAssignmentMutation = useDeleteScheduleAssignmentMutation(); + const confirmScheduleMutation = useConfirmScheduleMutation(); + const activePlanningPeriod = planningPeriodQuery.data?.period ?? null; + const workStartDate = activePlanningPeriod?.startDate ?? ""; + const workEndDate = activePlanningPeriod?.endDate ?? ""; + const draftScheduleQuery: DraftScheduleQuery = useMemo( + () => ({ + endDate: workEndDate, + startDate: workStartDate, + }), + [workEndDate, workStartDate], + ); + const canQueryDraftSchedule = Boolean(workStartDate && workEndDate); + const draftScheduleResult = useDraftScheduleQuery(draftScheduleQuery, canQueryDraftSchedule); + const workers = workersQuery.data?.items ?? EMPTY_WORKERS; + const staffingRules = staffingRulesQuery.data?.items ?? []; + const schedule = draftScheduleResult.data?.schedule ?? null; + const scheduleStatus = schedule?.status ?? null; + const schedules = useMemo( + () => schedule?.assignments.map(assignmentToScheduleItem) ?? [], + [schedule], + ); + const candidates = useMemo( + () => schedule?.candidates.map(candidateToScheduleCandidateItem) ?? [], + [schedule], + ); + const unfilledConditions = useMemo( + () => schedule?.workerShortages.map(shortageToUnfilledCondition) ?? [], + [schedule], + ); const [formOpen, setFormOpen] = useState(false); const [formMode, setFormMode] = useState("create"); const [editingScheduleId, setEditingScheduleId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [selectedDate, setSelectedDate] = useState(""); + const [dateDetailDismissed, setDateDetailDismissed] = useState(false); const [draft, setDraft] = useState(EMPTY_DRAFT); const [submitted, setSubmitted] = useState(false); - + const [operationMessage, setOperationMessage] = useState(null); const errors = useMemo( - () => - validateScheduleDraft(draft, RECOMMENDATION_WORK_START_DATE, RECOMMENDATION_WORK_END_DATE), - [draft], + () => validateScheduleDraft(draft, workStartDate, workEndDate), + [draft, workEndDate, workStartDate], ); const calendarDates = useMemo( - () => createCalendarDates(RECOMMENDATION_WORK_START_DATE, RECOMMENDATION_WORK_END_DATE), - [], + () => createCalendarDates(workStartDate, workEndDate), + [workEndDate, workStartDate], + ); + const editingSchedule = useMemo( + () => schedules.find((item) => item.id === editingScheduleId) ?? null, + [editingScheduleId, schedules], + ); + const workerOptions = useMemo( + () => createWorkerOptions(workers, editingSchedule), + [editingSchedule, workers], ); const hasErrors = Object.values(errors).some(Boolean); - const canGenerate = scheduleStatus === null; + const isPageLoading = + planningPeriodQuery.isPending || + staffingRulesQuery.isPending || + workersQuery.isPending || + (canQueryDraftSchedule && draftScheduleResult.isPending); + const queryError = + planningPeriodQuery.error ?? + staffingRulesQuery.error ?? + workersQuery.error ?? + draftScheduleResult.error; + const queryErrorMessage = queryError + ? getApiErrorMessage(queryError, "스케줄 정보를 불러오지 못했습니다.") + : ""; const isDraft = scheduleStatus === "DRAFT"; + const canGenerate = + Boolean(activePlanningPeriod) && + staffingRules.length > 0 && + !schedule && + !draftScheduleResult.isFetching && + !recommendScheduleMutation.isPending && + !queryErrorMessage; + const canConfirm = + Boolean(schedule) && + isDraft && + schedules.length > 0 && + !confirmScheduleMutation.isPending && + !queryErrorMessage; const formTitle = formMode === "create" ? "스케줄 추가" : "스케줄 수정"; - const selectedDateSchedules = schedules.filter((schedule) => schedule.date === selectedDate); + const selectedDateSchedules = schedules.filter((item) => item.date === selectedDate); + const selectedDateCandidates = candidates.filter((item) => item.date === selectedDate); const selectedDateUnfilledConditions = unfilledConditions.filter( (condition) => condition.date === selectedDate, ); + useEffect(() => { + if (!schedule) { + setSelectedDate(""); + setDateDetailDismissed(false); + setFormOpen(false); + setEditingScheduleId(null); + setDraft(EMPTY_DRAFT); + setSubmitted(false); + return; + } + + if (dateDetailDismissed) { + return; + } + + if (selectedDate && selectedDate >= schedule.startDate && selectedDate <= schedule.endDate) { + return; + } + + setSelectedDate(unfilledConditions[0]?.date ?? schedule.startDate); + }, [dateDetailDismissed, schedule, selectedDate, unfilledConditions]); + function openCreateForm(date: string) { + if (!isDraft) { + return; + } + setFormMode("create"); setEditingScheduleId(null); + setDateDetailDismissed(false); setSelectedDate(date); setDraft({ ...EMPTY_DRAFT, date }); setSubmitted(false); + setOperationMessage(null); setFormOpen(true); } - function openEditForm(schedule: ScheduleItem) { + function openEditForm(scheduleItem: ScheduleItem) { + if (!isDraft) { + return; + } + setFormMode("edit"); - setEditingScheduleId(schedule.id); - setSelectedDate(schedule.date); - setDraft(createDraftFromSchedule(schedule)); + setEditingScheduleId(scheduleItem.id); + setDateDetailDismissed(false); + setSelectedDate(scheduleItem.date); + setDraft(createDraftFromSchedule(scheduleItem)); setSubmitted(false); + setOperationMessage(null); setFormOpen(true); } @@ -287,69 +452,148 @@ export function MvpSchedulesPage() { setDraft(EMPTY_DRAFT); } - function generateRecommendation() { - if (!canGenerate) { + async function generateRecommendation() { + if (!canGenerate || !activePlanningPeriod) { return; } - setScheduleStatus("DRAFT"); - setSchedules( - createRecommendedSchedules(RECOMMENDATION_WORK_START_DATE, RECOMMENDATION_WORK_END_DATE), - ); - setUnfilledConditions(INITIAL_UNFILLED_CONDITIONS); - setSelectedDate(INITIAL_UNFILLED_CONDITIONS[0]?.date ?? RECOMMENDATION_WORK_START_DATE); + setOperationMessage(null); + + try { + const response = await recommendScheduleMutation.mutateAsync({ + endDate: activePlanningPeriod.endDate, + startDate: activePlanningPeriod.startDate, + }); + + setDateDetailDismissed(false); + setSelectedDate(response.workerShortages[0]?.workDate ?? response.startDate); + setOperationMessage({ + tone: "success", + text: "추천 스케줄이 생성되었습니다.", + }); + } catch (error) { + setOperationMessage({ + tone: "error", + text: getApiErrorMessage(error, "추천 스케줄을 생성하지 못했습니다."), + }); + } } - function saveSchedule() { + async function saveSchedule() { setSubmitted(true); + setOperationMessage(null); - if (hasErrors) { + if (!schedule || !isDraft || hasErrors) { return; } - const nextSchedule = { - date: draft.date, - dayLabel: getDayLabel(draft.date), - startTime: draft.startTime, - endTime: draft.endTime, - workerId: draft.workerId, - }; - - if (formMode === "create") { - setSchedules((currentSchedules) => [ - ...currentSchedules, - { - id: `schedule-${Date.now()}`, - ...nextSchedule, - }, - ]); - setScheduleStatus("DRAFT"); - } else if (editingScheduleId) { - setSchedules((currentSchedules) => - currentSchedules.map((schedule) => - schedule.id === editingScheduleId ? { ...schedule, ...nextSchedule } : schedule, - ), - ); + const request = createScheduleAssignmentInput(draft); + + try { + if (formMode === "create") { + await createScheduleAssignmentMutation.mutateAsync({ + query: draftScheduleQuery, + request, + scheduleId: schedule.id, + }); + } else if (editingScheduleId) { + await updateScheduleAssignmentMutation.mutateAsync({ + assignmentId: editingScheduleId, + query: draftScheduleQuery, + request, + scheduleId: schedule.id, + }); + } + + setSelectedDate(draft.date); + setDateDetailDismissed(false); + closeForm(); + setOperationMessage({ + tone: "success", + text: "스케줄이 저장되었습니다.", + }); + } catch (error) { + setOperationMessage({ + tone: "error", + text: getApiErrorMessage(error, "스케줄을 저장하지 못했습니다."), + }); } + } - closeForm(); + async function deleteSchedule() { + if (!schedule || !deleteTarget || deleteScheduleAssignmentMutation.isPending) { + return; + } + + setOperationMessage(null); + + try { + await deleteScheduleAssignmentMutation.mutateAsync({ + assignmentId: deleteTarget.id, + query: draftScheduleQuery, + scheduleId: schedule.id, + }); + + setDeleteTarget(null); + closeForm(); + setOperationMessage({ + tone: "success", + text: "스케줄이 삭제되었습니다.", + }); + } catch (error) { + setOperationMessage({ + tone: "error", + text: getApiErrorMessage(error, "스케줄을 삭제하지 못했습니다."), + }); + } + } + + async function confirmCurrentSchedule() { + if (!schedule || !canConfirm) { + return; + } + + setOperationMessage(null); + + try { + await confirmScheduleMutation.mutateAsync({ + query: draftScheduleQuery, + scheduleId: schedule.id, + }); + + setDateDetailDismissed(false); + setSelectedDate(""); + closeForm(); + setConfirmOpen(false); + setOperationMessage({ + tone: "success", + text: "스케줄이 확정되었습니다.", + }); + } catch (error) { + setOperationMessage({ + tone: "error", + text: getApiErrorMessage(error, "스케줄을 확정하지 못했습니다."), + }); + } } return ( {scheduleStatus === null ? ( ) : null} {isDraft ? ( @@ -357,9 +601,9 @@ export function MvpSchedulesPage() { type="button" variant="brand" onClick={() => setConfirmOpen(true)} - disabled={schedules.length === 0} + disabled={!canConfirm} > - 스케줄 확정 + {confirmScheduleMutation.isPending ? "확정 중" : "스케줄 확정"} ) : null} @@ -367,26 +611,69 @@ export function MvpSchedulesPage() { containerClassName="max-w-none" contentClassName="space-y-6" > - {scheduleStatus === null ? ( + {isPageLoading ? ( +
+ 스케줄 정보를 불러오는 중입니다. +
+ ) : null} + + {queryErrorMessage ? ( +
+ {queryErrorMessage} +
+ ) : null} + + {operationMessage ? ( +
+ {operationMessage.text} +
+ ) : null} + + {!activePlanningPeriod && !planningPeriodQuery.isPending ? ( +
+
+
+

근무 기간 없음

+

+ 가능 시간 관리에서 근무 기간을 먼저 설정하면 추천 스케줄을 생성할 수 있습니다. +

+
+ {getStatusLabel(null)} +
+
+ ) : null} + + {activePlanningPeriod && !schedule && !draftScheduleResult.isPending ? (

추천 결과 없음

- 가능 시간 관리에서 설정한 {RECOMMENDATION_WORK_START_DATE}~ - {RECOMMENDATION_WORK_END_DATE} 기간을 기준으로 추천 결과를 생성합니다. + 가능 시간 관리에서 설정한 {workStartDate}~{workEndDate} 기간을 기준으로 추천 결과를 + 생성합니다.

- 동일 입력 조건에서는 중복 생성하지 않습니다. 근무자, 가능 시간, 최소 인원 조건, 근무 - 기간 중 하나가 변경되면 다시 추천 생성할 수 있습니다. + 동일 입력 조건에서는 중복 생성하지 않습니다. 최소 인원 조건이 있는 시간대만 추천 + 대상으로 사용합니다.

+ {staffingRules.length === 0 ? ( +

+ 최소 인원 조건을 먼저 등록해야 추천 스케줄을 생성할 수 있습니다. +

+ ) : null}
{getStatusLabel(scheduleStatus)}
) : null} - {unfilledConditions.length > 0 && scheduleStatus === "DRAFT" ? ( + {unfilledConditions.length > 0 && isDraft ? (

미충족 조건

@@ -398,7 +685,7 @@ export function MvpSchedulesPage() { 요일 시간대 필요 인원 - 배정 인원 + 추천 배정 @@ -420,7 +707,7 @@ export function MvpSchedulesPage() {
) : null} - {scheduleStatus !== null ? ( + {schedule ? (
@@ -430,10 +717,10 @@ export function MvpSchedulesPage() {

근무 기간 달력

- {RECOMMENDATION_WORK_START_DATE}~{RECOMMENDATION_WORK_END_DATE} 추천 결과를 - 달력에서 검토하고 직접 조정합니다. + {workStartDate}~{workEndDate} 추천 결과를 달력에서 검토하고 직접 조정합니다.

+ {getStatusLabel(scheduleStatus)} @@ -450,9 +737,9 @@ export function MvpSchedulesPage() { ))} {calendarDates.map((date) => { - const inWorkRange = - date >= RECOMMENDATION_WORK_START_DATE && date <= RECOMMENDATION_WORK_END_DATE; - const dateSchedules = schedules.filter((schedule) => schedule.date === date); + const inWorkRange = date >= workStartDate && date <= workEndDate; + const dateSchedules = schedules.filter((item) => item.date === date); + const dateCandidates = candidates.filter((item) => item.date === date); const unfilled = unfilledConditions.some((condition) => condition.date === date); const selected = selectedDate === date; const visibleSchedules = dateSchedules.slice(0, 3); @@ -464,6 +751,7 @@ export function MvpSchedulesPage() { type="button" disabled={!inWorkRange} onClick={() => { + setDateDetailDismissed(false); setSelectedDate(date); closeForm(); }} @@ -496,23 +784,24 @@ export function MvpSchedulesPage() {
- {dateSchedules.length}명 배정 + + 후보 {dateCandidates.length}명 · 추천 {dateSchedules.length}명 +
{visibleSchedules.length > 0 ? ( - visibleSchedules.map((schedule) => ( + visibleSchedules.map((item) => (

- {schedule.startTime}-{schedule.endTime}{" "} - {getWorkerName(schedule.workerId)} + {item.startTime}-{item.endTime} {item.workerName}

)) ) : !inWorkRange ? null : (

- 배정 없음 + 추천 배정 없음

)} {hiddenScheduleCount > 0 ? ( @@ -537,7 +826,7 @@ export function MvpSchedulesPage() {
) : null} - {selectedDate && scheduleStatus !== null ? ( + {selectedDate && schedule ? ( From 5404b10685641627d59e971a7a95e7c8fdf3fa3b Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 22:24:12 +0900 Subject: [PATCH 10/16] =?UTF-8?q?style(repo):=20prettier=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../availability/availability.service.ts | 6 +- .../schedules/schedules.recommendation.ts | 14 +++- .../api/src/modules/scheduling-time-policy.ts | 14 +--- .../staffing-rules/staffing-rules.service.ts | 7 +- .../components/mvp-schedules-page.tsx | 76 +++++++++---------- .../components/mvp-staffing-rules-page.tsx | 9 +-- 6 files changed, 54 insertions(+), 72 deletions(-) diff --git a/apps/api/src/modules/availability/availability.service.ts b/apps/api/src/modules/availability/availability.service.ts index a3c1b56..38e9284 100644 --- a/apps/api/src/modules/availability/availability.service.ts +++ b/apps/api/src/modules/availability/availability.service.ts @@ -16,11 +16,7 @@ import { pruneAvailabilityTimesToSchedulableRanges, type PrunedAvailabilityTime, } from "@/modules/scheduling-time-policy"; -import { - dateStringToDate, - dateTimeStringToDate, - dateToDateString, -} from "@/utils/date-time"; +import { dateStringToDate, dateTimeStringToDate, dateToDateString } from "@/utils/date-time"; import { toApiId, toPrismaId } from "@/utils/mapper"; type AvailabilityDatabaseClient = PrismaClient | Prisma.TransactionClient; diff --git a/apps/api/src/modules/schedules/schedules.recommendation.ts b/apps/api/src/modules/schedules/schedules.recommendation.ts index 0ecef9b..0a7b1a8 100644 --- a/apps/api/src/modules/schedules/schedules.recommendation.ts +++ b/apps/api/src/modules/schedules/schedules.recommendation.ts @@ -109,8 +109,9 @@ const mergeAdjacentAssignments = (assignments: RecommendationAssignment[]) => { }); }; -const getAssignmentDurationMinutes = (assignment: Pick) => - Math.round((assignment.endsAt.getTime() - assignment.startsAt.getTime()) / 60000); +const getAssignmentDurationMinutes = ( + assignment: Pick, +) => Math.round((assignment.endsAt.getTime() - assignment.startsAt.getTime()) / 60000); const getWeekStartDate = (date: Date) => { const day = date.getUTCDay(); @@ -146,7 +147,11 @@ const compareCandidatesByBalance = ({ }) => { const weekStartDate = getWeekStartDate(workDate); const weekEndDate = new Date( - Date.UTC(weekStartDate.getUTCFullYear(), weekStartDate.getUTCMonth(), weekStartDate.getUTCDate() + 7), + Date.UTC( + weekStartDate.getUTCFullYear(), + weekStartDate.getUTCMonth(), + weekStartDate.getUTCDate() + 7, + ), ); const isSameWeek = (assignment: RecommendationAssignment) => assignment.workDate >= weekStartDate && assignment.workDate < weekEndDate; @@ -355,7 +360,8 @@ export const createRecommendations = ({ ); const selectableCandidates = availableCandidates .filter( - (worker) => !hasOverlap(assignments, worker.id, demandWindow.startsAt, demandWindow.endsAt), + (worker) => + !hasOverlap(assignments, worker.id, demandWindow.startsAt, demandWindow.endsAt), ) .sort((firstWorker, secondWorker) => compareCandidatesByBalance({ diff --git a/apps/api/src/modules/scheduling-time-policy.ts b/apps/api/src/modules/scheduling-time-policy.ts index ef03f56..0b29057 100644 --- a/apps/api/src/modules/scheduling-time-policy.ts +++ b/apps/api/src/modules/scheduling-time-policy.ts @@ -4,12 +4,7 @@ import type { WorkerAvailableTime, } from "@fragment/database"; -import { - addDays, - dateToTimeString, - parseTimeToMinutes, - timeStringToDate, -} from "@/utils/date-time"; +import { addDays, dateToTimeString, parseTimeToMinutes, timeStringToDate } from "@/utils/date-time"; import { getDayOfWeek } from "@/utils/day-of-week"; type TimeRange = { @@ -66,12 +61,7 @@ const getBusinessHourRange = ( const dayOfWeek = getDayOfWeek(workDate); const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek); - if ( - !businessHour || - businessHour.isClosed || - !businessHour.openTime || - !businessHour.closeTime - ) { + if (!businessHour || businessHour.isClosed || !businessHour.openTime || !businessHour.closeTime) { return null; } diff --git a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts index a3e2502..e681542 100644 --- a/apps/api/src/modules/staffing-rules/staffing-rules.service.ts +++ b/apps/api/src/modules/staffing-rules/staffing-rules.service.ts @@ -77,12 +77,7 @@ const assertMinimumStaffingRuleInsideBusinessHours = ({ }) => { const businessHour = businessHours.find((item) => item.dayOfWeek === dayOfWeek); - if ( - !businessHour || - businessHour.isClosed || - !businessHour.openTime || - !businessHour.closeTime - ) { + if (!businessHour || businessHour.isClosed || !businessHour.openTime || !businessHour.closeTime) { throw createMinimumStaffingRuleClosedDayError(); } 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 62c11b5..085fa51 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -1103,45 +1103,45 @@ export function MvpSchedulesPage() {
- {selectedDateSchedules.length > 0 ? ( - selectedDateSchedules.map((item) => ( -
-
-

- {item.startTime}-{item.endTime} -

-

- {item.workerName} -

-
- {isDraft ? ( -
- - + {selectedDateSchedules.length > 0 + ? selectedDateSchedules.map((item) => ( +
+
+

+ {item.startTime}-{item.endTime} +

+

+ {item.workerName} +

- ) : null} -
- )) - ) : null} + {isDraft ? ( +
+ + +
+ ) : null} +
+ )) + : null}
diff --git a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx index 2034ca7..9988ae4 100644 --- a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx +++ b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx @@ -119,15 +119,10 @@ function createTimeOptions( const endMinutes = getBusinessHourEndMinutes(businessHour); const firstMinutes = boundary === "start" ? startMinutes : startMinutes + TIME_OPTION_STEP_MINUTES; - const lastMinutes = - boundary === "start" ? endMinutes - TIME_OPTION_STEP_MINUTES : endMinutes; + const lastMinutes = boundary === "start" ? endMinutes - TIME_OPTION_STEP_MINUTES : endMinutes; const options: { label: string; value: string }[] = []; - for ( - let minutes = firstMinutes; - minutes <= lastMinutes; - minutes += TIME_OPTION_STEP_MINUTES - ) { + for (let minutes = firstMinutes; minutes <= lastMinutes; minutes += TIME_OPTION_STEP_MINUTES) { const value = minutesToTime(minutes); const label = minutes >= MINUTES_IN_DAY ? `${value} 다음날` : value; From a863113df6dc563081d755f37917c1d7b32eda2c Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Mon, 29 Jun 2026 22:52:25 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20PR=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../availability/availability.service.ts | 34 ++++++--- .../src/modules/schedules/schedules.mapper.ts | 9 ++- .../schedules/schedules.recommendation.ts | 75 ++++++++++++------- .../components/mvp-availability-page.tsx | 4 +- .../components/mvp-schedules-page.tsx | 26 ++++--- .../components/mvp-staffing-rules-page.tsx | 5 +- docs/openapi.yaml | 2 + 7 files changed, 103 insertions(+), 52 deletions(-) diff --git a/apps/api/src/modules/availability/availability.service.ts b/apps/api/src/modules/availability/availability.service.ts index 38e9284..71fe07e 100644 --- a/apps/api/src/modules/availability/availability.service.ts +++ b/apps/api/src/modules/availability/availability.service.ts @@ -72,6 +72,9 @@ const toAvailability = (availability: PrismaWorkerAvailableTime): Availability = endsAt: availability.endsAt.toISOString(), }); +const isPrismaClient = (client: AvailabilityDatabaseClient): client is PrismaClient => + "$transaction" in client; + const assertAvailabilityItemsInDateRange = (input: ReplaceAvailabilityRequest) => { for (const item of input.items) { if (item.availableDate < input.startDate || item.availableDate > input.endDate) { @@ -231,18 +234,27 @@ export async function pruneOrganizationAvailabilityForActivePlanningPeriod( }); const prunedItems = createPrunedAvailabilityItems(organization, existingItems); - await client.workerAvailableTime.deleteMany({ - where: { - worker: { - organizationId, + const replaceAvailability = async (transaction: AvailabilityDatabaseClient) => { + await transaction.workerAvailableTime.deleteMany({ + where: { + worker: { + organizationId, + }, + availableDate: dateRange, }, - availableDate: dateRange, - }, - }); - - if (prunedItems.length > 0) { - await client.workerAvailableTime.createMany({ - data: prunedItems, }); + + if (prunedItems.length > 0) { + await transaction.workerAvailableTime.createMany({ + data: prunedItems, + }); + } + }; + + if (isPrismaClient(client)) { + await client.$transaction(replaceAvailability); + return; } + + await replaceAvailability(client); } diff --git a/apps/api/src/modules/schedules/schedules.mapper.ts b/apps/api/src/modules/schedules/schedules.mapper.ts index 4cde601..9a67655 100644 --- a/apps/api/src/modules/schedules/schedules.mapper.ts +++ b/apps/api/src/modules/schedules/schedules.mapper.ts @@ -48,13 +48,16 @@ const isCandidateRecommended = ( candidate: PrismaScheduleCandidate, assignments: PrismaScheduleAssignment[], ) => - candidate.workerId !== null && assignments.some( (assignment) => - assignment.workerId === candidate.workerId && assignment.workDate.getTime() === candidate.workDate.getTime() && assignment.startsAt.getTime() === candidate.startsAt.getTime() && - assignment.endsAt.getTime() === candidate.endsAt.getTime(), + assignment.endsAt.getTime() === candidate.endsAt.getTime() && + (candidate.workerId !== null + ? assignment.workerId === candidate.workerId + : assignment.workerId === null && + assignment.workerNameSnapshot === candidate.workerNameSnapshot && + assignment.employeeCodeSnapshot === candidate.employeeCodeSnapshot), ); export const toScheduleCandidate = ( diff --git a/apps/api/src/modules/schedules/schedules.recommendation.ts b/apps/api/src/modules/schedules/schedules.recommendation.ts index 0a7b1a8..f2be20e 100644 --- a/apps/api/src/modules/schedules/schedules.recommendation.ts +++ b/apps/api/src/modules/schedules/schedules.recommendation.ts @@ -53,6 +53,8 @@ type DemandWindow = { const MINUTES_IN_DAY = 24 * 60; +const compareBigInt = (left: bigint, right: bigint) => (left < right ? -1 : left > right ? 1 : 0); + const hasOverlap = ( assignments: RecommendationAssignment[], workerId: bigint, @@ -282,32 +284,53 @@ export const createInputHash = ({ const payload = { startDate: dateToDateString(startDate), endDate: dateToDateString(endDate), - workers: workers.map((worker) => ({ - id: toApiId(worker.id), - employeeCode: worker.employeeCode, - name: worker.name, - weeklyContractHours: worker.weeklyContractHours, - })), - availableTimes: availableTimes.map((availableTime) => ({ - workerId: toApiId(availableTime.workerId), - availableDate: dateToDateString(availableTime.availableDate), - startsAt: availableTime.startsAt.toISOString(), - endsAt: availableTime.endsAt.toISOString(), - })), - businessHours: businessHours.map((businessHour) => ({ - dayOfWeek: businessHour.dayOfWeek, - isClosed: businessHour.isClosed, - openTime: businessHour.openTime ? dateToTimeString(businessHour.openTime) : null, - closeTime: businessHour.closeTime ? dateToTimeString(businessHour.closeTime) : null, - closesNextDay: businessHour.closesNextDay, - })), - rules: rules.map((rule) => ({ - dayOfWeek: rule.dayOfWeek, - startTime: dateToTimeString(rule.startTime), - endTime: dateToTimeString(rule.endTime), - endsNextDay: rule.endsNextDay, - requiredCount: rule.requiredCount, - })), + workers: [...workers] + .sort((left, right) => compareBigInt(left.id, right.id)) + .map((worker) => ({ + id: toApiId(worker.id), + employeeCode: worker.employeeCode, + name: worker.name, + weeklyContractHours: worker.weeklyContractHours, + })), + availableTimes: [...availableTimes] + .sort( + (left, right) => + compareBigInt(left.workerId, right.workerId) || + left.availableDate.getTime() - right.availableDate.getTime() || + left.startsAt.getTime() - right.startsAt.getTime() || + left.endsAt.getTime() - right.endsAt.getTime(), + ) + .map((availableTime) => ({ + workerId: toApiId(availableTime.workerId), + availableDate: dateToDateString(availableTime.availableDate), + startsAt: availableTime.startsAt.toISOString(), + endsAt: availableTime.endsAt.toISOString(), + })), + businessHours: [...businessHours] + .sort((left, right) => left.dayOfWeek.localeCompare(right.dayOfWeek)) + .map((businessHour) => ({ + dayOfWeek: businessHour.dayOfWeek, + isClosed: businessHour.isClosed, + openTime: businessHour.openTime ? dateToTimeString(businessHour.openTime) : null, + closeTime: businessHour.closeTime ? dateToTimeString(businessHour.closeTime) : null, + closesNextDay: businessHour.closesNextDay, + })), + rules: [...rules] + .sort( + (left, right) => + left.dayOfWeek.localeCompare(right.dayOfWeek) || + left.startTime.getTime() - right.startTime.getTime() || + left.endTime.getTime() - right.endTime.getTime() || + Number(left.endsNextDay) - Number(right.endsNextDay) || + left.requiredCount - right.requiredCount, + ) + .map((rule) => ({ + dayOfWeek: rule.dayOfWeek, + startTime: dateToTimeString(rule.startTime), + endTime: dateToTimeString(rule.endTime), + endsNextDay: rule.endsNextDay, + requiredCount: rule.requiredCount, + })), }; return createHash("sha256").update(JSON.stringify(payload)).digest("hex"); 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 bfed92c..65c6136 100644 --- a/apps/web/src/features/availability/components/mvp-availability-page.tsx +++ b/apps/web/src/features/availability/components/mvp-availability-page.tsx @@ -213,8 +213,10 @@ function isSlotSchedulable( date: string, startMinutes: number, ) { + const endMinutes = startMinutes + SLOT_INTERVAL_MINUTES; + return getSchedulableRanges(businessHours, staffingRules, date).some( - (range) => startMinutes >= range.startMinutes && startMinutes < range.endMinutes, + (range) => startMinutes >= range.startMinutes && endMinutes <= range.endMinutes, ); } 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 085fa51..97478ef 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -197,11 +197,11 @@ function dateTimeToTime(date: string, dateTime: string) { return minutesToTime(dateTimeToMinutes(date, dateTime)); } -function createDateTime(date: string, time: string) { +function createDateTime(date: string, time: string, addDay = false) { const [year, month, day] = date.split("-").map(Number); const [hours, minutes] = time.split(":").map(Number); - return new Date(Date.UTC(year, month - 1, day, hours, minutes)).toISOString(); + return new Date(Date.UTC(year, month - 1, day + (addDay ? 1 : 0), hours, minutes)).toISOString(); } function createDraftFromSchedule(schedule: ScheduleItem): ScheduleDraft { @@ -214,8 +214,10 @@ function createDraftFromSchedule(schedule: ScheduleItem): ScheduleDraft { } function createScheduleAssignmentInput(draft: ScheduleDraft): ScheduleAssignmentInput { + const endsNextDay = draft.endTime < draft.startTime; + return { - endsAt: createDateTime(draft.date, draft.endTime), + endsAt: createDateTime(draft.date, draft.endTime, endsNextDay), startsAt: createDateTime(draft.date, draft.startTime), workDate: draft.date, workerId: draft.workerId, @@ -231,14 +233,21 @@ function validateScheduleDraft(draft: ScheduleDraft, workStartDate: string, work : "", endTime: !draft.endTime ? "종료 시간을 선택하세요." - : draft.startTime && draft.endTime <= draft.startTime - ? "종료 시간은 시작 시간보다 늦어야 합니다." + : draft.startTime && draft.endTime === draft.startTime + ? "종료 시간은 시작 시간과 달라야 합니다." : "", startTime: !draft.startTime ? "시작 시간을 선택하세요." : "", workerId: !draft.workerId ? "근무자를 선택하세요." : "", }; } +function createScheduleEndTimeOptions(startTime: string) { + return SCHEDULE_TIME_OPTIONS.filter((option) => option.value !== startTime).map((option) => ({ + ...option, + label: startTime && option.value < startTime ? `${option.value} 다음날` : option.label, + })); +} + function getStatusLabel(status: ScheduleStatus | null) { if (status === "DRAFT") { return "DRAFT"; @@ -425,10 +434,7 @@ export function MvpSchedulesPage() { ); const isSelectedDateScheduleTarget = scheduleTargetDates.has(selectedDate); const scheduleEndTimeOptions = useMemo( - () => - draft.startTime - ? SCHEDULE_TIME_OPTIONS.filter((option) => option.value > draft.startTime) - : SCHEDULE_TIME_OPTIONS, + () => createScheduleEndTimeOptions(draft.startTime), [draft.startTime], ); @@ -994,7 +1000,7 @@ export function MvpSchedulesPage() { setDraft((current) => ({ ...current, endTime: - current.endTime && current.endTime <= value ? "" : current.endTime, + current.endTime && current.endTime === value ? "" : current.endTime, startTime: value, })) } diff --git a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx index 9988ae4..2fe184b 100644 --- a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx +++ b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx @@ -119,7 +119,10 @@ function createTimeOptions( const endMinutes = getBusinessHourEndMinutes(businessHour); const firstMinutes = boundary === "start" ? startMinutes : startMinutes + TIME_OPTION_STEP_MINUTES; - const lastMinutes = boundary === "start" ? endMinutes - TIME_OPTION_STEP_MINUTES : endMinutes; + const lastMinutes = + boundary === "start" + ? Math.min(endMinutes - TIME_OPTION_STEP_MINUTES, MINUTES_IN_DAY - TIME_OPTION_STEP_MINUTES) + : endMinutes; const options: { label: string; value: string }[] = []; for (let minutes = firstMinutes; minutes <= lastMinutes; minutes += TIME_OPTION_STEP_MINUTES) { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d15c5de..1b1959c 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1287,9 +1287,11 @@ components: properties: id: type: string + pattern: "^\\d+$" example: "20" scheduleId: type: string + pattern: "^\\d+$" example: "1" workerId: type: From 4cd3a1a1ba4d897c444998e96fe6d07632f85de0 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Mon, 29 Jun 2026 23:50:44 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(web):=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=9B=B9=20=EC=84=9C=EB=B2=84=20=ED=8F=AC=ED=8A=B8=EC=99=80=20?= =?UTF-8?q?API=20=ED=94=84=EB=A1=9D=EC=8B=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/next.config.ts | 16 ++++++++++++++++ apps/web/package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index fd94248..67aa17b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,23 @@ import type { NextConfig } from "next"; +const apiProxyOrigin = + process.env.API_PROXY_ORIGIN ?? + (process.env.NODE_ENV === "development" ? "http://localhost:3001" : undefined); + const nextConfig: NextConfig = { transpilePackages: ["@fragment/shared"], + ...(apiProxyOrigin + ? { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${apiProxyOrigin.replace(/\/$/, "")}/api/:path*`, + }, + ]; + }, + } + : {}), }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index d0c606b..2b5497c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3000", "build": "next build", "start": "next start", "lint": "eslint src/", From 21e0ff3da46352157a250c3d730d24164a91c329 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 00:01:01 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix(web):=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=ED=91=9C=EC=8B=9C=20=EC=95=84=EC=9D=B4=EC=BD=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/common/password-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/common/password-input.tsx b/apps/web/src/components/common/password-input.tsx index 4213b9e..a1a7877 100644 --- a/apps/web/src/components/common/password-input.tsx +++ b/apps/web/src/components/common/password-input.tsx @@ -23,7 +23,7 @@ export function PasswordInput({ className, ...props }: PasswordInputProps) { className="absolute right-1 top-1/2 size-9 -translate-y-1/2 text-muted-foreground hover:bg-transparent hover:text-foreground" onClick={() => setVisible((current) => !current)} > - {visible ? : } + {visible ? : } ); From a5ea8aff026ce5e20cc2f30d19e98b9742a5d4a8 Mon Sep 17 00:00:00 2001 From: Yeryeong Kang Date: Tue, 30 Jun 2026 00:25:58 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor(web):=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=20=ED=8F=BC=20=EA=B2=80=EC=A6=9D=EC=9D=84=20RHF?= =?UTF-8?q?=EC=99=80=20Zod=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-schedules-page.tsx | 282 +++++++++++------- 1 file changed, 170 insertions(+), 112 deletions(-) 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 97478ef..cf7007f 100644 --- a/apps/web/src/features/schedules/components/mvp-schedules-page.tsx +++ b/apps/web/src/features/schedules/components/mvp-schedules-page.tsx @@ -12,6 +12,8 @@ import type { 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 { Controller, type FieldErrors, type Resolver, useForm } from "react-hook-form"; +import { z } from "zod"; import { AdminPageShell } from "@/components/layout/admin-page-shell"; import { @@ -224,22 +226,72 @@ function createScheduleAssignmentInput(draft: ScheduleDraft): ScheduleAssignment }; } -function validateScheduleDraft(draft: ScheduleDraft, workStartDate: string, workEndDate: string) { - return { - date: !draft.date - ? "날짜를 선택하세요." - : draft.date < workStartDate || draft.date > workEndDate - ? "근무 시작 날짜와 종료 날짜 사이에서만 선택할 수 있습니다." - : "", - endTime: !draft.endTime - ? "종료 시간을 선택하세요." - : draft.startTime && draft.endTime === draft.startTime - ? "종료 시간은 시작 시간과 달라야 합니다." - : "", - startTime: !draft.startTime ? "시작 시간을 선택하세요." : "", - workerId: !draft.workerId ? "근무자를 선택하세요." : "", +const createScheduleFormSchema = (workStartDate: string, workEndDate: string) => + z + .object({ + date: z.string().min(1, "날짜를 선택하세요."), + endTime: z.string().min(1, "종료 시간을 선택하세요."), + startTime: z.string().min(1, "시작 시간을 선택하세요."), + workerId: z.string().min(1, "근무자를 선택하세요."), + }) + .superRefine((value, context) => { + if ( + value.date && + workStartDate && + workEndDate && + (value.date < workStartDate || value.date > workEndDate) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "근무 시작 날짜와 종료 날짜 사이에서만 선택할 수 있습니다.", + path: ["date"], + }); + } + + if (value.startTime && value.endTime && value.startTime === value.endTime) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "종료 시간은 시작 시간과 달라야 합니다.", + path: ["endTime"], + }); + } + }); + +const createScheduleFormResolver = + (workStartDate: string, workEndDate: string): Resolver => + (values) => { + const result = createScheduleFormSchema(workStartDate, workEndDate).safeParse(values); + + if (result.success) { + return { + errors: {}, + values, + }; + } + + const errors: FieldErrors = {}; + + result.error.issues.forEach((issue) => { + const [field] = issue.path; + + if ( + field === "date" || + field === "startTime" || + field === "endTime" || + field === "workerId" + ) { + errors[field] = { + type: "manual", + message: issue.message, + }; + } + }); + + return { + errors, + values: {}, + }; }; -} function createScheduleEndTimeOptions(startTime: string) { return SCHEDULE_TIME_OPTIONS.filter((option) => option.value !== startTime).map((option) => ({ @@ -372,6 +424,17 @@ export function MvpSchedulesPage() { () => unfilledConditions[0]?.date ?? schedules[0]?.date ?? candidates[0]?.date ?? "", [candidates, schedules, unfilledConditions], ); + const { + control, + formState: { errors }, + handleSubmit, + reset, + setValue, + watch, + } = useForm({ + defaultValues: EMPTY_DRAFT, + resolver: createScheduleFormResolver(workStartDate, workEndDate), + }); const [formOpen, setFormOpen] = useState(false); const [formMode, setFormMode] = useState("create"); const [editingScheduleId, setEditingScheduleId] = useState(null); @@ -379,13 +442,9 @@ export function MvpSchedulesPage() { const [confirmOpen, setConfirmOpen] = useState(false); const [selectedDate, setSelectedDate] = useState(""); const [dateDetailDismissed, setDateDetailDismissed] = useState(false); - const [draft, setDraft] = useState(EMPTY_DRAFT); - const [submitted, setSubmitted] = useState(false); const [operationMessage, setOperationMessage] = useState(null); - const errors = useMemo( - () => validateScheduleDraft(draft, workStartDate, workEndDate), - [draft, workEndDate, workStartDate], - ); + const scheduleFormDate = watch("date"); + const scheduleFormStartTime = watch("startTime"); const calendarDates = useMemo( () => createCalendarDates(workStartDate, workEndDate), [workEndDate, workStartDate], @@ -398,7 +457,6 @@ export function MvpSchedulesPage() { () => createWorkerOptions(workers, editingSchedule), [editingSchedule, workers], ); - const hasErrors = Object.values(errors).some(Boolean); const isPageLoading = planningPeriodQuery.isPending || staffingRulesQuery.isPending || @@ -434,8 +492,8 @@ export function MvpSchedulesPage() { ); const isSelectedDateScheduleTarget = scheduleTargetDates.has(selectedDate); const scheduleEndTimeOptions = useMemo( - () => createScheduleEndTimeOptions(draft.startTime), - [draft.startTime], + () => createScheduleEndTimeOptions(scheduleFormStartTime), + [scheduleFormStartTime], ); useEffect(() => { @@ -444,8 +502,7 @@ export function MvpSchedulesPage() { setDateDetailDismissed(false); setFormOpen(false); setEditingScheduleId(null); - setDraft(EMPTY_DRAFT); - setSubmitted(false); + reset(EMPTY_DRAFT); return; } @@ -474,8 +531,7 @@ export function MvpSchedulesPage() { setEditingScheduleId(null); setDateDetailDismissed(false); setSelectedDate(date); - setDraft({ ...EMPTY_DRAFT, date }); - setSubmitted(false); + reset({ ...EMPTY_DRAFT, date }); setOperationMessage(null); setFormOpen(true); } @@ -489,17 +545,15 @@ export function MvpSchedulesPage() { setEditingScheduleId(scheduleItem.id); setDateDetailDismissed(false); setSelectedDate(scheduleItem.date); - setDraft(createDraftFromSchedule(scheduleItem)); - setSubmitted(false); + reset(createDraftFromSchedule(scheduleItem)); setOperationMessage(null); setFormOpen(true); } function closeForm() { setFormOpen(false); - setSubmitted(false); setEditingScheduleId(null); - setDraft(EMPTY_DRAFT); + reset(EMPTY_DRAFT); } async function generateRecommendation() { @@ -529,15 +583,14 @@ export function MvpSchedulesPage() { } } - async function saveSchedule() { - setSubmitted(true); + async function saveSchedule(values: ScheduleDraft) { setOperationMessage(null); - if (!schedule || !isDraft || hasErrors) { + if (!schedule || !isDraft) { return; } - const request = createScheduleAssignmentInput(draft); + const request = createScheduleAssignmentInput(values); try { if (formMode === "create") { @@ -555,7 +608,7 @@ export function MvpSchedulesPage() { }); } - setSelectedDate(draft.date); + setSelectedDate(values.date); setDateDetailDismissed(false); closeForm(); setOperationMessage({ @@ -977,7 +1030,7 @@ export function MvpSchedulesPage() {

{formTitle}

- {formatDateTitle(draft.date)} + {scheduleFormDate ? formatDateTitle(scheduleFormDate) : "날짜 미선택"}

- } containerClassName="max-w-none" contentClassName="space-y-6" > +
+
+

조건 추가

+

+ 요일과 시간대, 최소 인원을 한 세트로 추가하고 여러 조건을 한 번에 저장하세요. +

+
+ +
+
+ + ( +
+ {DAY_ORDER.map((dayOfWeek) => { + const businessHour = getBusinessHourForDay(businessHours, dayOfWeek); + const isDisabled = !isSelectableBusinessHour(businessHour); + const checked = field.value.includes(dayOfWeek); + + return ( + + ); + })} +
+ )} + /> + {batchErrors.dayOfWeeks ? ( +

{batchErrors.dayOfWeeks.message}

+ ) : null} +

{batchBusinessHourLabel}

+
+ +
+
+ + ( + + )} + /> +
+ +
+ + ( + + )} + /> +
+ +
+ + +
+ + + {batchInputErrorMessage ? ( +

{batchInputErrorMessage}

+ ) : null} +
+ +
+
+

저장할 조건

+
+ {draftRules.length > 0 ? ( +
+ + + + + + + + + + + + + {groupedDraftRules.map((group) => ( + + + + + ))} + +
요일조건
+ {DAY_LABELS[group.dayOfWeek]} + +
+ {group.rules.map((rule) => ( +
+ + + {rule.startTime}-{rule.endTime} + {rule.endsNextDay ? " 다음날" : ""} + + | + {rule.requiredCount}명 + + +
+ ))} +
+
+
+ ) : ( +

+ 아직 추가된 조건이 없습니다. 요일과 시간대, 최소 인원을 입력한 뒤 목록에 추가하세요. +

+ )} +
+ + {batchFormError ?

{batchFormError}

: null} + +
+ + +
+
+
+
( )} /> - {errors.dayOfWeek ? ( -

{errors.dayOfWeek.message}

+ {editErrors.dayOfWeek ? ( +

{editErrors.dayOfWeek.message}

) : null} {selectedDayOfWeek ? (

{selectedBusinessHourLabel}

@@ -475,7 +1134,7 @@ export function MvpStaffingRulesPage() {
( )} /> - {errors.startTime ? ( -

{errors.startTime.message}

+ {editErrors.startTime ? ( +

{editErrors.startTime.message}

) : null}
( )} /> - {errors.endTime ? ( -

{errors.endTime.message}

+ {editErrors.endTime ? ( +

{editErrors.endTime.message}

) : null}
@@ -541,14 +1203,15 @@ export function MvpStaffingRulesPage() { type="number" min={1} placeholder="예: 2" - aria-invalid={Boolean(errors.requiredCount)} - {...register("requiredCount")} + aria-invalid={Boolean(editErrors.requiredCount)} + {...registerEdit("requiredCount")} /> - {errors.requiredCount ? ( -

{errors.requiredCount.message}

+ {editErrors.requiredCount ? ( +

{editErrors.requiredCount.message}

) : null} - {formError ?

{formError}

: null} + + {editFormError ?

{editFormError}

: null} Date: Tue, 30 Jun 2026 01:54:50 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix(web):=20=EC=B5=9C=EC=86=8C=20?= =?UTF-8?q?=EC=9D=B8=EC=9B=90=20=EC=A1=B0=EA=B1=B4=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=84=A0=ED=83=9D=EC=A7=80=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/mvp-staffing-rules-page.tsx | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx index 635cb5e..b08f0ff 100644 --- a/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx +++ b/apps/web/src/features/staffing-rules/components/mvp-staffing-rules-page.tsx @@ -134,9 +134,16 @@ function getBusinessHourEndMinutes(businessHour: BusinessHour) { return businessHour.closesNextDay ? closeMinutes + MINUTES_IN_DAY : closeMinutes; } +function getTimeMinutesWithinRange(time: string, rangeStartMinutes: number) { + const rawMinutes = parseTimeToMinutes(time); + + return rawMinutes < rangeStartMinutes ? rawMinutes + MINUTES_IN_DAY : rawMinutes; +} + function createTimeOptions( businessHour: BusinessHour | undefined, boundary: "start" | "end", + selectedStartTime?: string, ): { label: string; value: string }[] { if (!isSelectableBusinessHour(businessHour) || !businessHour?.openTime) { return []; @@ -144,8 +151,18 @@ function createTimeOptions( const startMinutes = parseTimeToMinutes(businessHour.openTime); const endMinutes = getBusinessHourEndMinutes(businessHour); + const selectedStartMinutes = selectedStartTime + ? getTimeMinutesWithinRange(selectedStartTime, startMinutes) + : null; const firstMinutes = - boundary === "start" ? startMinutes : startMinutes + TIME_OPTION_STEP_MINUTES; + boundary === "start" + ? startMinutes + : Math.max( + startMinutes + TIME_OPTION_STEP_MINUTES, + selectedStartMinutes !== null + ? selectedStartMinutes + TIME_OPTION_STEP_MINUTES + : startMinutes + TIME_OPTION_STEP_MINUTES, + ); const lastMinutes = boundary === "start" ? Math.min(endMinutes - TIME_OPTION_STEP_MINUTES, MINUTES_IN_DAY - TIME_OPTION_STEP_MINUTES) @@ -165,13 +182,24 @@ function createTimeOptions( function createTimeOptionsFromRange( range: { endMinutes: number; startMinutes: number } | null, boundary: "start" | "end", + selectedStartTime?: string, ): { label: string; value: string }[] { if (!range) { return []; } + const selectedStartMinutes = selectedStartTime + ? getTimeMinutesWithinRange(selectedStartTime, range.startMinutes) + : null; const firstMinutes = - boundary === "start" ? range.startMinutes : range.startMinutes + TIME_OPTION_STEP_MINUTES; + boundary === "start" + ? range.startMinutes + : Math.max( + range.startMinutes + TIME_OPTION_STEP_MINUTES, + selectedStartMinutes !== null + ? selectedStartMinutes + TIME_OPTION_STEP_MINUTES + : range.startMinutes + TIME_OPTION_STEP_MINUTES, + ); const lastMinutes = boundary === "start" ? Math.min( @@ -576,16 +604,22 @@ export function MvpStaffingRulesPage() { const selectedDayOfWeek = watchEditForm("dayOfWeek"); const selectedBusinessHour = getBusinessHourForDay(businessHours, selectedDayOfWeek); const selectedBusinessHourLabel = getBusinessHourLabel(selectedBusinessHour); + const selectedStartTime = watchEditForm("startTime"); const startTimeOptions = createTimeOptions(selectedBusinessHour, "start"); - const endTimeOptions = createTimeOptions(selectedBusinessHour, "end"); + const endTimeOptions = createTimeOptions(selectedBusinessHour, "end", selectedStartTime); const groupedDraftRules = useMemo(() => groupDraftRulesByDay(draftRules), [draftRules]); const selectedBatchDayOfWeeks = watchBatchForm("dayOfWeeks"); + const selectedBatchStartTime = watchBatchForm("startTime"); const batchCommonBusinessHourRange = getCommonBusinessHourRange( businessHours, selectedBatchDayOfWeeks, ); const batchStartTimeOptions = createTimeOptionsFromRange(batchCommonBusinessHourRange, "start"); - const batchEndTimeOptions = createTimeOptionsFromRange(batchCommonBusinessHourRange, "end"); + const batchEndTimeOptions = createTimeOptionsFromRange( + batchCommonBusinessHourRange, + "end", + selectedBatchStartTime, + ); const batchBusinessHourLabel = getBatchBusinessHourLabel(businessHours, selectedBatchDayOfWeeks); const batchInputErrorMessage = batchErrors.startTime?.message ?? @@ -861,7 +895,10 @@ export function MvpStaffingRulesPage() { render={({ field }) => ( { + field.onChange(value); + setEditValue("endTime", ""); + }} disabled={!isSelectableBusinessHour(selectedBusinessHour)} >