diff --git a/docs/decisions.md b/docs/decisions.md index 08c2396..3a80463 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -102,6 +102,7 @@ | DEC-095 | 사용자 결정 · 웹 · 2026-10-14 | (#327) 관리자 패드 폭(768~1279px)도 핸드폰·학생 패드처럼 마이페이지 맨 아래에 `로그아웃` 버튼을 보인다(DEC-093 뒤 전체 점검에서 패드만 숨겨져 있어 맞춤). 메뉴 시트의 로그아웃은 그대로다. 컴퓨터(1280px 이상)는 사이드바 로그아웃이라 버튼을 숨긴다. 메뉴 시트는 핸드폰과 같이 화면 폭 전체로 열리고 애니메이션(dim-in·sheet-rise)도 같다(사용자 결정). 호실 상세 등 관리자 다이얼로그의 어두운 배경은 하단 바(z-40) 위(z-50)에 와서 하단 바까지 덮는다. | 사용자 결정 | | DEC-096 | 사용자 결정 · 웹 · 2026-10-14 | (#330) 패드 폭 하단 바 전환(DEC-093·094·095) 뒤 전체 점검에서 정리했다. 관리자 마이페이지의 `마이페이지` 제목을 레일이 없어져 현재 화면 표시가 필요해 1280px 미만에서 보인다(컴퓨터는 사이드바 강조로 숨김). 학생 패드 홈은 QR 버튼이 호실 카드 모서리를 가려 패드 구간(md~xl)만 본문 아래 여백을 116px로 늘렸다(핸드폰은 Figma 그대로). 학생 사이드바의 레일용 기본 클래스를 정리했다(화면 변화 없음). 여백 116px은 Figma에 없어 작업자가 정했다. | 사용자 결정. 여백은 개발자 가정 | | DEC-098 | 사용자 결정 · 웹 · 2026-10-15 | (#334) 기자위 선배의 "당일 봉사 안 하고 도망간 학생은 봉사 횟수가 추가된다"에 맞춰 봉사자 관리 한 화면에서 처리·확인하도록 했다. 당일 봉사자 행에 `미이행` 버튼을 두고 누르면 학생 관리와 같은 다이얼로그(횟수 −/+, 사유)로 횟수를 추가한다. 서버에 `미이행` 상태가 없어 지정 제외 → 횟수 저장 순서의 두 호출로 처리한다(실패해도 횟수가 두 번 더해지지 않게). `+ 명단 수정` 옆에 날짜 버튼과 달력(연월 이동, 미이행 날 빨간 점, 날짜를 누르면 그날 안 한 학생)을 두었다. 달력 아이콘(Figma Calendar_Days)은 사용자가 넣지 말라고 해 글자만 쓴다. 횟수를 추가해 처리한 미이행은 달력에서 `횟수 처리됨`이고 누르면 추가 횟수·사유만 읽기 전용으로 본다. 처리하지 않은 미이행은 `미이행`이고 눌러서 조정한다. 달력은 오늘 처리분도 점으로 보이고 기기별 크기(300/340/420px)를 다르게 한다. 지난 날짜 기록은 서버 API(날짜별 당일 봉사자 기록, 미이행 상태, 추가 횟수·사유)가 필요해 실제 연결은 서버 지원 뒤다(CheckUp-server#250). | 사용자 결정. 서버 API는 미확정 | +| DEC-099 | 개발자 결정 · 웹 · 2026-10-15 | (#336) 서버가 날짜별 당일 봉사자 기록·월 요약·미이행 처리 API(스웨거 확인, CheckUp-server#250)를 제공해 웹을 연결했다. 오늘 미이행은 `POST /duty/missed` 한 번으로 지정을 `MISSED`로 바꾸며 횟수·사유를 저장하고(지정 취소 + 횟수 조정 두 호출은 폐기), 달력은 `GET /duties`·`/duties/summary`를, 지난 미이행 조정은 `PATCH /duties/{date}/{id}/missed`를 쓴다. `todayDuty`의 `MISSED`는 당일 봉사자 목록에서 빠지고 명단 편집에서 `미이행`으로 보인다. DEC-098의 화면이 들고 있던 임시 처리 상태는 제거해 새로고침해도 `횟수 처리됨`이 유지된다. 실제 서버 응답으로 로그인한 화면 확인은 하지 못했다(스웨거 계약 기준 연결). | 개발자 결정. 서버 계약 기준 | | DEC-097 | 사용자 결정 · 웹 · 2026-10-15 | (#332) 호실 상세에서 `수정`을 누르면 상세가 사라지고 수정 다이얼로그가 새로 열리며 딤 페이드인과 패널 올라오기가 다시 실행돼 깜빡였다. 수정 다이얼로그는 호실 상세에서만 열리므로 딤·패널 등장 애니메이션을 빼서 한 다이얼로그의 내용만 바뀌는 것처럼 보이게 했다. 호실 상세를 처음 열 때의 애니메이션(DEC-080)은 그대로다. | 사용자 결정 | ## 하네스 선택 diff --git a/docs/plans/implementation.md b/docs/plans/implementation.md index 4da34be..141e4cc 100644 --- a/docs/plans/implementation.md +++ b/docs/plans/implementation.md @@ -214,3 +214,4 @@ DataGSM OAuth 연동(DEC-001) 작업과 같은 시점에 화면까지 함께 구 | 2026-10-14 | 사용자가 머지된 패드 하단 바 작업 전체 재점검을 요청해 관리자 패드 마이페이지 제목 표시, 학생 패드 홈 QR 버튼 겹침 여백, 학생 사이드바 레일용 클래스·AGENTS 문구를 정리했다(#330, `feat/330-pad-bottom-bar-followup`, develop 기준, DEC-096). 관리자 패드 화면 높이는 실제 화면으로 점검한다. | | 2026-10-15 | 사용자가 호실 상세에서 수정을 누르면 깜빡인다고 보고해 수정 다이얼로그의 등장 애니메이션을 뺐다(#332, `fix/332-room-edit-dialog-flicker`, develop 기준, DEC-097). 로컬에서 수정 직후 애니메이션 0개·불투명도 1 확인. | | 2026-10-15 | 사용자가 봉사자 관리에 미이행 버튼(횟수 추가·사유)과 날짜별 확인 달력을 요청해 구현했다(#334, `feat/334-volunteer-missed-calendar`, develop 기준, DEC-098). 서버에 날짜별 기록 API·미이행 상태가 없어 지난 날짜 기록은 확인용 목업만 연결했다. | +| 2026-10-15 | 서버 미이행·날짜별 기록 API가 준비돼 봉사자 관리 미이행 버튼과 달력을 연결했다(#336, `feat/336-volunteer-missed-api`, develop 기준, DEC-099). 스웨거 계약으로 구현·테스트했고 로그인 세션이 필요한 실서버 화면 확인은 미실시. | diff --git a/docs/spec/community.md b/docs/spec/community.md index ff8c1d5..c32a3b6 100644 --- a/docs/spec/community.md +++ b/docs/spec/community.md @@ -70,9 +70,9 @@ 지정되면 그 학생에게 봉사 알림을 만든다. 지정을 취소하면 그 알림을 지운다. 지정·취소는 봉사 횟수를 바꾸지 않는다. `봉사자 관리`(Figma 06)는 오늘 운영일의 당일 봉사자 목록이다. 헤더는 `MM/DD 당일 봉사자 N명`이고 `+ 명단 수정`으로 07에 이동한다. 각 행은 이름·`학번 · 호실`과 `완료`·`봉사 제외` 버튼이다. `완료`는 그 지정을 완료로 표시하고 봉사 횟수를 1 줄이며 한 번만 반영된다. `봉사 제외`는 지정 취소다. 완료한 지정은 제외할 수 없다. 학생은 지정·완료 API를 사용할 수 없다. -`미이행`은 지정됐지만 봉사를 하지 않고 사라진 학생을 처리한다. 누르면 학생 관리와 같은 다이얼로그(봉사 횟수 −/+, 사유)가 현재 횟수 + 1과 기본 사유 `당일 봉사 미이행`으로 열리고, 저장하면 지정을 제외한 뒤 횟수와 사유를 한 번에 저장한다(서버에 미이행 상태가 없어 지정 제외 + 횟수 조정 두 호출이며, 횟수가 두 번 더해지지 않도록 제외를 먼저 한다). 횟수 저장만 실패하면 `당일 봉사자에서는 제외했지만 봉사 횟수 변경에 실패했습니다. 학생 관리에서 조정해 주세요.`를 보인다. -`+ 명단 수정` 옆의 날짜 버튼(지금 보는 날 `YYYY.MM.DD`, 처음에는 오늘 운영일)을 누르면 달력이 열린다. 달 이동·연월 선택으로 바꿀 수 있고 오늘 이후는 고를 수 없다. 미이행이 있는 날은 빨간 점이며 날짜를 누르면 달력 아래에 그날 안 한 학생이 보이고, 학생을 누르면 횟수·사유 다이얼로그로 조정한다. 지난 날짜의 기록(완료·미이행·제외)은 서버에 날짜별 조회 API가 없어(`DutyStatus`는 `ASSIGNED`·`COMPLETED`뿐) 실제 게이트웨이가 비어 있고, 그때는 `지난 날짜의 봉사자 기록은 서버 지원이 준비되면 볼 수 있어요.`를 보인다(DEC-098). -달력의 미이행 학생은 횟수를 추가해 처리했는지로 나눠 보인다. 처리한 학생은 초록 `횟수 처리됨`이고 누르면 추가된 횟수와 사유만 읽기 전용으로 보이며(−/+·저장 없음), 처리하지 않은 학생은 빨간 `미이행`이고 누르면 횟수·사유 조정 다이얼로그가 열려 저장하면 `횟수 처리됨`으로 바뀐다. 달력은 오늘 처리분도 오늘 칸의 빨간 점과 `오늘 미이행` 목록으로 보인다. 서버가 처리 기록을 주기 전에는 이 화면에서 처리한 내용만 화면이 들고 있어 새로고침하면 표시가 사라진다(횟수 자체는 서버에 남는다). 달력 크기는 핸드폰 300px·패드 340px·컴퓨터 420px이고, 지난 기록이 300ms 안에 오면 스켈레톤 없이 바로 바꾼다. +`미이행`은 지정됐지만 봉사를 하지 않고 사라진 학생을 처리한다. 누르면 학생 관리와 같은 다이얼로그(봉사 횟수 −/+, 사유)가 현재 횟수 + 1과 기본 사유 `당일 봉사 미이행`으로 열리고, 저장하면 서버 `POST /api/v1/volunteer/{id}/duty/missed` 한 번으로 지정을 미이행(`MISSED`)으로 바꾸며 횟수 변화(delta)와 사유를 저장한다. 지정은 삭제되지 않고 남으며 학생 알림은 만들거나 지우지 않는다. 이미 완료했으면 409 `DUTY_ALREADY_COMPLETED`, 횟수를 이미 처리했으면 409 `DUTY_ALREADY_ADJUSTED`다. +`+ 명단 수정` 옆의 날짜 버튼(지금 보는 날 `YYYY.MM.DD`, 처음에는 오늘 운영일)을 누르면 달력이 열린다. 달 이동·연월 선택으로 바꿀 수 있고 오늘 이후는 고를 수 없다. 미이행이 있는 날은 빨간 점이며(서버 `GET /api/v1/volunteer/duties/summary?month=`) 날짜를 누르면 달력 아래에 그날 안 한 학생이 보이고(서버 `GET /api/v1/volunteer/duties?date=`), 학생을 누르면 횟수·사유 다이얼로그로 조정한다(서버 `PATCH /api/v1/volunteer/duties/{date}/{id}/missed`). 운영일이 지났는데 처리가 없던 지정은 서버가 `MISSED`, 횟수 미처리(`adjusted=false`)로 준다. +달력의 미이행 학생은 횟수를 추가해 처리했는지로 나눠 보인다. 처리한 학생은 초록 `횟수 처리됨`이고 누르면 서버가 준 추가된 횟수(`addedCount`)와 사유(`reason`)만 읽기 전용으로 보이며(−/+·저장 없음), 처리하지 않은 학생은 빨간 `미이행`이고 누르면 횟수·사유 조정 다이얼로그가 열려 저장하면 `횟수 처리됨`으로 바뀐다. 오늘 처리분도 서버 기록이라 새로고침해도 유지된다. 달력 크기는 핸드폰 300px·패드 340px·컴퓨터 420px이고, 지난 기록이 300ms 안에 오면 스켈레톤 없이 바로 바꾼다. 지정과 횟수는 일일 출석 초기화(08:00) 때 지우지 않는다. 하네스는 지정·완료 버튼을 명단 행에 함께 둔다고 적었지만 Figma는 지정(07)과 완료·제외(06)로 나눠 Figma를 따른다. diff --git a/web/src/components/admin/AdminVolunteerDuty.test.tsx b/web/src/components/admin/AdminVolunteerDuty.test.tsx index cb479f1..6b52534 100644 --- a/web/src/components/admin/AdminVolunteerDuty.test.tsx +++ b/web/src/components/admin/AdminVolunteerDuty.test.tsx @@ -21,7 +21,8 @@ import { } from "@/lib/admin/volunteer-roster-store"; import { AdminVolunteerDuty } from "./AdminVolunteerDuty"; -const mockGateway = createMockVolunteerGateway(); +// 지난 기록이 오늘 기준 날짜로 만들어지므로 날짜를 고정한 뒤(beforeEach) 매 테스트마다 새로 만든다. +let mockGateway = createMockVolunteerGateway(); /** 서버 대신 목업 게이트웨이를 쓰는 화면. */ function render(ui: ReactElement) { @@ -40,6 +41,7 @@ beforeEach(() => { // 2026-10-01 12:00 KST. 타이머는 그대로 두고 날짜만 고정한다. vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-10-01T03:00:00Z")); + mockGateway = createMockVolunteerGateway(); setRoster(MOCK_VOLUNTEER_ROSTER); }); @@ -244,7 +246,7 @@ describe("AdminVolunteerDuty", () => { ); }); - it("미이행을 저장하면 지정을 제외하고 횟수를 더하며 목록에서 빠진다", async () => { + it("미이행을 저장하면 지정이 미이행이 되고 횟수가 더해지며 목록에서 빠진다", async () => { render(); expect(countOf("2405")).toBe(3); @@ -258,16 +260,14 @@ describe("AdminVolunteerDuty", () => { expect(countOf("2405")).toBe(4); expect( getRoster().find((student) => student.studentId === "2405")?.duty, - ).toBe("none"); + ).toBe("missed"); }); - it("미이행 저장에서 횟수 변경만 실패하면 제외는 됐다고 알리고 횟수는 그대로다", async () => { - const saveCount = vi.fn().mockRejectedValue(new Error("network")); + it("미이행 저장이 실패하면 오류 문구를 보여 주고 목록과 횟수는 그대로다", async () => { + const markMissed = vi.fn().mockRejectedValue(new Error("network")); renderPlain( - - - - + + , ); @@ -275,103 +275,32 @@ describe("AdminVolunteerDuty", () => { fireEvent.click(screen.getByRole("button", { name: "저장" })); expect(await screen.findByRole("alert")).toHaveTextContent( - "당일 봉사자에서는 제외했지만 봉사 횟수 변경에 실패했습니다. 학생 관리에서 조정해 주세요.", + "처리에 실패했습니다. 다시 시도해 주세요.", ); + expect(markMissed).toHaveBeenCalled(); + expect(screen.getAllByText("김도현").length).toBeGreaterThan(0); + expect(screen.getByRole("dialog", { name: "김도현" })).toBeInTheDocument(); expect(countOf("2405")).toBe(3); - expect(screen.queryByText("김도현")).not.toBeInTheDocument(); - }); - - it("미이행으로 횟수를 추가하면 달력의 오늘 미이행에 횟수 처리됨이 보인다", async () => { - render(); - - fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); - fireEvent.click(screen.getByRole("button", { name: "저장" })); - await screen.findByRole("status"); - - fireEvent.click( - screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), - ); - const calendar = screen.getByRole("dialog", { name: "날짜 선택" }); - expect(within(calendar).getByText("오늘 미이행")).toBeInTheDocument(); - expect(within(calendar).getByText("김도현")).toBeInTheDocument(); - expect(within(calendar).getByText("횟수 처리됨")).toBeInTheDocument(); - }); - - it("오늘 미이행을 처리하면 달력의 오늘 칸에 미이행 점(aria-label)이 생긴다", async () => { - render(); - - fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); - fireEvent.click(screen.getByRole("button", { name: "저장" })); - await screen.findByRole("status"); - - fireEvent.click( - screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), - ); - expect( - screen.getByRole("button", { name: "2026년 10월 1일, 미이행 1명" }), - ).toBeInTheDocument(); }); - it("지난 날짜 기록이 빨리 오면 스켈레톤 없이 바로 보여 준다", async () => { - render(); - - fireEvent.click( - screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), - ); - fireEvent.click(screen.getByRole("button", { name: "이전 달" })); - fireEvent.click( - await screen.findByRole("button", { - name: "2026년 9월 30일, 미이행 2명", - }), - ); - - expect( - await screen.findByText("09/30 봉사자 기록 · 3명"), - ).toBeInTheDocument(); - expect(screen.queryByText("불러오는 중…")).not.toBeInTheDocument(); - }); - - it("횟수 처리됨을 누르면 추가된 횟수와 사유만 읽기 전용으로 보인다", async () => { - render(); - - fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); - fireEvent.change(screen.getByPlaceholderText("사유를 입력해 주세요"), { - target: { value: "청소 시간에 자리를 비움" }, - }); - fireEvent.click(screen.getByRole("button", { name: "저장" })); - await screen.findByRole("status"); - - fireEvent.click( - screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), + it("서버가 횟수를 이미 처리했다고 막으면 안내하고 목록을 다시 받는다", async () => { + const markMissed = vi + .fn() + .mockRejectedValue(new VolunteerApiError(409, "DUTY_ALREADY_ADJUSTED")); + const list = vi.fn().mockResolvedValue(MOCK_VOLUNTEER_ROSTER); + renderPlain( + + + , ); - const calendar = screen.getByRole("dialog", { name: "날짜 선택" }); - fireEvent.click(within(calendar).getByRole("button", { name: /김도현/ })); - - const detail = await screen.findByRole("dialog", { name: "김도현" }); - expect(detail).toHaveTextContent("+1회"); - expect(detail).toHaveTextContent("청소 시간에 자리를 비움"); - expect( - within(detail).queryByRole("button", { name: "봉사 횟수 늘리기" }), - ).not.toBeInTheDocument(); - expect( - within(detail).queryByRole("button", { name: "저장" }), - ).not.toBeInTheDocument(); - }); - - it("미이행으로 처리하되 횟수를 바꾸지 않으면 달력에는 미이행으로 남는다", async () => { - render(); fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); - fireEvent.click(screen.getByRole("button", { name: "봉사 횟수 줄이기" })); fireEvent.click(screen.getByRole("button", { name: "저장" })); - await screen.findByRole("status"); - fireEvent.click( - screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), + expect(await screen.findByRole("status")).toHaveTextContent( + "이미 횟수를 처리한 미이행입니다. 학생 관리에서 조정해 주세요.", ); - const calendar = screen.getByRole("dialog", { name: "날짜 선택" }); - expect(within(calendar).getByText("미이행")).toBeInTheDocument(); - expect(within(calendar).queryByText("횟수 처리됨")).not.toBeInTheDocument(); + await waitFor(() => expect(list).toHaveBeenCalledTimes(1)); }); it("달력 버튼에는 오늘 운영일의 연월일이 보인다", () => { @@ -407,7 +336,7 @@ describe("AdminVolunteerDuty", () => { expect(adjust).toHaveTextContent("1회"); }); - it("서버가 지난 기록을 주지 않으면 지난 날짜를 서버 지원 전으로 안내한다", () => { + it("지난 기록을 받을 수 없는 연결이면 지난 날짜를 안내 문구로 보여 준다", () => { const { history: _history, ...withoutHistory } = mockGateway; void _history; renderPlain( @@ -424,7 +353,7 @@ describe("AdminVolunteerDuty", () => { expect( screen.getAllByText( - "지난 날짜의 봉사자 기록은 서버 지원이 준비되면 볼 수 있어요.", + "지난 날짜의 봉사자 기록을 불러올 수 없는 연결입니다.", ).length, ).toBeGreaterThan(0); }); diff --git a/web/src/components/admin/AdminVolunteerDuty.tsx b/web/src/components/admin/AdminVolunteerDuty.tsx index 267d11c..e26cf3d 100644 --- a/web/src/components/admin/AdminVolunteerDuty.tsx +++ b/web/src/components/admin/AdminVolunteerDuty.tsx @@ -14,7 +14,6 @@ import { MissedAdjustmentDialog } from "@/components/admin/MissedAdjustmentDialo import { StudentDetailDialog } from "@/components/admin/student-management/StudentDetailDialog"; import { VolunteerDutyRow } from "@/components/admin/VolunteerDutyRow"; import { operatingDayKey, operatingDayLabel } from "@/lib/admin/operating-day"; -import { useStudentManagementGateway } from "@/lib/admin/student-management-gateway"; import { failureToast, listFailureToast, @@ -32,17 +31,15 @@ import type { /** 서버가 이유를 알려 주지 않은 실패(Figma 06 state messages). */ const FAILURE_MESSAGE = "처리에 실패했습니다. 다시 시도해 주세요."; -/** 미이행 처리 문구. 서버에 미이행 상태가 없어 지정 제외 + 횟수 추가로 처리한다(#334). */ +/** 미이행 처리 문구. 서버가 지정을 미이행으로 바꾸고 횟수·사유를 한 번에 저장한다(#336). */ const MISSED_DEFAULT_REASON = "당일 봉사 미이행"; const MISSED_SUCCESS_MESSAGE = "미이행으로 처리하고 봉사 횟수를 변경했습니다."; const MISSED_NO_CHANGE_MESSAGE = "미이행으로 처리했습니다."; -const MISSED_COUNT_FAILURE_MESSAGE = - "당일 봉사자에서는 제외했지만 봉사 횟수 변경에 실패했습니다. 학생 관리에서 조정해 주세요."; const COUNT_SUCCESS_MESSAGE = "봉사 횟수를 변경했습니다."; const COUNT_FAILURE_MESSAGE = "봉사 횟수 변경에 실패했습니다. 다시 시도해 주세요."; const HISTORY_UNSUPPORTED_MESSAGE = - "지난 날짜의 봉사자 기록은 서버 지원이 준비되면 볼 수 있어요."; + "지난 날짜의 봉사자 기록을 불러올 수 없는 연결입니다."; const RECORD_CHIP: Record = { @@ -54,8 +51,8 @@ const RECORD_CHIP: Record = label: "미이행", style: "bg-admin-danger-bg text-admin-danger-text", }, - cancelled: { - label: "제외", + assigned: { + label: "지정됨", style: "bg-admin-absence-bg text-admin-textMuted", }, }; @@ -91,7 +88,6 @@ export function AdminVolunteerDuty({ const { roster, status, rateLimited, updateStudent, reload } = useVolunteerRoster(); const gateway = useVolunteerGateway(); - const studentGateway = useStudentManagementGateway(); const singleFlight = useSingleFlight(); const { showToast } = useToast(); const today = designatedToday(roster); @@ -103,13 +99,6 @@ export function AdminVolunteerDuty({ const [historyAttempt, setHistoryAttempt] = useState(0); // 기록이 `SKELETON_DELAY_MS`를 넘겨도 오지 않아 스켈레톤을 보여야 하는 날. const [slowKey, setSlowKey] = useState(null); - // 이 화면에서 처리한 미이행(서버에 날짜별 기록 API가 없어 화면이 들고 있다, #334). - const [sessionMissed, setSessionMissed] = useState< - Record - >({}); - const [handled, setHandled] = useState< - Record - >({}); const [viewRecord, setViewRecord] = useState(null); const [dialog, setDialog] = useState<{ student: RosterStudent; @@ -144,68 +133,14 @@ export function AdminVolunteerDuty({ // 고른 날의 기록이 아직 오지 않았으면 null(받는 중)이다. const records: DayRecords | null = dayRecords && dayRecords.key === selectedKey ? dayRecords : null; - // 서버가 준 기록에 이 화면에서 처리한 미이행을 합치고, 횟수를 추가한 학생은 처리됨으로 표시한다. - const dayItems: DutyRecord[] | null = (() => { - const supported = history !== undefined || selectedKey === todayKey; - if (!supported || (history && records === null)) return null; - const server = records?.status === "ready" ? records.items : []; - const local = sessionMissed[selectedKey] ?? []; - const merged = [ - ...server.filter( - (item) => !local.some((mine) => mine.studentId === item.studentId), - ), - ...local, - ]; - return merged.map((item) => { - const done = handled[`${selectedKey}:${item.studentId}`]; - if (item.status !== "missed") return item; - if (done) { - return { - ...item, - adjusted: true, - addedCount: done.added, - reason: done.reason, - }; - } - return item; - }); - })(); - const todayMissedMarks = (sessionMissed[todayKey] ?? []).length - ? { [todayKey]: (sessionMissed[todayKey] ?? []).length } - : undefined; + // 서버 기록 그대로다. 오늘 처리한 미이행도 서버가 `MISSED`로 돌려준다. + const dayItems: DutyRecord[] | null = + history && records?.status === "ready" ? records.items : null; const missedRecords = dayItems ? dayItems.filter((item) => item.status === "missed") : null; - - function markHandled( - dateKey: string, - studentId: string, - detail: { added: number; reason: string }, - ) { - setHandled((current) => ({ - ...current, - [`${dateKey}:${studentId}`]: detail, - })); - } - - function rememberMissed(student: RosterStudent, adjusted: boolean) { - const record: DutyRecord = { - studentId: student.studentId, - name: student.name, - roomNumber: student.roomNumber, - status: "missed", - adjusted, - }; - setSessionMissed((current) => ({ - ...current, - [todayKey]: [ - ...(current[todayKey] ?? []).filter( - (item) => item.studentId !== student.studentId, - ), - record, - ], - })); - } + /** 처리한 뒤 오늘·고른 날의 기록과 달력 요약을 다시 받는다. */ + const refreshRecords = () => setHistoryAttempt((count) => count + 1); useEffect(() => { if (status === "error") { @@ -248,51 +183,35 @@ export function AdminVolunteerDuty({ if (student) setDialog({ student, kind: "adjust" }); } - /** 지정을 제외한 뒤 횟수와 사유를 저장한다. 이 순서라야 중간에 실패해도 횟수가 두 번 더해지지 않는다. */ + /** 오늘 지정을 미이행으로 바꾸며 횟수와 사유를 서버에 한 번에 저장한다(`POST /duty/missed`). */ function handleMissed(change: { count: number; reason: string }) { if (!dialog) return; const { student } = dialog; return singleFlight(student.id, async () => { - let released: RosterStudent; try { - released = await gateway.cancelDuty(student.id); + const updated = await gateway.markMissed( + student.id, + change.count - student.count, + change.reason, + ); + updateStudent(updated); + setDialog(null); + refreshRecords(); + showToast({ + variant: "success", + message: + change.count === student.count + ? MISSED_NO_CHANGE_MESSAGE + : MISSED_SUCCESS_MESSAGE, + }); } catch (error) { const failure = failureToast(error, FAILURE_MESSAGE, reload); if (failure) showToast(failure); - return; - } - updateStudent(released); - setDialog(null); - rememberMissed(student, false); - if (change.count === student.count) { - showToast({ variant: "success", message: MISSED_NO_CHANGE_MESSAGE }); - return; - } - try { - updateStudent(await studentGateway.saveCount(released, change)); - markHandled(todayKey, student.studentId, { - added: change.count - student.count, - reason: change.reason, - }); - showToast({ variant: "success", message: MISSED_SUCCESS_MESSAGE }); - } catch (error) { - const failure = failureToast( - error, - MISSED_COUNT_FAILURE_MESSAGE, - reload, - ); - if (failure) { - showToast( - failure.variant === "error" - ? { variant: "error", message: MISSED_COUNT_FAILURE_MESSAGE } - : failure, - ); - } } }); } - /** 지난 미이행 학생의 횟수·사유만 고친다(학생 관리와 같은 저장). */ + /** 지난 날짜의 미이행 학생에게 횟수·사유를 저장한다(`PATCH /duties/{date}/{id}/missed`). */ function handleAdjust(change: { count: number; reason: string }) { if (!dialog) return; const { student } = dialog; @@ -302,12 +221,18 @@ export function AdminVolunteerDuty({ } return singleFlight(student.id, async () => { try { - updateStudent(await studentGateway.saveCount(student, change)); - markHandled(selectedKey, student.studentId, { - added: change.count - student.count, - reason: change.reason, + const record = await gateway.adjustMissed( + selectedKey, + student.id, + change.count - student.count, + change.reason, + ); + updateStudent({ + ...student, + count: Math.max(0, student.count + (record.addedCount ?? 0)), }); setDialog(null); + refreshRecords(); showToast({ variant: "success", message: COUNT_SUCCESS_MESSAGE }); } catch (error) { const failure = failureToast(error, COUNT_FAILURE_MESSAGE, reload); @@ -351,7 +276,6 @@ export function AdminVolunteerDuty({ selectedKey={selectedKey} onSelect={setSelectedKey} loadMonth={history?.month} - extraMarks={todayMissedMarks} missed={missedRecords} onPickMissed={openAdjust} /> diff --git a/web/src/components/admin/DutyCalendar.tsx b/web/src/components/admin/DutyCalendar.tsx index baa82e5..86f14cb 100644 --- a/web/src/components/admin/DutyCalendar.tsx +++ b/web/src/components/admin/DutyCalendar.tsx @@ -16,8 +16,6 @@ type DutyCalendarProps = { missed: DutyRecord[] | null; /** 미이행 학생을 눌렀을 때. 호출한 쪽이 조정 다이얼로그를 연다. */ onPickMissed: (record: DutyRecord) => void; - /** 서버 요약에 아직 없는, 이 화면에서 처리한 날짜별 미이행 인원(오늘 처리분). 서버 점과 합쳐 보인다. */ - extraMarks?: Record; }; const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; @@ -43,28 +41,23 @@ export function DutyCalendar({ loadMonth, missed, onPickMissed, - extraMarks, }: DutyCalendarProps) { const today = parseKey(todayKey); const selected = parseKey(selectedKey); const [open, setOpen] = useState(false); const [view, setView] = useState({ y: selected.y, m: selected.m }); const [picking, setPicking] = useState(false); - const [serverMarks, setServerMarks] = useState>({}); - const marks: Record = { ...serverMarks }; - for (const [key, count] of Object.entries(extraMarks ?? {})) { - marks[key] = Math.max(marks[key] ?? 0, count); - } + const [marks, setMarks] = useState>({}); useEffect(() => { if (!open || !loadMonth) return; let cancelled = false; loadMonth(view.y, view.m) .then((next) => { - if (!cancelled) setServerMarks(next); + if (!cancelled) setMarks(next); }) .catch(() => { - if (!cancelled) setServerMarks({}); + if (!cancelled) setMarks({}); }); return () => { cancelled = true; diff --git a/web/src/components/admin/VolunteerRosterRow.tsx b/web/src/components/admin/VolunteerRosterRow.tsx index bdebe68..2dffc50 100644 --- a/web/src/components/admin/VolunteerRosterRow.tsx +++ b/web/src/components/admin/VolunteerRosterRow.tsx @@ -69,7 +69,7 @@ export function VolunteerRosterRow({ type="button" aria-label={ designated - ? `${student.name} ${student.duty === "completed" ? "봉사 완료" : "당일 봉사자로 지정됨"}` + ? `${student.name} ${student.duty === "completed" ? "봉사 완료" : student.duty === "missed" ? "봉사 미이행" : "당일 봉사자로 지정됨"}` : `${student.name} 당일 봉사자로 지정` } disabled={noCount} @@ -86,7 +86,9 @@ export function VolunteerRosterRow({ ? "봉사자 지정" : student.duty === "designated" ? "지정됨" - : "완료됨"} + : student.duty === "missed" + ? "미이행" + : "완료됨"} diff --git a/web/src/lib/admin/volunteer-action.ts b/web/src/lib/admin/volunteer-action.ts index 040628e..8ff1c3d 100644 --- a/web/src/lib/admin/volunteer-action.ts +++ b/web/src/lib/admin/volunteer-action.ts @@ -12,6 +12,9 @@ const REFUSAL_MESSAGE: Record = { ALREADY_ON_DUTY: "이미 당일 봉사자로 지정된 학생입니다.", NO_VOLUNTEER_LEFT: "봉사가 없습니다.", DUTY_ALREADY_COMPLETED: "이미 봉사를 완료했습니다.", + DUTY_ALREADY_ADJUSTED: + "이미 횟수를 처리한 미이행입니다. 학생 관리에서 조정해 주세요.", + DUTY_NOT_FOUND: "그날의 당일 봉사자 지정을 찾지 못했습니다.", }; /** diff --git a/web/src/lib/admin/volunteer-api.test.ts b/web/src/lib/admin/volunteer-api.test.ts index 57cf6f8..02b4d7f 100644 --- a/web/src/lib/admin/volunteer-api.test.ts +++ b/web/src/lib/admin/volunteer-api.test.ts @@ -2,13 +2,17 @@ import { RateLimitedError } from "@/lib/rate-limit"; import { AdminUnauthorizedError } from "./qr-api"; import { VolunteerApiError, + adjustMissedDuty, adjustVolunteerCount, adjustVolunteerCountBy, cancelVolunteerDuty, completeVolunteerDuty, designateVolunteer, + fetchDutyRecords, + fetchMissedSummary, fetchVolunteerAdjustments, fetchVolunteers, + markVolunteerMissed, toRosterStudent, } from "./volunteer-api"; @@ -287,3 +291,122 @@ describe("429 응답", () => { expect((error as RateLimitedError).retryAfterMs).toBe(7000); }); }); + +describe("미이행·날짜별 기록 API", () => { + const RECORD = { + studentId: 3203, + studentNumber: 3203, + name: "류하준", + dormitoryRoom: 302, + status: "MISSED" as const, + adjusted: true, + addedCount: 1, + reason: "당일 봉사 미이행", + }; + + it("todayDuty가 MISSED면 지정 상태를 미이행으로 바꾼다", () => { + expect(toRosterStudent({ ...BODY, todayDuty: "MISSED" }).duty).toBe( + "missed", + ); + }); + + it("오늘 지정 미이행은 POST /duty/missed에 delta와 사유를 한 번에 보낸다", async () => { + const fetchMock = mockFetch(200, { ...BODY, todayDuty: "MISSED" }); + + const student = await markVolunteerMissed( + 17, + 1, + " 당일 봉사 미이행 ", + "k1", + ); + + expect(student.duty).toBe("missed"); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/v1/volunteer/17/duty/missed"); + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ "Idempotency-Key": "k1" }); + expect(JSON.parse(init.body as string)).toEqual({ + delta: 1, + reason: "당일 봉사 미이행", + }); + }); + + it("사유가 비어 있으면 delta만 보낸다", async () => { + const fetchMock = mockFetch(200, BODY); + + await markVolunteerMissed(17, 0, " ", "k2"); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(init.body as string)).toEqual({ delta: 0 }); + }); + + it("지난 날짜 미이행 조정은 PATCH /duties/{date}/{id}/missed이고 기록을 돌려받는다", async () => { + const fetchMock = mockFetch(200, RECORD); + + const record = await adjustMissedDuty("2026-10-14", 3203, 1, "사유", "k3"); + + expect(record).toEqual({ + id: 3203, + studentId: "3203", + name: "류하준", + roomNumber: 302, + status: "missed", + adjusted: true, + addedCount: 1, + reason: "당일 봉사 미이행", + }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/v1/volunteer/duties/2026-10-14/3203/missed"); + expect(init.method).toBe("PATCH"); + }); + + it("날짜별 기록은 GET /duties?date=를 호실순 그대로 화면 모델로 바꾼다", async () => { + const fetchMock = mockFetch(200, [ + RECORD, + { + ...RECORD, + studentId: 3115, + studentNumber: 3115, + status: "ASSIGNED", + adjusted: false, + addedCount: null, + reason: null, + }, + ]); + + const records = await fetchDutyRecords("2026-10-14"); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/v1/volunteer/duties?date=2026-10-14", + ); + expect(records.map((record) => record.status)).toEqual([ + "missed", + "assigned", + ]); + expect(records[1]).toMatchObject({ + adjusted: false, + addedCount: undefined, + reason: undefined, + }); + }); + + it("월 요약은 GET /duties/summary?month=YYYY-MM이고 날짜별 인원을 돌려준다", async () => { + const fetchMock = mockFetch(200, { "2026-10-13": 1, "2026-10-14": 2 }); + + const summary = await fetchMissedSummary(2026, 10); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/v1/volunteer/duties/summary?month=2026-10", + ); + expect(summary).toEqual({ "2026-10-13": 1, "2026-10-14": 2 }); + }); + + it("횟수를 이미 처리한 미이행은 409 DUTY_ALREADY_ADJUSTED 오류로 던진다", async () => { + mockFetch(409, { code: "DUTY_ALREADY_ADJUSTED" }); + + await expect(markVolunteerMissed(17, 1, "", "k4")).rejects.toMatchObject({ + status: 409, + code: "DUTY_ALREADY_ADJUSTED", + }); + }); +}); diff --git a/web/src/lib/admin/volunteer-api.ts b/web/src/lib/admin/volunteer-api.ts index b1f6f2d..9691f7c 100644 --- a/web/src/lib/admin/volunteer-api.ts +++ b/web/src/lib/admin/volunteer-api.ts @@ -1,6 +1,11 @@ import { AdminUnauthorizedError } from "@/lib/admin/qr-api"; import { throwIfRateLimited } from "@/lib/rate-limit"; -import type { DutyStatus, RosterStudent } from "@/lib/admin/volunteer-types"; +import type { + DutyRecord, + DutyRecordStatus, + DutyStatus, + RosterStudent, +} from "@/lib/admin/volunteer-types"; /** 서버 봉사 API 응답 한 건(`VolunteerResponse`). 시각은 ISO-8601 문자열이다. */ export type VolunteerApiResponse = { @@ -11,7 +16,7 @@ export type VolunteerApiResponse = { dormitoryRoom: number | null; volunteerCount: number; lastActivityAt?: string | null; - todayDuty?: "ASSIGNED" | "COMPLETED" | null; + todayDuty?: "ASSIGNED" | "COMPLETED" | "MISSED" | null; }; /** 서버가 준 오류. `code`는 서버 ErrorCode 이름(예: `NO_VOLUNTEER_LEFT`)이고 본문이 없으면 null이다. */ @@ -24,11 +29,50 @@ export class VolunteerApiError extends Error { } } -const DUTY_FROM_API: Record<"ASSIGNED" | "COMPLETED", DutyStatus> = { +const DUTY_FROM_API: Record<"ASSIGNED" | "COMPLETED" | "MISSED", DutyStatus> = { ASSIGNED: "designated", COMPLETED: "completed", + MISSED: "missed", }; +const RECORD_FROM_API: Record< + "ASSIGNED" | "COMPLETED" | "MISSED", + DutyRecordStatus +> = { + ASSIGNED: "assigned", + COMPLETED: "completed", + MISSED: "missed", +}; + +/** 서버 날짜별 당일 봉사자 기록 한 건(`VolunteerDutyRecordResponse`). */ +export type VolunteerDutyRecordApiResponse = { + studentId: number; + studentNumber: number; + name: string; + dormitoryRoom: number | null; + status: "ASSIGNED" | "COMPLETED" | "MISSED"; + /** 미이행으로 횟수를 처리했는지. */ + adjusted: boolean; + /** 미이행으로 실제 바꾼 횟수. 처리 전이면 null이고 음수일 수 있다. */ + addedCount?: number | null; + reason?: string | null; +}; + +export function toDutyRecord(body: VolunteerDutyRecordApiResponse): DutyRecord { + const status = RECORD_FROM_API[body.status]; + if (!status) throw new Error("dutyRecord: unexpected status"); + return { + id: body.studentId, + studentId: String(body.studentNumber), + name: body.name, + roomNumber: body.dormitoryRoom ?? null, + status, + adjusted: body.adjusted === true, + addedCount: body.addedCount ?? undefined, + reason: body.reason?.trim() || undefined, + }; +} + const MONTH_DAY = new Intl.DateTimeFormat("ko-KR", { timeZone: "Asia/Seoul", month: "2-digit", @@ -67,7 +111,7 @@ type VolunteerAdjustmentApiResponse = { /** 실제로 바뀐 횟수. 양수는 추가, 음수는 차감(당일 봉사 완료 포함). */ delta: number; reason?: string | null; - kind?: "ADMIN" | "DUTY_COMPLETION" | null; + kind?: "ADMIN" | "DUTY_COMPLETION" | "DUTY_MISSED" | null; }; /** 봉사 이력 한 줄. 날짜는 `MM/DD`, 변화는 실제로 바뀐 횟수(1이 아닐 수 있다)다. */ @@ -83,6 +127,7 @@ function adjustmentTitle(item: VolunteerAdjustmentApiResponse): string { const reason = item.reason?.trim(); if (reason) return reason; if (item.kind === "DUTY_COMPLETION") return "당일 봉사 완료"; + if (item.kind === "DUTY_MISSED") return "당일 봉사 미이행"; return item.delta > 0 ? "봉사 횟수 추가" : "봉사 횟수 감면"; } @@ -167,6 +212,88 @@ export function completeVolunteerDuty(id: number): Promise { return requestStudent(`/${id}/duty/complete`, { method: "POST" }); } +/** 미이행 처리에서 서버가 받는 횟수 변화의 크기(`VolunteerMissedRequest.delta`는 -99~99). */ +export const MAX_MISSED_DELTA = 99; + +/** + * 오늘 지정을 미이행으로 바꾸고 횟수 변화(`delta`, -99~99, 0이면 횟수는 그대로)와 사유를 한 번에 저장한다 + * (`POST /api/v1/volunteer/{id}/duty/missed`). 지정을 삭제하지 않고 `MISSED`로 남긴다. 같은 키로 재시도하면 서버가 + * 한 번만 반영한다. 지정이 없으면 404 `NOT_ON_DUTY`, 완료했으면 409 `DUTY_ALREADY_COMPLETED`, 횟수를 이미 처리했으면 + * 409 `DUTY_ALREADY_ADJUSTED`다. 학생 알림은 만들거나 지우지 않는다. + */ +export function markVolunteerMissed( + id: number, + delta: number, + reason: string, + idempotencyKey: string = crypto.randomUUID(), +): Promise { + return requestStudent(`/${id}/duty/missed`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify(missedBody(delta, reason)), + }); +} + +/** + * 지난 날짜(`date`, `YYYY-MM-DD`)의 지정을 미이행으로 바꾸고 횟수·사유를 저장한다 + * (`PATCH /api/v1/volunteer/duties/{date}/{id}/missed`). 지정됨이거나 횟수를 처리하지 않은 미이행만 된다. + * 지정이 없으면 404 `DUTY_NOT_FOUND`, 횟수를 이미 처리했으면 409 `DUTY_ALREADY_ADJUSTED`다. + */ +export async function adjustMissedDuty( + date: string, + id: number, + delta: number, + reason: string, + idempotencyKey: string = crypto.randomUUID(), +): Promise { + const res = await request(`/duties/${date}/${id}/missed`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify(missedBody(delta, reason)), + }); + return toDutyRecord((await res.json()) as VolunteerDutyRecordApiResponse); +} + +function missedBody(delta: number, reason: string) { + const trimmed = reason.trim().slice(0, MAX_REASON_LENGTH); + return trimmed ? { delta, reason: trimmed } : { delta }; +} + +/** 한 운영일(`YYYY-MM-DD`)의 당일 봉사자 기록을 호실순으로 받는다(`GET /api/v1/volunteer/duties?date=`, 관리자 전용). */ +export async function fetchDutyRecords(date: string): Promise { + const res = await request(`/duties?date=${encodeURIComponent(date)}`); + const body = (await res.json()) as VolunteerDutyRecordApiResponse[]; + if (!Array.isArray(body)) throw new Error("dutyRecords: unexpected response"); + return body.map(toDutyRecord); +} + +/** + * 그 달에서 미이행이 있는 운영일과 인원을 받는다(`GET /api/v1/volunteer/duties/summary?month=YYYY-MM`). + * 횟수를 처리한 미이행과 처리하지 않은 미이행을 함께 센다. + */ +export async function fetchMissedSummary( + year: number, + month: number, +): Promise> { + const key = `${year}-${String(month).padStart(2, "0")}`; + const res = await request(`/duties/summary?month=${key}`); + const body = (await res.json()) as Record; + const summary: Record = {}; + for (const [date, count] of Object.entries(body ?? {})) { + if (typeof count !== "number" || !Number.isFinite(count)) { + throw new Error("missedSummary: unexpected response"); + } + summary[date] = count; + } + return summary; +} + /** * 한 학생의 봉사 횟수 조정 이력을 최신순으로 받는다(`GET /api/v1/volunteer/{id}/adjustments`, 관리자 전용, 기본 50건). * 당일 봉사 완료로 줄어든 -1도 들어 있다. diff --git a/web/src/lib/admin/volunteer-gateway.tsx b/web/src/lib/admin/volunteer-gateway.tsx index 74a7512..49da78e 100644 --- a/web/src/lib/admin/volunteer-gateway.tsx +++ b/web/src/lib/admin/volunteer-gateway.tsx @@ -2,11 +2,15 @@ import { createContext, useContext } from "react"; import { + adjustMissedDuty, adjustVolunteerCount, cancelVolunteerDuty, completeVolunteerDuty, designateVolunteer, + fetchDutyRecords, + fetchMissedSummary, fetchVolunteers, + markVolunteerMissed, } from "@/lib/admin/volunteer-api"; import type { DutyRecord, RosterStudent } from "@/lib/admin/volunteer-types"; @@ -23,8 +27,24 @@ export type VolunteerGateway = { cancelDuty: (id: number) => Promise; completeDuty: (id: number) => Promise; /** - * 지난 날짜의 당일 봉사자 기록(달력). 서버에 날짜별 기록 조회 API가 없어 실제 게이트웨이는 비워 두고, 확인용 - * 페이지와 테스트의 목업만 채운다(#334). 비어 있으면 달력은 지난 날짜를 "서버 지원 전"으로 안내한다. + * 오늘 지정을 미이행으로 바꾸고 횟수 변화(`delta`, 0이면 그대로)와 사유를 한 번에 저장한다. 지정은 삭제하지 않고 + * `미이행`으로 남는다(서버 `POST /duty/missed`, #336). + */ + markMissed: ( + id: number, + delta: number, + reason: string, + ) => Promise; + /** 지난 날짜(`YYYY-MM-DD`)의 지정을 미이행으로 처리하거나 횟수·사유를 저장한다(서버 `PATCH /duties/{date}/{id}/missed`). */ + adjustMissed: ( + date: string, + id: number, + delta: number, + reason: string, + ) => Promise; + /** + * 날짜별 당일 봉사자 기록(달력). 서버 `GET /duties`·`/duties/summary`에 연결한다. 이 값이 없는 게이트웨이(일부 + * 테스트)에서는 달력이 지난 날짜를 "서버 지원 전"으로 안내한다. */ history?: { /** 그 달에서 미이행이 있는 날(`YYYY-MM-DD`)과 그날의 미이행 인원. */ @@ -40,6 +60,9 @@ export const apiVolunteerGateway: VolunteerGateway = { designate: designateVolunteer, cancelDuty: cancelVolunteerDuty, completeDuty: completeVolunteerDuty, + markMissed: markVolunteerMissed, + adjustMissed: adjustMissedDuty, + history: { month: fetchMissedSummary, day: fetchDutyRecords }, }; const VolunteerGatewayContext = diff --git a/web/src/lib/admin/volunteer-mock-gateway.ts b/web/src/lib/admin/volunteer-mock-gateway.ts index b3696a6..436f509 100644 --- a/web/src/lib/admin/volunteer-mock-gateway.ts +++ b/web/src/lib/admin/volunteer-mock-gateway.ts @@ -7,6 +7,7 @@ import { cancelDuty, completeDuty, designate, + markMissed, type RosterChange, } from "@/lib/admin/volunteer-roster"; import { getRoster } from "@/lib/admin/volunteer-roster-store"; @@ -35,6 +36,7 @@ function mockHistory(): Record { return { [daysAgoKey(1)]: [ { + id: 3203, studentId: "3203", name: "류하준", roomNumber: 302, @@ -43,29 +45,49 @@ function mockHistory(): Record { addedCount: 1, reason: "당일 봉사 미이행", }, - { studentId: "3115", name: "서지안", roomNumber: 301, status: "missed" }, { + id: 3115, + studentId: "3115", + name: "서지안", + roomNumber: 301, + status: "missed", + adjusted: false, + }, + { + id: 2405, studentId: "2405", name: "김도현", roomNumber: 412, status: "completed", + adjusted: false, }, ], [daysAgoKey(2)]: [ - { studentId: "2408", name: "이지후", roomNumber: 413, status: "missed" }, { + id: 2408, + studentId: "2408", + name: "이지후", + roomNumber: 413, + status: "missed", + adjusted: false, + }, + { + id: 2417, studentId: "2417", name: "한유진", roomNumber: 413, status: "completed", + adjusted: false, }, ], [daysAgoKey(3)]: [ { + id: 2403, studentId: "2403", name: "윤채원", roomNumber: 414, status: "completed", + adjusted: false, }, ], }; @@ -93,18 +115,67 @@ export function createMockVolunteerGateway(): VolunteerGateway { throw new VolunteerApiError(status, code); } + // 서버가 날짜별로 들고 있는 기록. 화면에서 처리한 미이행도 여기에 쌓인다. + const records = mockHistory(); + const today = () => operatingDayKey(new Date()); + + function upsert(date: string, record: DutyRecord) { + const day = records[date] ?? []; + records[date] = [ + ...day.filter((item) => item.studentId !== record.studentId), + record, + ]; + } + return { history: { month: async (year, month) => { const prefix = `${year}-${String(month).padStart(2, "0")}-`; const marks: Record = {}; - for (const [date, records] of Object.entries(mockHistory())) { - const missed = records.filter((r) => r.status === "missed").length; + for (const [date, items] of Object.entries(records)) { + const missed = items.filter((r) => r.status === "missed").length; if (date.startsWith(prefix) && missed > 0) marks[date] = missed; } return marks; }, - day: async (date) => mockHistory()[date] ?? [], + day: async (date) => records[date] ?? [], + }, + markMissed: async (id, delta, reason) => { + const updated = await mutate(id, (students, studentId) => + markMissed(students, studentId, delta, operatingDayLabel(new Date())), + ); + const before = getRoster().find((student) => student.id === id); + upsert(today(), { + id: updated.id, + studentId: updated.studentId, + name: updated.name, + roomNumber: updated.roomNumber, + status: "missed", + adjusted: delta !== 0, + addedCount: + delta !== 0 ? updated.count - (before?.count ?? 0) : undefined, + reason: reason.trim() || undefined, + }); + return updated; + }, + adjustMissed: async (date, id, delta, reason) => { + const current = (records[date] ?? []).find((item) => item.id === id); + if (!current) throw new VolunteerApiError(404, "DUTY_NOT_FOUND"); + if (current.status === "completed") { + throw new VolunteerApiError(409, "DUTY_ALREADY_COMPLETED"); + } + if (current.adjusted) { + throw new VolunteerApiError(409, "DUTY_ALREADY_ADJUSTED"); + } + const next: DutyRecord = { + ...current, + status: "missed", + adjusted: true, + addedCount: delta, + reason: reason.trim() || undefined, + }; + upsert(date, next); + return next; }, list: async () => MOCK_VOLUNTEER_ROSTER, adjustCount: (id, delta) => diff --git a/web/src/lib/admin/volunteer-roster.ts b/web/src/lib/admin/volunteer-roster.ts index d162ed5..34ad635 100644 --- a/web/src/lib/admin/volunteer-roster.ts +++ b/web/src/lib/admin/volunteer-roster.ts @@ -167,7 +167,9 @@ export function designate( ): RosterChange { return update(students, studentId, (student) => { if (student.duty === "designated") return "already-designated"; - if (student.duty === "completed") return "already-completed"; + if (student.duty === "completed" || student.duty === "missed") { + return "already-completed"; + } if (student.count < 1) return "no-count"; return { ...student, duty: "designated" }; }); @@ -179,7 +181,9 @@ export function cancelDuty( studentId: string, ): RosterChange { return update(students, studentId, (student) => { - if (student.duty === "completed") return "already-completed"; + if (student.duty === "completed" || student.duty === "missed") { + return "already-completed"; + } if (student.duty === "none") return "not-designated"; return { ...student, duty: "none" }; }); @@ -192,7 +196,9 @@ export function completeDuty( today: string, ): RosterChange { return update(students, studentId, (student) => { - if (student.duty === "completed") return "already-completed"; + if (student.duty === "completed" || student.duty === "missed") { + return "already-completed"; + } if (student.duty === "none") return "not-designated"; return { ...student, @@ -203,6 +209,30 @@ export function completeDuty( }); } +/** + * 미이행으로 바꾼다. 지정을 `missed`로 남기고 횟수를 `delta`만큼 바꾼다(0 미만은 만들지 않는다, REQ-COM-006). + * 완료했거나 이미 미이행이면 바꾸지 않는다. + */ +export function markMissed( + students: RosterStudent[], + studentId: string, + delta: number, + today: string, +): RosterChange { + return update(students, studentId, (student) => { + if (student.duty === "completed" || student.duty === "missed") { + return "already-completed"; + } + if (student.duty === "none") return "not-designated"; + return { + ...student, + duty: "missed", + count: Math.max(0, student.count + delta), + lastActivityDate: delta === 0 ? student.lastActivityDate : today, + }; + }); +} + /** * 패드 06(Figma 318:376)은 최근 활동을 `9월 12일`로 쓴다. 명단 편집(07)의 `MM/DD`를 그 모양으로 바꾸고, * 활동이 없으면 `-`다(REQ-COM-002). diff --git a/web/src/lib/admin/volunteer-types.ts b/web/src/lib/admin/volunteer-types.ts index d3b4eb3..68f87ee 100644 --- a/web/src/lib/admin/volunteer-types.ts +++ b/web/src/lib/admin/volunteer-types.ts @@ -1,5 +1,6 @@ /** 오늘 지정 상태. 없음 → 지정됨 → 완료됨 순으로만 간다(REQ-COM-006). */ -export type DutyStatus = "none" | "designated" | "completed"; +/** 오늘 지정 상태. 없음 → 지정됨 → 완료됨 또는 미이행으로만 간다(REQ-COM-006). */ +export type DutyStatus = "none" | "designated" | "completed" | "missed"; export type RosterStudent = { /** 서버 봉사 API 경로에 쓰는 학생 ID(`studentId`). 화면에 보이는 학번과 다르다. */ @@ -17,10 +18,12 @@ export type RosterStudent = { }; /** 지난 날짜 당일 봉사자 기록의 결과. 미이행은 봉사 없이 사라져 횟수가 추가된 지정이다(#334). */ -export type DutyRecordStatus = "completed" | "missed" | "cancelled"; +export type DutyRecordStatus = "assigned" | "completed" | "missed"; /** 한 운영일의 당일 봉사자 기록 한 건. `studentId`는 학번이다. */ export type DutyRecord = { + /** 서버 봉사 API 경로에 쓰는 학생 ID(`RosterStudent.id`와 같다). */ + id: number; studentId: string; name: string; roomNumber: number | null;