Skip to content
20 changes: 20 additions & 0 deletions src/entities/user/api/applyUserPenalty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { normalizeApiError, post, userUrl } from "@/shared/api";
import type { BaseResponseType } from "@/shared/api/types";

interface ApplyUserPenaltyRequest {
reason: string;
}

export async function applyUserPenalty(
userId: number,
request: ApplyUserPenaltyRequest,
): Promise<void> {
try {
await post<BaseResponseType<null>>(
userUrl.applyUserPenalty(userId),
request,
);
} catch (error) {
throw normalizeApiError(error);
}
}
10 changes: 10 additions & 0 deletions src/entities/user/api/extendUserPenalty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { normalizeApiError, patch, userUrl } from "@/shared/api";
import type { BaseResponseType } from "@/shared/api/types";

export async function extendUserPenalty(userId: number, days: number): Promise<void> {
try {
await patch<BaseResponseType<null>>(userUrl.extendUserPenalty(userId), { days });
} catch (error) {
throw normalizeApiError(error);
}
}
2 changes: 2 additions & 0 deletions src/entities/user/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export { getUsers } from "./getUsers";
export { useGetMyInfo } from "./useGetMyInfo";
export { useGetUsers } from "./useGetUsers";
export { useDeleteUserPenalty } from "./useDeleteUserPenalty";
export { useApplyUserPenalty } from "./useApplyUserPenalty";
export { useExtendUserPenalty } from "./useExtendUserPenalty";
15 changes: 15 additions & 0 deletions src/entities/user/api/useApplyUserPenalty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { userQueryKeys } from "@/shared/api";
import { applyUserPenalty } from "./applyUserPenalty";

export function useApplyUserPenalty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ userId, reason }: { userId: number; reason: string }) =>
applyUserPenalty(userId, { reason }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userQueryKeys.all });
},
});
}
13 changes: 13 additions & 0 deletions src/entities/user/api/useExtendUserPenalty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { userQueryKeys } from "@/shared/api";
import { extendUserPenalty } from "./extendUserPenalty";

export function useExtendUserPenalty() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ userId, days }: { userId: number; days: number }) =>
extendUserPenalty(userId, days),
onSuccess: () => queryClient.invalidateQueries({ queryKey: userQueryKeys.all }),
});
}
1 change: 1 addition & 0 deletions src/features/user/apply-penalty/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ApplyUserPenaltyModal } from "./ui/ApplyUserPenaltyModal";
7 changes: 7 additions & 0 deletions src/features/user/apply-penalty/model/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export const applyUserPenaltySchema = z.object({
reason: z.string().trim().min(1, "부과 사유를 입력해주세요.").max(200, "부과 사유는 200자 이내로 입력해주세요."),
});

export type ApplyUserPenaltyFormValues = z.infer<typeof applyUserPenaltySchema>;
78 changes: 78 additions & 0 deletions src/features/user/apply-penalty/ui/ApplyUserPenaltyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useApplyUserPenalty } from "@/entities/user";
import { applyUserPenaltySchema, type ApplyUserPenaltyFormValues } from "../model/schema";

interface ApplyUserPenaltyModalProps {
open: boolean;
userId: number;
userName: string;
room: string;
onClose: () => void;
}

