refactor(front): list — 구조 분리 및 상태 색상 정비

- TableSection에서 컬럼 설정(tableColumns)·날짜 유틸(datetime) 분리
- PageSection → Pagination 개명, 인라인 SVG를 paginationIcons로 분리
- 상태 색상 정비: 협상중=강조(다크), 미참여=회색, 나머지 의미색 유지
- 활성 페이지·목데이터 격리(mocks), 페이지 크기 20 복구, 주석 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-17 10:47:47 +09:00
parent 32db1300e0
commit 5eb83f9d54
18 changed files with 585 additions and 73 deletions

View File

@ -0,0 +1,24 @@
import { cn, interactive } from '@/lib'
const PILL =
'flex items-center justify-center w-full max-w-[130px] h-[50px] rounded-full py-4 px-6 ' +
'text-lg font-semibold whitespace-nowrap ' +
interactive
export function ActionSection() {
return (
<div className="flex w-full py-[35px] items-center justify-end gap-3">
{/* TODO: 협상 참여 동작 연동 */}
<button type="button" className={cn(PILL, 'bg-primary text-primary-foreground')}>
협상 참여
</button>
{/* TODO: 거부 동작 연동 */}
<button
type="button"
className={cn(PILL, 'bg-background text-primary border border-primary')}
>
거부
</button>
</div>
)
}

View File

