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) => ( + + ))} +
+ )} ); }