export default function ApplyUserPenaltyModal({
open,
userId,
userName,
room,
onClose,
}: ApplyUserPenaltyModalProps) {
const { mutateAsync, isPending } = useApplyUserPenalty();
const { register, handleSubmit, reset, formState: { errors } } = useForm<ApplyUserPenaltyFormValues>({
resolver: zodResolver(applyUserPenaltySchema),
defaultValues: { reason: "" },
});

useEffect(() => {
if (!open) reset();
}, [open, reset]);

if (!open) return null;

const onSubmit = async ({ reason }: ApplyUserPenaltyFormValues) => {
try {
await mutateAsync({ userId, reason });
toast.success("세탁 패널티가 부과되었습니다.");
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "패널티 부과에 실패했습니다.");
}
};

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true" aria-labelledby="apply-penalty-title">
<form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl" noValidate>
<h2 id="apply-penalty-title" className="text-lg font-semibold text-[#4A4A4F]">세탁 패널티 부과</h2>
<p className="mt-2 text-sm text-[#71717A]">
{userName} · {room}호실에 48시간 세탁 예약 차단을 부과합니다.
</p>
<label htmlFor="penalty-reason" className="mt-5 block text-sm font-medium text-[#4A4A4F]">
부과 사유
</label>
<textarea
id="penalty-reason"
{...register("reason")}
placeholder="패널티를 부과하는 사유를 입력하세요."
maxLength={200}
required
rows={4}
className="mt-2 w-full resize-none rounded-xl border border-[#E1E1E6] p-3 text-sm outline-none focus:border-[#EF4B4F]"
/>
{errors.reason && <p className="mt-1 text-xs text-[#EF4B4F]">{errors.reason.message}</p>}
<div className="mt-5 flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={isPending} className="rounded-full px-4 py-2 text-sm text-[#71717A]">
취소
</button>
<button type="submit" disabled={isPending} className="rounded-full bg-[#EF4B4F] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">
{isPending ? "부과 중..." : "패널티 부과"}
</button>
</div>
</form>
</div>
);
}
1 change: 1 addition & 0 deletions src/features/user/extend-penalty/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ExtendUserPenaltyModal } from "./ui/ExtendUserPenaltyModal";
7 changes: 7 additions & 0 deletions src/features/user/extend-penalty/model/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from "zod";

export const extendUserPenaltySchema = z.object({
days: z.number().int().min(1, "연장 일수는 1일 이상이어야 합니다.").max(30, "연장 일수는 30일 이내여야 합니다."),
});

export type ExtendUserPenaltyFormValues = z.infer<typeof extendUserPenaltySchema>;
48 changes: 48 additions & 0 deletions src/features/user/extend-penalty/ui/ExtendUserPenaltyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { useExtendUserPenalty } from "@/entities/user";
import { extendUserPenaltySchema, type ExtendUserPenaltyFormValues } from "../model/schema";

interface ExtendUserPenaltyModalProps {
open: boolean;
userId: number;
userName: string;
onClose: () => void;
}

export default function ExtendUserPenaltyModal({ open, userId, userName, onClose }: ExtendUserPenaltyModalProps) {
const { mutateAsync, isPending } = useExtendUserPenalty();
const { register, handleSubmit, formState: { errors } } = useForm<ExtendUserPenaltyFormValues>({ resolver: zodResolver(extendUserPenaltySchema), defaultValues: { days: 1 } });

if (!open) return null;

const onSubmit = async ({ days }: ExtendUserPenaltyFormValues) => {
try {
await mutateAsync({ userId, days });
toast.success("예약 차단 기간이 연장되었습니다.");
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "예약 차단 기간 연장에 실패했습니다.");
}
};

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true" aria-labelledby="extend-penalty-title">
<form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl" noValidate>
<h2 id="extend-penalty-title" className="text-lg font-semibold text-[#4A4A4F]">패널티 기간 연장</h2>
<p className="mt-2 text-sm text-[#71717A]">{userName} 사용자의 예약 차단 기간을 연장합니다.</p>
<label htmlFor="penalty-days" className="mt-5 block text-sm font-medium text-[#4A4A4F]">연장 일수</label>
<input id="penalty-days" type="number" min={1} max={30} {...register("days", { valueAsNumber: true })} required className="mt-2 w-full rounded-xl border border-[#E1E1E6] p-3 text-sm outline-none focus:border-[#EF4B4F]" />
<p className="mt-1 text-xs text-[#9A9AA0]">1~30일까지 입력할 수 있습니다.</p>
{errors.days && <p className="mt-1 text-xs text-[#EF4B4F]">{errors.days.message}</p>}
<div className="mt-5 flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={isPending} className="rounded-full px-4 py-2 text-sm text-[#71717A]">취소</button>
<button type="submit" disabled={isPending} className="rounded-full bg-[#EF4B4F] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50">{isPending ? "연장 중..." : "연장"}</button>
</div>
</form>
</div>
);
}
4 changes: 4 additions & 0 deletions src/shared/api/apiUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const userUrl = {
getMyInfo: () => "/api/v2/users/my",
deleteUserPenalty: (userId: number) =>
`/api/v2/admin/reservations/users/${userId}/penalty`,
applyUserPenalty: (userId: number) =>
`/api/v2/admin/reservations/users/${userId}/penalty`,
extendUserPenalty: (userId: number) =>
`/api/v2/admin/reservations/users/${userId}/penalty/block`,
} as const;

