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
24 changes: 20 additions & 4 deletions src/entities/machine/api/useGetMachines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,25 @@ import type { MachineResponseType } from "../model/types";
export const useGetMachines = (params: { floor?: number } = {}) => {
return useQuery({
queryKey: machineQueryKeys.getMachines(params),
queryFn: () =>
get<BaseResponseType<MachineResponseType>>(machineUrl.getMachines(), {
params,
}),
queryFn: async () => {
const [washers, dryers] = await Promise.all([
get<BaseResponseType<MachineResponseType>>(machineUrl.getMachines(), {
params: { ...params, type: "WASHER" },
}),
get<BaseResponseType<MachineResponseType>>(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,
},
};
},
});
};
1 change: 1 addition & 0 deletions src/entities/report/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./getMalfunctionReports";
export * from "./useGetMalfunctionReports";
export * from "./useUpdateReportStatus";
25 changes: 25 additions & 0 deletions src/entities/report/api/useUpdateReportStatus.ts
Original file line number Diff line number Diff line change
@@ -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<BaseResponseType<null>>(reportUrl.updateMalfunctionReportStatus(id), {
status,
});
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: reportQueryKeys.all,
});
alert("신고 상태가 변경되었습니다.");
},
onError: () => {
alert("신고 상태 변경 중 오류가 발생했습니다.");
},
});
}
4 changes: 2 additions & 2 deletions src/entities/report/model/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const reportStatusStyleMap: Record<ReportStatusType, string> = {
};

export const reportStatusLabelMap: Record<ReportStatusType, string> = {
PENDING: "신고",
PENDING: "대기",
IN_PROGRESS: "처리중",
RESOLVED: "완료",
RESOLVED: "처리 완료",
};
25 changes: 18 additions & 7 deletions src/entities/reservation/api/getReservations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,23 @@ import type {
export async function getReservations(
params?: ReservationParamsType,
): Promise<ReservationItem[]> {
const response = await get<BaseResponseType<ReservationResponseType>>(
reservationUrl.getReservations(),
{
params,
},
);
const [washersResponse, dryersResponse] = await Promise.all([
get<BaseResponseType<ReservationResponseType>>(
reservationUrl.getReservations(),
{
params: { ...params, machineType: "WASHER" },
},
),
get<BaseResponseType<ReservationResponseType>>(
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];
}
8 changes: 8 additions & 0 deletions src/entities/reservation/lib/mapReservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "확인필요";
}
Expand Down
2 changes: 1 addition & 1 deletion src/entities/reservation/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ReservationStatusLabel = "예약중" | "사용중" | "확인필요";
export type ReservationStatusLabel = "예약중" | "사용중" | "확인필요" | "사용 완료" | "취소됨";

export type ReservationMachineType = "WASHER" | "DRYER";

Expand Down
1 change: 1 addition & 0 deletions src/entities/user/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { getUsers } from "./getUsers";
export { useGetMyInfo } from "./useGetMyInfo";
export { useGetUsers } from "./useGetUsers";
export { useDeleteUserPenalty } from "./useDeleteUserPenalty";
22 changes: 22 additions & 0 deletions src/entities/user/api/useDeleteUserPenalty.ts
Original file line number Diff line number Diff line change
@@ -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<BaseResponseType<null>>(userUrl.deleteUserPenalty(userId));
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: userQueryKeys.all,
});
alert("세탁 정지 해제가 완료되었습니다.");
},
onError: () => {
alert("세탁 정지 해제 중 오류가 발생했습니다.");
},
});
}
3 changes: 2 additions & 1 deletion src/entities/user/api/useGetUsers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,5 +13,6 @@ export const useGetUsers = (
queryKey,
queryFn: () => fetchUsers(params),
initialData,
placeholderData: keepPreviousData,
});
};
2 changes: 2 additions & 0 deletions src/shared/api/apiUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// 개별 상수로 분리하여 확실하게 정의
Expand All @@ -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;
43 changes: 42 additions & 1 deletion src/widgets/reports-page/ui/ReportsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -66,11 +69,49 @@ const ReportRow = ({

<div className="flex shrink-0 items-center gap-2">
<ReportStatusBadge status={item.status} />
<ReportActionButton item={item} />
</div>
</div>
);
};

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 (
<button
type="button"
onClick={handleNextStatus}
disabled={isPending}
className={cn(
"inline-flex h-7 min-w-[64px] cursor-pointer items-center justify-center rounded-full px-3 text-xs font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed",
buttonBg
)}
>
{buttonText}
</button>
);
};

const ReportsPanel = ({
title,
reports,
Expand Down
8 changes: 8 additions & 0 deletions src/widgets/reservations-page/ui/ReservationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export default function ReservationRow({
기기를 현재 사용할 수 없습니다.
</p>
)}

{item.badgeStatus === "사용 완료" && (
<p className="mt-1 text-sm text-[#969696]">완료된 기기입니다.</p>
)}

{item.badgeStatus === "취소됨" && (
<p className="mt-1 text-sm text-[#EA3B42]">취소된 예약입니다.</p>
)}
</div>
</div>

Expand Down
76 changes: 57 additions & 19 deletions src/widgets/users-page/UserPage.tsx
Original file line number Diff line number Diff line change
@@ -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<number | undefined>();

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);
};
Comment thread
LeeSangHyeok0731 marked this conversation as resolved.

if (isLoading) {
return <div>사용자 정보를 불러오는 중입니다.</div>;
}

if (isError) {
return <div>사용자 정보를 불러오지 못했습니다.</div>;
}
// Remove early returns so the filter panel doesn't unmount

return (
<div className="admin-page-grid xl:grid-cols-[1.9fr_0.62fr]">
<div className="admin-page-item">
<UserStatusPanel users={filteredUsers} />
<div className="admin-page-item relative min-h-[300px]">
{isLoading ? (
<div className="flex h-full items-center justify-center text-sm font-medium text-gray-500">
사용자 정보를 불러오는 중입니다...
</div>
) : isError ? (
<div className="flex h-full items-center justify-center text-sm font-medium text-red-500">
사용자 정보를 불러오지 못했습니다.
</div>
) : (
<UserStatusPanel users={users} />
)}
</div>

<div className="admin-page-item">
<UserFilterPanel
search={search}
onSearchChange={setSearch}
roomSearch={roomSearch}
onRoomSearchChange={setRoomSearch}
floor={floor}
onFloorChange={setFloor}
onReset={handleReset}
Expand Down
13 changes: 12 additions & 1 deletion src/widgets/users-page/ui/UserFilterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface UserFilterPanelProps {
onFloorChange: (floor: number | undefined) => void;
search: string;
onSearchChange: (value: string) => void;
roomSearch: string;
onRoomSearchChange: (value: string) => void;
onReset: () => void;
}

Expand All @@ -17,11 +19,20 @@ export default function UserFilterPanel({
onFloorChange,
search,
onSearchChange,
roomSearch,
onRoomSearchChange,
onReset,
}: UserFilterPanelProps) {
return (
<FilterPanelShell onReset={onReset}>
<FilterSearchField value={search} onChange={onSearchChange} />
<div className="flex flex-col gap-3">
<FilterSearchField value={search} onChange={onSearchChange} />
<FilterSearchField
placeholder="호실을 입력해주세요 (예: 420)"
value={roomSearch}
onChange={onRoomSearchChange}
/>
Comment thread
LeeSangHyeok0731 marked this conversation as resolved.
</div>
<FloorGenderFilters selectedFloor={floor} onFloorChange={onFloorChange} />
</FilterPanelShell>
);
Expand Down
Loading
Loading