@ -1,8 +1,8 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/components'
import { cn, interactive } from '@/lib'
export interface ListFilterProps {
export interface FilterGroupProps {
title: string
items: readonly string[]
selectedItem: string | null
@ -10,13 +10,13 @@ export interface ListFilterProps {
defaultOpen?: boolean
}
export function ListFilter({
export function FilterGroup({
title,
items,
selectedItem,
onItemClick,
defaultOpen = true,
}: ListFilterProps) {
}: FilterGroupProps) {
const [open, setOpen] = useState(defaultOpen)
return (
@ -29,7 +29,7 @@ export function ListFilter({
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="w-10 h-10 flex items-center justify-center cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
className={cn('w-10 h-10 flex items-center justify-center text-muted-foreground', interactive)}
>
<ChevronDown
size={24}
@ -38,7 +38,7 @@ export function ListFilter({
</button>
</div>
{/* grid-rows 0fr→1fr 로 높이를 부드럽게 펼침 */}
{/* grid-rows 0fr→1fr 펼침 */}
<div
className={cn(
'grid transition-[grid-template-rows,opacity] duration-300 ease-out',
@ -54,8 +54,8 @@ export function ListFilter({
onClick={() => 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}

View File

@ -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 <div className="w-full my-[35px] h-[50px]" />
}
const items = buildPageItems(currentPage, totalPages)
return (
<div className="flex w-full my-[35px]">
<div className="flex w-full h-[50px] items-center justify-center gap-3">
<NavButton
dir="prev"
enabled={currentPage > 1}
onClick={() => onPageChange(currentPage - 1)}
/>
<div className="flex items-center justify-center h-full rounded-[999px] border border-neutral-30 bg-neutral-00">
<div className="flex items-center justify-center gap-1 h-[38px]">
{items.map((item, idx) =>
item === 'dots' ? (
<DotsIcon key={`dots-${idx}`} />
) : (
<PageNumber
key={item}
page={item}
active={item === currentPage}
onClick={() => onPageChange(item)}
/>
),
)}
</div>
</div>
<NavButton
dir="next"
enabled={currentPage < totalPages}
onClick={() => onPageChange(currentPage + 1)}
/>
</div>
</div>
)
}
function PageNumber({
page,
active,
onClick,
}: {
page: number
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={active ? undefined : onClick}
className={cn(
'flex w-[50px] h-[50px] items-center justify-center shrink-0 text-[18px] tracking-[-0.36px] select-none',
active
? 'rounded-full bg-primary text-primary-foreground cursor-default'
: cn('text-neutral-80', interactive),
)}
>
{page}
</button>
)
}
function NavButton({
dir,
enabled,
onClick,
}: {
dir: 'prev' | 'next'
enabled: boolean
onClick: () => void
}) {
return (
<button
type="button"
aria-label={dir === 'prev' ? '이전 페이지' : '다음 페이지'}
disabled={!enabled}
onClick={enabled ? onClick : undefined}
className={cn(
'flex items-center justify-center w-[50px] h-[50px] rounded-full border border-neutral-30 bg-neutral-00 select-none',
enabled ? cn('text-neutral-60', interactive) : 'text-neutral-40 cursor-default',
)}
>
<ArrowIcon dir={dir} />
</button>
)
}

View File

@ -1,21 +0,0 @@
import { Button } from '@/components'
/** 좌측 사이드바 하단: 공급사명 + 로그아웃 */
export function SidebarFooter() {
return (
<div className="flex w-full h-[60px] py-3 px-8 gap-3 items-center">
<div className="flex-1 flex items-center min-w-0 text-sm text-foreground">
{/* TODO: 공급사명 (auth store 연동) */}
<span className="truncate">-</span>
</div>
<Button
variant="secondary"
size="sm"
className="w-[61px] px-2 rounded-[4px] text-xs flex-shrink-0"
// TODO: 로그아웃 mutation 연동
>
로그아웃
</Button>
</div>
)
}

View File

@ -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 (
<div className="flex flex-1 flex-col w-full min-h-0">
<div className="h-full overflow-auto rounded-[8px] bg-background">
<table className="w-full min-w-[1431px] table-fixed border-separate border-spacing-0">
<thead className="sticky top-0 z-10">
<tr className="h-[53px] bg-table-header text-foreground text-lg font-semibold">
{COLUMNS.map((col) => (
<th key={col.key} className={cn(col.width, 'px-4 whitespace-nowrap', col.align)}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<StateRow>
<Loader2 className="size-10 animate-spin text-muted-foreground" />
</StateRow>
) : items.length === 0 ? (
<StateRow>
<span className="text-lg text-muted-foreground">조회된 협상 내역이 없습니다.</span>
</StateRow>
) : (
items.map((item) => (
<tr
key={item.session_id}
onClick={() => 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) => (
<td
key={col.key}
className={cn(
col.width,
'px-4 border-b border-border overflow-hidden text-ellipsis whitespace-nowrap',
col.align,
)}
>
{col.render ? col.render(item) : item[col.key] || '-'}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
// 로딩/빈 상태 행
function StateRow({ children }: { children: ReactNode }) {
return (
<tr>
<td colSpan={COLUMNS.length} className="h-[240px]">
<div className="flex items-center justify-center">{children}</div>
</td>
</tr>
)
}

View File

@ -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 (
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d={ARROW_PATH[dir]} fill="currentColor" />
</svg>
)
}
export function DotsIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="select-none text-neutral-60"
>
<path
d="M6.23047 13.5C5.81797 13.5 5.46489 13.3531 5.17122 13.0592C4.87739 12.7656 4.73047 12.4125 4.73047 12C4.73047 11.5875 4.87739 11.2344 5.17122 10.9408C5.46489 10.6469 5.81797 10.5 6.23047 10.5C6.64297 10.5 6.99614 10.6469 7.28997 10.9408C7.58364 11.2344 7.73047 11.5875 7.73047 12C7.73047 12.4125 7.58364 12.7656 7.28997 13.0592C6.99614 13.3531 6.64297 13.5 6.23047 13.5ZM11.9997 13.5C11.5872 13.5 11.2341 13.3531 10.9405 13.0592C10.6466 12.7656 10.4997 12.4125 10.4997 12C10.4997 11.5875 10.6466 11.2344 10.9405 10.9408C11.2341 10.6469 11.5872 10.5 11.9997 10.5C12.4122 10.5 12.7653 10.6469 13.059 10.9408C13.3528 11.2344 13.4997 11.5875 13.4997 12C13.4997 12.4125 13.3528 12.7656 13.059 13.0592C12.7653 13.3531 12.4122 13.5 11.9997 13.5ZM17.769 13.5C17.3565 13.5 17.0033 13.3531 16.7095 13.0592C16.4158 12.7656 16.269 12.4125 16.269 12C16.269 11.5875 16.4158 11.2344 16.7095 10.9408C17.0033 10.6469 17.3565 10.5 17.769 10.5C18.1815 10.5 18.5346 10.6469 18.8282 10.9408C19.1221 11.2344 19.269 11.5875 19.269 12C19.269 12.4125 19.1221 12.7656 18.8282 13.0592C18.5346 13.3531 18.1815 13.5 17.769 13.5Z"
fill="currentColor"
/>
</svg>
)
}

View File

@ -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<string, string> = {
협상생성: '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) => (
<span className={STATUS_COLOR[item.session_status]}>{item.session_status || '-'}</span>
),
},
{ 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),
},
]

View File

@ -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) 일괄 적용
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
<ActionSection />
<TableSection
items={items}
isLoading={isLoading}
selectedId={selectedId ?? undefined}
onItemClick={handleItemClick}
/>
<Pagination
totalPages={totalPages}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</div>
)
}

View File

@ -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<FilterKey, string | null> = {
type: selectedType,
status: selectedStatus,
deadline: selectedDeadline,
}
const toggle: Record<FilterKey, (item: string) => void> = {
type: toggleType,
status: toggleStatus,
deadline: toggleDeadline,
}
return (
<div className="flex flex-1 pt-8 pr-2 pl-5 overflow-hidden bg-background">
<div className="flex flex-col gap-4 pr-2 w-full overflow-y-auto">
{FILTER_GROUPS.map((group) => (
<FilterGroup
key={group.key}
title={group.title}
items={group.items}
selectedItem={selected[group.key]}
onItemClick={toggle[group.key]}
/>
))}
</div>
</div>
)
}

View File

@ -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<FilterKey, string | null> = {
type: store.selectedType,
status: store.selectedStatus,
deadline: store.selectedDeadline,
}
const toggle: Record<FilterKey, (item: string) => void> = {
type: store.toggleType,
status: store.toggleStatus,
deadline: store.toggleDeadline,
}
return (
<div className="flex flex-1 pt-8 pr-2 pl-5 overflow-hidden bg-background">
<div className="flex flex-col gap-4 pr-2 w-full overflow-y-auto">
{FILTER_GROUPS.map((group) => (
<ListFilter
key={group.key}
title={group.title}
items={group.items}
selectedItem={selected[group.key]}
onItemClick={toggle[group.key]}
/>
))}
</div>
</div>
)
}

View File

@ -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',

View File

@ -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<string | null>(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,
}
}

View File

@ -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'

View File

@ -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())}`
}

View File

@ -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]
}

View File

@ -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: '로지텍',
},
]

View File

@ -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<ListFilterState>((set) => ({
export const useListStore = create<ListState>((set) => ({
selectedType: null,
selectedStatus: null,
selectedDeadline: null,

View File

@ -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
}