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 @@ -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)은 그대로다. | 사용자 결정 |

## 하네스 선택
Expand Down
1 change: 1 addition & 0 deletions docs/plans/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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·미이행 상태가 없어 지난 날짜 기록은 확인용 목업만 연결했다. |
4 changes: 4 additions & 0 deletions docs/spec/community.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 따른다.

| 결과 | 문구 |
Expand Down
5 changes: 4 additions & 1 deletion tests/acceptance/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,10 @@
"0회 학생 지정은 `봉사가 없습니다.`, 이미 지정된 학생은 `이미 당일 봉사자로 지정된 학생입니다.`를 보여 준다",
"봉사자 관리에 `MM/DD 당일 봉사자 N명`과 지정된 학생이 `완료`·`봉사 제외`와 함께 보인다",
"`완료`는 횟수를 한 번만 1 줄이고 `봉사 제외`는 횟수를 바꾸지 않는다",
"오늘 지정이 없으면 `오늘 지정된 봉사자가 없습니다. 봉사자 명단에서 지정해 주세요.`를 보여 준다"
"오늘 지정이 없으면 `오늘 지정된 봉사자가 없습니다. 봉사자 명단에서 지정해 주세요.`를 보여 준다",
"미이행을 누르면 횟수 −/+와 사유 다이얼로그가 열려 저장하면 지정이 제외되고 횟수가 더해진다",
"달력에서 오늘 운영일 연월일이 처음 보이고 연월일을 바꿀 수 있으며, 미이행이 있는 날을 누르면 안 한 학생이 보인다",
"횟수를 추가해 처리한 미이행은 달력에서 횟수 처리됨으로 보이고 누르면 추가 횟수와 사유만 읽기 전용으로 보이며, 처리하지 않은 미이행은 미이행으로 보이고 눌러서 조정하면 횟수 처리됨으로 바뀐다"
],
"status": "implemented",
"implementation": [
Expand Down
207 changes: 206 additions & 1 deletion web/src/components/admin/AdminVolunteerDuty.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,7 +27,11 @@ const mockGateway = createMockVolunteerGateway();
function render(ui: ReactElement) {
return renderPlain(
<VolunteerGatewayProvider value={mockGateway}>
{ui}
<StudentManagementGatewayProvider
value={createMockStudentManagementGateway()}
>
{ui}
</StudentManagementGatewayProvider>
</VolunteerGatewayProvider>,
);
}
Expand Down Expand Up @@ -224,6 +231,204 @@ describe("AdminVolunteerDuty", () => {
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

it("미이행을 누르면 횟수·사유 다이얼로그가 현재 횟수 + 1과 기본 사유로 열린다", () => {
render(<AdminVolunteerDuty />);

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

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(<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" }),
);
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" }),
);
const calendar = screen.getByRole("dialog", { name: "날짜 선택" });
expect(within(calendar).getByText("미이행")).toBeInTheDocument();
expect(within(calendar).queryByText("횟수 처리됨")).not.toBeInTheDocument();
});

it("달력 버튼에는 오늘 운영일의 연월일이 보인다", () => {
render(<AdminVolunteerDuty />);

expect(
screen.getByRole("button", { name: "날짜 선택, 지금 2026.10.01" }),
).toHaveTextContent("2026.10.01");
});

it("달력에서 미이행이 있는 날을 누르면 그날 안 한 학생이 보이고, 누르면 횟수를 조정한다", async () => {
render(<AdminVolunteerDuty />);

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(
<VolunteerGatewayProvider value={withoutHistory}>
<AdminVolunteerDuty />
</VolunteerGatewayProvider>,
);

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"));
Expand Down
Loading
Loading