onItemClick(item)}
className={cn(
'h-11 px-3 flex items-center rounded-lg text-left text-base text-foreground',
- 'whitespace-nowrap cursor-pointer transition-colors duration-200',
- selectedItem === item ? 'bg-secondary' : 'hover:bg-secondary/60',
+ 'whitespace-nowrap cursor-pointer transition active:scale-[0.98]',
+ selectedItem === item ? 'bg-secondary' : 'hover:bg-muted',
)}
>
{item}
diff --git a/front/src/features/list/components/Pagination.tsx b/front/src/features/list/components/Pagination.tsx
new file mode 100644
index 0000000..44f3893
--- /dev/null
+++ b/front/src/features/list/components/Pagination.tsx
@@ -0,0 +1,102 @@
+import { cn, interactive } from '@/lib'
+import { buildPageItems } from '@/features/list/lib/pagination'
+import { ArrowIcon, DotsIcon } from '@/features/list/components/paginationIcons'
+
+interface PaginationProps {
+ totalPages: number
+ currentPage: number
+ onPageChange: (page: number) => void
+}
+
+export function Pagination({ totalPages, currentPage, onPageChange }: PaginationProps) {
+ if (totalPages === 0) {
+ return
+ }
+
+ const items = buildPageItems(currentPage, totalPages)
+
+ return (
+
+
+
1}
+ onClick={() => onPageChange(currentPage - 1)}
+ />
+
+
+
+ {items.map((item, idx) =>
+ item === 'dots' ? (
+
+ ) : (
+
onPageChange(item)}
+ />
+ ),
+ )}
+
+
+
+ onPageChange(currentPage + 1)}
+ />
+
+
+ )
+}
+
+function PageNumber({
+ page,
+ active,
+ onClick,
+}: {
+ page: number
+ active: boolean
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+function NavButton({
+ dir,
+ enabled,
+ onClick,
+}: {
+ dir: 'prev' | 'next'
+ enabled: boolean
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
diff --git a/front/src/features/list/components/SidebarFooter.tsx b/front/src/features/list/components/SidebarFooter.tsx
deleted file mode 100644
index 454abc0..0000000
--- a/front/src/features/list/components/SidebarFooter.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Button } from '@/components'
-
-/** 좌측 사이드바 하단: 공급사명 + 로그아웃 */
-export function SidebarFooter() {
- return (
-
-
- {/* TODO: 공급사명 (auth store 연동) */}
- -
-
-
-
- )
-}
diff --git a/front/src/features/list/components/TableSection.tsx b/front/src/features/list/components/TableSection.tsx
new file mode 100644
index 0000000..76d276b
--- /dev/null
+++ b/front/src/features/list/components/TableSection.tsx
@@ -0,0 +1,79 @@
+import { type ReactNode } from 'react'
+import { Loader2 } from 'lucide-react'
+import { cn } from '@/lib'
+import type { ListItem } from '@/features/list/types'
+import { COLUMNS } from '@/features/list/components/tableColumns'
+
+interface TableSectionProps {
+ items: ListItem[]
+ selectedId?: string
+ isLoading?: boolean
+ onItemClick?: (item: ListItem) => void
+}
+
+export function TableSection({ items, selectedId, isLoading, onItemClick }: TableSectionProps) {
+ return (
+
+
+
+
+
+ {COLUMNS.map((col) => (
+ |
+ {col.label}
+ |
+ ))}
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : items.length === 0 ? (
+
+ 조회된 협상 내역이 없습니다.
+
+ ) : (
+ items.map((item) => (
+ onItemClick?.(item)}
+ className={cn(
+ 'h-[53px] cursor-pointer text-lg text-foreground transition-colors',
+ item.session_id === selectedId ? 'bg-table-selected' : 'hover:bg-table-hover',
+ )}
+ >
+ {COLUMNS.map((col) => (
+ |
+ {col.render ? col.render(item) : item[col.key] || '-'}
+ |
+ ))}
+
+ ))
+ )}
+
+
+
+
+ )
+}
+
+// 로딩/빈 상태 행
+function StateRow({ children }: { children: ReactNode }) {
+ return (
+
+ |
+ {children}
+ |
+
+ )
+}
diff --git a/front/src/features/list/components/paginationIcons.tsx b/front/src/features/list/components/paginationIcons.tsx
new file mode 100644
index 0000000..b7f8ba8
--- /dev/null
+++ b/front/src/features/list/components/paginationIcons.tsx
@@ -0,0 +1,32 @@
+// 페이지네이션 인라인 SVG (색 = currentColor)
+
+const ARROW_PATH = {
+ prev: 'M26.9995 30.6532L21.3457 24.9995L26.9995 19.3457L28.0532 20.3995L23.4532 24.9995L28.0532 29.5995L26.9995 30.6532Z',
+ next: 'M23.0005 30.6532L28.6543 24.9995L23.0005 19.3457L21.9468 20.3995L26.5468 24.9995L21.9468 29.5995L23.0005 30.6532Z',
+} as const
+
+export function ArrowIcon({ dir }: { dir: 'prev' | 'next' }) {
+ return (
+
+ )
+}
+
+export function DotsIcon() {
+ return (
+
+ )
+}
diff --git a/front/src/features/list/components/tableColumns.tsx b/front/src/features/list/components/tableColumns.tsx
new file mode 100644
index 0000000..16f5a0f
--- /dev/null
+++ b/front/src/features/list/components/tableColumns.tsx
@@ -0,0 +1,45 @@
+import { type ReactNode } from 'react'
+import { formatDateTime } from '@/features/list/lib/datetime'
+import type { ListItem } from '@/features/list/types'
+
+// 컬럼 설정 (렌더링은 TableSection)
+export interface Column {
+ key: keyof ListItem
+ label: string
+ width: string
+ align: 'text-left' | 'text-center'
+ render?: (item: ListItem) => ReactNode
+}
+
+const STATUS_COLOR: Record
= {
+ 협상생성: 'text-info font-semibold',
+ 협상중: 'text-foreground font-semibold',
+ 협상완료: 'text-success font-semibold',
+ 미참여: 'text-muted-foreground font-semibold',
+ 협상거부: 'text-destructive font-semibold',
+}
+
+export const COLUMNS: Column[] = [
+ { key: 'item_code', label: '상품코드', width: 'w-[8.39%]', align: 'text-center' },
+ {
+ key: 'session_status',
+ label: '상태',
+ width: 'w-[6.57%]',
+ align: 'text-center',
+ render: (item) => (
+ {item.session_status || '-'}
+ ),
+ },
+ { key: 'qt_number', label: '견적번호', width: 'w-[14.29%]', align: 'text-center' },
+ { key: 'qt_type', label: '구분', width: 'w-[5.52%]', align: 'text-center' },
+ { key: 'item_name', label: '상품명', width: 'w-[20.96%]', align: 'text-left' },
+ { key: 'model_name', label: '모델명', width: 'w-[22.89%]', align: 'text-left' },
+ { key: 'maker_name', label: '제조사', width: 'w-[8.67%]', align: 'text-center' },
+ {
+ key: 'qt_end_time',
+ label: '마감일',
+ width: 'w-[13.72%]',
+ align: 'text-center',
+ render: (item) => formatDateTime(item.qt_end_time),
+ },
+]
diff --git a/front/src/features/list/containers/ContentContainer.tsx b/front/src/features/list/containers/ContentContainer.tsx
new file mode 100644
index 0000000..eb89662
--- /dev/null
+++ b/front/src/features/list/containers/ContentContainer.tsx
@@ -0,0 +1,27 @@
+import { useList } from '@/features/list/hooks/useList'
+import { ActionSection } from '@/features/list/components/ActionSection'
+import { TableSection } from '@/features/list/components/TableSection'
+import { Pagination } from '@/features/list/components/Pagination'
+
+export function ContentContainer() {
+ const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
+ useList()
+
+ return (
+ // 좌우 거터(80px) 일괄 적용
+
+ )
+}
diff --git a/front/src/features/list/containers/FilterContainer.tsx b/front/src/features/list/containers/FilterContainer.tsx
new file mode 100644
index 0000000..b2bc295
--- /dev/null
+++ b/front/src/features/list/containers/FilterContainer.tsx
@@ -0,0 +1,52 @@
+import { useShallow } from 'zustand/react/shallow'
+import { FilterGroup } from '@/features/list/components/FilterGroup'
+import { FILTER_GROUPS, type FilterKey } from '@/features/list/filterOptions'
+import { useListStore } from '@/features/list/stores/useListStore'
+
+export function FilterContainer() {
+ // 필터 필드만 구독 → 페이지 변경 시 리렌더 방지
+ const {
+ selectedType,
+ selectedStatus,
+ selectedDeadline,
+ toggleType,
+ toggleStatus,
+ toggleDeadline,
+ } = useListStore(
+ useShallow((s) => ({
+ selectedType: s.selectedType,
+ selectedStatus: s.selectedStatus,
+ selectedDeadline: s.selectedDeadline,
+ toggleType: s.toggleType,
+ toggleStatus: s.toggleStatus,
+ toggleDeadline: s.toggleDeadline,
+ })),
+ )
+
+ const selected: Record = {
+ type: selectedType,
+ status: selectedStatus,
+ deadline: selectedDeadline,
+ }
+ const toggle: Record void> = {
+ type: toggleType,
+ status: toggleStatus,
+ deadline: toggleDeadline,
+ }
+
+ return (
+
+
+ {FILTER_GROUPS.map((group) => (
+
+ ))}
+
+
+ )
+}
diff --git a/front/src/features/list/containers/FilterSection.tsx b/front/src/features/list/containers/FilterSection.tsx
deleted file mode 100644
index 13fd808..0000000
--- a/front/src/features/list/containers/FilterSection.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { ListFilter } from '@/features/list/components/ListFilter'
-import { FILTER_GROUPS, type FilterKey } from '@/features/list/model/filterOptions'
-import { useListFilterStore } from '@/features/list/model/useListFilterStore'
-
-export function FilterSection() {
- const store = useListFilterStore()
-
- // 필터 키 → store 상태/핸들러 매핑
- const selected: Record = {
- type: store.selectedType,
- status: store.selectedStatus,
- deadline: store.selectedDeadline,
- }
- const toggle: Record void> = {
- type: store.toggleType,
- status: store.toggleStatus,
- deadline: store.toggleDeadline,
- }
-
- return (
-
-
- {FILTER_GROUPS.map((group) => (
-
- ))}
-
-
- )
-}
diff --git a/front/src/features/list/model/filterOptions.ts b/front/src/features/list/filterOptions.ts
similarity index 81%
rename from front/src/features/list/model/filterOptions.ts
rename to front/src/features/list/filterOptions.ts
index 7cdf965..bf8f38c 100644
--- a/front/src/features/list/model/filterOptions.ts
+++ b/front/src/features/list/filterOptions.ts
@@ -1,12 +1,12 @@
export type FilterKey = 'type' | 'status' | 'deadline'
-export interface FilterGroup {
+export interface FilterGroupConfig {
key: FilterKey
title: string
items: readonly string[]
}
-export const FILTER_GROUPS: readonly FilterGroup[] = [
+export const FILTER_GROUPS: readonly FilterGroupConfig[] = [
{ key: 'type', title: '구분', items: ['재견적', '재협상'] },
{
key: 'status',
diff --git a/front/src/features/list/hooks/useList.ts b/front/src/features/list/hooks/useList.ts
new file mode 100644
index 0000000..dc16832
--- /dev/null
+++ b/front/src/features/list/hooks/useList.ts
@@ -0,0 +1,57 @@
+import { useMemo, useState } from 'react'
+import { useListStore } from '@/features/list/stores/useListStore'
+import { MOCK_ITEMS } from '@/features/list/mocks/mockItems'
+import type { ListItem } from '@/features/list/types'
+
+const PAGE_SIZE = 20
+
+// 목데이터 클라이언트 필터 (추후 API 조회로 교체)
+export function useList() {
+ const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
+ useListStore()
+ const [selectedId, setSelectedId] = useState(null)
+
+ const filtered = useMemo(() => {
+ const result = MOCK_ITEMS.filter(
+ (item) =>
+ (!selectedType || item.qt_type === selectedType) &&
+ (!selectedStatus || item.session_status === selectedStatus),
+ )
+
+ if (selectedDeadline) {
+ const dir = selectedDeadline === '남은 시간 적은 순' ? 1 : -1
+ result.sort(
+ (a, b) =>
+ (new Date(a.qt_end_time).getTime() - new Date(b.qt_end_time).getTime()) * dir,
+ )
+ }
+
+ return result
+ }, [selectedType, selectedStatus, selectedDeadline])
+
+ const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
+
+ const items = useMemo(
+ () => filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
+ [filtered, currentPage],
+ )
+
+ const selectedItem = useMemo(
+ () => items.find((item) => item.session_id === selectedId) ?? null,
+ [items, selectedId],
+ )
+
+ const handleItemClick = (item: ListItem) =>
+ setSelectedId((prev) => (prev === item.session_id ? null : item.session_id))
+
+ return {
+ items,
+ isLoading: false,
+ totalPages,
+ currentPage,
+ setCurrentPage,
+ selectedId,
+ selectedItem,
+ handleItemClick,
+ }
+}
diff --git a/front/src/features/list/index.ts b/front/src/features/list/index.ts
index 43ad6cc..44120b7 100644
--- a/front/src/features/list/index.ts
+++ b/front/src/features/list/index.ts
@@ -1,4 +1,3 @@
-// 공개 API: 페이지에서 쓰는 것만 노출 (ListFilter/FILTER_GROUPS 등은 feature 내부 구현)
-export { FilterSection } from '@/features/list/containers/FilterSection'
-export { SidebarFooter } from '@/features/list/components/SidebarFooter'
-export { useListFilterStore } from '@/features/list/model/useListFilterStore'
+// 공개 API (페이지에서 쓰는 것만)
+export { FilterContainer } from '@/features/list/containers/FilterContainer'
+export { ContentContainer } from '@/features/list/containers/ContentContainer'
diff --git a/front/src/features/list/lib/datetime.ts b/front/src/features/list/lib/datetime.ts
new file mode 100644
index 0000000..80da812
--- /dev/null
+++ b/front/src/features/list/lib/datetime.ts
@@ -0,0 +1,8 @@
+// 'YYYY-MM-DD HH:mm'. 빈 값 '-', 파싱 실패 시 원본.
+export function formatDateTime(value: string): string {
+ if (!value) return '-'
+ const d = new Date(value)
+ if (Number.isNaN(d.getTime())) return value
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
diff --git a/front/src/features/list/lib/pagination.ts b/front/src/features/list/lib/pagination.ts
new file mode 100644
index 0000000..e477056
--- /dev/null
+++ b/front/src/features/list/lib/pagination.ts
@@ -0,0 +1,18 @@
+export type PageItem = number | 'dots'
+
+// 9개 이하: 전체 / 초과: 앞4·뒤4 또는 1·중앙5·끝 + 생략(...)
+export function buildPageItems(currentPage: number, totalPages: number): PageItem[] {
+ const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
+ if (pages.length <= 9) return pages
+
+ const prev = pages.slice(0, 4)
+ const middle = pages.slice(3, pages.length - 3)
+ const next = pages.slice(pages.length - 4)
+
+ if (!middle.includes(currentPage)) {
+ return [...prev, 'dots', ...next]
+ }
+
+ const center = [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2]
+ return [1, 'dots', ...center, 'dots', totalPages]
+}
diff --git a/front/src/features/list/mocks/mockItems.ts b/front/src/features/list/mocks/mockItems.ts
new file mode 100644
index 0000000..ece904b
--- /dev/null
+++ b/front/src/features/list/mocks/mockItems.ts
@@ -0,0 +1,115 @@
+import type { ListItem } from '@/features/list/types'
+
+// 임시 목데이터 (API 연동 전)
+export const MOCK_ITEMS: ListItem[] = [
+ {
+ session_id: 's-001',
+ session_status: '협상생성',
+ qt_type: '재견적',
+ qt_number: 'QT-2026-000101',
+ qt_end_time: '2026-06-18T18:00:00',
+ item_code: 'IMK-10231',
+ item_name: '사무용 노트북 14인치',
+ model_name: 'NB-1400-PRO',
+ maker_name: '삼성전자',
+ },
+ {
+ session_id: 's-002',
+ session_status: '협상중',
+ qt_type: '재협상',
+ qt_number: 'QT-2026-000102',
+ qt_end_time: '2026-06-17T12:30:00',
+ item_code: 'IMK-10232',
+ item_name: '레이저 복합기',
+ model_name: 'MFC-7890DW',
+ maker_name: '브라더',
+ },
+ {
+ session_id: 's-003',
+ session_status: '협상완료',
+ qt_type: '재견적',
+ qt_number: 'QT-2026-000103',
+ qt_end_time: '2026-06-20T09:00:00',
+ item_code: 'IMK-10233',
+ item_name: '27인치 4K 모니터',
+ model_name: 'U2723QE',
+ maker_name: '델',
+ },
+ {
+ session_id: 's-004',
+ session_status: '협상거부',
+ qt_type: '재협상',
+ qt_number: 'QT-2026-000104',
+ qt_end_time: '2026-06-19T15:45:00',
+ item_code: 'IMK-10234',
+ item_name: '무선 기계식 키보드',
+ model_name: 'MX-KEYS-M',
+ maker_name: '로지텍',
+ },
+ {
+ session_id: 's-005',
+ session_status: '미참여',
+ qt_type: '재견적',
+ qt_number: 'QT-2026-000105',
+ qt_end_time: '2026-06-22T11:00:00',
+ item_code: 'IMK-10235',
+ item_name: 'A4 무선 레이저프린터',
+ model_name: 'SL-M2030',
+ maker_name: 'HP',
+ },
+ {
+ session_id: 's-006',
+ session_status: '협상중',
+ qt_type: '재견적',
+ qt_number: 'QT-2026-000106',
+ qt_end_time: '2026-06-16T20:00:00',
+ item_code: 'IMK-10236',
+ item_name: '회의실 대형 디스플레이 65인치',
+ model_name: 'QM65R',
+ maker_name: '삼성전자',
+ },
+ {
+ session_id: 's-007',
+ session_status: '협상생성',
+ qt_type: '재협상',
+ qt_number: 'QT-2026-000107',
+ qt_end_time: '2026-06-25T17:00:00',
+ item_code: 'IMK-10237',
+ item_name: '인체공학 사무용 의자',
+ model_name: 'ERGO-700',
+ maker_name: '시디즈',
+ },
+ {
+ session_id: 's-008',
+ session_status: '협상완료',
+ qt_type: '재견적',
+ qt_number: 'QT-2026-000108',
+ qt_end_time: '2026-06-21T10:30:00',
+ item_code: 'IMK-10238',
+ item_name: '네트워크 스위치 24포트',
+ model_name: 'SG350-28',
+ maker_name: '시스코',
+ },
+ {
+ session_id: 's-009',
+ session_status: '미참여',
+ qt_type: '재협상',
+ qt_number: 'QT-2026-000109',
+ qt_end_time: '2026-06-23T14:00:00',
+ item_code: 'IMK-10239',
+ item_name: '외장 SSD 2TB',
+ model_name: 'T7-Shield-2T',
+ maker_name: '삼성전자',
+ },
+ {
+ session_id: 's-010',
+ session_status: '협상중',
+ qt_type: '재견적',
+ qt_number: 'QT-2026-000110',
+ qt_end_time: '2026-06-24T16:20:00',
+ item_code: 'IMK-10240',
+ item_name: '화상회의용 웹캠',
+ model_name: 'BRIO-4K',
+ maker_name: '로지텍',
+ },
+]
diff --git a/front/src/features/list/model/useListFilterStore.ts b/front/src/features/list/stores/useListStore.ts
similarity index 87%
rename from front/src/features/list/model/useListFilterStore.ts
rename to front/src/features/list/stores/useListStore.ts
index 27e892b..4be6085 100644
--- a/front/src/features/list/model/useListFilterStore.ts
+++ b/front/src/features/list/stores/useListStore.ts
@@ -1,6 +1,6 @@
import { create } from 'zustand'
-interface ListFilterState {
+interface ListState {
selectedType: string | null
selectedStatus: string | null
selectedDeadline: string | null
@@ -13,10 +13,9 @@ interface ListFilterState {
resetFilters: () => void
}
-// 같은 항목을 다시 누르면 해제
const toggle = (current: string | null, item: string) => (current === item ? null : item)
-export const useListFilterStore = create((set) => ({
+export const useListStore = create((set) => ({
selectedType: null,
selectedStatus: null,
selectedDeadline: null,
diff --git a/front/src/features/list/types.ts b/front/src/features/list/types.ts
new file mode 100644
index 0000000..0c97875
--- /dev/null
+++ b/front/src/features/list/types.ts
@@ -0,0 +1,11 @@
+export type ListItem = {
+ session_id: string
+ session_status: string
+ qt_type: string
+ qt_number: string
+ qt_end_time: string
+ item_code: string
+ item_name: string
+ model_name: string
+ maker_name: string
+}