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:
parent
32db1300e0
commit
5eb83f9d54
24
front/src/features/list/components/ActionSection.tsx
Normal file
24
front/src/features/list/components/ActionSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
102
front/src/features/list/components/Pagination.tsx
Normal file
102
front/src/features/list/components/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
79
front/src/features/list/components/TableSection.tsx
Normal file
79
front/src/features/list/components/TableSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
front/src/features/list/components/paginationIcons.tsx
Normal file
32
front/src/features/list/components/paginationIcons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
front/src/features/list/components/tableColumns.tsx
Normal file
45
front/src/features/list/components/tableColumns.tsx
Normal 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),
|
||||
},
|
||||
]
|
||||
27
front/src/features/list/containers/ContentContainer.tsx
Normal file
27
front/src/features/list/containers/ContentContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
front/src/features/list/containers/FilterContainer.tsx
Normal file
52
front/src/features/list/containers/FilterContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
57
front/src/features/list/hooks/useList.ts
Normal file
57
front/src/features/list/hooks/useList.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
8
front/src/features/list/lib/datetime.ts
Normal file
8
front/src/features/list/lib/datetime.ts
Normal 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())}`
|
||||
}
|
||||
18
front/src/features/list/lib/pagination.ts
Normal file
18
front/src/features/list/lib/pagination.ts
Normal 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]
|
||||
}
|
||||
115
front/src/features/list/mocks/mockItems.ts
Normal file
115
front/src/features/list/mocks/mockItems.ts
Normal 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: '로지텍',
|
||||
},
|
||||
]
|
||||
@ -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,
|
||||
11
front/src/features/list/types.ts
Normal file
11
front/src/features/list/types.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user