Skip to content

Commit 779a05b

Browse files
authored
Merge pull request #85 from UruruLab/feat/82-productRead
상품 관리 페이지에서 상품 조회 API 연동 및 UI/UX 디자인
2 parents 46ec386 + e3bcbb2 commit 779a05b

7 files changed

Lines changed: 479 additions & 5 deletions

File tree

src/app/seller/products/page.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
'use client';
22

3-
import { EmptyPage } from '@/components/seller/common';
3+
import { AuthGuard } from '@/components/auth/AuthGuard';
4+
import { ProductManagement } from '@/components/seller/ProductManagement';
5+
6+
// 인증된 판매자만 접근할 수 있는 상품 관리 컴포넌트
7+
function AuthenticatedProductManagement() {
8+
return <ProductManagement />;
9+
}
410

511
export default function ProductsPage() {
6-
return <EmptyPage title="준비중이에요" />;
12+
return (
13+
<AuthGuard requireAuth requireSeller>
14+
<AuthenticatedProductManagement />
15+
</AuthGuard>
16+
);
717
}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import { Button } from '@/components/ui/button';
5+
import { Card, CardContent } from '@/components/ui/card';
6+
import { Badge } from '@/components/ui/badge';
7+
import { SectionHeader } from '@/components/common/SectionHeader';
8+
import { EmptyState } from '@/components/common/EmptyState';
9+
import { LoadingSkeleton } from '@/components/common/LoadingSkeleton';
10+
import { ScrollToTopButton } from '@/components/common';
11+
import { FORM_STYLES } from '@/constants/form-styles';
12+
import { PRODUCT_CONSTANTS } from '@/constants/product-constants';
13+
import { ProductService } from '@/services/productService';
14+
import type { SellerProduct, SellerProductListResponse } from '@/types/product';
15+
import { Plus, Edit, Trash2, Eye } from 'lucide-react';
16+
import { StatusBadge } from '@/components/common/StatusBadge';
17+
import { Pagination } from '@/components/seller/common/Pagination';
18+
19+
export function ProductManagement() {
20+
const [isLoading, setIsLoading] = useState(true);
21+
const [error, setError] = useState<string | null>(null);
22+
const [productData, setProductData] = useState<SellerProductListResponse | null>(null);
23+
const [allProducts, setAllProducts] = useState<SellerProduct[]>([]);
24+
const [currentPage, setCurrentPage] = useState(0);
25+
const [pageSize] = useState(10);
26+
27+
// 상품 목록 조회
28+
const fetchProducts = async (page: number = 0) => {
29+
setIsLoading(true);
30+
setError(null);
31+
try {
32+
const [data, allData] = await Promise.all([
33+
ProductService.getSellerProducts(page, pageSize),
34+
ProductService.getAllSellerProducts(),
35+
]);
36+
setProductData(data);
37+
setAllProducts(allData);
38+
} catch (err: any) {
39+
setError(err.message || '상품 목록을 불러오는데 실패했습니다.');
40+
} finally {
41+
setIsLoading(false);
42+
}
43+
};
44+
45+
useEffect(() => {
46+
fetchProducts(currentPage);
47+
}, [currentPage]);
48+
49+
const handleRefresh = () => {
50+
fetchProducts(currentPage);
51+
};
52+
53+
const handlePageChange = (newPage: number) => {
54+
setCurrentPage(newPage);
55+
};
56+
57+
const getStatusBadge = (status: string) => {
58+
switch (status) {
59+
case 'ACTIVE':
60+
return <StatusBadge status="in_progress" />;
61+
case 'INACTIVE':
62+
return (
63+
<span className="inline-flex items-center rounded-lg bg-bg-200 px-3 py-1.5 text-xs font-medium text-text-200">
64+
공구 대기중
65+
</span>
66+
);
67+
case 'DELETED':
68+
return null;
69+
default:
70+
return null;
71+
}
72+
};
73+
74+
const formatDate = (dateString: string) => {
75+
return new Date(dateString).toLocaleDateString('ko-KR');
76+
};
77+
78+
const getCategoryPath = (categories: any[]) => {
79+
if (!categories || categories.length === 0) return null;
80+
return categories.map((cat) => cat.name).join(' > ');
81+
};
82+
83+
const getTagNames = (tagCategories: any[]) => {
84+
if (!tagCategories || tagCategories.length === 0) return [];
85+
return tagCategories.map((tag) => tag.tagCategoryName);
86+
};
87+
88+
const products = productData?.content || [];
89+
const totalElements = productData?.totalElements || 0;
90+
const totalPages = productData?.totalPages || 0;
91+
const isFirst = productData?.first || true;
92+
const isLast = productData?.last || true;
93+
94+
// 전체 데이터에서 카운트 계산
95+
const activeCount = allProducts.filter((p) => p.status === 'ACTIVE').length;
96+
const inactiveCount = allProducts.filter((p) => p.status === 'INACTIVE').length;
97+
const totalCount = allProducts.length;
98+
99+
if (error) {
100+
return <div className="py-20 text-center text-red-500">서버 오류가 발생했습니다.</div>;
101+
}
102+
if (isLoading) {
103+
return (
104+
<div className="mx-auto max-w-3xl px-4 py-10 md:px-0">
105+
<h1 className="mb-10 text-center text-3xl font-semibold text-text-100">상품 관리</h1>
106+
<div className="space-y-4">
107+
{Array.from({ length: 3 }).map((_, index) => (
108+
<LoadingSkeleton key={index} className="h-24 w-full" />
109+
))}
110+
</div>
111+
<ScrollToTopButton />
112+
</div>
113+
);
114+
}
115+
116+
return (
117+
<div className="mx-auto max-w-3xl px-4 py-10 md:px-0">
118+
{/* 타이틀 */}
119+
<h1 className="mb-10 text-center text-3xl font-semibold text-text-100">상품 관리</h1>
120+
121+
{/* 상단 카운트 3개 */}
122+
<div className="mx-auto mb-10 flex w-full max-w-md justify-center">
123+
<div className="flex flex-1 flex-col items-center">
124+
<span className="text-2xl font-bold text-text-100 md:text-4xl">{totalCount}</span>
125+
<span className="mt-1 text-center text-sm font-medium text-text-200 md:text-lg">
126+
전체
127+
</span>
128+
</div>
129+
<div className="flex flex-1 flex-col items-center">
130+
<span className="text-2xl font-bold text-text-100 md:text-4xl">{activeCount}</span>
131+
<span className="mt-1 text-center text-sm font-medium text-text-200 md:text-lg">
132+
공구 중
133+
</span>
134+
</div>
135+
<div className="flex flex-1 flex-col items-center">
136+
<span className="text-2xl font-bold text-text-100 md:text-4xl">{inactiveCount}</span>
137+
<span className="mt-1 text-center text-sm font-medium text-text-200 md:text-lg">
138+
공구 대기
139+
</span>
140+
</div>
141+
</div>
142+
143+
{/* 상품 목록 섹션 */}
144+
<section>
145+
<SectionHeader title="등록된 상품" />
146+
<div className="mt-4">
147+
{products.length === 0 ? (
148+
<div className="space-y-6">
149+
<EmptyState
150+
icon="📦"
151+
title="등록된 상품이 없습니다"
152+
description="첫 번째 상품을 등록해보세요"
153+
/>
154+
<div className="text-center">
155+
<Button className={FORM_STYLES.button.submit}>
156+
<Plus className="mr-2 h-4 w-4" />
157+
상품 등록하기
158+
</Button>
159+
</div>
160+
</div>
161+
) : (
162+
<div
163+
className="space-y-4"
164+
style={{
165+
scrollBehavior: 'smooth',
166+
scrollbarWidth: 'none',
167+
msOverflowStyle: 'none',
168+
}}
169+
>
170+
{products.map((product) => (
171+
<Card key={product.id} className={FORM_STYLES.card.seller}>
172+
<CardContent className="relative p-6">
173+
{/* 상단: 카테고리(좌, 진한 글씨) + 중앙 구분선(bg-text-300, h-5) + 태그(우) */}
174+
<div className="mb-1 flex items-center">
175+
{getCategoryPath(product.categories) && (
176+
<div className="text-sm text-text-100">
177+
{getCategoryPath(product.categories)}
178+
</div>
179+
)}
180+
{getTagNames(product.tagCategories).length > 0 && (
181+
<>
182+
<div className="mx-2 h-5 w-px self-center bg-text-300" />
183+
<div className="flex flex-wrap gap-1">
184+
{getTagNames(product.tagCategories).map((tag, index) => (
185+
<Badge
186+
key={index}
187+
variant="outline"
188+
className="rounded-lg border-bg-300 bg-bg-100 px-2 py-0.5 text-xs text-text-200"
189+
>
190+
{tag}
191+
</Badge>
192+
))}
193+
</div>
194+
</>
195+
)}
196+
</div>
197+
{/* 상태 뱃지: 우측 상단 고정 */}
198+
<div className="absolute right-6 top-6 z-10">
199+
{getStatusBadge(product.status)}
200+
</div>
201+
{/* 제목, 설명 */}
202+
<div className="min-w-0 flex-1">
203+
<h2 className="text-lg font-semibold text-text-100">{product.name}</h2>
204+
<p className="mb-2 mt-1 line-clamp-2 text-sm text-text-200">
205+
{product.description}
206+
</p>
207+
</div>
208+
{/* 하단: 버튼 3개(좌) */}
209+
<div className="mt-4 flex gap-2">
210+
<Button
211+
onClick={() => console.log('View product:', product.id)}
212+
className="h-10 rounded-lg border border-primary-300 bg-bg-100 px-6 text-base text-primary-300 shadow-none transition-colors hover:bg-primary-100 active:bg-primary-100 active:text-primary-300"
213+
>
214+
상세보기
215+
</Button>
216+
<Button
217+
onClick={() => console.log('Edit product:', product.id)}
218+
className="h-10 rounded-lg border border-primary-300 bg-bg-100 px-6 text-base text-primary-300 shadow-none transition-colors hover:bg-primary-100 active:bg-primary-100 active:text-primary-300"
219+
>
220+
수정하기
221+
</Button>
222+
<Button
223+
onClick={() => console.log('Delete product:', product.id)}
224+
className="h-10 rounded-lg border border-primary-200 bg-bg-100 px-6 text-base text-primary-200 shadow-none transition-colors hover:bg-primary-100 active:bg-primary-100 active:text-primary-200"
225+
>
226+
삭제하기
227+
</Button>
228+
</div>
229+
{/* 등록일/수정일: 오른쪽 하단, 글자 크기 text-sm */}
230+
<div className="absolute bottom-6 right-6 whitespace-nowrap text-sm text-text-300">
231+
등록일: {formatDate(product.createdAt)} 수정일:{' '}
232+
{formatDate(product.updatedAt)}
233+
</div>
234+
</CardContent>
235+
</Card>
236+
))}
237+
</div>
238+
)}
239+
</div>
240+
</section>
241+
242+
{/* 페이지네이션: 상품 관리 페이지 하단 */}
243+
{totalPages > 1 && (
244+
<div className="mt-12">
245+
<Pagination
246+
currentPage={currentPage}
247+
totalPages={totalPages}
248+
onPageChange={setCurrentPage}
249+
/>
250+
</div>
251+
)}
252+
253+
{/* ScrollToTopButton - 일관된 스크롤 동작 */}
254+
<ScrollToTopButton />
255+
</div>
256+
);
257+
}

