From 8e59d0cf55c016dcc014ad4c83381869c6acff3d Mon Sep 17 00:00:00 2001 From: hdg0116 Date: Wed, 9 Sep 2026 13:35:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20=EB=A7=A4=EC=9E=A5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/stores/route.ts | 61 ++++++++++++++ .../store-management/api/store-api.ts | 81 +++++++++++++++++++ .../store-management/api/store-queries.ts | 24 ++++++ .../StoreManagementTableContent.tsx | 2 + .../components/StoreStatusTag.tsx | 21 +++-- src/features/store-management/model/store.ts | 8 +- 6 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 src/app/api/stores/route.ts create mode 100644 src/features/store-management/api/store-api.ts create mode 100644 src/features/store-management/api/store-queries.ts diff --git a/src/app/api/stores/route.ts b/src/app/api/stores/route.ts new file mode 100644 index 0000000..8a54da1 --- /dev/null +++ b/src/app/api/stores/route.ts @@ -0,0 +1,61 @@ +import {NextRequest, NextResponse} from 'next/server'; + +import {API_ENDPOINTS, type AdminApiTypes} from '@/src/shared/api'; +import {normalizeApiError} from '@/src/shared/api/api-error'; +import {createServerApi} from '@/src/shared/api/server-client'; +import {AUTH_COOKIE_NAMES} from '@/src/shared/auth'; + +const DEFAULT_PAGE = 1; +const DEFAULT_PAGE_SIZE = 10; + +const createApiErrorResponse = (error: unknown) => { + const apiError = normalizeApiError(error); + + return NextResponse.json( + { + code: apiError.code, + message: apiError.message, + }, + { + status: apiError.status ?? 500, + } + ); +}; + +const getPositiveIntegerParam = ( + searchParams: URLSearchParams, + name: string, + fallback: number +) => { + const value = Number(searchParams.get(name)); + + return Number.isInteger(value) && value > 0 ? value : fallback; +}; + +export async function GET(request: NextRequest) { + const {searchParams} = new URL(request.url); + const keyword = searchParams.get('keyword')?.trim(); + const page = getPositiveIntegerParam(searchParams, 'page', DEFAULT_PAGE); + const size = getPositiveIntegerParam(searchParams, 'size', DEFAULT_PAGE_SIZE); + const accessToken = request.cookies.get(AUTH_COOKIE_NAMES.accessToken)?.value; + + try { + const serverApi = await createServerApi({accessToken}); + const {data} = + await serverApi.get( + API_ENDPOINTS.stores.root, + { + maxRedirects: 0, + params: { + ...(keyword ? {keyword} : {}), + page, + size, + }, + } + ); + + return NextResponse.json(data); + } catch (error) { + return createApiErrorResponse(error); + } +} diff --git a/src/features/store-management/api/store-api.ts b/src/features/store-management/api/store-api.ts new file mode 100644 index 0000000..58c6e84 --- /dev/null +++ b/src/features/store-management/api/store-api.ts @@ -0,0 +1,81 @@ +import { + API_ENDPOINTS, + buildApiPath, + getBrowserApi, + type AdminApiTypes, +} from '@/src/shared/api'; + +import type {Store, StoreStatus} from '../model/store'; + +type GetStoreListParams = { + keyword?: string; + page: number; + size: number; +}; + +type StoreListResult = { + stores: Store[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNextPage: boolean; +}; + +type ApiStoreStatus = NonNullable; + +const storeStatusMap = { + OPEN_SOON: 'upcoming', + NEW_OPEN: 'new', + NORMAL: 'operating', + RENEWAL: 'renovation', + CLOSING_SOON: 'closing', + CLOSED: 'closed', +} satisfies Record; + +const mapApiStoreStatus = ( + status: AdminApiTypes.GetStoreListResponse['status'] +): StoreStatus => { + return status ? storeStatusMap[status] : 'operating'; +}; + +const mapApiStore = ( + store: AdminApiTypes.GetStoreListResponse, + index: number +): Store => { + return { + id: store.id ?? index + 1, + name: store.name ?? '', + address: store.address ?? '', + station: store.station ?? '', + status: mapApiStoreStatus(store.status), + phone: store.contact ?? '', + website: store.websiteUrl ?? '', + }; +}; + +export const getStoreList = async ({ + keyword, + page, + size, +}: GetStoreListParams): Promise => { + const {data} = + await getBrowserApi().get( + buildApiPath(API_ENDPOINTS.stores.root, { + keyword: keyword?.trim() || undefined, + page, + size, + }) + ); + + return { + stores: data.contents?.map(mapApiStore) ?? [], + page: data.page ?? page, + size: data.size ?? size, + totalElements: data.totalElements ?? 0, + totalPages: data.totalPages ?? 1, + hasNextPage: data.hasNextPage ?? false, + }; +}; + +export type {GetStoreListParams, StoreListResult}; diff --git a/src/features/store-management/api/store-queries.ts b/src/features/store-management/api/store-queries.ts new file mode 100644 index 0000000..fa002de --- /dev/null +++ b/src/features/store-management/api/store-queries.ts @@ -0,0 +1,24 @@ +import {useQuery} from '@tanstack/react-query'; + +import { + getStoreList, + type GetStoreListParams, + type StoreListResult, +} from './store-api'; + +const storeQueryKeys = { + all: ['stores'] as const, + lists: () => [...storeQueryKeys.all, 'list'] as const, + list: (params: GetStoreListParams) => + [...storeQueryKeys.lists(), params] as const, +}; + +const useStoreListQuery = (params: GetStoreListParams) => { + return useQuery({ + queryKey: storeQueryKeys.list(params), + queryFn: () => getStoreList(params), + placeholderData: (previousData) => previousData, + }); +}; + +export {storeQueryKeys, useStoreListQuery}; diff --git a/src/features/store-management/components/StoreManagementTableContent.tsx b/src/features/store-management/components/StoreManagementTableContent.tsx index 87ef541..687643e 100644 --- a/src/features/store-management/components/StoreManagementTableContent.tsx +++ b/src/features/store-management/components/StoreManagementTableContent.tsx @@ -14,6 +14,8 @@ import {StoreStatusTag, type StoreStatusTagVariant} from './StoreStatusTag'; const storeStatusTagVariant = { operating: 'default', new: 'new', + upcoming: 'upcoming', + renovation: 'renovation', closing: 'expect-delete', closed: 'delete', } satisfies Record; diff --git a/src/features/store-management/components/StoreStatusTag.tsx b/src/features/store-management/components/StoreStatusTag.tsx index b8853f3..a2c4894 100644 --- a/src/features/store-management/components/StoreStatusTag.tsx +++ b/src/features/store-management/components/StoreStatusTag.tsx @@ -1,17 +1,22 @@ import {cva} from 'class-variance-authority'; -import {cn} from '@/src/shared/lib/utils'; - -type StoreStatusTagVariant = 'default' | 'new' | 'delete' | 'expect-delete'; +type StoreStatusTagVariant = + | 'default' + | 'new' + | 'upcoming' + | 'renovation' + | 'delete' + | 'expect-delete'; type StoreStatusTagProps = { variant?: StoreStatusTagVariant; - className?: string; }; const statusTagLabel = { default: '정상 운영', new: '신규 오픈', + upcoming: '오픈 예정', + renovation: '리뉴얼', delete: '폐업', 'expect-delete': '폐업 예정', } satisfies Record; @@ -23,6 +28,10 @@ const statusTagVariants = cva( variant: { default: 'bg-tag-default-background text-tag-default-foreground', new: 'bg-tag-new-background text-tag-new-foreground', + upcoming: + 'bg-status-upcoming-background text-status-upcoming-foreground', + renovation: + 'bg-status-renovation-background text-status-renovation-foreground', delete: 'bg-tag-delete-background text-tag-delete-foreground', 'expect-delete': 'bg-tag-expect-delete-background text-tag-expect-delete-foreground', @@ -34,9 +43,9 @@ const statusTagVariants = cva( } ); -function StoreStatusTag({variant = 'default', className}: StoreStatusTagProps) { +function StoreStatusTag({variant = 'default'}: StoreStatusTagProps) { return ( - + {statusTagLabel[variant]} ); diff --git a/src/features/store-management/model/store.ts b/src/features/store-management/model/store.ts index cf2fbbf..2cac333 100644 --- a/src/features/store-management/model/store.ts +++ b/src/features/store-management/model/store.ts @@ -1,4 +1,10 @@ -type StoreStatus = 'operating' | 'new' | 'closing' | 'closed'; +type StoreStatus = + | 'operating' + | 'new' + | 'upcoming' + | 'renovation' + | 'closing' + | 'closed'; type Store = { id: number; From d9d799244aa25f51f856d54080637dd078df9767 Mon Sep 17 00:00:00 2001 From: hdg0116 Date: Wed, 9 Sep 2026 13:36:11 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20=EB=A7=A4=EC=9E=A5=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A9=EB=A1=9D=20API=20=EC=97=B0=EB=8F=99=20(#3?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store-management/StoreManagementPage.tsx | 21 ++++- .../StoreManagementClientProvider.tsx | 90 ++++++++++++------ .../components/StoreManagementTable.tsx | 12 +-- .../StoreManagementTableContent.tsx | 92 ++++++++++--------- 4 files changed, 135 insertions(+), 80 deletions(-) diff --git a/src/features/store-management/StoreManagementPage.tsx b/src/features/store-management/StoreManagementPage.tsx index e214f02..6c8918d 100644 --- a/src/features/store-management/StoreManagementPage.tsx +++ b/src/features/store-management/StoreManagementPage.tsx @@ -1,19 +1,34 @@ +'use client'; + import {PageTitle} from '@/src/shared/components/layout/PageTitle'; import {StoreAddDialogTrigger} from './components/StoreAddDialogTrigger'; +import { + StoreManagementClientProvider, + useStoreManagementSummary, +} from './components/StoreManagementClientProvider'; import {StoreManagementTable} from './components/StoreManagementTable'; -import {stores} from './model/mockStores'; function StoreManagementPage() { + return ( + + + + ); +} + +function StoreManagementPageContent() { + const {totalElements} = useStoreManagementSummary(); + return (
매장 관리} - subtitle={`총 ${stores.length}개의 매장`} + subtitle={`총 ${totalElements}개의 매장`} action={} /> - +
); } diff --git a/src/features/store-management/components/StoreManagementClientProvider.tsx b/src/features/store-management/components/StoreManagementClientProvider.tsx index b7709ce..d044430 100644 --- a/src/features/store-management/components/StoreManagementClientProvider.tsx +++ b/src/features/store-management/components/StoreManagementClientProvider.tsx @@ -2,6 +2,9 @@ import * as React from 'react'; +import {isApiError} from '@/src/shared/api'; + +import {useStoreListQuery} from '../api/store-queries'; import type {Store} from '../model/store'; type StoreManagementControlsContextValue = { @@ -13,6 +16,9 @@ type StoreManagementControlsContextValue = { type StoreManagementRowsContextValue = { stores: Store[]; + isError: boolean; + isLoading: boolean; + errorMessage: string; }; type StoreManagementPaginationContextValue = { @@ -23,8 +29,11 @@ type StoreManagementPaginationContextValue = { movePage: (nextPage: number) => void; }; +type StoreManagementSummaryContextValue = { + totalElements: number; +}; + type StoreManagementClientProviderProps = { - stores: Store[]; children: React.ReactNode; }; @@ -34,35 +43,23 @@ const StoreManagementRowsContext = React.createContext(null); const StoreManagementPaginationContext = React.createContext(null); +const StoreManagementSummaryContext = + React.createContext(null); function StoreManagementClientProvider({ - stores, children, }: StoreManagementClientProviderProps) { const [pageSize, setPageSize] = React.useState(10); const [currentPage, setCurrentPage] = React.useState(1); const [searchKeyword, setSearchKeyword] = React.useState(''); - - const normalizedSearchKeyword = searchKeyword.trim().toLowerCase(); - const filteredStores = React.useMemo(() => { - if (!normalizedSearchKeyword) { - return stores; - } - - return stores.filter((store) => - [store.name, store.address, store.station, store.phone].some((value) => - value.toLowerCase().includes(normalizedSearchKeyword) - ) - ); - }, [normalizedSearchKeyword, stores]); - - const totalPages = Math.max(Math.ceil(filteredStores.length / pageSize), 1); + const normalizedSearchKeyword = searchKeyword.trim(); + const storeListQuery = useStoreListQuery({ + keyword: normalizedSearchKeyword || undefined, + page: currentPage, + size: pageSize, + }); + const totalPages = Math.max(storeListQuery.data?.totalPages ?? 1, 1); const safeCurrentPage = Math.min(currentPage, totalPages); - const firstVisibleStoreIndex = (safeCurrentPage - 1) * pageSize; - const paginatedStores = filteredStores.slice( - firstVisibleStoreIndex, - firstVisibleStoreIndex + pageSize - ); const handlePageSizeChange = React.useCallback((nextPageSize: string) => { setPageSize(Number(nextPageSize)); @@ -96,9 +93,17 @@ function StoreManagementClientProvider({ const rowsValue = React.useMemo( () => ({ - stores: paginatedStores, + stores: storeListQuery.data?.stores ?? [], + isError: storeListQuery.isError, + isLoading: storeListQuery.isLoading, + errorMessage: getStoreListErrorMessage(storeListQuery.error), }), - [paginatedStores] + [ + storeListQuery.data?.stores, + storeListQuery.error, + storeListQuery.isError, + storeListQuery.isLoading, + ] ); const paginationValue = React.useMemo( @@ -106,23 +111,41 @@ function StoreManagementClientProvider({ currentPage: safeCurrentPage, totalPages, hasPreviousPage: safeCurrentPage > 1, - hasNextPage: safeCurrentPage < totalPages, + hasNextPage: + storeListQuery.data?.hasNextPage ?? safeCurrentPage < totalPages, movePage, }), - [safeCurrentPage, totalPages, movePage] + [safeCurrentPage, totalPages, storeListQuery.data?.hasNextPage, movePage] + ); + + const summaryValue = React.useMemo( + () => ({ + totalElements: storeListQuery.data?.totalElements ?? 0, + }), + [storeListQuery.data?.totalElements] ); return ( - {children} + + {children} + ); } +function getStoreListErrorMessage(error: unknown) { + if (!error) { + return ''; + } + + return isApiError(error) ? error.message : '매장 목록을 불러오지 못했습니다.'; +} + function useStoreManagementControls() { const value = React.useContext(StoreManagementControlsContext); @@ -159,9 +182,22 @@ function useStoreManagementPagination() { return value; } +function useStoreManagementSummary() { + const value = React.useContext(StoreManagementSummaryContext); + + if (!value) { + throw new Error( + 'useStoreManagementSummary must be used within StoreManagementClientProvider.' + ); + } + + return value; +} + export { StoreManagementClientProvider, useStoreManagementControls, useStoreManagementPagination, useStoreManagementRows, + useStoreManagementSummary, }; diff --git a/src/features/store-management/components/StoreManagementTable.tsx b/src/features/store-management/components/StoreManagementTable.tsx index 4f7d923..5c2e0b6 100644 --- a/src/features/store-management/components/StoreManagementTable.tsx +++ b/src/features/store-management/components/StoreManagementTable.tsx @@ -1,20 +1,14 @@ -import type {Store} from '../model/store'; -import {StoreManagementClientProvider} from './StoreManagementClientProvider'; import {StoreManagementPagination} from './StoreManagementPagination'; import {StoreManagementTableContent} from './StoreManagementTableContent'; import {StoreManagementToolbar} from './StoreManagementToolbar'; -type StoreManagementTableProps = { - stores: Store[]; -}; - -function StoreManagementTable({stores}: StoreManagementTableProps) { +function StoreManagementTable() { return ( - + <> - + ); } diff --git a/src/features/store-management/components/StoreManagementTableContent.tsx b/src/features/store-management/components/StoreManagementTableContent.tsx index 687643e..9a2b73f 100644 --- a/src/features/store-management/components/StoreManagementTableContent.tsx +++ b/src/features/store-management/components/StoreManagementTableContent.tsx @@ -4,7 +4,6 @@ import Link from 'next/link'; import {Trash2} from 'lucide-react'; import {Button} from '@/src/shared/components/ui/button'; -import {cn} from '@/src/shared/lib/utils'; import type {Store} from '../model/store'; import {StoreEditDialogTrigger} from './StoreEditDialogTrigger'; @@ -21,17 +20,17 @@ const storeStatusTagVariant = { } satisfies Record; const columnHeaders = [ - 'ID', - '매장명', - '주소', - '상태', - '연락처', - '웹사이트', - '작업', + {label: 'ID', className: 'w-[2rem]'}, + {label: '매장명', className: 'w-[9.375rem]'}, + {label: '주소', className: 'w-[17.75rem]'}, + {label: '상태', className: 'w-[4.8125rem] text-center'}, + {label: '연락처', className: 'w-[7.625rem]'}, + {label: '웹사이트', className: 'w-[4rem]'}, + {label: '작업', className: 'w-[5.875rem]'}, ]; function StoreManagementTableContent() { - const {stores} = useStoreManagementRows(); + const {stores, isLoading, isError, errorMessage} = useStoreManagementRows(); return (
@@ -41,25 +40,22 @@ function StoreManagementTableContent() { {columnHeaders.map((header) => ( - {header} + className={`text-body3 text-riu-monochrome-800 px-2 text-left align-middle ${header.className}`}> + {header.label} ))} - {stores.length > 0 ? ( + {isLoading ? ( + + ) : isError ? ( + + ) : stores.length > 0 ? ( stores.map((store) => ( {store.address} - - {store.station} - + {store.station ? ( + + {store.station} + + ) : null}
- + - {store.phone} + {store.phone || '-'} - - 링크 - + {store.website ? ( + + 링크 + + ) : ( + + - + + )}
@@ -114,13 +118,7 @@ function StoreManagementTableContent() { )) ) : ( - - - 검색 결과가 없습니다. - - + )} @@ -129,4 +127,16 @@ function StoreManagementTableContent() { ); } +function StoreTableMessageRow({message}: {message: string}) { + return ( + + + {message} + + + ); +} + export {StoreManagementTableContent};