Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)은 그대로다. | 사용자 결정 |

## 하네스 선택
Expand Down
1 change: 1 addition & 0 deletions docs/plans/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). 스웨거 계약으로 구현·테스트했고 로그인 세션이 필요한 실서버 화면 확인은 미실시. |
6 changes: 3 additions & 3 deletions docs/spec/community.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 따른다.

Expand Down
125 changes: 27 additions & 98 deletions web/src/components/admin/AdminVolunteerDuty.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
});

Expand Down Expand Up @@ -244,7 +246,7 @@ describe("AdminVolunteerDuty", () => {
);
});

it("미이행을 저장하면 지정을 제외하고 횟수를 더하며 목록에서 빠진다", async () => {
it("미이행을 저장하면 지정이 미이행이 되고 횟수가 더해지며 목록에서 빠진다", async () => {
render(<AdminVolunteerDuty />);
expect(countOf("2405")).toBe(3);

Expand All @@ -258,120 +260,47 @@ 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(
<VolunteerGatewayProvider value={mockGateway}>
<StudentManagementGatewayProvider value={{ saveCount }}>
<AdminVolunteerDuty />
</StudentManagementGatewayProvider>
<VolunteerGatewayProvider value={{ ...mockGateway, markMissed }}>
<AdminVolunteerDuty />
</VolunteerGatewayProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "김도현 봉사 미이행" }));
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(<AdminVolunteerDuty />);

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(<AdminVolunteerDuty />);

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(<AdminVolunteerDuty />);

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(<AdminVolunteerDuty />);

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(
<VolunteerGatewayProvider value={{ ...mockGateway, markMissed, list }}>
<AdminVolunteerDuty />
</VolunteerGatewayProvider>,
);
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(<AdminVolunteerDuty />);

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("달력 버튼에는 오늘 운영일의 연월일이 보인다", () => {
Expand Down Expand Up @@ -407,7 +336,7 @@ describe("AdminVolunteerDuty", () => {
expect(adjust).toHaveTextContent("1회");
});

it("서버가 지난 기록을 주지 않으면 지난 날짜를 서버 지원 전으로 안내한다", () => {
it("지난 기록을 받을 수 없는 연결이면 지난 날짜를 안내 문구로 보여 준다", () => {
const { history: _history, ...withoutHistory } = mockGateway;
void _history;
renderPlain(
Expand All @@ -424,7 +353,7 @@ describe("AdminVolunteerDuty", () => {

expect(
screen.getAllByText(
"지난 날짜의 봉사자 기록은 서버 지원이 준비되면 볼 수 있어요.",
"지난 날짜의 봉사자 기록을 불러올 수 없는 연결입니다.",
).length,
).toBeGreaterThan(0);
});
Expand Down
Loading
Loading