feat(frontend): 협상 목록/참여/거부 연동

- 세션 목록을 useSessionListQuery 로 서버 조회(필터·정렬·페이지 위임),
  mock 제거. 정수 코드↔한국어 라벨 변환 어댑터(list/lib/adapter)
- 협상 참여: useParticipateMutation, 성공 시 채팅 진입·실패 토스트
- 거부: 사유 입력 팝업(RejectPopup) + useRejectMutation, 상태별 차단 안내
- 선택/상태 검증을 토스트로 일원화(ActionSection 은 버튼만 담당)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-18 13:12:31 +09:00
parent c0d224ef21
commit fab8e5aa53
6 changed files with 298 additions and 151 deletions

View File

@ -5,17 +5,42 @@ const PILL =
'text-lg font-semibold whitespace-nowrap ' +
interactive
export function ActionSection() {
export interface ActionSectionProps {
isParticipating: boolean
isRejecting: boolean
onParticipate: () => void
onReject: () => void
}
export function ActionSection({
isParticipating,
isRejecting,
onParticipate,
onReject,
}: ActionSectionProps) {
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')}
onClick={onParticipate}
disabled={isParticipating}
className={cn(
PILL,
'bg-primary text-primary-foreground',
isParticipating && 'opacity-50 cursor-not-allowed',
)}
>
협상 참여
</button>
<button
type="button"
onClick={onReject}
disabled={isRejecting}
className={cn(
PILL,
'bg-background text-primary border border-primary',
isRejecting && 'opacity-50 cursor-not-allowed',
)}
>
거부
</button>

View File

@ -0,0 +1,124 @@
import { useState } from 'react'
import { Modal } from '@/components'
import { cn, interactive } from '@/lib'
const REASONS = ['단종', '품절', '기타'] as const
export interface RejectPopupProps {
onClose: () => void
/** 최종 거부 사유 (프리셋 라벨 또는 기타 입력 텍스트) */
onSubmit: (reason: string) => void
}
// 거부 사유 입력 팝업 (단종/품절/기타).
export function RejectPopup({ onClose, onSubmit }: RejectPopupProps) {
const [selectedReason, setSelectedReason] = useState<string | null>(null)
const [customReason, setCustomReason] = useState('')
const [showError, setShowError] = useState(false)
const isEtcOpen = selectedReason === '기타'
const isSubmitDisabled = !selectedReason || (isEtcOpen && !customReason.trim())
const handleReasonClick = (reason: string) => {
setShowError(false)
if (selectedReason === reason) {
setSelectedReason(null)
setCustomReason('')
} else {
setSelectedReason(reason)
if (reason !== '기타') setCustomReason('')
}
}
const handleSubmit = () => {
if (isEtcOpen && !customReason.trim()) {
setShowError(true)
return
}
if (isSubmitDisabled || !selectedReason) return
const reason = isEtcOpen ? customReason.trim() : selectedReason
onSubmit(reason)
onClose()
}
return (
<Modal onClose={onClose}>
<div className="flex h-[500px] w-[700px] flex-col items-center justify-between rounded-[10px] bg-background py-20 shadow-lg transition-all duration-300 ease-out">
{/* 헤더 */}
<p className="text-center text-[26px] font-bold leading-9 tracking-[-0.52px] text-foreground">
거부 사유를 입력해주세요
</p>
{/* 사유 선택 */}
<div className={cn('flex gap-5 transition-all duration-300 ease-out', isEtcOpen ? 'mt-0' : 'mt-12')}>
{REASONS.map((reason) => (
<button
key={reason}
type="button"
onClick={() => handleReasonClick(reason)}
className={cn(
'flex h-[50px] w-[120px] items-center justify-center rounded-full px-8 title-2 text-foreground',
'bg-neutral-20 border',
interactive,
selectedReason === reason ? 'border-foreground' : 'border-transparent',
)}
>
{reason}
</button>
))}
</div>
{/* 기타 입력 + 에러 */}
<div className="flex flex-col items-center">
<div
className={cn(
'flex w-[400px] overflow-hidden transition-all duration-300 ease-out',
isEtcOpen ? 'visible h-[120px] opacity-100' : 'invisible h-0 opacity-0',
)}
>
<textarea
placeholder="사유를 입력하여 주십시오"
value={customReason}
onChange={(e) => {
setCustomReason(e.target.value)
setShowError(false)
}}
className={cn(
'h-full w-full resize-none rounded-lg bg-background p-2.5 body-3 text-foreground',
'placeholder:text-muted-foreground focus:outline-none',
'transition-colors duration-200',
showError ? 'border-2 border-destructive' : 'border border-foreground',
)}
/>
</div>
{/* 에러 메시지 (항상 공간 확보) */}
<div
className={cn(
'w-[400px] overflow-hidden transition-all duration-300 ease-out',
showError && isEtcOpen ? 'mt-2 h-[24px] opacity-100' : 'h-0 opacity-0',
)}
>
<p className="text-left body-5 text-destructive">기타 사유를 입력해주세요</p>
</div>
</div>
{/* 전송 */}
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitDisabled}
className={cn(
'flex h-[50px] w-full max-w-[130px] items-center justify-center rounded-full px-8 title-2',
'bg-foreground text-background',
interactive,
isSubmitDisabled && 'cursor-not-allowed opacity-40',
)}
>
전송
</button>
</div>
</Modal>
)
}

View File

@ -1,16 +1,92 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { getApiErrorMessage, useParticipateMutation, useRejectMutation } from '@/apis'
import { toast } from '@/lib'
import { useList } from '@/features/list/hooks/useList'
import { ActionSection } from '@/features/list/components/ActionSection'
import { RejectPopup } from '@/features/list/components/RejectPopup'
import { TableSection } from '@/features/list/components/TableSection'
import { Pagination } from '@/features/list/components/Pagination'
// 상태별 거부 불가 안내
const REJECT_BLOCKED: Record<string, string> = {
미참여: '미참여 상태인 협상은 거부할 수 없습니다.',
협상거부: '협상거부 상태인 협상은 거부할 수 없습니다.',
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
}
export function ContentContainer() {
const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
useList()
const navigate = useNavigate()
const {
items,
isLoading,
totalPages,
currentPage,
setCurrentPage,
selectedId,
selectedItem,
handleItemClick,
} = useList()
const participate = useParticipateMutation()
const reject = useRejectMutation()
const [isRejectOpen, setIsRejectOpen] = useState(false)
// 선택 검증: 선택된 협상이 없으면 안내 후 false
const requireSelection = (): boolean => {
if (!selectedItem) {
toast.info('상품을 선택해주세요.')
return false
}
return true
}
const handleParticipate = () => {
if (!requireSelection()) return
if (['협상거부', '미참여'].includes(selectedItem!.session_status)) {
toast.error('협상거부 또는 미참여한 협상에는 참여할 수 없습니다.')
return
}
const sessionId = selectedItem!.session_id
participate.mutate(sessionId, {
onSuccess: () => navigate(`/chat?session_id=${sessionId}`),
onError: (error) => toast.error(getApiErrorMessage(error, '협상 참여에 실패했습니다.')),
})
}
const handleReject = () => {
if (!requireSelection()) return
const blocked = REJECT_BLOCKED[selectedItem!.session_status]
if (blocked) {
toast.error(blocked)
return
}
setIsRejectOpen(true)
}
const handleRejectSubmit = (reason: string) => {
if (!selectedItem) return
reject.mutate(
{ sessionId: selectedItem.session_id, request: { reject_reason: reason } },
{
onSuccess: () => toast.warning('참여 거절이 완료되었습니다.'),
onError: (error) => toast.error(getApiErrorMessage(error, '거절 처리에 실패했습니다.')),
},
)
}
return (
// 좌우 거터(80px) 일괄 적용
<div className="flex flex-1 flex-col min-h-0 px-[80px]">
<ActionSection />
<ActionSection
isParticipating={participate.isPending}
isRejecting={reject.isPending}
onParticipate={handleParticipate}
onReject={handleReject}
/>
<TableSection
items={items}
isLoading={isLoading}
@ -22,6 +98,9 @@ export function ContentContainer() {
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
{isRejectOpen && (
<RejectPopup onClose={() => setIsRejectOpen(false)} onSubmit={handleRejectSubmit} />
)}
</div>
)
}

View File

@ -1,40 +1,33 @@
import { useMemo, useState } from 'react'
import { useSessionListQuery } from '@/apis'
import { useListStore } from '@/features/list/stores/useListStore'
import { MOCK_ITEMS } from '@/features/list/mocks/mockItems'
import {
deadlineToOrder,
statusLabelToCode,
toListItem,
typeLabelToCode,
} from '@/features/list/lib/adapter'
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),
)
const query = useSessionListQuery({
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
page: currentPage,
page_size: PAGE_SIZE,
})
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 items = useMemo(() => (query.data?.items ?? []).map(toListItem), [query.data])
const total = query.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const selectedItem = useMemo(
() => items.find((item) => item.session_id === selectedId) ?? null,
@ -46,7 +39,7 @@ export function useList() {
return {
items,
isLoading: false,
isLoading: query.isLoading,
totalPages,
currentPage,
setCurrentPage,

View File

@ -0,0 +1,41 @@
// 백엔드 정수 코드값 ↔ 리스트 UI 의 한국어 라벨 변환 어댑터.
// 라벨 단일 출처는 apis 의 *_LABEL 맵이며, 필터 라벨(FILTER_GROUPS)도 이와 일치한다.
import {
QT_TYPE_LABEL,
SESSION_STATUS_LABEL,
type QtType,
type SessionListItem,
type SessionStatus,
} from '@/apis'
import type { ListItem } from '@/features/list/types'
const invert = (labelMap: Record<number, string>): Record<string, number> =>
Object.fromEntries(Object.entries(labelMap).map(([code, label]) => [label, Number(code)]))
const STATUS_LABEL_TO_CODE = invert(SESSION_STATUS_LABEL)
const TYPE_LABEL_TO_CODE = invert(QT_TYPE_LABEL)
/** 필터 상태 라벨 → SessionStatus 코드 */
export const statusLabelToCode = (label: string): number | undefined => STATUS_LABEL_TO_CODE[label]
/** 필터 구분 라벨 → QtType 코드 */
export const typeLabelToCode = (label: string): number | undefined => TYPE_LABEL_TO_CODE[label]
/** 마감일 필터 라벨 → 정렬 방향 (임박순=asc) */
export const deadlineToOrder = (label: string): 'asc' | 'desc' =>
label === '남은 시간 적은 순' ? 'asc' : 'desc'
/** API 세션 항목(정수 코드) → 화면용 ListItem(한국어 라벨) */
export function toListItem(api: SessionListItem): ListItem {
return {
session_id: api.session_id,
session_status: SESSION_STATUS_LABEL[api.session_status as SessionStatus] ?? '-',
qt_type: QT_TYPE_LABEL[api.qt_type as QtType] ?? '-',
qt_number: api.qt_number,
qt_end_time: api.qt_end_time,
item_code: api.item_code,
item_name: api.item_name,
model_name: api.model_name,
maker_name: api.maker_name,
}
}

View File

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