src/components/seller/ProductRegistration.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useState } from 'react';
4+
import { useRouter } from 'next/navigation';
45
import { Button } from '@/components/ui/button';
56
import { Input } from '@/components/ui/input';
67
import { Textarea } from '@/components/ui/textarea';
@@ -35,6 +36,7 @@ import { ErrorDialog } from '@/components/common/ErrorDialog';
3536
import { validateProductForm } from '@/lib/product/validation';
3637

3738
export function ProductRegistration({ categories, tags }: ProductRegistrationProps) {
39+
const router = useRouter();
3840
const [formData, setFormData] = useState<ProductFormData>({
3941
name: '',
4042
description: '',
@@ -93,7 +95,10 @@ export function ProductRegistration({ categories, tags }: ProductRegistrationPro
9395
const [submitLoading, setSubmitLoading] = useState(false);
9496
const [submitError, setSubmitError] = useState<string | null>(null);
9597
const [submitSuccess, setSubmitSuccess] = useState(false);
96-
const handleSuccessDialogClose = () => setSubmitSuccess(false);
98+
const handleSuccessDialogClose = () => {
99+
setSubmitSuccess(false);
100+
router.push('/seller/products');
101+
};
97102
const handleErrorDialogClose = () => setSubmitError(null);
98103

99104
const handleSubmit = async (e: React.FormEvent) => {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Button } from '@/components/ui/button';
2+
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
3+
import React from 'react';
4+
5+
interface PaginationProps {
6+
currentPage: number;
7+
totalPages: number;
8+
onPageChange: (page: number) => void;
9+
maxPageButtons?: number;
10+
className?: string;
11+
}
12+
13+
export function Pagination({
14+
currentPage,
15+
totalPages,
16+
onPageChange,
17+
maxPageButtons = 10,
18+
className = '',
19+
}: PaginationProps) {
20+
// 현재 페이지 주변의 페이지들을 계산
21+
const startPage = Math.max(0, currentPage - Math.floor(maxPageButtons / 2));
22+
const endPage = Math.min(totalPages - 1, startPage + maxPageButtons - 1);
23+
const adjustedStartPage = Math.max(0, endPage - maxPageButtons + 1);
24+
25+
return (
26+
<div className={`flex items-center justify-center gap-2 ${className}`}>
27+
<Button
28+
onClick={() => onPageChange(currentPage - 1)}
29+
disabled={currentPage === 0}
30+
className="h-10 w-10 rounded-lg border border-primary-300 bg-bg-100 px-0 font-semibold text-primary-300 transition-colors hover:bg-primary-100"
31+
aria-label="이전 페이지"
32+
>
33+
<ChevronsLeft className="h-5 w-5" />
34+
</Button>
35+
{Array.from({ length: endPage - adjustedStartPage + 1 }).map((_, idx) => {
36+
const pageNum = adjustedStartPage + idx;
37+
return (
38+
<Button
39+
key={pageNum}
40+
onClick={() => onPageChange(pageNum)}
41+
className={
42+
'h-10 w-10 rounded-lg border px-0 font-semibold transition-colors ' +
43+
(currentPage === pageNum
44+
? 'border-primary-300 bg-primary-300 text-text-on'
45+
: 'border-primary-300 bg-bg-100 text-primary-300 hover:bg-primary-100')
46+
}
47+
>
48+
{pageNum + 1}
49+
</Button>
50+
);
51+
})}
52+
<Button
53+
onClick={() => onPageChange(currentPage + 1)}
54+
disabled={currentPage === totalPages - 1}
55+
className="h-10 w-10 rounded-lg border border-primary-300 bg-bg-100 px-0 font-semibold text-primary-300 transition-colors hover:bg-primary-100"
56+
aria-label="다음 페이지"
57+
>
58+
<ChevronsRight className="h-5 w-5" />
59+
</Button>
60+
</div>
61+
);
62+
}

src/components/seller/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// export { SellerLayout } from './SellerLayout';
2-
export { SellerSidebar } from './SellerSidebar';
32
export { ProductRegistration } from './ProductRegistration';
43
export { GroupBuyRegistration } from './GroupBuyRegistration';
4+
export { SellerSidebar } from './SellerSidebar';
5+
export { ProductManagement } from './ProductManagement';

0 commit comments

Comments
 (0)