diff --git a/src/entities/machine/api/useGetMachines.ts b/src/entities/machine/api/useGetMachines.ts
index 7ac15ba..26726eb 100644
--- a/src/entities/machine/api/useGetMachines.ts
+++ b/src/entities/machine/api/useGetMachines.ts
@@ -6,9 +6,25 @@ import type { MachineResponseType } from "../model/types";
export const useGetMachines = (params: { floor?: number } = {}) => {
return useQuery({
queryKey: machineQueryKeys.getMachines(params),
- queryFn: () =>
- get>(machineUrl.getMachines(), {
- params,
- }),
+ queryFn: async () => {
+ const [washers, dryers] = await Promise.all([
+ get>(machineUrl.getMachines(), {
+ params: { ...params, type: "WASHER" },
+ }),
+ get>(machineUrl.getMachines(), {
+ params: { ...params, type: "DRYER" },
+ }),
+ ]);
+
+ return {
+ ...washers,
+ data: {
+ machines: [...washers.data.machines, ...dryers.data.machines],
+ totalCount: washers.data.totalCount + dryers.data.totalCount,
+ totalPages: Math.max(washers.data.totalPages, dryers.data.totalPages),
+ currentPage: washers.data.currentPage,
+ },
+ };
+ },
});
};
diff --git a/src/entities/report/api/index.ts b/src/entities/report/api/index.ts
index e76dec0..611e42f 100644
--- a/src/entities/report/api/index.ts
+++ b/src/entities/report/api/index.ts
@@ -1,2 +1,3 @@
export * from "./getMalfunctionReports";
export * from "./useGetMalfunctionReports";
+export * from "./useUpdateReportStatus";
diff --git a/src/entities/report/api/useUpdateReportStatus.ts b/src/entities/report/api/useUpdateReportStatus.ts
new file mode 100644
index 0000000..71f606d
--- /dev/null
+++ b/src/entities/report/api/useUpdateReportStatus.ts
@@ -0,0 +1,25 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { put, reportUrl, reportQueryKeys } from "@/shared/api";
+import type { BaseResponseType } from "@/shared/api/types";
+import type { ReportStatusType } from "../model/types";
+
+export function useUpdateReportStatus() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, status }: { id: number; status: ReportStatusType }) => {
+ await put>(reportUrl.updateMalfunctionReportStatus(id), {
+ status,
+ });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: reportQueryKeys.all,
+ });
+ alert("신고 상태가 변경되었습니다.");
+ },
+ onError: () => {
+ alert("신고 상태 변경 중 오류가 발생했습니다.");
+ },
+ });
+}
diff --git a/src/entities/report/model/status.ts b/src/entities/report/model/status.ts
index b3fbcb3..cdbfb25 100644
--- a/src/entities/report/model/status.ts
+++ b/src/entities/report/model/status.ts
@@ -7,7 +7,7 @@ export const reportStatusStyleMap: Record = {
};
export const reportStatusLabelMap: Record = {
- PENDING: "신고",
+ PENDING: "대기",
IN_PROGRESS: "처리중",
- RESOLVED: "완료",
+ RESOLVED: "처리 완료",
};
diff --git a/src/entities/reservation/api/getReservations.ts b/src/entities/reservation/api/getReservations.ts
index 38ce6ae..bfce3a8 100644
--- a/src/entities/reservation/api/getReservations.ts
+++ b/src/entities/reservation/api/getReservations.ts
@@ -10,12 +10,23 @@ import type {
export async function getReservations(
params?: ReservationParamsType,
): Promise {
- const response = await get>(
- reservationUrl.getReservations(),
- {
- params,
- },
- );
+ const [washersResponse, dryersResponse] = await Promise.all([
+ get>(
+ reservationUrl.getReservations(),
+ {
+ params: { ...params, machineType: "WASHER" },
+ },
+ ),
+ get>(
+ reservationUrl.getReservations(),
+ {
+ params: { ...params, machineType: "DRYER" },
+ },
+ ),
+ ]);
- return mapReservations(response.data.reservations);
+ const washers = mapReservations(washersResponse.data.reservations);
+ const dryers = mapReservations(dryersResponse.data.reservations);
+
+ return [...washers, ...dryers];
}
diff --git a/src/entities/reservation/lib/mapReservation.ts b/src/entities/reservation/lib/mapReservation.ts
index e791fdb..091f0c1 100644
--- a/src/entities/reservation/lib/mapReservation.ts
+++ b/src/entities/reservation/lib/mapReservation.ts
@@ -17,6 +17,14 @@ function getMachineType(machineName: string): ReservationMachineType {
}
function mapBadgeStatus(dto: ReservationDTO): ReservationStatusLabel {
+ if (dto.status === "CANCELLED") {
+ return "취소됨";
+ }
+
+ if (dto.status === "COMPLETED") {
+ return "사용 완료";
+ }
+
if (dto.machineAvailability === "UNAVAILABLE") {
return "확인필요";
}
diff --git a/src/entities/reservation/model/types.ts b/src/entities/reservation/model/types.ts
index 9bf8014..f2e6d73 100644
--- a/src/entities/reservation/model/types.ts
+++ b/src/entities/reservation/model/types.ts
@@ -1,4 +1,4 @@
-export type ReservationStatusLabel = "예약중" | "사용중" | "확인필요";
+export type ReservationStatusLabel = "예약중" | "사용중" | "확인필요" | "사용 완료" | "취소됨";
export type ReservationMachineType = "WASHER" | "DRYER";
diff --git a/src/entities/user/api/index.ts b/src/entities/user/api/index.ts
index 440161f..6da2b9e 100644
--- a/src/entities/user/api/index.ts
+++ b/src/entities/user/api/index.ts
@@ -1,3 +1,4 @@
export { getUsers } from "./getUsers";
export { useGetMyInfo } from "./useGetMyInfo";
export { useGetUsers } from "./useGetUsers";
+export { useDeleteUserPenalty } from "./useDeleteUserPenalty";
diff --git a/src/entities/user/api/useDeleteUserPenalty.ts b/src/entities/user/api/useDeleteUserPenalty.ts
new file mode 100644
index 0000000..94ca9b4
--- /dev/null
+++ b/src/entities/user/api/useDeleteUserPenalty.ts
@@ -0,0 +1,22 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { del, userUrl, userQueryKeys } from "@/shared/api";
+import type { BaseResponseType } from "@/shared/api/types";
+
+export function useDeleteUserPenalty() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (userId: number) => {
+ await del>(userUrl.deleteUserPenalty(userId));
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: userQueryKeys.all,
+ });
+ alert("세탁 정지 해제가 완료되었습니다.");
+ },
+ onError: () => {
+ alert("세탁 정지 해제 중 오류가 발생했습니다.");
+ },
+ });
+}
diff --git a/src/entities/user/api/useGetUsers.ts b/src/entities/user/api/useGetUsers.ts
index b45ab71..9dcc084 100644
--- a/src/entities/user/api/useGetUsers.ts
+++ b/src/entities/user/api/useGetUsers.ts
@@ -1,4 +1,4 @@
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { userQueryKeys } from "@/shared/api";
import type { ManagedUserItem, UserParamsType } from "../model/types";
import { getUsers as fetchUsers } from "./getUsers";
@@ -13,5 +13,6 @@ export const useGetUsers = (
queryKey,
queryFn: () => fetchUsers(params),
initialData,
+ placeholderData: keepPreviousData,
});
};
diff --git a/src/shared/api/apiUrls.ts b/src/shared/api/apiUrls.ts
index d7a8099..14454b7 100644
--- a/src/shared/api/apiUrls.ts
+++ b/src/shared/api/apiUrls.ts
@@ -5,6 +5,7 @@ export const authUrl = {
export const reportUrl = {
getMalfunctionReports: () => "/api/v2/admin/malfunction-reports",
+ updateMalfunctionReportStatus: (id: number) => `/api/v2/admin/malfunction-reports/${id}/status`,
} as const;
// 개별 상수로 분리하여 확실하게 정의
@@ -29,4 +30,5 @@ export const reservationUrl = {
export const userUrl = {
getUsers: () => "/api/v2/admin/users",
getMyInfo: () => "/api/v2/users/my",
+ deleteUserPenalty: (userId: number) => `/api/v2/admin/reservations/users/${userId}/penalty`,
} as const;
diff --git a/src/widgets/reports-page/ui/ReportsPanel.tsx b/src/widgets/reports-page/ui/ReportsPanel.tsx
index e93efca..c1fcc5b 100644
--- a/src/widgets/reports-page/ui/ReportsPanel.tsx
+++ b/src/widgets/reports-page/ui/ReportsPanel.tsx
@@ -1,7 +1,10 @@
+"use client";
+
import { TriangleAlert } from "lucide-react";
import Image from "next/image";
+import { cn } from "@/shared/lib/cn";
import type { ReportItemType } from "@/entities/report";
-import { ReportStatusBadge } from "@/entities/report";
+import { useUpdateReportStatus, ReportStatusBadge } from "@/entities/report";
import StatusPanelShell from "@/shared/ui/admin/StatusPanelShell";
import { Button } from "@/shared/ui/button";
@@ -66,11 +69,49 @@ const ReportRow = ({
+
);
};
+const ReportActionButton = ({ item }: { item: ReportItemType }) => {
+ const { mutate: updateStatus, isPending } = useUpdateReportStatus();
+
+ if (item.status === "RESOLVED") {
+ return null;
+ }
+
+ const handleNextStatus = () => {
+ if (item.status === "PENDING") {
+ const confirmed = window.confirm(
+ "처리중으로 변경된다면 기기가 고장 상태로 변경되고 처리 완료 상태가 되기 전까지는 사용이 불가능 합니다. 계속하시겠습니까?"
+ );
+ if (!confirmed) return;
+ updateStatus({ id: item.id, status: "IN_PROGRESS" });
+ } else if (item.status === "IN_PROGRESS") {
+ updateStatus({ id: item.id, status: "RESOLVED" });
+ }
+ };
+
+ const buttonText = item.status === "PENDING" ? "처리 시작" : "완료 처리";
+ const buttonBg = item.status === "PENDING" ? "bg-[#4D83F6]" : "bg-[#20C997]";
+
+ return (
+
+ );
+};
+
const ReportsPanel = ({
title,
reports,
diff --git a/src/widgets/reservations-page/ui/ReservationRow.tsx b/src/widgets/reservations-page/ui/ReservationRow.tsx
index a00e0e0..f92da92 100644
--- a/src/widgets/reservations-page/ui/ReservationRow.tsx
+++ b/src/widgets/reservations-page/ui/ReservationRow.tsx
@@ -83,6 +83,14 @@ export default function ReservationRow({
기기를 현재 사용할 수 없습니다.
)}
+
+ {item.badgeStatus === "사용 완료" && (
+ 완료된 기기입니다.
+ )}
+
+ {item.badgeStatus === "취소됨" && (
+ 취소된 예약입니다.
+ )}
diff --git a/src/widgets/users-page/UserPage.tsx b/src/widgets/users-page/UserPage.tsx
index 5529b5f..fec9b1f 100644
--- a/src/widgets/users-page/UserPage.tsx
+++ b/src/widgets/users-page/UserPage.tsx
@@ -1,50 +1,88 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useGetUsers } from "@/entities/user";
+import type { UserParamsType } from "@/entities/user";
import UserFilterPanel from "./ui/UserFilterPanel";
import UserStatusPanel from "./ui/UserStatusPanel";
export default function UsersPage() {
const [search, setSearch] = useState("");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+ const [roomSearch, setRoomSearch] = useState("");
+ const [debouncedRoomSearch, setDebouncedRoomSearch] = useState("");
const [floor, setFloor] = useState();
- const { data: users = [], isLoading, isError } = useGetUsers();
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearch(search);
+ }, 300);
+ return () => clearTimeout(handler);
+ }, [search]);
- const filteredUsers = useMemo(() => {
- return users.filter((user) => {
- const matchesFloor =
- floor === undefined || user.room.startsWith(floor.toString());
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedRoomSearch(roomSearch);
+ }, 300);
+ return () => clearTimeout(handler);
+ }, [roomSearch]);
- const matchesSearch = user.name.includes(search);
+ const queryParams = useMemo(() => {
+ const params: UserParamsType = {};
+ if (floor !== undefined) {
+ params.floor = floor;
+ }
+ const term = debouncedSearch.trim();
+ if (term) {
+ if (/^\d+$/.test(term)) {
+ params.studentId = term;
+ } else {
+ params.name = term;
+ }
+ }
- return matchesFloor && matchesSearch;
- });
- }, [users, search, floor]);
+ const roomTerm = debouncedRoomSearch.trim();
+ if (roomTerm && /^\d{3}$/.test(roomTerm)) {
+ params.roomNumber = roomTerm;
+ }
+
+ return params;
+ }, [debouncedSearch, debouncedRoomSearch, floor]);
+
+ const { data: users = [], isLoading, isError } = useGetUsers(queryParams);
const handleReset = () => {
setSearch("");
+ setDebouncedSearch("");
+ setRoomSearch("");
+ setDebouncedRoomSearch("");
setFloor(undefined);
};
- if (isLoading) {
- return 사용자 정보를 불러오는 중입니다.
;
- }
-
- if (isError) {
- return 사용자 정보를 불러오지 못했습니다.
;
- }
+ // Remove early returns so the filter panel doesn't unmount
return (
-
-
+
+ {isLoading ? (
+
+ 사용자 정보를 불러오는 중입니다...
+
+ ) : isError ? (
+
+ 사용자 정보를 불러오지 못했습니다.
+
+ ) : (
+
+ )}
void;
search: string;
onSearchChange: (value: string) => void;
+ roomSearch: string;
+ onRoomSearchChange: (value: string) => void;
onReset: () => void;
}
@@ -17,11 +19,20 @@ export default function UserFilterPanel({
onFloorChange,
search,
onSearchChange,
+ roomSearch,
+ onRoomSearchChange,
onReset,
}: UserFilterPanelProps) {
return (
-
+
+
+
+
);
diff --git a/src/widgets/users-page/ui/UserRowActions.tsx b/src/widgets/users-page/ui/UserRowActions.tsx
index 890dc16..582bbdd 100644
--- a/src/widgets/users-page/ui/UserRowActions.tsx
+++ b/src/widgets/users-page/ui/UserRowActions.tsx
@@ -1,23 +1,47 @@
+import { useDeleteUserPenalty } from "@/entities/user";
+
interface UserRowActionsProps {
+ userId: number;
isRestrictedCase?: boolean;
}
export default function UserRowActions({
+ userId,
isRestrictedCase = false,
}: UserRowActionsProps) {
+ const { mutate: deleteUserPenalty, isPending } = useDeleteUserPenalty();
+
+ const handleStopLaundry = () => {
+ alert("아직 준비 중인 기능입니다.");
+ };
+
+ const handleExtend = () => {
+ alert("아직 준비 중인 기능입니다.");
+ };
+
+ const handleRelease = () => {
+ const confirmed = window.confirm("이 사용자의 세탁 정지(패널티)를 해제하시겠습니까?");
+ if (!confirmed) return;
+
+ deleteUserPenalty(userId);
+ };
+
if (isRestrictedCase) {
return (
@@ -29,7 +53,8 @@ export default function UserRowActions({
diff --git a/src/widgets/users-page/ui/UserStatusPanel.tsx b/src/widgets/users-page/ui/UserStatusPanel.tsx
index 596d36e..608b0c3 100644
--- a/src/widgets/users-page/ui/UserStatusPanel.tsx
+++ b/src/widgets/users-page/ui/UserStatusPanel.tsx
@@ -37,7 +37,7 @@ function UserRow({ item }: { item: ManagedUserItem }) {
)}
-
+
);
}
@@ -48,9 +48,22 @@ export default function UserStatusPanel({ users }: UserStatusPanelProps) {
title="사용자 관리"
icon={}
>
- {users.map((item) => (
-
- ))}
+ {users.length === 0 ? (
+
+
+ 적합한 사용자가 존재하지 않습니다.
+
+
+ 필터 요소를 잘 확인해주세요.
+
+
+ ) : (
+
+ {users.map((item) => (
+
+ ))}
+
+ )}
);
}