diff --git a/docs/decisions.md b/docs/decisions.md index 09f239e..08c2396 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -101,6 +101,7 @@ | DEC-094 | 사용자 결정 · 웹 · 2026-10-14 | (#325) 사용자가 학생 패드 폭(768~1279px)도 사이드바(96px 레일, DEC-086) 대신 핸드폰처럼 하단 탭(홈·마이)을 쓰라고 지시. 알림이 사이드바에 있어 패드에서 핸드폰 방식(홈 머리 종 → 알림 화면)으로 들어가야 하는데 화면이 커서 종이 작아 보이므로 패드 폭에서 종을 22px→32px로 키운다. QR 출석은 핸드폰과 같은 홈 QR 버튼 자리(DEC-092의 패드 36px 자리는 폐기)다. 뒤로가기·마이페이지 제목·로그아웃 버튼은 1280px 미만에서 보인다. 컴퓨터(1280px 이상) 사이드바와 본문 배치는 그대로다. 종 크기 32px은 Figma에 없어 작업자가 정했다. | 사용자 결정. 종 크기는 개발자 가정 | | 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-097 | 사용자 결정 · 웹 · 2026-10-15 | (#332) 호실 상세에서 `수정`을 누르면 상세가 사라지고 수정 다이얼로그가 새로 열리며 딤 페이드인과 패널 올라오기가 다시 실행돼 깜빡였다. 수정 다이얼로그는 호실 상세에서만 열리므로 딤·패널 등장 애니메이션을 빼서 한 다이얼로그의 내용만 바뀌는 것처럼 보이게 했다. 호실 상세를 처음 열 때의 애니메이션(DEC-080)은 그대로다. | 사용자 결정 | ## 하네스 선택 diff --git a/docs/plans/implementation.md b/docs/plans/implementation.md index 47c8512..4da34be 100644 --- a/docs/plans/implementation.md +++ b/docs/plans/implementation.md @@ -213,3 +213,4 @@ DataGSM OAuth 연동(DEC-001) 작업과 같은 시점에 화면까지 함께 구 | 2026-10-14 | 사용자가 관리자·학생 패드 폭 전환 전체 점검을 요청해 관리자 마이페이지 로그아웃을 패드에서도 보이게 하고 ACC-UI-004·005 문구를 맞췄다(#327, `feat/327-admin-pad-mypage-logout`, develop 기준, DEC-095). 다이얼로그와 하단 바 겹침은 별도 확인. | | 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·미이행 상태가 없어 지난 날짜 기록은 확인용 목업만 연결했다. | diff --git a/docs/spec/community.md b/docs/spec/community.md index 09eb07c..ff8c1d5 100644 --- a/docs/spec/community.md +++ b/docs/spec/community.md @@ -70,6 +70,10 @@ 지정되면 그 학생에게 봉사 알림을 만든다. 지정을 취소하면 그 알림을 지운다. 지정·취소는 봉사 횟수를 바꾸지 않는다. `봉사자 관리`(Figma 06)는 오늘 운영일의 당일 봉사자 목록이다. 헤더는 `MM/DD 당일 봉사자 N명`이고 `+ 명단 수정`으로 07에 이동한다. 각 행은 이름·`학번 · 호실`과 `완료`·`봉사 제외` 버튼이다. `완료`는 그 지정을 완료로 표시하고 봉사 횟수를 1 줄이며 한 번만 반영된다. `봉사 제외`는 지정 취소다. 완료한 지정은 제외할 수 없다. 학생은 지정·완료 API를 사용할 수 없다. +`미이행`은 지정됐지만 봉사를 하지 않고 사라진 학생을 처리한다. 누르면 학생 관리와 같은 다이얼로그(봉사 횟수 −/+, 사유)가 현재 횟수 + 1과 기본 사유 `당일 봉사 미이행`으로 열리고, 저장하면 지정을 제외한 뒤 횟수와 사유를 한 번에 저장한다(서버에 미이행 상태가 없어 지정 제외 + 횟수 조정 두 호출이며, 횟수가 두 번 더해지지 않도록 제외를 먼저 한다). 횟수 저장만 실패하면 `당일 봉사자에서는 제외했지만 봉사 횟수 변경에 실패했습니다. 학생 관리에서 조정해 주세요.`를 보인다. +`+ 명단 수정` 옆의 날짜 버튼(지금 보는 날 `YYYY.MM.DD`, 처음에는 오늘 운영일)을 누르면 달력이 열린다. 달 이동·연월 선택으로 바꿀 수 있고 오늘 이후는 고를 수 없다. 미이행이 있는 날은 빨간 점이며 날짜를 누르면 달력 아래에 그날 안 한 학생이 보이고, 학생을 누르면 횟수·사유 다이얼로그로 조정한다. 지난 날짜의 기록(완료·미이행·제외)은 서버에 날짜별 조회 API가 없어(`DutyStatus`는 `ASSIGNED`·`COMPLETED`뿐) 실제 게이트웨이가 비어 있고, 그때는 `지난 날짜의 봉사자 기록은 서버 지원이 준비되면 볼 수 있어요.`를 보인다(DEC-098). +달력의 미이행 학생은 횟수를 추가해 처리했는지로 나눠 보인다. 처리한 학생은 초록 `횟수 처리됨`이고 누르면 추가된 횟수와 사유만 읽기 전용으로 보이며(−/+·저장 없음), 처리하지 않은 학생은 빨간 `미이행`이고 누르면 횟수·사유 조정 다이얼로그가 열려 저장하면 `횟수 처리됨`으로 바뀐다. 달력은 오늘 처리분도 오늘 칸의 빨간 점과 `오늘 미이행` 목록으로 보인다. 서버가 처리 기록을 주기 전에는 이 화면에서 처리한 내용만 화면이 들고 있어 새로고침하면 표시가 사라진다(횟수 자체는 서버에 남는다). 달력 크기는 핸드폰 300px·패드 340px·컴퓨터 420px이고, 지난 기록이 300ms 안에 오면 스켈레톤 없이 바로 바꾼다. + 지정과 횟수는 일일 출석 초기화(08:00) 때 지우지 않는다. 하네스는 지정·완료 버튼을 명단 행에 함께 둔다고 적었지만 Figma는 지정(07)과 완료·제외(06)로 나눠 Figma를 따른다. | 결과 | 문구 | diff --git a/tests/acceptance/scenarios.json b/tests/acceptance/scenarios.json index f33c113..309ffa4 100644 --- a/tests/acceptance/scenarios.json +++ b/tests/acceptance/scenarios.json @@ -828,7 +828,10 @@ "0회 학생 지정은 `봉사가 없습니다.`, 이미 지정된 학생은 `이미 당일 봉사자로 지정된 학생입니다.`를 보여 준다", "봉사자 관리에 `MM/DD 당일 봉사자 N명`과 지정된 학생이 `완료`·`봉사 제외`와 함께 보인다", "`완료`는 횟수를 한 번만 1 줄이고 `봉사 제외`는 횟수를 바꾸지 않는다", - "오늘 지정이 없으면 `오늘 지정된 봉사자가 없습니다. 봉사자 명단에서 지정해 주세요.`를 보여 준다" + "오늘 지정이 없으면 `오늘 지정된 봉사자가 없습니다. 봉사자 명단에서 지정해 주세요.`를 보여 준다", + "미이행을 누르면 횟수 −/+와 사유 다이얼로그가 열려 저장하면 지정이 제외되고 횟수가 더해진다", + "달력에서 오늘 운영일 연월일이 처음 보이고 연월일을 바꿀 수 있으며, 미이행이 있는 날을 누르면 안 한 학생이 보인다", + "횟수를 추가해 처리한 미이행은 달력에서 횟수 처리됨으로 보이고 누르면 추가 횟수와 사유만 읽기 전용으로 보이며, 처리하지 않은 미이행은 미이행으로 보이고 눌러서 조정하면 횟수 처리됨으로 바뀐다" ], "status": "implemented", "implementation": [ diff --git a/web/src/components/admin/AdminVolunteerDuty.test.tsx b/web/src/components/admin/AdminVolunteerDuty.test.tsx index 4d8d231..cb479f1 100644 --- a/web/src/components/admin/AdminVolunteerDuty.test.tsx +++ b/web/src/components/admin/AdminVolunteerDuty.test.tsx @@ -5,10 +5,13 @@ import { render as renderPlain, screen, waitFor, + within, } from "@testing-library/react"; import type { ReactElement } from "react"; import { MOCK_VOLUNTEER_ROSTER } from "@/lib/admin/mock-volunteer-roster"; import { VolunteerApiError } from "@/lib/admin/volunteer-api"; +import { StudentManagementGatewayProvider } from "@/lib/admin/student-management-gateway"; +import { createMockStudentManagementGateway } from "@/lib/admin/student-management-mock-gateway"; import { VolunteerGatewayProvider } from "@/lib/admin/volunteer-gateway"; import { createMockVolunteerGateway } from "@/lib/admin/volunteer-mock-gateway"; import { @@ -24,7 +27,11 @@ const mockGateway = createMockVolunteerGateway(); function render(ui: ReactElement) { return renderPlain( - {ui} + + {ui} + , ); } @@ -224,6 +231,204 @@ describe("AdminVolunteerDuty", () => { expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); + it("미이행을 누르면 횟수·사유 다이얼로그가 현재 횟수 + 1과 기본 사유로 열린다", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveTextContent("김도현"); + expect(dialog).toHaveTextContent("4회"); + expect(screen.getByPlaceholderText("사유를 입력해 주세요")).toHaveValue( + "당일 봉사 미이행", + ); + }); + + it("미이행을 저장하면 지정을 제외하고 횟수를 더하며 목록에서 빠진다", async () => { + render(); + expect(countOf("2405")).toBe(3); + + fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); + fireEvent.click(screen.getByRole("button", { name: "저장" })); + + expect(await screen.findByRole("status")).toHaveTextContent( + "미이행으로 처리하고 봉사 횟수를 변경했습니다.", + ); + expect(screen.queryByText("김도현")).not.toBeInTheDocument(); + expect(countOf("2405")).toBe(4); + expect( + getRoster().find((student) => student.studentId === "2405")?.duty, + ).toBe("none"); + }); + + it("미이행 저장에서 횟수 변경만 실패하면 제외는 됐다고 알리고 횟수는 그대로다", async () => { + const saveCount = vi.fn().mockRejectedValue(new Error("network")); + renderPlain( + + + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" })); + fireEvent.click(screen.getByRole("button", { name: "저장" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "당일 봉사자에서는 제외했지만 봉사 횟수 변경에 실패했습니다. 학생 관리에서 조정해 주세요.", + ); + 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" }), + ); + 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" }), + ); + const calendar = screen.getByRole("dialog", { name: "날짜 선택" }); + expect(within(calendar).getByText("미이행")).toBeInTheDocument(); + expect(within(calendar).queryByText("횟수 처리됨")).not.toBeInTheDocument(); + }); + + it("달력 버튼에는 오늘 운영일의 연월일이 보인다", () => { + render(); + + expect( + screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), + ).toHaveTextContent("2026.10.01"); + }); + + it("달력에서 미이행이 있는 날을 누르면 그날 안 한 학생이 보이고, 누르면 횟수를 조정한다", async () => { + render(); + + fireEvent.click( + screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), + ); + // 목업 기록은 오늘(10월 1일) 기준 하루 전인 9월 30일에 미이행 2명이 있어 이전 달에서 점이 보인다. + fireEvent.click(screen.getByRole("button", { name: "이전 달" })); + fireEvent.click( + await screen.findByRole("button", { + name: "2026년 9월 30일, 미이행 2명", + }), + ); + + const calendar = screen.getByRole("dialog", { name: "날짜 선택" }); + expect( + await within(calendar).findByText("2026년 9월 30일 미이행"), + ).toBeInTheDocument(); + fireEvent.click(within(calendar).getByRole("button", { name: /류하준/ })); + + const adjust = await screen.findByRole("dialog", { name: "류하준" }); + expect(adjust).toHaveTextContent("302호"); + expect(adjust).toHaveTextContent("1회"); + }); + + it("서버가 지난 기록을 주지 않으면 지난 날짜를 서버 지원 전으로 안내한다", () => { + const { history: _history, ...withoutHistory } = mockGateway; + void _history; + renderPlain( + + + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }), + ); + fireEvent.click(screen.getByRole("button", { name: "이전 달" })); + fireEvent.click(screen.getByRole("button", { name: "2026년 9월 15일" })); + + expect( + screen.getAllByText( + "지난 날짜의 봉사자 기록은 서버 지원이 준비되면 볼 수 있어요.", + ).length, + ).toBeGreaterThan(0); + }); + it("08:00 전에는 아직 전날 운영일로 보여 준다", () => { // 2026-10-02 07:30 KST vi.setSystemTime(new Date("2026-10-01T22:30:00Z")); diff --git a/web/src/components/admin/AdminVolunteerDuty.tsx b/web/src/components/admin/AdminVolunteerDuty.tsx index 23a0365..267d11c 100644 --- a/web/src/components/admin/AdminVolunteerDuty.tsx +++ b/web/src/components/admin/AdminVolunteerDuty.tsx @@ -2,28 +2,77 @@ import { ListRowsSkeleton } from "@/components/admin/ListRowsSkeleton"; import Link from "next/link"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { AdminContentState } from "@/components/admin/AdminContentState"; import { ToastLayer, useToast, type ToastMessage, } from "@/components/admin/Toast"; +import { DutyCalendar } from "@/components/admin/DutyCalendar"; +import { MissedAdjustmentDialog } from "@/components/admin/MissedAdjustmentDialog"; +import { StudentDetailDialog } from "@/components/admin/student-management/StudentDetailDialog"; import { VolunteerDutyRow } from "@/components/admin/VolunteerDutyRow"; -import { operatingDayLabel } from "@/lib/admin/operating-day"; +import { operatingDayKey, operatingDayLabel } from "@/lib/admin/operating-day"; +import { useStudentManagementGateway } from "@/lib/admin/student-management-gateway"; import { failureToast, listFailureToast, useSingleFlight, } from "@/lib/admin/volunteer-action"; import { useVolunteerGateway } from "@/lib/admin/volunteer-gateway"; -import { designatedToday } from "@/lib/admin/volunteer-roster"; +import { designatedToday, roomLabel } from "@/lib/admin/volunteer-roster"; import { useVolunteerRoster } from "@/lib/admin/volunteer-roster-store"; -import type { RosterStudent } from "@/lib/admin/volunteer-types"; +import type { + DutyRecord, + DutyRecordStatus, + RosterStudent, +} from "@/lib/admin/volunteer-types"; /** 서버가 이유를 알려 주지 않은 실패(Figma 06 state messages). */ const FAILURE_MESSAGE = "처리에 실패했습니다. 다시 시도해 주세요."; +/** 미이행 처리 문구. 서버에 미이행 상태가 없어 지정 제외 + 횟수 추가로 처리한다(#334). */ +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 = + { + completed: { + label: "완료", + style: "bg-admin-attendance-bg text-admin-attendance-text", + }, + missed: { + label: "미이행", + style: "bg-admin-danger-bg text-admin-danger-text", + }, + cancelled: { + label: "제외", + style: "bg-admin-absence-bg text-admin-textMuted", + }, + }; + +/** 횟수를 추가해 처리한 미이행의 표시(달력과 지난 기록 목록 공통). */ +const ADJUSTED_CHIP = { + label: "횟수 처리됨", + style: "bg-admin-attendance-bg text-admin-attendance-text", +}; + +/** 지난 기록이 이 시간 안에 오면 스켈레톤 없이 바로 바꿔 깜빡이지 않게 한다(AdminHomeFloorPlan과 같다). */ +const SKELETON_DELAY_MS = 300; + +type DayRecords = + | { key: string; status: "error" } + | { key: string; status: "ready"; items: DutyRecord[] }; + /** 당일 봉사자 목록을 받지 못했을 때(Figma 06 state messages). */ const LIST_FAILURE_MESSAGE = "당일 봉사자 목록을 불러오지 못했습니다. 다시 시도해 주세요."; @@ -42,10 +91,121 @@ 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); const dayLabel = operatingDayLabel(new Date()); + const todayKey = operatingDayKey(new Date()); + const history = gateway.history; + const [selectedKey, setSelectedKey] = useState(todayKey); + const [dayRecords, setDayRecords] = useState(null); + 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; + kind: "missed" | "adjust"; + } | null>(null); + const viewingPast = selectedKey !== todayKey; + const [, selectedMonth, selectedDay] = selectedKey.split("-").map(Number); + const pastLabel = `${String(selectedMonth).padStart(2, "0")}/${String(selectedDay).padStart(2, "0")}`; + + useEffect(() => { + if (!history) return; + let cancelled = false; + const slowTimer = setTimeout(() => { + if (!cancelled) setSlowKey(selectedKey); + }, SKELETON_DELAY_MS); + history + .day(selectedKey) + .then((items) => { + if (!cancelled) { + setDayRecords({ key: selectedKey, status: "ready", items }); + } + }) + .catch(() => { + if (!cancelled) setDayRecords({ key: selectedKey, status: "error" }); + }); + return () => { + cancelled = true; + clearTimeout(slowTimer); + }; + }, [selectedKey, todayKey, history, historyAttempt]); + + // 고른 날의 기록이 아직 오지 않았으면 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; + 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, + ], + })); + } useEffect(() => { if (status === "error") { @@ -73,6 +233,89 @@ export function AdminVolunteerDuty({ }); } + function openMissed(studentId: string) { + const student = roster.find((item) => item.studentId === studentId); + if (student) setDialog({ student, kind: "missed" }); + } + + /** 횟수를 이미 처리한 미이행은 추가된 횟수·사유만 읽기 전용으로 보이고, 아니면 횟수를 조정한다. */ + function openAdjust(record: DutyRecord) { + if (record.adjusted) { + setViewRecord(record); + return; + } + const student = roster.find((item) => item.studentId === record.studentId); + if (student) setDialog({ student, kind: "adjust" }); + } + + /** 지정을 제외한 뒤 횟수와 사유를 저장한다. 이 순서라야 중간에 실패해도 횟수가 두 번 더해지지 않는다. */ + 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); + } 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, + ); + } + } + }); + } + + /** 지난 미이행 학생의 횟수·사유만 고친다(학생 관리와 같은 저장). */ + function handleAdjust(change: { count: number; reason: string }) { + if (!dialog) return; + const { student } = dialog; + if (change.count === student.count) { + setDialog(null); + return; + } + return singleFlight(student.id, async () => { + try { + updateStudent(await studentGateway.saveCount(student, change)); + markHandled(selectedKey, student.studentId, { + added: change.count - student.count, + reason: change.reason, + }); + setDialog(null); + showToast({ variant: "success", message: COUNT_SUCCESS_MESSAGE }); + } catch (error) { + const failure = failureToast(error, COUNT_FAILURE_MESSAGE, reload); + if (failure) showToast(failure); + } + }); + } + function handleComplete(studentId: string) { return runDutyAction(studentId, gateway.completeDuty, { variant: "success", @@ -101,31 +344,141 @@ export function AdminVolunteerDuty({ 봉사자 관리 - {/* 폰·패드는 제목 옆, 컴퓨터는 아래 줄 오른쪽(Figma 06). 링크 하나를 격자 위치만 바꿔 쓴다. */} - - + 수정 - + 명단 수정 - + {/* 폰·패드는 제목 옆, 컴퓨터는 아래 줄 오른쪽(Figma 06). 묶음을 격자 위치만 바꿔 쓴다. */} +
+ + + + 수정 + + 명단 수정 + +

- {dayLabel} 당일 봉사자 + {viewingPast + ? `${pastLabel} 봉사자 기록` + : `${dayLabel} 당일 봉사자`}

- {today.length}명 + {viewingPast + ? dayItems + ? `${dayItems.length}명` + : "" + : `${today.length}명`}

-

- {dayLabel} 당일 봉사자 · {today.length}명 -

+
+

+ {viewingPast + ? `${pastLabel} 봉사자 기록${dayItems ? ` · ${dayItems.length}명` : ""}` + : `${dayLabel} 당일 봉사자 · ${today.length}명`} +

+ {viewingPast && ( + + )} +
- {status === "error" ? ( + {viewingPast ? ( + !history ? ( + + ) : dayItems === null && records?.status !== "error" ? ( + slowKey === selectedKey ? ( + + ) : null + ) : records?.status === "error" ? ( + { + setDayRecords(null); + setHistoryAttempt((count) => count + 1); + }} + /> + ) : dayItems === null || dayItems.length === 0 ? ( + + ) : ( + dayItems.map((record) => { + const chip = record.adjusted + ? ADJUSTED_CHIP + : RECORD_CHIP[record.status]; + const adjustable = + record.status === "missed" && + (record.adjusted || + roster.some((item) => item.studentId === record.studentId)); + const body = ( + <> + + + {record.name} + + + {record.studentId} · {roomLabel(record.roomNumber)} + + + + + {chip.label} + + {adjustable && ( + + )} + + + ); + const base = + "flex w-full items-center justify-between gap-3 rounded-[11px] bg-admin-rowSurface px-3.5 py-[11px] text-left md:rounded-xl md:px-4 md:py-3 xl:rounded-card xl:px-5 xl:py-3.5"; + return adjustable ? ( + + ) : ( +
+ {body} +
+ ); + }) + ) + ) : status === "error" ? ( ) : status !== "ready" ? ( @@ -141,11 +494,34 @@ export function AdminVolunteerDuty({ student={student} onComplete={handleComplete} onCancel={handleCancel} + onMissed={openMissed} /> )) )}
+ + {viewRecord ? ( + setViewRecord(null)} + /> + ) : null} + + {dialog ? ( + setDialog(null)} + onSave={dialog.kind === "missed" ? handleMissed : handleAdjust} + initialCount={ + dialog.kind === "missed" ? dialog.student.count + 1 : undefined + } + initialReason={ + dialog.kind === "missed" ? MISSED_DEFAULT_REASON : undefined + } + /> + ) : null} ); } diff --git a/web/src/components/admin/DutyCalendar.tsx b/web/src/components/admin/DutyCalendar.tsx new file mode 100644 index 0000000..baa82e5 --- /dev/null +++ b/web/src/components/admin/DutyCalendar.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { DutyRecord } from "@/lib/admin/volunteer-types"; +import { roomLabel } from "@/lib/admin/volunteer-roster"; + +type DutyCalendarProps = { + /** 오늘 운영일 `YYYY-MM-DD`. 이후 날짜는 고를 수 없다. */ + todayKey: string; + /** 지금 보고 있는 날 `YYYY-MM-DD`. */ + selectedKey: string; + onSelect: (key: string) => void; + /** 그 달의 미이행 날짜별 인원. 없으면(서버 지원 전) 점을 찍지 않는다. */ + loadMonth?: (year: number, month: number) => Promise>; + /** 고른 날의 미이행 기록. 아직 받는 중이면 null이다. */ + missed: DutyRecord[] | null; + /** 미이행 학생을 눌렀을 때. 호출한 쪽이 조정 다이얼로그를 연다. */ + onPickMissed: (record: DutyRecord) => void; + /** 서버 요약에 아직 없는, 이 화면에서 처리한 날짜별 미이행 인원(오늘 처리분). 서버 점과 합쳐 보인다. */ + extraMarks?: Record; +}; + +const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; + +function dayKey(year: number, month: number, day: number): string { + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +function parseKey(key: string): { y: number; m: number; d: number } { + const [y, m, d] = key.split("-").map(Number); + return { y, m, d }; +} + +/** + * 봉사자 관리에서 날짜별 당일 봉사자 기록을 확인하는 달력(#334). 버튼에는 지금 보는 날의 `YYYY.MM.DD`가 보이고, + * 처음에는 오늘 운영일이다. 눌러서 연 달력은 그 날이 속한 달을 보이고 달 이동·연월 선택으로 바꿀 수 있다. + * 미이행이 있는 날은 빨간 점이고, 날짜를 누르면 달력 아래에 그날 안 한 학생이 나온다. + */ +export function DutyCalendar({ + todayKey, + selectedKey, + onSelect, + 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); + } + + useEffect(() => { + if (!open || !loadMonth) return; + let cancelled = false; + loadMonth(view.y, view.m) + .then((next) => { + if (!cancelled) setServerMarks(next); + }) + .catch(() => { + if (!cancelled) setServerMarks({}); + }); + return () => { + cancelled = true; + }; + }, [open, view, loadMonth]); + + useEffect(() => { + if (!open) return; + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open]); + + function toggle() { + if (!open) { + setView({ y: selected.y, m: selected.m }); + setPicking(false); + } + setOpen((value) => !value); + } + + function move(delta: number) { + const index = view.y * 12 + (view.m - 1) + delta; + setView({ y: Math.floor(index / 12), m: (index % 12) + 1 }); + } + + const isFutureMonth = (year: number, month: number) => + year > today.y || (year === today.y && month > today.m); + const first = new Date(Date.UTC(view.y, view.m - 1, 1)).getUTCDay(); + const daysInMonth = new Date(Date.UTC(view.y, view.m, 0)).getUTCDate(); + const cells: (number | null)[] = [ + ...Array(first).fill(null), + ...Array.from({ length: daysInMonth }, (_, i) => i + 1), + ]; + const label = `${selected.y}.${String(selected.m).padStart(2, "0")}.${String(selected.d).padStart(2, "0")}`; + const arrow = + "flex size-8 items-center justify-center rounded-lg text-admin-textMuted transition-colors hover:bg-admin-rowSurface disabled:opacity-30 disabled:hover:bg-transparent"; + + return ( +
+ + + {open && ( + <> + + + {view.y}년 + + +
+ )} +
+ + + +
+ + {picking ? ( +
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( + + ))} +
+ ) : ( + <> +
+ {WEEKDAYS.map((weekday) => ( + + {weekday} + + ))} + {cells.map((day, index) => { + if (day === null) return ; + const key = dayKey(view.y, view.m, day); + const future = key > todayKey; + return ( + + ); + })} +
+

+

+ + {(loadMonth || selectedKey === todayKey) && ( +
+

+ {selectedKey === todayKey + ? "오늘 미이행" + : `${selected.y}년 ${selected.m}월 ${selected.d}일 미이행`} + {missed && ( + + {missed.length}명 + + )} +

+ {missed === null ? ( +

+ 불러오는 중… +

+ ) : missed.length === 0 ? ( +

+ 미이행한 학생이 없습니다. +

+ ) : ( + missed.map((record) => ( + + )) + )} +
+ )} + + )} + + + )} + + ); +} diff --git a/web/src/components/admin/MissedAdjustmentDialog.tsx b/web/src/components/admin/MissedAdjustmentDialog.tsx new file mode 100644 index 0000000..d2e9a09 --- /dev/null +++ b/web/src/components/admin/MissedAdjustmentDialog.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useId } from "react"; +import { roomLabel } from "@/lib/admin/volunteer-roster"; +import type { DutyRecord } from "@/lib/admin/volunteer-types"; +import { useModalDialog } from "@/lib/admin/use-modal-dialog"; + +type MissedAdjustmentDialogProps = { + record: DutyRecord; + onClose: () => void; +}; + +/** + * 이미 횟수를 추가해 처리한 미이행(`횟수 처리됨`)의 읽기 전용 상세(#334). 추가된 횟수와 사유만 보이고 −/+ 조작은 + * 없다. 횟수를 더 고치려면 학생 관리에서 한다. 너비는 핸드폰 310·패드 400·컴퓨터 460이다. + */ +export function MissedAdjustmentDialog({ + record, + onClose, +}: MissedAdjustmentDialogProps) { + const titleId = useId(); + const panelRef = useModalDialog(onClose); + const added = record.addedCount; + const reason = record.reason?.trim(); + + return ( +
+
event.stopPropagation()} + > +

+ {record.name} +

+

+ {record.studentId} · {roomLabel(record.roomNumber)} +

+ +
+

+ 추가된 봉사 횟수 +

+

+ {added === undefined ? "-" : `${added > 0 ? "+" : ""}${added}회`} +

+
+ +
+

+ 사유 +

+

+ {reason || ( + + 입력한 사유가 없습니다. + + )} +

+
+ +
+ +
+
+
+ ); +} diff --git a/web/src/components/admin/VolunteerDutyRow.tsx b/web/src/components/admin/VolunteerDutyRow.tsx index 954d99b..b2162ec 100644 --- a/web/src/components/admin/VolunteerDutyRow.tsx +++ b/web/src/components/admin/VolunteerDutyRow.tsx @@ -5,6 +5,8 @@ type VolunteerDutyRowProps = { student: RosterStudent; onComplete: (studentId: string) => void; onCancel: (studentId: string) => void; + /** 봉사를 하지 않고 사라진 학생. 횟수 추가·사유 다이얼로그를 연다(#334). */ + onMissed: (studentId: string) => void; }; /** @@ -16,6 +18,7 @@ export function VolunteerDutyRow({ student, onComplete, onCancel, + onMissed, }: VolunteerDutyRowProps) { return (
@@ -39,6 +42,14 @@ export function VolunteerDutyRow({ > 완료 +