export const dashboardUrl = {
Expand Down
79 changes: 31 additions & 48 deletions src/widgets/users-page/ui/UserRowActions.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,48 @@
import { useState } from "react";
import { useDeleteUserPenalty } from "@/entities/user";
import type { UserRole } from "@/entities/user";
import { ApplyUserPenaltyModal } from "@/features/user/apply-penalty";
import { ExtendUserPenaltyModal } from "@/features/user/extend-penalty";

interface UserRowActionsProps {
userId: number;
userName: string;
room: string;
role?: UserRole;
isRestrictedCase?: boolean;
}

export default function UserRowActions({
userId,
isRestrictedCase = false,
}: UserRowActionsProps) {
export default function UserRowActions({ userId, userName, room, role, isRestrictedCase = false }: UserRowActionsProps) {
const { mutate: deleteUserPenalty, isPending } = useDeleteUserPenalty();

const handleStopLaundry = () => {
alert("아직 준비 중인 기능입니다.");
};

const handleExtend = () => {
alert("아직 준비 중인 기능입니다.");
};
const [isApplyModalOpen, setIsApplyModalOpen] = useState(false);
const [isExtendModalOpen, setIsExtendModalOpen] = useState(false);
const canApplyPenalty = role === "ADMIN" || role === "DORMITORY_COUNCIL";
const canManagePenalty = role === "ADMIN";

const handleRelease = () => {
const confirmed = window.confirm(
"이 사용자의 세탁 정지(패널티)를 해제하시겠습니까?",
);
if (!confirmed) return;

if (!window.confirm("이 사용자의 세탁 정지(패널티)를 해제하시겠습니까?")) return;
deleteUserPenalty(userId);
};

if (isRestrictedCase) {
return (
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
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"
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>
</div>
);
}
if (!canApplyPenalty) return null;

return (
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
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>
</div>
<>
<div className="flex shrink-0 items-center gap-2">
{!isRestrictedCase && (
<button type="button" onClick={() => setIsApplyModalOpen(true)} 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>
)}
{isRestrictedCase && canManagePenalty && (
<>
<button type="button" onClick={() => setIsExtendModalOpen(true)} 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">연장</button>
<button type="button" 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 disabled:cursor-not-allowed disabled:opacity-50">해제</button>
</>
)}
</div>
<ApplyUserPenaltyModal open={isApplyModalOpen} userId={userId} userName={userName} room={room} onClose={() => setIsApplyModalOpen(false)} />
<ExtendUserPenaltyModal open={isExtendModalOpen} userId={userId} userName={userName} onClose={() => setIsExtendModalOpen(false)} />
</>
);
}
10 changes: 6 additions & 4 deletions src/widgets/users-page/ui/UserStatusPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { User } from "lucide-react";
import type { ManagedUserItem } from "@/entities/user/model/types";
import { useGetMyInfo, type ManagedUserItem, type UserRole } from "@/entities/user";
import StatusPanelShell from "@/shared/ui/admin/StatusPanelShell";
import UserRowActions from "./UserRowActions";

interface UserStatusPanelProps {
users: ManagedUserItem[];
}

function UserRow({ item }: { item: ManagedUserItem }) {
function UserRow({ item, role }: { item: ManagedUserItem; role?: UserRole }) {
const isRestrictedCase = Boolean(item.remain);

return (
Expand Down Expand Up @@ -37,12 +37,14 @@ function UserRow({ item }: { item: ManagedUserItem }) {
)}
</div>

<UserRowActions userId={item.id} isRestrictedCase={isRestrictedCase} />
<UserRowActions userId={item.id} userName={item.name} room={item.room} role={role} isRestrictedCase={isRestrictedCase} />
</div>
);
}

export default function UserStatusPanel({ users }: UserStatusPanelProps) {
const { data: myInfoData } = useGetMyInfo();
const role = myInfoData?.data.role;
return (
<StatusPanelShell
title="사용자 관리"
Expand All @@ -60,7 +62,7 @@ export default function UserStatusPanel({ users }: UserStatusPanelProps) {
) : (
<div className="sidebar-scrollbar max-h-full overflow-y-auto">
{users.map((item) => (
<UserRow key={item.id} item={item} />
<UserRow key={item.id} item={item} role={role} />
))}
</div>
)}
Expand Down
Loading