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:
parent
c0d224ef21
commit
fab8e5aa53
@ -5,17 +5,42 @@ const PILL =
|
|||||||
'text-lg font-semibold whitespace-nowrap ' +
|
'text-lg font-semibold whitespace-nowrap ' +
|
||||||
interactive
|
interactive
|
||||||
|
|
||||||
export function ActionSection() {
|
export interface ActionSectionProps {
|
||||||
|
isParticipating: boolean
|
||||||
|
isRejecting: boolean
|
||||||
|
onParticipate: () => void
|
||||||
|
onReject: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionSection({
|
||||||
|
isParticipating,
|
||||||
|
isRejecting,
|
||||||
|
onParticipate,
|
||||||
|
onReject,
|
||||||
|
}: ActionSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full py-[35px] items-center justify-end gap-3">
|
<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
|
<button
|
||||||
type="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>
|
</button>
|
||||||
|
|||||||
124
frontend/src/features/list/components/RejectPopup.tsx
Normal file
124
frontend/src/features/list/components/RejectPopup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 { useList } from '@/features/list/hooks/useList'
|
||||||
import { ActionSection } from '@/features/list/components/ActionSection'
|
import { ActionSection } from '@/features/list/components/ActionSection'
|
||||||
|
import { RejectPopup } from '@/features/list/components/RejectPopup'
|
||||||
import { TableSection } from '@/features/list/components/TableSection'
|
import { TableSection } from '@/features/list/components/TableSection'
|
||||||
import { Pagination } from '@/features/list/components/Pagination'
|
import { Pagination } from '@/features/list/components/Pagination'
|
||||||
|
|
||||||
|
// 상태별 거부 불가 안내
|
||||||
|
const REJECT_BLOCKED: Record<string, string> = {
|
||||||
|
미참여: '미참여 상태인 협상은 거부할 수 없습니다.',
|
||||||
|
협상거부: '협상거부 상태인 협상은 거부할 수 없습니다.',
|
||||||
|
협상완료: '협상완료 상태인 협상은 거부할 수 없습니다.',
|
||||||
|
}
|
||||||
|
|
||||||
export function ContentContainer() {
|
export function ContentContainer() {
|
||||||
const { items, isLoading, totalPages, currentPage, setCurrentPage, selectedId, handleItemClick } =
|
const navigate = useNavigate()
|
||||||
useList()
|
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 (
|
return (
|
||||||
// 좌우 거터(80px) 일괄 적용
|
// 좌우 거터(80px) 일괄 적용
|
||||||
<div className="flex flex-1 flex-col min-h-0 px-[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
|
<TableSection
|
||||||
items={items}
|
items={items}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@ -22,6 +98,9 @@ export function ContentContainer() {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onPageChange={setCurrentPage}
|
onPageChange={setCurrentPage}
|
||||||
/>
|
/>
|
||||||
|
{isRejectOpen && (
|
||||||
|
<RejectPopup onClose={() => setIsRejectOpen(false)} onSubmit={handleRejectSubmit} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,40 +1,33 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useSessionListQuery } from '@/apis'
|
||||||
import { useListStore } from '@/features/list/stores/useListStore'
|
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'
|
import type { ListItem } from '@/features/list/types'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// 목데이터 클라이언트 필터 (추후 API 조회로 교체)
|
// 필터/정렬/페이지는 서버에 위임하고, 응답(정수 코드)을 화면용 라벨로 변환한다.
|
||||||
export function useList() {
|
export function useList() {
|
||||||
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
|
const { selectedType, selectedStatus, selectedDeadline, currentPage, setCurrentPage } =
|
||||||
useListStore()
|
useListStore()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const query = useSessionListQuery({
|
||||||
const result = MOCK_ITEMS.filter(
|
status: selectedStatus ? statusLabelToCode(selectedStatus) : undefined,
|
||||||
(item) =>
|
qt_type: selectedType ? typeLabelToCode(selectedType) : undefined,
|
||||||
(!selectedType || item.qt_type === selectedType) &&
|
order: selectedDeadline ? deadlineToOrder(selectedDeadline) : undefined,
|
||||||
(!selectedStatus || item.session_status === selectedStatus),
|
page: currentPage,
|
||||||
)
|
page_size: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
|
||||||
if (selectedDeadline) {
|
const items = useMemo(() => (query.data?.items ?? []).map(toListItem), [query.data])
|
||||||
const dir = selectedDeadline === '남은 시간 적은 순' ? 1 : -1
|
const total = query.data?.total ?? 0
|
||||||
result.sort(
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
(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(
|
const selectedItem = useMemo(
|
||||||
() => items.find((item) => item.session_id === selectedId) ?? null,
|
() => items.find((item) => item.session_id === selectedId) ?? null,
|
||||||
@ -46,7 +39,7 @@ export function useList() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
isLoading: false,
|
isLoading: query.isLoading,
|
||||||
totalPages,
|
totalPages,
|
||||||
currentPage,
|
currentPage,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
41
frontend/src/features/list/lib/adapter.ts
Normal file
41
frontend/src/features/list/lib/adapter.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: '로지텍',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
Loading…
Reference in New Issue
Block a user