Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 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: "처리 완료",
};
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;
45 changes: 43 additions & 2 deletions 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 type { ReportItemType } from "@/entities/report";
import { ReportStatusBadge } from "@/entities/report";
import { cn } from "@/shared/lib/cn";
import type { ReportItemType, ReportStatusType } from "@/entities/report";
import { useUpdateReportStatus, reportStatusLabelMap, reportStatusStyleMap, ReportStatusBadge } from "@/entities/report";
Comment thread
LeeSangHyeok0731 marked this conversation as resolved.
Outdated
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
67 changes: 48 additions & 19 deletions src/widgets/users-page/UserPage.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,79 @@
"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);
setDebouncedRoomSearch(roomSearch);
}, 300);
return () => clearTimeout(handler);
}, [search, roomSearch]);
Comment thread
LeeSangHyeok0731 marked this conversation as resolved.
Outdated

const filteredUsers = useMemo(() => {
return users.filter((user) => {
const matchesFloor =
floor === undefined || user.room.startsWith(floor.toString());
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;
}
}

const matchesSearch = user.name.includes(search);
const roomTerm = debouncedRoomSearch.trim();
if (roomTerm && /^\d{3}$/.test(roomTerm)) {
params.roomNumber = roomTerm;
}

return matchesFloor && matchesSearch;
});
}, [users, search, floor]);
return params;
}, [debouncedSearch, debouncedRoomSearch, floor]);

const { data: users = [], isLoading, isError } = useGetUsers(queryParams);

const handleReset = () => {
setSearch("");
setRoomSearch("");
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="absolute inset-0 flex items-center justify-center bg-white/50 z-10 text-sm font-medium text-gray-500">
사용자 정보를 불러오는 중입니다...
</div>
) : isError ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/50 z-10 text-sm font-medium text-red-500">
사용자 정보를 불러오지 못했습니다.
</div>
) : null}
<UserStatusPanel users={users} />
Comment thread
LeeSangHyeok0731 marked this conversation as resolved.
Outdated
</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
31 changes: 28 additions & 3 deletions src/widgets/users-page/ui/UserRowActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="inline-flex h-7 min-w-[54px] items-center justify-center rounded-full bg-[#EF4B4F] px-3 text-xs font-semibold text-white"
onClick={handleExtend}
className="inline-flex h-7 min-w-[54px] cursor-pointer items-center justify-center rounded-full bg-[#EF4B4F] px-3 text-xs font-semibold text-white transition-opacity hover:opacity-90"
>
연장
</button>

<button
type="button"
className="inline-flex h-7 min-w-[54px] items-center justify-center rounded-full bg-[#4D83F6] px-3 text-xs font-semibold text-white"
onClick={handleRelease}
disabled={isPending}
className="inline-flex h-7 min-w-[54px] cursor-pointer items-center justify-center rounded-full bg-[#4D83F6] px-3 text-xs font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
>
해제
</button>
Expand All @@ -29,7 +53,8 @@ export default function UserRowActions({
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="inline-flex h-7 min-w-[76px] items-center justify-center rounded-full bg-[#EF4B4F] px-3 text-xs font-semibold text-white"
onClick={handleStopLaundry}
className="inline-flex h-7 min-w-[76px] cursor-pointer items-center justify-center rounded-full bg-[#EF4B4F] px-3 text-xs font-semibold text-white transition-opacity hover:opacity-90"
>
세탁 정지
</button>
Expand Down
Loading
Loading