diff --git a/src/app/app-download/page.tsx b/src/app/app-download/page.tsx index 34d3b05..27aa255 100644 --- a/src/app/app-download/page.tsx +++ b/src/app/app-download/page.tsx @@ -32,7 +32,12 @@ export default function AppDownloadPage() { rel="noopener noreferrer" className="flex-1 flex items-center justify-center gap-4 rounded-xl bg-[#1D1D1F] hover:bg-black text-white py-3.5 px-4 transition-all duration-200 shadow-sm hover:shadow-md hover:-translate-y-0.5" > - +
diff --git a/src/entities/dashboard/api/getDashboardSummary.ts b/src/entities/dashboard/api/getDashboardSummary.ts index ae09401..5dfe582 100644 --- a/src/entities/dashboard/api/getDashboardSummary.ts +++ b/src/entities/dashboard/api/getDashboardSummary.ts @@ -1,12 +1,16 @@ -import { dashboardUrl, get } from "@/shared/api"; +import { dashboardUrl, get, normalizeApiError } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; import type { DashboardSummaryDTO } from "../model/types"; import { dashboardSummarySchema } from "./schemas"; export async function getDashboardSummary(): Promise { - const response = await get>( - dashboardUrl.getSummary(), - ); + try { + const response = await get>( + dashboardUrl.getSummary(), + ); - return dashboardSummarySchema.parse(response.data); -} \ No newline at end of file + return dashboardSummarySchema.parse(response.data); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/dashboard/api/index.ts b/src/entities/dashboard/api/index.ts index aa5918f..7c33aa6 100644 --- a/src/entities/dashboard/api/index.ts +++ b/src/entities/dashboard/api/index.ts @@ -1,2 +1,2 @@ export { getDashboardSummary } from "./getDashboardSummary"; -export { useGetDashboardSummary } from "./useGetDashboardSummary"; \ No newline at end of file +export { useGetDashboardSummary } from "./useGetDashboardSummary"; diff --git a/src/entities/dashboard/api/schemas.ts b/src/entities/dashboard/api/schemas.ts index 500ae1f..f03b934 100644 --- a/src/entities/dashboard/api/schemas.ts +++ b/src/entities/dashboard/api/schemas.ts @@ -8,4 +8,4 @@ export const dashboardSummarySchema = z.object({ totalMachines: z.number(), malfunctionMachines: z.number(), suspendedStudents: z.number(), -}); \ No newline at end of file +}); diff --git a/src/entities/dashboard/api/useGetDashboardSummary.ts b/src/entities/dashboard/api/useGetDashboardSummary.ts index 9b24f73..ea74b40 100644 --- a/src/entities/dashboard/api/useGetDashboardSummary.ts +++ b/src/entities/dashboard/api/useGetDashboardSummary.ts @@ -14,4 +14,4 @@ export const useGetDashboardSummary = ({ queryFn: getDashboardSummary, enabled, }); -}; \ No newline at end of file +}; diff --git a/src/entities/dashboard/index.ts b/src/entities/dashboard/index.ts index 45c102a..43711ed 100644 --- a/src/entities/dashboard/index.ts +++ b/src/entities/dashboard/index.ts @@ -3,4 +3,4 @@ export { mapDashboard } from "./lib/mapDashboard"; export type { DashboardItem, DashboardSummaryDTO, -} from "./model/types"; \ No newline at end of file +} from "./model/types"; diff --git a/src/entities/dashboard/model/types.ts b/src/entities/dashboard/model/types.ts index 83b75b4..e5d724e 100644 --- a/src/entities/dashboard/model/types.ts +++ b/src/entities/dashboard/model/types.ts @@ -6,4 +6,4 @@ export type DashboardSummaryDTO = z.infer; export interface DashboardItem { label: string; value: string; -} \ No newline at end of file +} diff --git a/src/entities/machine/api/deleteMachine.ts b/src/entities/machine/api/deleteMachine.ts new file mode 100644 index 0000000..460868b --- /dev/null +++ b/src/entities/machine/api/deleteMachine.ts @@ -0,0 +1,9 @@ +import { del, machineUrl, normalizeApiError } from "@/shared/api"; + +export async function deleteMachine(id: number): Promise { + try { + await del(machineUrl.deleteMachine(id)); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/machine/api/getMachines.ts b/src/entities/machine/api/getMachines.ts index 2365742..2858a56 100644 --- a/src/entities/machine/api/getMachines.ts +++ b/src/entities/machine/api/getMachines.ts @@ -1,4 +1,4 @@ -import { get, machineUrl } from "@/shared/api"; +import { get, machineUrl, normalizeApiError } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; import { mapMachines } from "../lib/mapMachine"; import type { @@ -30,10 +30,14 @@ async function getMachinesByType( export async function getMachines( params?: MachineParamsType, ): Promise { - const [washers, dryers] = await Promise.all([ - getMachinesByType("WASHER", params), - getMachinesByType("DRYER", params), - ]); + try { + const [washers, dryers] = await Promise.all([ + getMachinesByType("WASHER", params), + getMachinesByType("DRYER", params), + ]); - return [...washers, ...dryers]; -} \ No newline at end of file + return [...washers, ...dryers]; + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/machine/api/schemas.ts b/src/entities/machine/api/schemas.ts index 816ceb8..d1ca992 100644 --- a/src/entities/machine/api/schemas.ts +++ b/src/entities/machine/api/schemas.ts @@ -4,10 +4,7 @@ import { z } from "zod"; export const machineTypeSchema = z.enum(["WASHER", "DRYER"]); // 기기 고장 상태 -export const machineConditionStatusSchema = z.enum([ - "NORMAL", - "MALFUNCTION", -]); +export const machineConditionStatusSchema = z.enum(["NORMAL", "MALFUNCTION"]); // 기기 사용 가능 상태 export const machineAvailabilityStatusSchema = z.enum([ @@ -39,4 +36,4 @@ export const machineResponseSchema = z.object({ totalCount: z.number(), totalPages: z.number(), currentPage: z.number(), -}); \ No newline at end of file +}); diff --git a/src/entities/machine/api/updateMachineStatus.ts b/src/entities/machine/api/updateMachineStatus.ts new file mode 100644 index 0000000..51ff720 --- /dev/null +++ b/src/entities/machine/api/updateMachineStatus.ts @@ -0,0 +1,20 @@ +import { machineUrl, normalizeApiError, put } from "@/shared/api"; +import type { MachineConditionStatusDTO } from "../model/types"; + +type UpdateMachineStatusParams = { + id: number; + status: MachineConditionStatusDTO; +}; + +export async function updateMachineStatus({ + id, + status, +}: UpdateMachineStatusParams): Promise { + try { + await put(machineUrl.updateMachineStatus(id), { + status, + }); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/machine/api/useDeleteMachine.ts b/src/entities/machine/api/useDeleteMachine.ts index aac9d55..a823a89 100644 --- a/src/entities/machine/api/useDeleteMachine.ts +++ b/src/entities/machine/api/useDeleteMachine.ts @@ -1,11 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { del, machineQueryKeys, machineUrl } from "@/shared/api"; +import { machineQueryKeys } from "@/shared/api"; +import { deleteMachine } from "./deleteMachine"; export const useDeleteMachine = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: number) => del(machineUrl.deleteMachine(id)), + mutationFn: deleteMachine, onSuccess: () => { queryClient.invalidateQueries({ queryKey: machineQueryKeys.all, diff --git a/src/entities/machine/api/useGetMachines.ts b/src/entities/machine/api/useGetMachines.ts index 427c967..6100ba4 100644 --- a/src/entities/machine/api/useGetMachines.ts +++ b/src/entities/machine/api/useGetMachines.ts @@ -20,4 +20,4 @@ export const useGetMachines = ( queryFn: () => fetchMachines(params), enabled: options?.enabled, }); -}; \ No newline at end of file +}; diff --git a/src/entities/machine/api/useUpdateMachineStatus.ts b/src/entities/machine/api/useUpdateMachineStatus.ts index d42a255..22e86ef 100644 --- a/src/entities/machine/api/useUpdateMachineStatus.ts +++ b/src/entities/machine/api/useUpdateMachineStatus.ts @@ -1,18 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { machineQueryKeys, machineUrl, put } from "@/shared/api"; -import type { MachineConditionStatusDTO } from "../model/types"; +import { machineQueryKeys } from "@/shared/api"; +import { updateMachineStatus } from "./updateMachineStatus"; export const useUpdateMachineStatus = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ - id, - status, - }: { - id: number; - status: MachineConditionStatusDTO; - }) => put(machineUrl.updateMachineStatus(id), { status }), + mutationFn: updateMachineStatus, onSuccess: () => { queryClient.invalidateQueries({ queryKey: machineQueryKeys.all, diff --git a/src/entities/machine/model/types.ts b/src/entities/machine/model/types.ts index 7d5e48d..1991284 100644 --- a/src/entities/machine/model/types.ts +++ b/src/entities/machine/model/types.ts @@ -53,4 +53,4 @@ export type MachineResponseType = z.infer; // 조회 파라미터 export interface MachineParamsType { floor?: number; -} \ No newline at end of file +} diff --git a/src/entities/report/api/getMalfunctionReports.ts b/src/entities/report/api/getMalfunctionReports.ts index 55a1da3..eb6f905 100644 --- a/src/entities/report/api/getMalfunctionReports.ts +++ b/src/entities/report/api/getMalfunctionReports.ts @@ -1,25 +1,26 @@ -import { get, reportUrl } from "@/shared/api"; +import { get, normalizeApiError, reportUrl } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; -import type { - ReportParamsType, - ReportResponseType, -} from "../model/types"; +import type { ReportParamsType, ReportResponseType } from "../model/types"; import { reportResponseSchema } from "./schemas"; export const getMalfunctionReports = async ( params?: ReportParamsType, ): Promise> => { - const response = await get>( - reportUrl.getMalfunctionReports(), - { - params, - }, - ); + try { + const response = await get>( + reportUrl.getMalfunctionReports(), + { + params, + }, + ); - const parsedData = reportResponseSchema.parse(response.data); + const parsedData = reportResponseSchema.parse(response.data); - return { - ...response, - data: parsedData, - }; -}; \ No newline at end of file + return { + ...response, + data: parsedData, + }; + } catch (error) { + throw normalizeApiError(error); + } +}; diff --git a/src/entities/report/api/schemas.ts b/src/entities/report/api/schemas.ts index d381b3f..41cd9ca 100644 --- a/src/entities/report/api/schemas.ts +++ b/src/entities/report/api/schemas.ts @@ -29,4 +29,4 @@ export const reportResponseSchema = z.object({ totalCount: z.number(), totalPages: z.number(), currentPage: z.number(), -}); \ No newline at end of file +}); diff --git a/src/entities/report/api/updateReportStatus.ts b/src/entities/report/api/updateReportStatus.ts new file mode 100644 index 0000000..47c1987 --- /dev/null +++ b/src/entities/report/api/updateReportStatus.ts @@ -0,0 +1,24 @@ +import { normalizeApiError, put, reportUrl } from "@/shared/api"; +import type { BaseResponseType } from "@/shared/api/types"; +import type { ReportStatusType } from "../model/types"; + +type UpdateReportStatusParams = { + id: number; + status: ReportStatusType; +}; + +export async function updateReportStatus({ + id, + status, +}: UpdateReportStatusParams): Promise { + try { + await put>( + reportUrl.updateMalfunctionReportStatus(id), + { + status, + }, + ); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/report/api/useUpdateReportStatus.ts b/src/entities/report/api/useUpdateReportStatus.ts index 71f606d..a2fcce0 100644 --- a/src/entities/report/api/useUpdateReportStatus.ts +++ b/src/entities/report/api/useUpdateReportStatus.ts @@ -1,25 +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"; +import { reportQueryKeys } from "@/shared/api"; +import { updateReportStatus } from "./updateReportStatus"; export function useUpdateReportStatus() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ id, status }: { id: number; status: ReportStatusType }) => { - await put>(reportUrl.updateMalfunctionReportStatus(id), { - status, - }); - }, + mutationFn: updateReportStatus, onSuccess: () => { queryClient.invalidateQueries({ queryKey: reportQueryKeys.all, }); + alert("신고 상태가 변경되었습니다."); }, - onError: () => { - alert("신고 상태 변경 중 오류가 발생했습니다."); + onError: (error) => { + alert( + error instanceof Error + ? error.message + : "신고 상태 변경 중 오류가 발생했습니다.", + ); }, }); } diff --git a/src/entities/report/model/types.ts b/src/entities/report/model/types.ts index c152f67..215e335 100644 --- a/src/entities/report/model/types.ts +++ b/src/entities/report/model/types.ts @@ -15,4 +15,4 @@ export type ReportResponseType = z.infer; // 고장 신고 목록 조회 요청 파라미터 export interface ReportParamsType { status?: ReportStatusType; -} \ No newline at end of file +} diff --git a/src/entities/reservation/api/deleteReservation.ts b/src/entities/reservation/api/deleteReservation.ts index 78b1784..84b51fe 100644 --- a/src/entities/reservation/api/deleteReservation.ts +++ b/src/entities/reservation/api/deleteReservation.ts @@ -1,5 +1,9 @@ -import { del, reservationUrl } from "@/shared/api"; +import { del, normalizeApiError, reservationUrl } from "@/shared/api"; -export async function deleteReservation(id: number) { - await del(reservationUrl.deleteReservation(id)); +export async function deleteReservation(id: number): Promise { + try { + await del(reservationUrl.deleteReservation(id)); + } catch (error) { + throw normalizeApiError(error); + } } diff --git a/src/entities/reservation/api/getMachineReservationHistory.ts b/src/entities/reservation/api/getMachineReservationHistory.ts index 0b3d397..7024634 100644 --- a/src/entities/reservation/api/getMachineReservationHistory.ts +++ b/src/entities/reservation/api/getMachineReservationHistory.ts @@ -1,24 +1,33 @@ -import { get, reservationUrl } from "@/shared/api"; +import { get, normalizeApiError, reservationUrl } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; import { mapMachineReservationHistory } from "../lib/mapReservationHistory"; import type { MachineReservationHistory, MachineReservationHistoryParamsType, - MachineReservationHistoryResponseType, } from "../model/historyTypes"; +import { machineReservationHistoryResponseSchema } from "./schemas"; export async function getMachineReservationHistory( params?: MachineReservationHistoryParamsType, ): Promise { - const response = await get< - BaseResponseType - >(reservationUrl.getMachineReservationHistory(), { - params, - }); + try { + const response = await get>( + reservationUrl.getMachineReservationHistory(), + { + params, + }, + ); - const machine = response.data.machines[0]; + const parsedData = machineReservationHistoryResponseSchema.parse( + response.data, + ); - if (!machine) return null; + const machine = parsedData.machines[0]; - return mapMachineReservationHistory(machine); + if (!machine) return null; + + return mapMachineReservationHistory(machine); + } catch (error) { + throw normalizeApiError(error); + } } diff --git a/src/entities/reservation/api/getReservations.ts b/src/entities/reservation/api/getReservations.ts index e3f0b18..6e50704 100644 --- a/src/entities/reservation/api/getReservations.ts +++ b/src/entities/reservation/api/getReservations.ts @@ -1,13 +1,12 @@ -import { get, reservationUrl } from "@/shared/api"; +import { get, normalizeApiError, reservationUrl } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; import { mapReservations } from "../lib/mapReservation"; -import { reservationResponseSchema } from "./schemas"; - import type { ReservationItem, ReservationMachineType, ReservationParamsType, } from "../model/types"; +import { reservationResponseSchema } from "./schemas"; async function getReservationsByMachineType( machineType: ReservationMachineType, @@ -31,11 +30,14 @@ async function getReservationsByMachineType( export async function getReservations( params?: ReservationParamsType, ): Promise { - const [washers, dryers] = await Promise.all([ - getReservationsByMachineType("WASHER", params), - getReservationsByMachineType("DRYER", params), - ]); + try { + const [washers, dryers] = await Promise.all([ + getReservationsByMachineType("WASHER", params), + getReservationsByMachineType("DRYER", params), + ]); - return [...washers, ...dryers]; + return [...washers, ...dryers]; + } catch (error) { + throw normalizeApiError(error); + } } - diff --git a/src/entities/reservation/api/schemas.ts b/src/entities/reservation/api/schemas.ts index 8d14a6e..656010c 100644 --- a/src/entities/reservation/api/schemas.ts +++ b/src/entities/reservation/api/schemas.ts @@ -42,4 +42,24 @@ export const reservationResponseSchema = z.object({ totalCount: z.number(), totalPages: z.number(), currentPage: z.number(), -}); \ No newline at end of file +}); + +// 예약 히스토리 한 건의 응답 구조 +export const machineReservationHistoryItemSchema = z.object({ + roomNumber: z.string(), + reservedAt: z.string(), + actualCompletionTime: z.string().nullable(), + cancelledAt: z.string().nullable(), + status: z.enum(["COMPLETED", "CANCELLED", "RESERVED", "IN_USE"]), +}); + +// 기기별 예약 히스토리 응답 구조 +export const machineReservationHistorySchema = z.object({ + machineName: z.string(), + reservations: z.array(machineReservationHistoryItemSchema), +}); + +// 예약 히스토리 API의 data 응답 구조 +export const machineReservationHistoryResponseSchema = z.object({ + machines: z.array(machineReservationHistorySchema), +}); diff --git a/src/entities/reservation/lib/mapReservation.ts b/src/entities/reservation/lib/mapReservation.ts index ea62d51..ec46ea4 100644 --- a/src/entities/reservation/lib/mapReservation.ts +++ b/src/entities/reservation/lib/mapReservation.ts @@ -51,10 +51,10 @@ export function mapReservation( : undefined, expectedCompletionTime: badgeStatus === "사용중" - ? dto.expectedCompletionTime ?? undefined + ? (dto.expectedCompletionTime ?? undefined) : undefined, startTime: - badgeStatus === "예약중" ? dto.startTime ?? undefined : undefined, + badgeStatus === "예약중" ? (dto.startTime ?? undefined) : undefined, }; } @@ -63,4 +63,4 @@ export function mapReservations( machineType: ReservationMachineType, ): ReservationItem[] { return dtos.map((dto) => mapReservation(dto, machineType)); -} \ No newline at end of file +} diff --git a/src/entities/reservation/model/types.ts b/src/entities/reservation/model/types.ts index f135140..fde8347 100644 --- a/src/entities/reservation/model/types.ts +++ b/src/entities/reservation/model/types.ts @@ -30,9 +30,7 @@ export interface ReservationItem { } // API 응답 타입 -export type ReservationDTOStatus = z.infer< - typeof reservationStatusSchema ->; +export type ReservationDTOStatus = z.infer; export type MachineAvailabilityStatus = z.infer< typeof machineAvailabilityStatusSchema @@ -40,9 +38,7 @@ export type MachineAvailabilityStatus = z.infer< export type ReservationDTO = z.infer; -export type ReservationResponseType = z.infer< - typeof reservationResponseSchema ->; +export type ReservationResponseType = z.infer; // 예약 목록 조회 요청 파라미터 export interface ReservationParamsType { @@ -55,4 +51,4 @@ export interface ReservationParamsType { page?: number; size?: number; sort?: string[]; -} \ No newline at end of file +} diff --git a/src/entities/user/api/deleteUserPenalty.ts b/src/entities/user/api/deleteUserPenalty.ts new file mode 100644 index 0000000..45df3a3 --- /dev/null +++ b/src/entities/user/api/deleteUserPenalty.ts @@ -0,0 +1,10 @@ +import { del, normalizeApiError, userUrl } from "@/shared/api"; +import type { BaseResponseType } from "@/shared/api/types"; + +export async function deleteUserPenalty(userId: number): Promise { + try { + await del>(userUrl.deleteUserPenalty(userId)); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/user/api/getMyInfo.ts b/src/entities/user/api/getMyInfo.ts index db1cffd..b2a0a6b 100644 --- a/src/entities/user/api/getMyInfo.ts +++ b/src/entities/user/api/getMyInfo.ts @@ -1,15 +1,19 @@ -import { get, userUrl } from "@/shared/api"; +import { get, normalizeApiError, userUrl } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; import type { MyInfoType } from "../model/types"; import { myInfoSchema } from "./schemas"; export async function getMyInfo(): Promise> { - const response = await get>(userUrl.getMyInfo()); + try { + const response = await get>(userUrl.getMyInfo()); - const parsedData = myInfoSchema.parse(response.data); + const parsedData = myInfoSchema.parse(response.data); - return { - ...response, - data: parsedData, - }; -} \ No newline at end of file + return { + ...response, + data: parsedData, + }; + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/user/api/getUsers.ts b/src/entities/user/api/getUsers.ts index a0b951a..d69b28f 100644 --- a/src/entities/user/api/getUsers.ts +++ b/src/entities/user/api/getUsers.ts @@ -1,23 +1,21 @@ -import { get, userUrl } from "@/shared/api"; +import { get, normalizeApiError, userUrl } from "@/shared/api"; import type { BaseResponseType } from "@/shared/api/types"; import { mapUsers } from "../lib/mapUser"; -import type { - ManagedUserItem, - UserParamsType, -} from "../model/types"; +import type { ManagedUserItem, UserParamsType } from "../model/types"; import { userResponseSchema } from "./schemas"; export async function getUsers( params?: UserParamsType, ): Promise { - const response = await get>( - userUrl.getUsers(), - { + try { + const response = await get>(userUrl.getUsers(), { params, - }, - ); + }); - const parsedData = userResponseSchema.parse(response.data); + const parsedData = userResponseSchema.parse(response.data); - return mapUsers(parsedData.users); -} \ No newline at end of file + return mapUsers(parsedData.users); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/user/api/schemas.ts b/src/entities/user/api/schemas.ts index 333b0b8..28eb40c 100644 --- a/src/entities/user/api/schemas.ts +++ b/src/entities/user/api/schemas.ts @@ -1,11 +1,7 @@ import { z } from "zod"; // 사용자 권한 -export const userRoleSchema = z.enum([ - "ADMIN", - "USER", - "DORMITORY_COUNCIL", -]); +export const userRoleSchema = z.enum(["ADMIN", "USER", "DORMITORY_COUNCIL"]); // 사용자 한 명의 API 응답 구조 export const userDTOSchema = z.object({ @@ -44,4 +40,4 @@ export const myInfoSchema = z.object({ canReserve: z.boolean(), penaltyExpiresAt: z.string().nullable(), role: userRoleSchema, -}); \ No newline at end of file +}); diff --git a/src/entities/user/api/useDeleteUserPenalty.ts b/src/entities/user/api/useDeleteUserPenalty.ts index 94ca9b4..0b67dce 100644 --- a/src/entities/user/api/useDeleteUserPenalty.ts +++ b/src/entities/user/api/useDeleteUserPenalty.ts @@ -1,22 +1,25 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { del, userUrl, userQueryKeys } from "@/shared/api"; -import type { BaseResponseType } from "@/shared/api/types"; +import { userQueryKeys } from "@/shared/api"; +import { deleteUserPenalty } from "./deleteUserPenalty"; export function useDeleteUserPenalty() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (userId: number) => { - await del>(userUrl.deleteUserPenalty(userId)); - }, + mutationFn: deleteUserPenalty, onSuccess: () => { queryClient.invalidateQueries({ queryKey: userQueryKeys.all, }); + alert("세탁 정지 해제가 완료되었습니다."); }, - onError: () => { - alert("세탁 정지 해제 중 오류가 발생했습니다."); + onError: (error) => { + alert( + error instanceof Error + ? error.message + : "세탁 정지 해제 중 오류가 발생했습니다.", + ); }, }); } diff --git a/src/entities/user/api/useGetMyInfo.ts b/src/entities/user/api/useGetMyInfo.ts index aea4933..8b1cced 100644 --- a/src/entities/user/api/useGetMyInfo.ts +++ b/src/entities/user/api/useGetMyInfo.ts @@ -9,4 +9,4 @@ export const useGetMyInfo = () => { queryKey: userQueryKeys.getMyInfo(), queryFn: fetchMyInfo, }); -}; \ No newline at end of file +}; diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index 30a9368..48f62a6 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -37,4 +37,4 @@ export interface ManagedUserItem { warningCount: number; reason?: string; remain?: string; -} \ No newline at end of file +} diff --git a/src/proxy.ts b/src/proxy.ts index 39af9af..ebab67c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,7 +5,11 @@ export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const accessToken = request.cookies.get(COOKIE_KEYS.ACCESS_TOKEN)?.value; - if (pathname.startsWith("/api/") || pathname === "/sign-in" || pathname === "/app-download") { + if ( + pathname.startsWith("/api/") || + pathname === "/sign-in" || + pathname === "/app-download" + ) { return NextResponse.next(); } diff --git a/src/shared/api/apiUrls.ts b/src/shared/api/apiUrls.ts index bd4a605..73f8ebb 100644 --- a/src/shared/api/apiUrls.ts +++ b/src/shared/api/apiUrls.ts @@ -5,7 +5,8 @@ export const authUrl = { export const reportUrl = { getMalfunctionReports: () => "/api/v2/admin/malfunction-reports", - updateMalfunctionReportStatus: (id: number) => `/api/v2/admin/malfunction-reports/${id}/status`, + updateMalfunctionReportStatus: (id: number) => + `/api/v2/admin/malfunction-reports/${id}/status`, } as const; // 개별 상수로 분리하여 확실하게 정의 @@ -30,9 +31,10 @@ 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`, + deleteUserPenalty: (userId: number) => + `/api/v2/admin/reservations/users/${userId}/penalty`, } as const; export const dashboardUrl = { getSummary: () => "/api/v2/admin/dashboard", -} as const; \ No newline at end of file +} as const; diff --git a/src/shared/api/errors/appError.ts b/src/shared/api/errors/appError.ts new file mode 100644 index 0000000..814f620 --- /dev/null +++ b/src/shared/api/errors/appError.ts @@ -0,0 +1,35 @@ +export const APP_ERROR_TYPE = { + AUTHENTICATION: "AUTHENTICATION", + FORBIDDEN: "FORBIDDEN", + BAD_REQUEST: "BAD_REQUEST", + NOT_FOUND: "NOT_FOUND", + CONFLICT: "CONFLICT", + SERVER: "SERVER", + NETWORK: "NETWORK", + VALIDATION: "VALIDATION", + UNKNOWN: "UNKNOWN", +} as const; + +export type AppErrorType = (typeof APP_ERROR_TYPE)[keyof typeof APP_ERROR_TYPE]; + +type AppErrorOptions = { + type: AppErrorType; + message: string; + status?: number; + cause?: unknown; +}; + +export class AppError extends Error { + readonly type: AppErrorType; + readonly status?: number; + readonly cause?: unknown; + + constructor({ type, message, status, cause }: AppErrorOptions) { + super(message); + + this.name = "AppError"; + this.type = type; + this.status = status; + this.cause = cause; + } +} diff --git a/src/shared/api/errors/index.ts b/src/shared/api/errors/index.ts new file mode 100644 index 0000000..fcd6056 --- /dev/null +++ b/src/shared/api/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./appError"; +export * from "./normalizeApiError"; diff --git a/src/shared/api/errors/normalizeApiError.ts b/src/shared/api/errors/normalizeApiError.ts new file mode 100644 index 0000000..438af67 --- /dev/null +++ b/src/shared/api/errors/normalizeApiError.ts @@ -0,0 +1,145 @@ +import axios from "axios"; +import { ZodError } from "zod"; +import { APP_ERROR_TYPE, AppError } from "./appError"; + +type ErrorResponse = { + message?: string; +}; + +const getServerErrorMessage = ( + data: unknown, + fallbackMessage: string, +): string => { + if (typeof data === "object" && data !== null && "message" in data) { + const { message } = data as ErrorResponse; + + if (typeof message === "string") { + return message; + } + } + + return fallbackMessage; +}; + +export const normalizeApiError = (error: unknown): AppError => { + if (error instanceof AppError) { + return error; + } + + if (error instanceof ZodError) { + return new AppError({ + type: APP_ERROR_TYPE.VALIDATION, + message: "서버 응답 형식이 올바르지 않습니다.", + cause: error, + }); + } + + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const responseData: unknown = error.response?.data; + + if (!error.response) { + return new AppError({ + type: APP_ERROR_TYPE.NETWORK, + message: "서버에 연결할 수 없습니다.", + cause: error, + }); + } + + if (status === 401) { + return new AppError({ + type: APP_ERROR_TYPE.AUTHENTICATION, + message: getServerErrorMessage( + responseData, + "로그인이 만료되었거나 유효하지 않습니다.", + ), + status, + cause: error, + }); + } + + if (status === 403) { + return new AppError({ + type: APP_ERROR_TYPE.FORBIDDEN, + message: getServerErrorMessage( + responseData, + "요청한 기능에 접근할 권한이 없습니다.", + ), + status, + cause: error, + }); + } + + if (status === 400 || status === 422) { + return new AppError({ + type: APP_ERROR_TYPE.BAD_REQUEST, + message: getServerErrorMessage( + responseData, + "요청한 정보를 확인해주세요.", + ), + status, + cause: error, + }); + } + + if (status === 404) { + return new AppError({ + type: APP_ERROR_TYPE.NOT_FOUND, + message: getServerErrorMessage( + responseData, + "요청한 정보를 찾을 수 없습니다.", + ), + status, + cause: error, + }); + } + + if (status === 409) { + return new AppError({ + type: APP_ERROR_TYPE.CONFLICT, + message: getServerErrorMessage( + responseData, + "현재 상태에서는 요청을 처리할 수 없습니다.", + ), + status, + cause: error, + }); + } + + if (status !== undefined && status >= 500) { + return new AppError({ + type: APP_ERROR_TYPE.SERVER, + message: getServerErrorMessage( + responseData, + "서버에서 요청을 처리하지 못했습니다.", + ), + status, + cause: error, + }); + } + + return new AppError({ + type: APP_ERROR_TYPE.UNKNOWN, + message: getServerErrorMessage( + responseData, + "API 요청 중 오류가 발생했습니다.", + ), + status, + cause: error, + }); + } + + if (error instanceof Error) { + return new AppError({ + type: APP_ERROR_TYPE.UNKNOWN, + message: error.message, + cause: error, + }); + } + + return new AppError({ + type: APP_ERROR_TYPE.UNKNOWN, + message: "알 수 없는 오류가 발생했습니다.", + cause: error, + }); +}; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 91138b8..79e78c4 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -2,3 +2,4 @@ export * from "./apiUrls"; export * from "./http"; export * from "./queryKeys"; export * from "./types"; +export * from "./errors"; diff --git a/src/shared/api/queryKeys.ts b/src/shared/api/queryKeys.ts index 21ace0b..6694d1d 100644 --- a/src/shared/api/queryKeys.ts +++ b/src/shared/api/queryKeys.ts @@ -32,4 +32,4 @@ export const reservationQueryKeys = { export const dashboardQueryKeys = { all: ["dashboard"] as const, summary: () => ["dashboard", "summary"] as const, -} as const; \ No newline at end of file +} as const; diff --git a/src/shared/index.ts b/src/shared/index.ts index 1483aa4..7e2e907 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,5 +1,6 @@ export * from "./api"; export * from "./constants/cookies"; +export * from "./lib/auth"; export * from "./lib/axios"; export * from "./lib/cn"; export * from "./lib/TanStackProvider"; diff --git a/src/shared/lib/auth/clearAuthSession.ts b/src/shared/lib/auth/clearAuthSession.ts new file mode 100644 index 0000000..cd45f0c --- /dev/null +++ b/src/shared/lib/auth/clearAuthSession.ts @@ -0,0 +1,10 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { COOKIE_KEYS } from "@/shared/constants/cookies"; +import { deleteCookie } from "@/shared/utils/cookies"; + +export const clearAuthSession = (queryClient: QueryClient): void => { + queryClient.clear(); + + deleteCookie(COOKIE_KEYS.ACCESS_TOKEN); + deleteCookie(COOKIE_KEYS.REFRESH_TOKEN); +}; diff --git a/src/shared/lib/auth/index.ts b/src/shared/lib/auth/index.ts new file mode 100644 index 0000000..a46da07 --- /dev/null +++ b/src/shared/lib/auth/index.ts @@ -0,0 +1 @@ +export * from "./clearAuthSession"; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 3c73327..9eaabb6 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,3 +1,4 @@ +export { clearAuthSession } from "./auth"; export { axiosInstance } from "./axios"; export { cn } from "./cn"; export { formatDateTime } from "./formatDateTime"; diff --git a/src/shared/utils/cookies.ts b/src/shared/utils/cookies.ts index f5e7285..1e0ca70 100644 --- a/src/shared/utils/cookies.ts +++ b/src/shared/utils/cookies.ts @@ -58,4 +58,4 @@ export const clearAllCookies = (): void => { for (const name in cookies) { deleteCookie(name); } -}; \ No newline at end of file +}; diff --git a/src/widgets/layout/admin-layout/ui/AdminLayout/index.tsx b/src/widgets/layout/admin-layout/ui/AdminLayout/index.tsx index a9ebe12..3b25169 100644 --- a/src/widgets/layout/admin-layout/ui/AdminLayout/index.tsx +++ b/src/widgets/layout/admin-layout/ui/AdminLayout/index.tsx @@ -3,13 +3,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, type PropsWithChildren } from "react"; import { toast } from "sonner"; -import { - mapDashboard, - useGetDashboardSummary, -} from "@/entities/dashboard"; +import { mapDashboard, useGetDashboardSummary } from "@/entities/dashboard"; import { useGetMyInfo } from "@/entities/user"; -import { COOKIE_KEYS } from "@/shared"; -import { deleteCookie } from "@/shared/utils/cookies"; +import { APP_ERROR_TYPE, AppError, clearAuthSession } from "@/shared"; import DashboardTabs from "../DashboardTabs"; import Header from "../Header"; import SummaryCards from "../SummaryCards"; @@ -19,56 +15,50 @@ export default function AdminLayout({ children }: PropsWithChildren) { const { data: myInfoData, + error: myInfoError, isLoading: isMyInfoLoading, isError: isMyInfoError, + refetch: refetchMyInfo, } = useGetMyInfo(); const myInfo = myInfoData?.data; useEffect(() => { - if (isMyInfoError) { - toast.error( - "로그인이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.", - ); - - queryClient.clear(); - deleteCookie(COOKIE_KEYS.ACCESS_TOKEN); - deleteCookie(COOKIE_KEYS.REFRESH_TOKEN); - - window.location.href = "/sign-in"; + if ( + isMyInfoError && + myInfoError instanceof AppError && + myInfoError.type === APP_ERROR_TYPE.AUTHENTICATION + ) { + toast.error(myInfoError.message); + + clearAuthSession(queryClient); + window.location.replace("/sign-in"); return; } if (myInfo?.role === "USER") { toast.error("관리자만 접근 가능합니다."); - - queryClient.clear(); - deleteCookie(COOKIE_KEYS.ACCESS_TOKEN); - deleteCookie(COOKIE_KEYS.REFRESH_TOKEN); - - window.location.href = "/app-download"; + window.location.replace("/app-download"); } - }, [myInfo, isMyInfoError, queryClient]); + }, [myInfo, myInfoError, isMyInfoError, queryClient]); const { data: dashboardSummary, isLoading: isDashboardLoading, isError: isDashboardError, } = useGetDashboardSummary({ - enabled: Boolean( - myInfo && - myInfo.role !== "USER" && - !isMyInfoError, - ), + enabled: Boolean(myInfo && myInfo.role !== "USER" && !isMyInfoError), }); - const summaryItems = dashboardSummary - ? mapDashboard(dashboardSummary) - : []; + const summaryItems = dashboardSummary ? mapDashboard(dashboardSummary) : []; + + const isAuthenticationError = + myInfoError instanceof AppError && + myInfoError.type === APP_ERROR_TYPE.AUTHENTICATION; const isCheckingAccess = isMyInfoLoading || - isMyInfoError || + isAuthenticationError || myInfo?.role === "USER" || (!myInfo && !isMyInfoError); @@ -80,6 +70,29 @@ export default function AdminLayout({ children }: PropsWithChildren) { ); } + if (isMyInfoError) { + const errorMessage = + myInfoError instanceof AppError + ? myInfoError.message + : "사용자 정보를 불러오지 못했습니다."; + + return ( +
+

{errorMessage}

+ + +
+ ); + } + return (
@@ -105,4 +118,4 @@ export default function AdminLayout({ children }: PropsWithChildren) {
); -} \ No newline at end of file +} diff --git a/src/widgets/layout/admin-layout/ui/Header.tsx b/src/widgets/layout/admin-layout/ui/Header.tsx index 297778e..a36eb78 100644 --- a/src/widgets/layout/admin-layout/ui/Header.tsx +++ b/src/widgets/layout/admin-layout/ui/Header.tsx @@ -6,26 +6,19 @@ import Image from "next/image"; import { useCallback } from "react"; import { toast } from "sonner"; import { useGetMyInfo } from "@/entities/user"; -import { COOKIE_KEYS } from "@/shared"; -import { deleteCookie } from "@/shared/utils/cookies"; +import { clearAuthSession } from "@/shared"; export default function Header() { const queryClient = useQueryClient(); const { data: myInfoData } = useGetMyInfo(); + const myInfo = myInfoData?.data; const handleLogout = useCallback(() => { - queryClient.clear(); - - deleteCookie(COOKIE_KEYS.ACCESS_TOKEN); - deleteCookie(COOKIE_KEYS.REFRESH_TOKEN); - - // 페이지 새로고침 - window.location.reload(); + clearAuthSession(queryClient); + window.location.replace("/sign-in"); }, [queryClient]); - - return (
@@ -37,17 +30,19 @@ export default function Header() {

{myInfo.studentId} {myInfo.name}

+

{myInfo.roomNumber}호 |{" "} {myInfo.role !== "USER" ? "관리자" : "사용자"}

)} +
); -} \ No newline at end of file +} diff --git a/src/widgets/reports-page/ReportsPage.tsx b/src/widgets/reports-page/ReportsPage.tsx index f02b550..308df3d 100644 --- a/src/widgets/reports-page/ReportsPage.tsx +++ b/src/widgets/reports-page/ReportsPage.tsx @@ -23,15 +23,14 @@ const ReportsPage = () => { status, }); - const { data: machines = [], isLoading: isMachinesLoading } = - useGetMachines( - { - floor, - }, - { - enabled: floor !== undefined, - }, - ); + const { data: machines = [], isLoading: isMachinesLoading } = useGetMachines( + { + floor, + }, + { + enabled: floor !== undefined, + }, + ); const isLoading = isReportsLoading || (floor !== undefined && isMachinesLoading); @@ -43,9 +42,7 @@ const ReportsPage = () => { // Filter by floor if selected if (floor !== undefined) { - const machineIdsOnFloor = new Set( - machines.map((machine) => machine.id), - ); + const machineIdsOnFloor = new Set(machines.map((machine) => machine.id)); result = result.filter((report) => machineIdsOnFloor.has(report.machineId), @@ -98,4 +95,4 @@ const ReportsPage = () => { ); }; -export default ReportsPage; \ No newline at end of file +export default ReportsPage; diff --git a/src/widgets/reports-page/ui/ReportsPanel.tsx b/src/widgets/reports-page/ui/ReportsPanel.tsx index 09eb683..2186f2e 100644 --- a/src/widgets/reports-page/ui/ReportsPanel.tsx +++ b/src/widgets/reports-page/ui/ReportsPanel.tsx @@ -85,7 +85,7 @@ const ReportActionButton = ({ item }: { item: ReportItemType }) => { const handleNextStatus = () => { if (item.status === "PENDING") { const confirmed = window.confirm( - "처리중으로 변경된다면 기기가 고장 상태로 변경되고 처리 완료 상태가 되기 전까지는 사용이 불가능 합니다. 계속하시겠습니까?" + "처리중으로 변경된다면 기기가 고장 상태로 변경되고 처리 완료 상태가 되기 전까지는 사용이 불가능 합니다. 계속하시겠습니까?", ); if (!confirmed) return; updateStatus({ id: item.id, status: "IN_PROGRESS" }); @@ -104,7 +104,7 @@ const ReportActionButton = ({ item }: { item: ReportItemType }) => { disabled={isPending} className={cn( "inline-flex h-7 min-w-16 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 + buttonBg, )} > {buttonText} diff --git a/src/widgets/reservations-page/ui/ReservationRow.tsx b/src/widgets/reservations-page/ui/ReservationRow.tsx index dc4813f..607e278 100644 --- a/src/widgets/reservations-page/ui/ReservationRow.tsx +++ b/src/widgets/reservations-page/ui/ReservationRow.tsx @@ -54,7 +54,9 @@ export default function ReservationRow({

{item.machine} - {item.userRoomNumber}호 + + {item.userRoomNumber}호 +

{item.badgeStatus === "사용중" && ( diff --git a/src/widgets/users-page/ui/UserFilterPanel.tsx b/src/widgets/users-page/ui/UserFilterPanel.tsx index 96f3e31..7bdda65 100644 --- a/src/widgets/users-page/ui/UserFilterPanel.tsx +++ b/src/widgets/users-page/ui/UserFilterPanel.tsx @@ -27,10 +27,10 @@ export default function UserFilterPanel({
-
diff --git a/src/widgets/users-page/ui/UserRowActions.tsx b/src/widgets/users-page/ui/UserRowActions.tsx index 582bbdd..d709181 100644 --- a/src/widgets/users-page/ui/UserRowActions.tsx +++ b/src/widgets/users-page/ui/UserRowActions.tsx @@ -20,7 +20,9 @@ export default function UserRowActions({ }; const handleRelease = () => { - const confirmed = window.confirm("이 사용자의 세탁 정지(패널티)를 해제하시겠습니까?"); + const confirmed = window.confirm( + "이 사용자의 세탁 정지(패널티)를 해제하시겠습니까?", + ); if (!confirmed) return; deleteUserPenalty(userId);