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;