[feat] negodata: 견적·카드 검색/필터 서버사이드 전환 + 견적 상세 Sheet 리팩터
- 백엔드: quotation 목록 search 파라미터(name/number ILIKE) 추가; card 목록 is_wildcard 탭 필터 + total_nego/total_wild 탭 카운트 추가; status/type 코드 int 비교 보정 - 프론트: cards/quotation 검색·상태·유형 필터·페이지네이션을 useServerList 기반 서버사이드로 전환; 엔터 즉시검색 + 디바운스 500ms·최소 2글자; 죽은 client 필터 훅(useCardFilters/useQuotationFilters) 제거 - generated 파라미터/응답 타입 손보강(listCardsParams.is_wildcard, listQuotationsParams.search, resCardList.total_nego/total_wild) — orval 재생성 시 동일 - 견적 상세 Drawer→Sheet 분리, useOverlayParams→useOverlayRouter, CreateQuotationWizard→QuotationCreateModal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c3d96fb7d3
commit
6fa024137f
@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import select, func, and_, update
|
from sqlalchemy import select, func, and_, or_, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
@ -19,7 +19,7 @@ from common.utils.gtime import GTime
|
|||||||
class IQuotationCRUD(ABC):
|
class IQuotationCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search(
|
async def search(
|
||||||
self, cdb: AsyncSession, status, type_, start_from, start_to, skip, limit
|
self, cdb: AsyncSession, search, status, type_, start_from, start_to, skip, limit
|
||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -88,6 +88,7 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
cdb: AsyncSession,
|
cdb: AsyncSession,
|
||||||
|
search: Optional[str],
|
||||||
status: Optional[str],
|
status: Optional[str],
|
||||||
type_: Optional[str],
|
type_: Optional[str],
|
||||||
start_from: Optional[datetime],
|
start_from: Optional[datetime],
|
||||||
@ -97,10 +98,12 @@ class QuotationCRUD(IQuotationCRUD):
|
|||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
try:
|
try:
|
||||||
conditions = [quotations.deleted == False] # noqa: E712
|
conditions = [quotations.deleted == False] # noqa: E712
|
||||||
|
if search:
|
||||||
|
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
|
||||||
if status:
|
if status:
|
||||||
conditions.append(quotations.status == status)
|
conditions.append(quotations.status == int(status)) # status/type 는 SMALLINT 코드 — 문자열 쿼리값을 정수로
|
||||||
if type_:
|
if type_:
|
||||||
conditions.append(quotations.type == type_)
|
conditions.append(quotations.type == int(type_))
|
||||||
if start_from:
|
if start_from:
|
||||||
conditions.append(quotations.start_time >= start_from)
|
conditions.append(quotations.start_time >= start_from)
|
||||||
if start_to:
|
if start_to:
|
||||||
|
|||||||
@ -21,9 +21,10 @@ async def list_cards(
|
|||||||
service: CardService = Depends(),
|
service: CardService = Depends(),
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"),
|
search: str | None = Query(None, description="카드명/카드번호/스크립트 검색"),
|
||||||
|
is_wildcard: bool | None = Query(None, description="탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드"),
|
||||||
pg: PageParams = Depends(),
|
pg: PageParams = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, pg))
|
return RemoveNoneResponse(await service.list_cards(user_info.user_id, search, is_wildcard, pg))
|
||||||
|
|
||||||
|
|
||||||
@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록")
|
@router.post(path="/create", response_model=Res_Card, summary="협상카드 등록")
|
||||||
|
|||||||
@ -57,6 +57,8 @@ class Res_Card(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_CardList(Res_PageProtocol):
|
class Res_CardList(Res_PageProtocol):
|
||||||
cards: list[CardData] = []
|
cards: list[CardData] = []
|
||||||
|
total_nego: int = 0 # 협상카드 탭 카운트(검색 필터 반영)
|
||||||
|
total_wild: int = 0 # 와일드카드 탭 카운트(검색 필터 반영)
|
||||||
|
|
||||||
|
|
||||||
class Res_DeleteCard(Res_WebPacketProtocol):
|
class Res_DeleteCard(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -31,13 +31,14 @@ router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {
|
|||||||
async def list_quotations(
|
async def list_quotations(
|
||||||
service: QuotationService = Depends(),
|
service: QuotationService = Depends(),
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
search: str | None = Query(None, description="견적명/견적번호 검색"),
|
||||||
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
|
status: str | None = Query(None, description="상태 필터(정확히 일치)"),
|
||||||
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
|
||||||
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
|
||||||
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
|
||||||
pg: PageParams = Depends(),
|
pg: PageParams = Depends(),
|
||||||
):
|
):
|
||||||
return RemoveNoneResponse(await service.list_quotations(status, type, start_from, start_to, pg))
|
return RemoveNoneResponse(await service.list_quotations(search, status, type, start_from, start_to, pg))
|
||||||
|
|
||||||
|
|
||||||
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
|
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
|
||||||
|
|||||||
@ -79,18 +79,23 @@ class CardService:
|
|||||||
return ErrorType.CARD_NOT_FOUND, None, None, None, False
|
return ErrorType.CARD_NOT_FOUND, None, None, None, False
|
||||||
|
|
||||||
# ---- 목록 ----------------------------------------------------------------
|
# ---- 목록 ----------------------------------------------------------------
|
||||||
async def list_cards(self, user_id: str, search, pg: PageParams) -> Res_CardList:
|
async def list_cards(self, user_id: str, search, is_wildcard, pg: PageParams) -> Res_CardList:
|
||||||
|
"""is_wildcard: None=전체(두 테이블 머지) / False=협상카드만 / True=와일드카드만.
|
||||||
|
탭이 무엇이든 양쪽 카운트(total_nego/total_wild)는 항상 채운다(검색 필터 반영).
|
||||||
|
선택 안 된 탭은 limit=0 으로 카운트만 받아 행은 가져오지 않는다."""
|
||||||
res = Res_CardList(page=pg.page, size=pg.size)
|
res = Res_CardList(page=pg.page, size=pg.size)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return res
|
return res
|
||||||
user_uuid = uuid.UUID(user_id)
|
user_uuid = uuid.UUID(user_id)
|
||||||
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
|
# 합쳐서 정렬/페이징하므로 각 테이블에서 skip+limit 까지 받아온다(카드 수가 적어 충분).
|
||||||
fetch = pg.skip + pg.size
|
fetch = pg.skip + pg.size
|
||||||
|
nego_limit = 0 if is_wildcard is True else fetch
|
||||||
|
wild_limit = 0 if is_wildcard is False else fetch
|
||||||
|
|
||||||
err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda(
|
err_n, nego_rows, total_n = await DB_SESSION_MNG.execute_lambda(
|
||||||
nego_cards.DBType(),
|
nego_cards.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, fetch),
|
lambda s: self.card_crud.search(s, nego_cards, user_uuid, search, 0, nego_limit),
|
||||||
)
|
)
|
||||||
if err_n != ErrorType.SUCCESS:
|
if err_n != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_n)
|
res.result.SetResult(err_n)
|
||||||
@ -99,7 +104,7 @@ class CardService:
|
|||||||
err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda(
|
err_w, wild_rows, total_w = await DB_SESSION_MNG.execute_lambda(
|
||||||
wild_cards.DBType(),
|
wild_cards.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, fetch),
|
lambda s: self.card_crud.search(s, wild_cards, user_uuid, search, 0, wild_limit),
|
||||||
)
|
)
|
||||||
if err_w != ErrorType.SUCCESS:
|
if err_w != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_w)
|
res.result.SetResult(err_w)
|
||||||
@ -108,7 +113,15 @@ class CardService:
|
|||||||
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
|
merged = [self._nego_to_data(r) for r in nego_rows] + [self._wild_to_data(r) for r in wild_rows]
|
||||||
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
merged.sort(key=lambda c: c.created_at or "", reverse=True)
|
||||||
res.cards = merged[pg.skip : pg.skip + pg.size]
|
res.cards = merged[pg.skip : pg.skip + pg.size]
|
||||||
res.total = total_n + total_w
|
res.total_nego = total_n
|
||||||
|
res.total_wild = total_w
|
||||||
|
# 선택된 탭 기준 페이지네이션 총건수(전체=합산).
|
||||||
|
if is_wildcard is True:
|
||||||
|
res.total = total_w
|
||||||
|
elif is_wildcard is False:
|
||||||
|
res.total = total_n
|
||||||
|
else:
|
||||||
|
res.total = total_n + total_w
|
||||||
return res
|
return res
|
||||||
|
|
||||||
# ---- 단건 조회 -----------------------------------------------------------
|
# ---- 단건 조회 -----------------------------------------------------------
|
||||||
|
|||||||
@ -82,13 +82,13 @@ class QuotationService:
|
|||||||
return ErrorType.QUOTATION_NOT_FOUND, None
|
return ErrorType.QUOTATION_NOT_FOUND, None
|
||||||
return ErrorType.SUCCESS, quotation
|
return ErrorType.SUCCESS, quotation
|
||||||
|
|
||||||
async def list_quotations(self, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
|
||||||
res = Res_QuotationList(page=pg.page, size=pg.size)
|
res = Res_QuotationList(page=pg.page, size=pg.size)
|
||||||
|
|
||||||
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
|
||||||
quotations.DBType(),
|
quotations.DBType(),
|
||||||
DBWRType.DB_READ.value,
|
DBWRType.DB_READ.value,
|
||||||
lambda s: self.quotation_crud.search(s, status, type_, start_from, start_to, pg.skip, pg.size),
|
lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size),
|
||||||
)
|
)
|
||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
|
|||||||
@ -10,6 +10,10 @@ export type ListCardsParams = {
|
|||||||
* 카드명/카드번호/스크립트 검색
|
* 카드명/카드번호/스크립트 검색
|
||||||
*/
|
*/
|
||||||
search?: string | null;
|
search?: string | null;
|
||||||
|
/**
|
||||||
|
* 탭 필터: 미지정=전체 / false=협상카드 / true=와일드카드
|
||||||
|
*/
|
||||||
|
is_wildcard?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* @minimum 1
|
* @minimum 1
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,6 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type ListQuotationsParams = {
|
export type ListQuotationsParams = {
|
||||||
|
/**
|
||||||
|
* 견적명/견적번호 검색
|
||||||
|
*/
|
||||||
|
search?: string | null;
|
||||||
/**
|
/**
|
||||||
* 상태 필터(정확히 일치)
|
* 상태 필터(정확히 일치)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,4 +15,8 @@ export interface ResCardList {
|
|||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
cards?: CardData[];
|
cards?: CardData[];
|
||||||
|
/** 협상카드 탭 카운트(검색 필터 반영) */
|
||||||
|
total_nego?: number;
|
||||||
|
/** 와일드카드 탭 카운트(검색 필터 반영) */
|
||||||
|
total_wild?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef, DragEvent, ChangeEvent } from 'react';
|
import React, { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react';
|
||||||
import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react';
|
import { Upload, Image as ImageIcon, X, AlertCircle, Link2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
interface ImageDropzoneProps {
|
interface ImageDropzoneProps {
|
||||||
@ -31,6 +31,12 @@ export default function ImageDropzone({
|
|||||||
const [urlDraft, setUrlDraft] = useState('');
|
const [urlDraft, setUrlDraft] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 현재 값이 일반 URL 이면 입력칸에 그대로 노출(보기/수정/복사 가능).
|
||||||
|
// base64 data URL(파일 업로드 폴백)은 거대 문자열이라 칸엔 넣지 않는다.
|
||||||
|
useEffect(() => {
|
||||||
|
setUrlDraft(value && /^https?:\/\//i.test(value) ? value : '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const processFile = async (file: File) => {
|
const processFile = async (file: File) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@ -107,8 +113,7 @@ export default function ImageDropzone({
|
|||||||
const url = urlDraft.trim();
|
const url = urlDraft.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
onChange(url);
|
onChange(url); // value 변경 → 위 effect 가 입력칸을 적용된 URL 로 다시 채움
|
||||||
setUrlDraft('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -97,10 +97,7 @@ export function DataTable<T>({
|
|||||||
const detailCols = mobileCols.filter((c) => c !== primaryCol)
|
const detailCols = mobileCols.filter((c) => c !== primaryCol)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 전환 기준은 뷰포트가 아니라 '표가 들어갈 실제 폭'(컨테이너). 사이드바가 폭을 먹어도
|
|
||||||
// 어긋나지 않는다. 컨테이너 ≥ 48rem(@3xl)이면 표, 그 미만은 카드 리스트. (Tailwind v4 내장 @container)
|
|
||||||
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
|
<div className={cn("@container border border-border rounded-lg bg-card overflow-hidden", className)}>
|
||||||
{/* 넓을 때: 표 — 그래도 넘치면 Table 내부에서 가로 스크롤 */}
|
|
||||||
<div className="hidden @3xl:block">
|
<div className="hidden @3xl:block">
|
||||||
<Table className="w-full text-xs">
|
<Table className="w-full text-xs">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -184,7 +181,6 @@ export function DataTable<T>({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좁을 때(컨테이너 < 48rem): 카드 리스트 — 행=카드, 컬럼=라벨:값 */}
|
|
||||||
<div className="@3xl:hidden divide-y divide-border">
|
<div className="@3xl:hidden divide-y divide-border">
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
data.map((row) => {
|
data.map((row) => {
|
||||||
@ -218,7 +214,6 @@ export function DataTable<T>({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상세: 기본=라벨:값 한 줄(컴팩트) / mobileBlock=라벨 아래 풀폭 */}
|
|
||||||
{detailCols.length > 0 && (
|
{detailCols.length > 0 && (
|
||||||
<dl className="mt-2.5 space-y-1.5 border-t border-border/40 pt-2.5 text-[11px] leading-tight">
|
<dl className="mt-2.5 space-y-1.5 border-t border-border/40 pt-2.5 text-[11px] leading-tight">
|
||||||
{detailCols.map((c, i) =>
|
{detailCols.map((c, i) =>
|
||||||
|
|||||||
@ -54,10 +54,11 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: '스크립트',
|
header: '스크립트',
|
||||||
|
headClassName: 'w-[22rem]', // 컬럼 폭 고정 → 긴 스크립트가 표를 늘리지 않게
|
||||||
cellClassName: 'font-mono text-muted-foreground',
|
cellClassName: 'font-mono text-muted-foreground',
|
||||||
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
|
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
|
||||||
cell: (card) => (
|
cell: (card) => (
|
||||||
<div className="line-clamp-1 bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
|
<div className="max-w-[22rem] truncate bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
|
||||||
{card.scriptPreview}
|
{card.scriptPreview}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { NegotiationCard, CardTab } from '../types';
|
|
||||||
|
|
||||||
// 카드 목록의 탭(전체/협상/와일드) + 검색 필터 state와 파생 결과/카운트.
|
|
||||||
export function useCardFilters(cards: NegotiationCard[]) {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [activeTab, setActiveTab] = useState<CardTab>('ALL');
|
|
||||||
|
|
||||||
const filtered = cards.filter((card) => {
|
|
||||||
const matchesTab =
|
|
||||||
activeTab === 'ALL' || (activeTab === 'WILD' ? card.isWildcard : !card.isWildcard);
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const matchesSearch =
|
|
||||||
card.title.toLowerCase().includes(q) ||
|
|
||||||
card.code.toLowerCase().includes(q) ||
|
|
||||||
card.scriptPreview.toLowerCase().includes(q);
|
|
||||||
return matchesTab && matchesSearch;
|
|
||||||
});
|
|
||||||
|
|
||||||
const counts = {
|
|
||||||
all: cards.length,
|
|
||||||
card: cards.filter((c) => !c.isWildcard).length,
|
|
||||||
wild: cards.filter((c) => c.isWildcard).length,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { search, setSearch, activeTab, setActiveTab, filtered, counts };
|
|
||||||
}
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
useListCards,
|
useListCards,
|
||||||
createCard,
|
createCard,
|
||||||
updateCard,
|
updateCard,
|
||||||
deleteCard,
|
deleteCard,
|
||||||
getListCardsQueryKey,
|
|
||||||
} from '@/api/generated/card/card';
|
} from '@/api/generated/card/card';
|
||||||
|
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
|
||||||
import type { Descendant } from 'slate';
|
import type { Descendant } from 'slate';
|
||||||
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
||||||
import type { ResCard } from '@/api/generated/model/resCard';
|
import type { ResCard } from '@/api/generated/model/resCard';
|
||||||
@ -13,8 +13,6 @@ import type { NegotiationCard } from '@/types';
|
|||||||
import { mapCardData, toCardStatusCode } from '../types';
|
import { mapCardData, toCardStatusCode } from '../types';
|
||||||
import { serializeToText } from '../editor';
|
import { serializeToText } from '../editor';
|
||||||
|
|
||||||
const LIST_PARAMS = { size: 100 };
|
|
||||||
|
|
||||||
// 카드 폼이 넘기는 입력값(편집/생성 공통).
|
// 카드 폼이 넘기는 입력값(편집/생성 공통).
|
||||||
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
|
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
|
||||||
export type CardInput = {
|
export type CardInput = {
|
||||||
@ -50,12 +48,13 @@ function toReq(input: CardInput): ReqCreateCard {
|
|||||||
|
|
||||||
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
|
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
|
||||||
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
|
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
|
||||||
export function useCards() {
|
export function useCards(params: ListCardsParams) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const cardsQuery = useListCards(LIST_PARAMS);
|
// 테이블용(현재 페이지). 페이지 이동 시 placeholderData 로 이전 데이터 유지(깜빡임 방지).
|
||||||
|
const cardsQuery = useListCards(params, { query: { placeholderData: keepPreviousData } });
|
||||||
|
|
||||||
const refresh = () =>
|
// 변경 후 모든 카드 목록 쿼리(파라미터별 키 전부) 재조회.
|
||||||
queryClient.invalidateQueries({ queryKey: getListCardsQueryKey(LIST_PARAMS) });
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ['/v1/card/list'] });
|
||||||
|
|
||||||
const createCardFn = async (input: CardInput) => {
|
const createCardFn = async (input: CardInput) => {
|
||||||
const msg = cardError(await createCard(toReq(input)));
|
const msg = cardError(await createCard(toReq(input)));
|
||||||
@ -74,9 +73,15 @@ export function useCards() {
|
|||||||
|
|
||||||
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
|
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
|
||||||
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
|
||||||
|
const total = cardsQuery.data?.total ?? 0; // 선택 탭 기준 총건수(페이지네이션)
|
||||||
|
const totalNego = cardsQuery.data?.total_nego ?? 0; // 협상카드 탭 카운트
|
||||||
|
const totalWild = cardsQuery.data?.total_wild ?? 0; // 와일드카드 탭 카운트
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cards,
|
cards,
|
||||||
|
total,
|
||||||
|
totalNego,
|
||||||
|
totalWild,
|
||||||
createCard: createCardFn,
|
createCard: createCardFn,
|
||||||
updateCard: updateCardFn,
|
updateCard: updateCardFn,
|
||||||
deleteCard: deleteCardFn,
|
deleteCard: deleteCardFn,
|
||||||
|
|||||||
@ -27,9 +27,7 @@ function supplierError(res: ResSupplier): string | null {
|
|||||||
return r.desc || '협력사 등록에 실패했습니다.';
|
return r.desc || '협력사 등록에 실패했습니다.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 협력사 서버 데이터 + CRUD.
|
|
||||||
// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다)
|
|
||||||
// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리.
|
|
||||||
export function usePartners(params: ListSuppliersParams) {
|
export function usePartners(params: ListSuppliersParams) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export function ProductTable({
|
|||||||
rowKey={(prod) => prod.item_id}
|
rowKey={(prod) => prod.item_id}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
selection={{ selectedKeys: selectedIds, onSelectionChange }}
|
||||||
empty="부합하는 B2B 상품 데이터 정보가 식별되지 않습니다."
|
empty="부합하는 상품 데이터 정보가 식별되지 않습니다."
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
page={page}
|
page={page}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
|
||||||
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
import type { CreateQuotationInput } from '../hooks/useQuotations';
|
||||||
|
|
||||||
type CreateQuotationWizardProps = {
|
type QuotationCreateModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
partners: Partner[];
|
partners: Partner[];
|
||||||
@ -17,7 +17,7 @@ type CreateQuotationWizardProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CreateQuotationWizard({
|
export function QuotationCreateModal({
|
||||||
open,
|
open,
|
||||||
products,
|
products,
|
||||||
partners,
|
partners,
|
||||||
@ -25,7 +25,7 @@ export function CreateQuotationWizard({
|
|||||||
quotationSettings,
|
quotationSettings,
|
||||||
onCreate,
|
onCreate,
|
||||||
onClose,
|
onClose,
|
||||||
}: CreateQuotationWizardProps) {
|
}: QuotationCreateModalProps) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [type, setType] = useState<'RE_NEGOTIATION' | 'RE_ESTIMATE'>('RE_NEGOTIATION');
|
const [type, setType] = useState<'RE_NEGOTIATION' | 'RE_ESTIMATE'>('RE_NEGOTIATION');
|
||||||
@ -1,741 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import {
|
|
||||||
StopCircle,
|
|
||||||
X,
|
|
||||||
UserCheck,
|
|
||||||
MessageSquare,
|
|
||||||
Layers,
|
|
||||||
Sparkles,
|
|
||||||
Package,
|
|
||||||
ExternalLink,
|
|
||||||
Copy,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { showToast } from '@/lib/notify';
|
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
|
||||||
import { Typography } from '@/components/ui/typography';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import SlateRenderer from '@/components/SlateRenderer';
|
|
||||||
import {
|
|
||||||
useGetQuotationSessions,
|
|
||||||
useGetSessionChat,
|
|
||||||
useGetQuotationCards,
|
|
||||||
} from '@/api/generated/quotation/quotation';
|
|
||||||
import {
|
|
||||||
type Estimate,
|
|
||||||
type Product,
|
|
||||||
type Partner,
|
|
||||||
type QuotationSetting,
|
|
||||||
normalizeQuotationStatus,
|
|
||||||
buildBidSummary,
|
|
||||||
mapServerSessionView,
|
|
||||||
sessionStatusLabel,
|
|
||||||
mapServerCardView,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
type DrawerTab = 'status' | 'cards' | 'chat';
|
|
||||||
|
|
||||||
type QuotationDetailDrawerProps = {
|
|
||||||
estimate: Estimate;
|
|
||||||
products: Product[];
|
|
||||||
partners: Partner[];
|
|
||||||
quotationSettings: QuotationSetting[];
|
|
||||||
onStop: (id: string, name: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function QuotationDetailDrawer({
|
|
||||||
estimate,
|
|
||||||
products,
|
|
||||||
partners,
|
|
||||||
quotationSettings,
|
|
||||||
onStop,
|
|
||||||
onClose,
|
|
||||||
}: QuotationDetailDrawerProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
|
|
||||||
const [showHeaderCards, setShowHeaderCards] = useState(true);
|
|
||||||
|
|
||||||
const qtId = estimate.id ?? '';
|
|
||||||
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
|
|
||||||
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } });
|
|
||||||
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
|
|
||||||
const serverSessions = sessionsQuery.data?.sessions ?? [];
|
|
||||||
const serverCards = cardsQuery.data?.cards ?? [];
|
|
||||||
|
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
|
||||||
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
|
|
||||||
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
|
|
||||||
query: { enabled: !!effectiveSessionId },
|
|
||||||
});
|
|
||||||
const chatMessages = chatQuery.data?.messages ?? [];
|
|
||||||
|
|
||||||
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
|
||||||
const currentSupplierName =
|
|
||||||
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
|
|
||||||
// 현재 세션의 상품(이미지·규격 등 상세 + 카드 변수 치환용 상품명).
|
|
||||||
const currentProduct = products.find((p) => p.id === currentSession?.item_id);
|
|
||||||
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
|
|
||||||
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) => (b == null ? '-' : b ? yes : no);
|
|
||||||
const productSpecRows = currentProduct
|
|
||||||
? [
|
|
||||||
{ label: '상품코드', value: currentProduct.code || '-' },
|
|
||||||
{ label: '단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' },
|
|
||||||
{ label: '모델명', value: currentProduct.model_name || '-' },
|
|
||||||
{ label: '규격', value: currentProduct.spec || '-' },
|
|
||||||
{ label: '제조사', value: currentProduct.manufacturer || '-' },
|
|
||||||
{ label: '원산지', value: currentProduct.made_in || '-' },
|
|
||||||
{ label: 'MOQ', value: currentProduct.moq || '-' },
|
|
||||||
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' },
|
|
||||||
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
|
|
||||||
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === estimate.settingApplied);
|
|
||||||
|
|
||||||
const bidSummaryObj = buildBidSummary(estimate, partners);
|
|
||||||
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
|
|
||||||
const quotationCardViews = serverCards.map(mapServerCardView);
|
|
||||||
|
|
||||||
// Quotations DDL 표시값
|
|
||||||
const q_name = estimate.name || estimate.title || '미지정';
|
|
||||||
const q_number = estimate.number || 'EST-000000-0000';
|
|
||||||
const q_type = estimate.type || '1:1';
|
|
||||||
const q_round = estimate.round || 1;
|
|
||||||
const q_status = estimate.status || '견적생성';
|
|
||||||
const q_end_time = estimate.end_time || estimate.dueDate || '미지정';
|
|
||||||
const q_manager_name = estimate.manager_name || '홍길동 파트너';
|
|
||||||
const q_manager_email = estimate.manager_email || 'gildong@negodata.com';
|
|
||||||
const q_memo = estimate.memo || '안내사항 없음';
|
|
||||||
const statusKey = normalizeQuotationStatus(q_status);
|
|
||||||
|
|
||||||
const goToChat = (sessionId: string) => {
|
|
||||||
setSelectedSessionId(sessionId);
|
|
||||||
setActiveTab('chat');
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [
|
|
||||||
{ id: 'status', label: '협상 현황', icon: UserCheck },
|
|
||||||
{ id: 'chat', label: '협상 대화', icon: MessageSquare },
|
|
||||||
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
|
|
||||||
<div className="flex-1 cursor-pointer" onClick={onClose} />
|
|
||||||
|
|
||||||
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left">
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-6 border-b border-border bg-muted/30">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
|
|
||||||
<span>B2B 견적 상세 // {q_number}</span>
|
|
||||||
</div>
|
|
||||||
<Typography variant="h3" className="mt-1">{q_name}</Typography>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHeaderCards(!showHeaderCards)}
|
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted hover:bg-muted-foreground/15 text-foreground border border-border rounded text-xs font-semibold cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
|
|
||||||
</button>
|
|
||||||
{statusKey === '견적진행중' && (
|
|
||||||
<button
|
|
||||||
onClick={() => onStop(estimate.id ?? '', q_name)}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<StopCircle size={14} />
|
|
||||||
<span>중지</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DB mapping info cards */}
|
|
||||||
{showHeaderCards && (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
|
|
||||||
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Quotations */}
|
|
||||||
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
|
|
||||||
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
|
|
||||||
견적 정보
|
|
||||||
</span>
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">견적명</span>
|
|
||||||
<span className="text-foreground font-semibold font-sans">{q_name}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">견적번호</span>
|
|
||||||
<span className="text-foreground font-semibold">{q_number}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">유형</span>
|
|
||||||
<span className="text-foreground font-semibold">
|
|
||||||
{q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">차수</span>
|
|
||||||
<span className="text-foreground font-semibold">{q_round}차</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-90 font-bold mb-1">견적상태</span>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] font-bold rounded-full border shadow-2xs ${
|
|
||||||
statusKey === '견적생성'
|
|
||||||
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse'
|
|
||||||
: statusKey === '견적진행중'
|
|
||||||
? 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50'
|
|
||||||
: statusKey === '견적마감'
|
|
||||||
? 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50'
|
|
||||||
: statusKey === '협상보류'
|
|
||||||
? 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50'
|
|
||||||
: 'bg-zinc-100 text-zinc-800 border-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`h-1.5 w-1.5 rounded-full ${
|
|
||||||
statusKey === '견적생성'
|
|
||||||
? 'bg-amber-500'
|
|
||||||
: statusKey === '견적진행중'
|
|
||||||
? 'bg-emerald-500'
|
|
||||||
: statusKey === '견적마감'
|
|
||||||
? 'bg-blue-500'
|
|
||||||
: statusKey === '협상보류'
|
|
||||||
? 'bg-rose-500'
|
|
||||||
: 'bg-zinc-500'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{statusKey || q_status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">마감시각</span>
|
|
||||||
<span className="text-foreground font-semibold">{q_end_time}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">담당자</span>
|
|
||||||
<span className="text-foreground font-semibold font-sans">{q_manager_name} ({q_manager_email})</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">메모</span>
|
|
||||||
<span className="text-foreground font-semibold font-sans truncate block" title={q_memo || ''}>
|
|
||||||
{q_memo}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bid Summary */}
|
|
||||||
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
|
|
||||||
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
|
|
||||||
견적 진행상태/결과
|
|
||||||
</span>
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">식별자</span>
|
|
||||||
<span className="text-foreground font-semibold">{bidSummaryObj.bid_summary_id}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">진행/결과 상태</span>
|
|
||||||
<span className="text-foreground font-semibold">{bidSummaryObj.status}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">반복횟수</span>
|
|
||||||
<span className="text-foreground font-semibold">{bidSummaryObj.qt_iteration}회</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">우선협상자 존재여부</span>
|
|
||||||
<span className={`text-foreground font-semibold ${bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : ''}`}>
|
|
||||||
{bidSummaryObj.has_preferred ? '존재' : '미존재'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-[10px] block opacity-70">우선협상자명</span>
|
|
||||||
<span className="text-foreground font-semibold font-sans">{bidSummaryObj.preferred_sp_name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-[10px] block opacity-70">동가입찰정보</span>
|
|
||||||
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
|
|
||||||
{bidSummaryObj.equal_data}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 우측 컬럼: 상품정보 + 세팅 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 상품 정보 (협상 대상 상품) */}
|
|
||||||
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
|
|
||||||
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
|
|
||||||
상품 정보
|
|
||||||
</span>
|
|
||||||
{currentProduct ? (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
|
||||||
{currentProduct.image_url ? (
|
|
||||||
<img
|
|
||||||
src={currentProduct.image_url}
|
|
||||||
alt={currentProduct.name || '상품'}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Package size={28} className="text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<Link
|
|
||||||
to={`/products?edit=${currentProduct.id}`}
|
|
||||||
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
|
|
||||||
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
|
|
||||||
>
|
|
||||||
{currentProduct.name || '-'}
|
|
||||||
</Link>
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
|
|
||||||
{productSpecRows.map((r) => (
|
|
||||||
<div key={r.label}>
|
|
||||||
<span className="text-[10px] block opacity-70">{r.label}</span>
|
|
||||||
<span className="text-foreground font-semibold font-sans truncate block" title={r.value}>
|
|
||||||
{r.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quotation Settings */}
|
|
||||||
<div className="p-3 bg-card border border-border/80 rounded shadow-xs space-y-2">
|
|
||||||
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
|
|
||||||
견적 세팅
|
|
||||||
</span>
|
|
||||||
{selectedSettingObj ? (
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">목표 마진율</span>
|
|
||||||
<span className="text-foreground font-bold text-emerald-600 dark:text-emerald-400 font-sans">{selectedSettingObj.target_margin}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] block opacity-70">앵커링 설정 값</span>
|
|
||||||
<span className="text-foreground font-semibold font-sans">{selectedSettingObj.anchoring_value}</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 col-start-1">
|
|
||||||
<span className="text-[10px] block opacity-70">카드 사용 횟수</span>
|
|
||||||
<span className="text-foreground font-semibold">{selectedSettingObj.card_use_count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-border bg-background px-6">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(tab.id);
|
|
||||||
if (tab.id === 'chat') {
|
|
||||||
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
|
|
||||||
setShowHeaderCards(false);
|
|
||||||
if (serverSessions.length > 0 && !selectedSessionId) {
|
|
||||||
setSelectedSessionId(serverSessions[0].session_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-primary text-primary font-bold'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
<span>{tab.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div className="flex-1 p-6 overflow-y-auto bg-background/50">
|
|
||||||
|
|
||||||
{/* Tab: Sessions Status */}
|
|
||||||
{activeTab === 'status' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border border-border rounded-lg bg-card overflow-x-auto">
|
|
||||||
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
|
|
||||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="p-3 font-semibold">세션 ID</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold text-right">목표가</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold">마감시각</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">거절사유</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold text-right">거절가격</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">거절배송방식</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="divide-y divide-border">
|
|
||||||
{sessionViews.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
|
|
||||||
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{sessionViews.map((sess) => (
|
|
||||||
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
|
||||||
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
|
|
||||||
<TableCell className="p-3 font-bold text-foreground font-sans">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{sess.supplier_name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => goToChat(sess.session_id)}
|
|
||||||
title="협상 대화방으로 이동"
|
|
||||||
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<MessageSquare size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="p-3 font-mono">
|
|
||||||
{sess.url ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<a
|
|
||||||
href={sess.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={sess.url}
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
|
|
||||||
>
|
|
||||||
<ExternalLink size={11} /> 세션 열기
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
void navigator.clipboard?.writeText(sess.url);
|
|
||||||
showToast('협상 URL을 복사했습니다.', 'success');
|
|
||||||
}}
|
|
||||||
title="협상 URL 복사"
|
|
||||||
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Copy size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
|
|
||||||
<TableCell className="p-3 text-center">
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-2 py-0.5 rounded-full text-[10px] font-bold ${
|
|
||||||
sess.status === '협상완료' || sess.status === 'COMPLETED'
|
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/20 dark:text-blue-300'
|
|
||||||
: sess.status === '협상거부' || sess.status === 'REJECTED'
|
|
||||||
? 'bg-red-100 text-red-800 dark:bg-red-950/20 dark:text-red-300'
|
|
||||||
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/20 dark:text-emerald-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sess.status}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="p-3 text-right font-bold text-muted-foreground">
|
|
||||||
₩{sess.target_price?.toLocaleString() || '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="p-3 text-right font-bold text-foreground">
|
|
||||||
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
|
|
||||||
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
|
|
||||||
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
|
|
||||||
<TableCell className="p-3 text-right text-rose-600 font-mono">{sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'}</TableCell>
|
|
||||||
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab: Quotation Cards */}
|
|
||||||
{activeTab === 'cards' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
|
||||||
<Table className="w-full text-left text-xs border-collapse font-mono">
|
|
||||||
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="p-3 font-semibold">세션 카드 ID</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">카드 이름</TableHead>
|
|
||||||
<TableHead className="p-3 font-semibold font-sans">타입</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="divide-y divide-border">
|
|
||||||
{quotationCardViews.length > 0 ? (
|
|
||||||
quotationCardViews.map((qc) => (
|
|
||||||
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
|
||||||
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
|
|
||||||
<TableCell className="p-3 text-foreground font-sans font-semibold">
|
|
||||||
{qc.card_id ? (
|
|
||||||
<Link
|
|
||||||
to={`/cards?edit=${qc.card_id}`}
|
|
||||||
className="hover:text-primary hover:underline"
|
|
||||||
title={`${qc.card_name} — 협상카드 상세로 이동`}
|
|
||||||
>
|
|
||||||
{qc.card_name}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
qc.card_name
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="p-3">
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-2 py-0.5 rounded text-[10px] font-bold ${
|
|
||||||
qc.type === '와일드 카드'
|
|
||||||
? 'bg-amber-100 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300'
|
|
||||||
: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{qc.type}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
|
|
||||||
사용된 협상 카드가 없습니다. (리스트가 비어 있습니다)
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab: Chat */}
|
|
||||||
{activeTab === 'chat' && (
|
|
||||||
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
|
||||||
{/* Sessions list */}
|
|
||||||
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
|
|
||||||
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
|
|
||||||
참여자 협력사 리스트
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans">
|
|
||||||
{serverSessions.length === 0 && (
|
|
||||||
<div className="p-4 text-center text-muted-foreground text-xs font-mono">
|
|
||||||
참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{serverSessions.map((sd) => {
|
|
||||||
const isSelected = sd.session_id === effectiveSessionId;
|
|
||||||
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
|
|
||||||
const statusLabel = sessionStatusLabel(sd.status);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={sd.session_id}
|
|
||||||
onClick={() => setSelectedSessionId(sd.session_id)}
|
|
||||||
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
|
|
||||||
isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-bold text-foreground text-xs">{name}</span>
|
|
||||||
<span
|
|
||||||
className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
|
|
||||||
statusLabel === '협상거부'
|
|
||||||
? 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300'
|
|
||||||
: statusLabel === '협상완료'
|
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300'
|
|
||||||
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2">
|
|
||||||
<span>최종 제의</span>
|
|
||||||
<span className="font-bold text-foreground">
|
|
||||||
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat zone */}
|
|
||||||
<div className="flex-1 flex flex-col bg-background justify-between">
|
|
||||||
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono">
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 p-4 overflow-y-auto space-y-4">
|
|
||||||
{!effectiveSessionId ? (
|
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
|
||||||
선택된 협력사가 없습니다.
|
|
||||||
</div>
|
|
||||||
) : chatMessages.length === 0 ? (
|
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
|
||||||
기록된 협상 대화가 없습니다.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
chatMessages.map((m) => {
|
|
||||||
const isBot = m.sender === 1;
|
|
||||||
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
|
|
||||||
const usedCard = m.card_used_yn
|
|
||||||
? serverCards.find((c) => c.session_card_id === m.chat_id)
|
|
||||||
: undefined;
|
|
||||||
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
|
|
||||||
const isWildCard = usedCard?.type === 2;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={m.chat_id}
|
|
||||||
className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-1 max-w-[85%]">
|
|
||||||
<div className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${isBot ? '' : 'justify-end'}`}>
|
|
||||||
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>#{m.index}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-md border text-xs shadow-xs ${
|
|
||||||
isBot ? 'bg-secondary border-border text-foreground' : 'bg-primary border-transparent text-primary-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-bold">제시 단가 ₩{Number(m.target_price).toLocaleString()}</div>
|
|
||||||
{usedCard && (
|
|
||||||
<div
|
|
||||||
className={`mt-2 rounded border p-2 ${
|
|
||||||
isBot
|
|
||||||
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
|
||||||
: 'bg-white/10 border-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1 text-[10px] font-semibold ${
|
|
||||||
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Sparkles size={10} />
|
|
||||||
<span>협상카드</span>
|
|
||||||
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
|
|
||||||
{usedCard.name && <span>· {usedCard.name}</span>}
|
|
||||||
<span
|
|
||||||
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
|
|
||||||
isWildCard
|
|
||||||
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
|
|
||||||
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isWildCard ? '와일드' : '협상'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
|
|
||||||
{cardNodes ? (
|
|
||||||
<div className="mt-1.5">
|
|
||||||
<SlateRenderer
|
|
||||||
nodes={cardNodes}
|
|
||||||
variables={{
|
|
||||||
target_price: m.target_price,
|
|
||||||
partner_name: currentSupplierName,
|
|
||||||
product_name: currentProduct?.name ?? '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : usedCard.script ? (
|
|
||||||
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
|
||||||
{usedCard.script}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
|
|
||||||
{isWildCard && (usedCard.condition || usedCard.memo) && (
|
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
|
|
||||||
{usedCard.condition && (
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold">조건:</span> {usedCard.condition}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{usedCard.memo && (
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold">메모:</span> {usedCard.memo}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
disabled
|
|
||||||
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
|
|
||||||
className="flex-1 text-xs text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
|
|
||||||
>
|
|
||||||
전송
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
import type { SessionData } from '@/api/generated/model/sessionData';
|
||||||
|
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||||
|
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import SlateRenderer from '@/components/SlateRenderer';
|
||||||
|
import { StatusPill, chatStatusTone } from './StatusPill';
|
||||||
|
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||||
|
|
||||||
|
export function ChatTab({
|
||||||
|
serverSessions,
|
||||||
|
partners,
|
||||||
|
effectiveSessionId,
|
||||||
|
onSelectSession,
|
||||||
|
chatMessages,
|
||||||
|
currentSupplierName,
|
||||||
|
currentProduct,
|
||||||
|
serverCards,
|
||||||
|
}: {
|
||||||
|
serverSessions: SessionData[];
|
||||||
|
partners: Partner[];
|
||||||
|
effectiveSessionId: string | null;
|
||||||
|
onSelectSession: (sessionId: string) => void;
|
||||||
|
chatMessages: ChatMessageData[];
|
||||||
|
currentSupplierName: string;
|
||||||
|
currentProduct: Product | undefined;
|
||||||
|
serverCards: QuotationCardData[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="h-[500px] border border-border rounded-lg overflow-hidden bg-card flex">
|
||||||
|
{/* Sessions list */}
|
||||||
|
<div className="w-1/3 border-r border-border bg-muted/20 flex flex-col">
|
||||||
|
<div className="p-3 border-b border-border bg-muted/40 font-mono text-[10px] text-muted-foreground uppercase">
|
||||||
|
참여자 협력사 리스트
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto divide-y divide-border font-sans">
|
||||||
|
{serverSessions.length === 0 && (
|
||||||
|
<div className="p-4 text-center text-muted-foreground text-xs font-mono">
|
||||||
|
참여 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{serverSessions.map((sd) => {
|
||||||
|
const isSelected = sd.session_id === effectiveSessionId;
|
||||||
|
const name = partners.find((p) => p.id === sd.supplier_id)?.name || sd.supplier_id;
|
||||||
|
const statusLabel = sessionStatusLabel(sd.status);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sd.session_id}
|
||||||
|
onClick={() => onSelectSession(sd.session_id)}
|
||||||
|
className={`w-full text-left p-3 flex flex-col justify-between transition-colors cursor-pointer ${
|
||||||
|
isSelected ? 'bg-primary/5 border-l-4 border-primary' : 'hover:bg-muted/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-bold text-foreground text-xs">{name}</span>
|
||||||
|
<StatusPill tone={chatStatusTone(statusLabel)} className="text-[9px] px-1.5 rounded">
|
||||||
|
{statusLabel}
|
||||||
|
</StatusPill>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground mt-2">
|
||||||
|
<span>최종 제의</span>
|
||||||
|
<span className="font-bold text-foreground">
|
||||||
|
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat zone */}
|
||||||
|
<div className="flex-1 flex flex-col bg-background justify-between">
|
||||||
|
<div className="p-3 bg-muted/30 border-b border-border text-xs flex items-center justify-between font-mono">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
협력사: <strong className="text-foreground">{currentSupplierName}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-4 overflow-y-auto space-y-4">
|
||||||
|
{!effectiveSessionId ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
||||||
|
선택된 협력사가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : chatMessages.length === 0 ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
|
||||||
|
기록된 협상 대화가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
chatMessages.map((m) => {
|
||||||
|
const isBot = m.sender === 1;
|
||||||
|
// 메시지가 쓴 협상카드 전체(이름만이 아니라 멘트/조건/메모까지) 를 chat_id 로 매칭.
|
||||||
|
const usedCard = m.card_used_yn
|
||||||
|
? serverCards.find((c) => c.session_card_id === m.chat_id)
|
||||||
|
: undefined;
|
||||||
|
const cardNodes = Array.isArray(usedCard?.edit_script) ? (usedCard.edit_script as unknown[]) : null;
|
||||||
|
const isWildCard = usedCard?.type === 2;
|
||||||
|
return (
|
||||||
|
<div key={m.chat_id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
|
||||||
|
<div className="space-y-1 max-w-[85%]">
|
||||||
|
<div
|
||||||
|
className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${
|
||||||
|
isBot ? '' : 'justify-end'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{isBot ? 'Negosium Bot' : currentSupplierName}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>#{m.index}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md border text-xs shadow-xs ${
|
||||||
|
isBot
|
||||||
|
? 'bg-secondary border-border text-foreground'
|
||||||
|
: 'bg-primary border-transparent text-primary-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-bold">제시 단가 ₩{Number(m.target_price).toLocaleString()}</div>
|
||||||
|
{usedCard && (
|
||||||
|
<div
|
||||||
|
className={`mt-2 rounded border p-2 ${
|
||||||
|
isBot
|
||||||
|
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
||||||
|
: 'bg-white/10 border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 text-[10px] font-semibold ${
|
||||||
|
isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles size={10} />
|
||||||
|
<span>협상카드</span>
|
||||||
|
{usedCard.number && <span className="font-mono opacity-70">#{usedCard.number}</span>}
|
||||||
|
{usedCard.name && <span>· {usedCard.name}</span>}
|
||||||
|
<span
|
||||||
|
className={`ml-auto px-1.5 py-0.5 rounded font-bold ${
|
||||||
|
isWildCard
|
||||||
|
? 'bg-amber-200 text-amber-900 dark:bg-amber-400/25 dark:text-amber-100'
|
||||||
|
: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-600/40 dark:text-zinc-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isWildCard ? '와일드' : '협상'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script */}
|
||||||
|
{cardNodes ? (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<SlateRenderer
|
||||||
|
nodes={cardNodes}
|
||||||
|
variables={{
|
||||||
|
target_price: m.target_price,
|
||||||
|
partner_name: currentSupplierName,
|
||||||
|
product_name: currentProduct?.name ?? '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : usedCard.script ? (
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
||||||
|
{usedCard.script}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
|
||||||
|
{isWildCard && (usedCard.condition || usedCard.memo) && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{usedCard.condition && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">조건:</span> {usedCard.condition}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{usedCard.memo && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">메모:</span> {usedCard.memo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-border bg-muted/20 flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
placeholder="이 대화방은 입찰 참여 세션 기록이므로 정독 전용입니다."
|
||||||
|
className="flex-1 text-xs text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="py-1.5 px-3 bg-muted text-muted-foreground text-xs rounded border border-border cursor-not-allowed"
|
||||||
|
>
|
||||||
|
전송
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { InfoField } from './InfoField';
|
||||||
|
import { QuotationStatusBadge } from './StatusPill';
|
||||||
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
|
import {
|
||||||
|
type Product,
|
||||||
|
type Partner,
|
||||||
|
type QuotationSetting,
|
||||||
|
normalizeQuotationStatus,
|
||||||
|
normalizeQuotationType,
|
||||||
|
buildBidSummary,
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
const fmtYn = (b: boolean | null | undefined, yes: string, no: string) =>
|
||||||
|
b == null ? '-' : b ? yes : no;
|
||||||
|
|
||||||
|
/** 헤더 정보 카드 컨테이너. ui/Card 의 넉넉한 기본 여백을 촘촘하게 덮어쓴다. */
|
||||||
|
function SectionCard({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-3 gap-2 rounded shadow-xs border-border/80">
|
||||||
|
<span className="font-bold text-foreground text-[11px] border-b border-border pb-1 block font-sans">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawerHeaderCardsProps = {
|
||||||
|
quotation: QuotationData;
|
||||||
|
partners: Partner[];
|
||||||
|
quotationSettings: QuotationSetting[];
|
||||||
|
/** 현재 선택 세션의 상품(없으면 상품 카드는 빈 상태). */
|
||||||
|
currentProduct: Product | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DrawerHeaderCards({
|
||||||
|
quotation,
|
||||||
|
partners,
|
||||||
|
quotationSettings,
|
||||||
|
currentProduct,
|
||||||
|
}: DrawerHeaderCardsProps) {
|
||||||
|
// Quotations DDL 표시값
|
||||||
|
const q_name = quotation.name || '미지정';
|
||||||
|
const q_number = quotation.number || 'EST-000000-0000';
|
||||||
|
const q_type = normalizeQuotationType(quotation.type);
|
||||||
|
const q_round = quotation.round || 1;
|
||||||
|
const q_status = String(quotation.status ?? '견적생성');
|
||||||
|
const q_end_time = quotation.end_time || '미지정';
|
||||||
|
const q_manager_name = quotation.manager_name || '홍길동 파트너';
|
||||||
|
const q_manager_email = quotation.manager_email || 'gildong@negodata.com';
|
||||||
|
const q_memo = quotation.memo || '안내사항 없음';
|
||||||
|
const statusKey = normalizeQuotationStatus(quotation.status);
|
||||||
|
|
||||||
|
const bidSummaryObj = buildBidSummary(quotation, partners);
|
||||||
|
const selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === quotation.qt_setting_id);
|
||||||
|
|
||||||
|
// 상품 상세 패널 행(negowiz 협상대화의 상품 정보 대응). negodata 컬럼명: maker_name→manufacturer, min_order_quantity→moq.
|
||||||
|
const productSpecRows = currentProduct
|
||||||
|
? [
|
||||||
|
{ label: '상품코드', value: currentProduct.code || '-' },
|
||||||
|
{ label: '단가', value: currentProduct.price != null ? `₩${Number(currentProduct.price).toLocaleString()}` : '-' },
|
||||||
|
{ label: '모델명', value: currentProduct.model_name || '-' },
|
||||||
|
{ label: '규격', value: currentProduct.spec || '-' },
|
||||||
|
{ label: '제조사', value: currentProduct.manufacturer || '-' },
|
||||||
|
{ label: '원산지', value: currentProduct.made_in || '-' },
|
||||||
|
{ label: 'MOQ', value: currentProduct.moq || '-' },
|
||||||
|
{ label: '리드타임', value: currentProduct.lead_time != null ? `${currentProduct.lead_time}일` : '-' },
|
||||||
|
{ label: 'VAT', value: fmtYn(currentProduct.vat_yn, '포함', '별도') },
|
||||||
|
{ label: '배송비', value: fmtYn(currentProduct.delivery_fee_yn, '포함', '별도') },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 text-xs font-mono">
|
||||||
|
{/* 좌측 컬럼: 견적정보 + 진행상태 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Quotations */}
|
||||||
|
<SectionCard title="견적 정보">
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
|
<InfoField label="견적명" value={q_name} valueClassName="font-sans" />
|
||||||
|
<InfoField label="견적번호" value={q_number} />
|
||||||
|
<InfoField label="유형" value={q_type === 'RE_NEGOTIATION' ? '재협상' : '재견적'} />
|
||||||
|
<InfoField label="차수" value={`${q_round}차`} />
|
||||||
|
<InfoField label="견적상태" labelClassName="opacity-90 font-bold mb-1">
|
||||||
|
<QuotationStatusBadge statusKey={statusKey} fallbackLabel={q_status} />
|
||||||
|
</InfoField>
|
||||||
|
<InfoField label="마감시각" value={q_end_time} />
|
||||||
|
<InfoField label="담당자" value={`${q_manager_name} (${q_manager_email})`} valueClassName="font-sans" />
|
||||||
|
<InfoField
|
||||||
|
label="메모"
|
||||||
|
value={q_memo}
|
||||||
|
valueClassName="font-sans truncate block"
|
||||||
|
title={q_memo || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Bid Summary */}
|
||||||
|
<SectionCard title="견적 진행상태/결과">
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
|
<InfoField label="식별자" value={bidSummaryObj.bid_summary_id} />
|
||||||
|
<InfoField label="진행/결과 상태" value={bidSummaryObj.status} />
|
||||||
|
<InfoField label="반복횟수" value={`${bidSummaryObj.qt_iteration}회`} />
|
||||||
|
<InfoField
|
||||||
|
label="우선협상자 존재여부"
|
||||||
|
value={bidSummaryObj.has_preferred ? '존재' : '미존재'}
|
||||||
|
valueClassName={bidSummaryObj.has_preferred ? 'text-emerald-600 font-bold' : undefined}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="우선협상자명"
|
||||||
|
value={bidSummaryObj.preferred_sp_name}
|
||||||
|
className="col-span-2"
|
||||||
|
valueClassName="font-sans"
|
||||||
|
/>
|
||||||
|
<InfoField label="동가입찰정보" className="col-span-2">
|
||||||
|
<code className="text-foreground font-semibold bg-muted/60 p-1 rounded text-[10px] block overflow-x-auto whitespace-pre">
|
||||||
|
{bidSummaryObj.equal_data}
|
||||||
|
</code>
|
||||||
|
</InfoField>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 컬럼: 상품정보 + 세팅 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 상품 정보 (협상 대상 상품) */}
|
||||||
|
<SectionCard title="상품 정보">
|
||||||
|
{currentProduct ? (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="h-24 w-24 shrink-0 rounded-md border border-border bg-background overflow-hidden flex items-center justify-center">
|
||||||
|
{currentProduct.image_url ? (
|
||||||
|
<img
|
||||||
|
src={currentProduct.image_url}
|
||||||
|
alt={currentProduct.name || '상품'}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Package size={28} className="text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link
|
||||||
|
to={`/products?edit=${currentProduct.id}`}
|
||||||
|
className="text-foreground font-bold font-sans text-[12px] mb-2 truncate block hover:text-primary hover:underline"
|
||||||
|
title={`${currentProduct.name || ''} — 상품 상세로 이동`}
|
||||||
|
>
|
||||||
|
{currentProduct.name || '-'}
|
||||||
|
</Link>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-x-3 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
|
{productSpecRows.map((r) => (
|
||||||
|
<InfoField
|
||||||
|
key={r.label}
|
||||||
|
label={r.label}
|
||||||
|
value={r.value}
|
||||||
|
valueClassName="font-sans truncate block"
|
||||||
|
title={r.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-6 text-center">상품 정보가 비어있습니다.</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Quotation Settings */}
|
||||||
|
<SectionCard title="견적 세팅">
|
||||||
|
{selectedSettingObj ? (
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 font-mono text-muted-foreground">
|
||||||
|
<InfoField
|
||||||
|
label="목표 마진율"
|
||||||
|
value={selectedSettingObj.target_margin}
|
||||||
|
valueClassName="font-bold text-emerald-600 dark:text-emerald-400 font-sans"
|
||||||
|
/>
|
||||||
|
<InfoField label="앵커링 설정 값" value={selectedSettingObj.anchoring_value} valueClassName="font-sans" />
|
||||||
|
<InfoField
|
||||||
|
label="카드 사용 횟수"
|
||||||
|
value={selectedSettingObj.card_use_count}
|
||||||
|
className="col-span-2 col-start-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-6 text-center">적용된 견적 세팅이 비어있습니다.</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type InfoFieldProps = {
|
||||||
|
label: string;
|
||||||
|
/** 단순 텍스트 값. 커스텀 마크업이 필요하면 value 대신 children 을 쓴다. */
|
||||||
|
value?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
valueClassName?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 헤더 카드의 `라벨 / 값` 한 칸. (드로어 곳곳에서 ~20회 반복되던 패턴) */
|
||||||
|
export function InfoField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
valueClassName,
|
||||||
|
title,
|
||||||
|
}: InfoFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<span className={cn('text-[10px] block opacity-70', labelClassName)}>{label}</span>
|
||||||
|
{children ?? (
|
||||||
|
<span className={cn('text-foreground font-semibold', valueClassName)} title={title}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
|
import { StatusPill } from './StatusPill';
|
||||||
|
import { mapServerCardView } from '../../types';
|
||||||
|
|
||||||
|
type CardView = ReturnType<typeof mapServerCardView>;
|
||||||
|
|
||||||
|
export function QuotationCardsTab({ quotationCardViews }: { quotationCardViews: CardView[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border border-border rounded-lg bg-card overflow-hidden">
|
||||||
|
<Table className="w-full text-left text-xs border-collapse font-mono">
|
||||||
|
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="p-3 font-semibold">세션 카드 ID</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">카드 이름</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">타입</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="divide-y divide-border">
|
||||||
|
{quotationCardViews.length > 0 ? (
|
||||||
|
quotationCardViews.map((qc) => (
|
||||||
|
<TableRow key={qc.session_card_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
||||||
|
<TableCell className="p-3 text-muted-foreground">{qc.session_card_id}</TableCell>
|
||||||
|
<TableCell className="p-3 text-foreground font-sans font-semibold">
|
||||||
|
{qc.card_id ? (
|
||||||
|
<Link
|
||||||
|
to={`/cards?edit=${qc.card_id}`}
|
||||||
|
className="hover:text-primary hover:underline"
|
||||||
|
title={`${qc.card_name} — 협상카드 상세로 이동`}
|
||||||
|
>
|
||||||
|
{qc.card_name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
qc.card_name
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3">
|
||||||
|
<StatusPill tone={qc.type === '와일드 카드' ? 'amber' : 'zinc'} className="rounded">
|
||||||
|
{qc.type}
|
||||||
|
</StatusPill>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
|
||||||
|
사용된 협상 카드가 없습니다. (리스트가 비어 있습니다)
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
import { MessageSquare, ExternalLink, Copy } from 'lucide-react';
|
||||||
|
import { showToast } from '@/lib/notify';
|
||||||
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
|
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||||
|
import { mapServerSessionView } from '../../types';
|
||||||
|
|
||||||
|
type SessionView = ReturnType<typeof mapServerSessionView>;
|
||||||
|
|
||||||
|
export function SessionsStatusTab({
|
||||||
|
sessionViews,
|
||||||
|
onOpenChat,
|
||||||
|
}: {
|
||||||
|
sessionViews: SessionView[];
|
||||||
|
onOpenChat: (sessionId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border border-border rounded-lg bg-card overflow-x-auto">
|
||||||
|
<Table className="w-full text-left text-xs border-collapse font-mono min-w-[1250px]">
|
||||||
|
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="p-3 font-semibold">세션 ID</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">협력사</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">협상 URL</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">상품</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold text-center font-sans">협상상태</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold text-right">목표가</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold text-right">투찰가</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold">투찰시각</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold">마감시각</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">거절사유</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold text-right">거절가격</TableHead>
|
||||||
|
<TableHead className="p-3 font-semibold font-sans">거절배송방식</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="divide-y divide-border">
|
||||||
|
{sessionViews.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={12} className="p-12 text-center text-muted-foreground">
|
||||||
|
참여 중인 협상 세션이 없습니다. (리스트가 비어 있습니다)
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{sessionViews.map((sess) => (
|
||||||
|
<TableRow key={sess.session_id} className="hover:bg-muted/30 transition-colors text-[11px]">
|
||||||
|
<TableCell className="p-3 text-muted-foreground font-mono">{sess.session_id}</TableCell>
|
||||||
|
<TableCell className="p-3 font-bold text-foreground font-sans">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{sess.supplier_name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenChat(sess.session_id)}
|
||||||
|
title="협상 대화방으로 이동"
|
||||||
|
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<MessageSquare size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 font-mono">
|
||||||
|
{sess.url ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<a
|
||||||
|
href={sess.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={sess.url}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-[10px] font-semibold"
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} /> 세션 열기
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(sess.url);
|
||||||
|
showToast('협상 URL을 복사했습니다.', 'success');
|
||||||
|
}}
|
||||||
|
title="협상 URL 복사"
|
||||||
|
className="p-1 hover:bg-muted rounded text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 font-semibold font-sans">{sess.item_name}</TableCell>
|
||||||
|
<TableCell className="p-3 text-center">
|
||||||
|
<StatusPill tone={sessionStatusTone(sess.status)}>{sess.status}</StatusPill>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-right font-bold text-muted-foreground">
|
||||||
|
₩{sess.target_price?.toLocaleString() || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-right font-bold text-foreground">
|
||||||
|
{sess.bid_price ? `₩${sess.bid_price.toLocaleString()}` : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-muted-foreground">{sess.bid_at || '-'}</TableCell>
|
||||||
|
<TableCell className="p-3 text-muted-foreground font-sans">{sess.end_time || '-'}</TableCell>
|
||||||
|
<TableCell className="p-3 text-rose-600 font-sans">{sess.reject_reason || '-'}</TableCell>
|
||||||
|
<TableCell className="p-3 text-right text-rose-600 font-mono">
|
||||||
|
{sess.reject_price ? `₩${sess.reject_price.toLocaleString()}` : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-3 text-muted-foreground font-sans">{sess.reject_delivery_type || '-'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/* ── 작은 상태 pill (세션 상태 / 카드 타입 / 채팅 목록 상태) ──
|
||||||
|
기존엔 곳마다 색맵을 손으로 박았고 dark 알파(/20·/30)와 red·rose 가 미묘하게
|
||||||
|
달랐다. 여기서 한 팔레트로 통일한다. */
|
||||||
|
export type PillTone = 'blue' | 'rose' | 'emerald' | 'amber' | 'zinc';
|
||||||
|
|
||||||
|
const PILL_TONE: Record<PillTone, string> = {
|
||||||
|
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300',
|
||||||
|
rose: 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300',
|
||||||
|
emerald: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300',
|
||||||
|
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950/20 dark:text-amber-300',
|
||||||
|
zinc: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800/40 dark:text-zinc-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusPill({
|
||||||
|
tone,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone: PillTone;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold',
|
||||||
|
PILL_TONE[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 협상 세션 상태(현황 테이블) → pill 색 */
|
||||||
|
export function sessionStatusTone(status: string | null | undefined): PillTone {
|
||||||
|
if (status === '협상완료' || status === 'COMPLETED') return 'blue';
|
||||||
|
if (status === '협상거부' || status === 'REJECTED') return 'rose';
|
||||||
|
return 'emerald';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 채팅 목록의 협상 상태 라벨 → pill 색 */
|
||||||
|
export function chatStatusTone(label: string): PillTone {
|
||||||
|
if (label === '협상거부') return 'rose';
|
||||||
|
if (label === '협상완료') return 'blue';
|
||||||
|
return 'emerald';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 견적 상태 배지 (헤더, dot + border + 견적생성 시 pulse) ──
|
||||||
|
작은 pill 들과 모양이 달라(테두리·점·pulse) 별도 컴포넌트로 둔다. */
|
||||||
|
const QSTATUS_TONE: Record<string, { box: string; dot: string }> = {
|
||||||
|
견적생성: {
|
||||||
|
box: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-700/50 animate-pulse',
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
},
|
||||||
|
견적진행중: {
|
||||||
|
box: 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-700/50',
|
||||||
|
dot: 'bg-emerald-500',
|
||||||
|
},
|
||||||
|
견적마감: {
|
||||||
|
box: 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-700/50',
|
||||||
|
dot: 'bg-blue-500',
|
||||||
|
},
|
||||||
|
협상보류: {
|
||||||
|
box: 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-700/50',
|
||||||
|
dot: 'bg-rose-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const QSTATUS_FALLBACK = { box: 'bg-zinc-100 text-zinc-800 border-zinc-300', dot: 'bg-zinc-500' };
|
||||||
|
|
||||||
|
export function QuotationStatusBadge({
|
||||||
|
statusKey,
|
||||||
|
fallbackLabel,
|
||||||
|
}: {
|
||||||
|
statusKey: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
}) {
|
||||||
|
const t = QSTATUS_TONE[statusKey] ?? QSTATUS_FALLBACK;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] font-bold rounded-full border shadow-2xs',
|
||||||
|
t.box,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', t.dot)} />
|
||||||
|
{statusKey || fallbackLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { StopCircle, X, UserCheck, MessageSquare, Layers } from 'lucide-react';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import {
|
||||||
|
useGetQuotationSessions,
|
||||||
|
useGetSessionChat,
|
||||||
|
useGetQuotationCards,
|
||||||
|
} from '@/api/generated/quotation/quotation';
|
||||||
|
import { useListItems } from '@/api/generated/item/item';
|
||||||
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
|
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
|
||||||
|
import type { ItemData } from '@/api/generated/model/itemData';
|
||||||
|
import type { SupplierData } from '@/api/generated/model/supplierData';
|
||||||
|
import type { QuotationSettingData } from '@/api/generated/model/quotationSettingData';
|
||||||
|
import type { QuotationData } from '@/api/generated/model/quotationData';
|
||||||
|
import {
|
||||||
|
unwrap,
|
||||||
|
mapItem,
|
||||||
|
mapSupplier,
|
||||||
|
mapSetting,
|
||||||
|
normalizeQuotationStatus,
|
||||||
|
mapServerSessionView,
|
||||||
|
mapServerCardView,
|
||||||
|
} from '../../types';
|
||||||
|
import { DrawerHeaderCards } from './DrawerHeaderCards';
|
||||||
|
import { SessionsStatusTab } from './SessionsStatusTab';
|
||||||
|
import { QuotationCardsTab } from './QuotationCardsTab';
|
||||||
|
import { ChatTab } from './ChatTab';
|
||||||
|
|
||||||
|
type DrawerTab = 'status' | 'cards' | 'chat';
|
||||||
|
|
||||||
|
type QuotationDetailSheetProps = {
|
||||||
|
quotation: QuotationData;
|
||||||
|
onStop: (id: string, name: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QuotationDetailSheet({
|
||||||
|
quotation,
|
||||||
|
onStop,
|
||||||
|
onClose,
|
||||||
|
}: QuotationDetailSheetProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
|
||||||
|
const [showHeaderCards, setShowHeaderCards] = useState(true);
|
||||||
|
|
||||||
|
// 상품·협력사·견적세팅 목록은 sheet 안에서 직접 서버(orval)로 읽는다(부모 props 의존 제거).
|
||||||
|
const itemsQuery = useListItems({ size: 100 });
|
||||||
|
const suppliersQuery = useListSuppliers({ size: 100 });
|
||||||
|
const settingsQuery = useListSettings();
|
||||||
|
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
||||||
|
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
||||||
|
const quotationSettings = (
|
||||||
|
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
|
||||||
|
).map(mapSetting);
|
||||||
|
|
||||||
|
const qtId = quotation.qt_id ?? '';
|
||||||
|
// 협상 세션·사용 카드는 견적 단위, 채팅은 선택 세션 단위로 서버에서 읽는다.
|
||||||
|
const sessionsQuery = useGetQuotationSessions(qtId, { query: { enabled: !!qtId } });
|
||||||
|
const cardsQuery = useGetQuotationCards(qtId, { query: { enabled: !!qtId } });
|
||||||
|
const serverSessions = sessionsQuery.data?.sessions ?? [];
|
||||||
|
const serverCards = cardsQuery.data?.cards ?? [];
|
||||||
|
|
||||||
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||||
|
const effectiveSessionId = selectedSessionId ?? serverSessions[0]?.session_id ?? null;
|
||||||
|
const chatQuery = useGetSessionChat(effectiveSessionId ?? '', {
|
||||||
|
query: { enabled: !!effectiveSessionId },
|
||||||
|
});
|
||||||
|
const chatMessages = chatQuery.data?.messages ?? [];
|
||||||
|
|
||||||
|
const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId);
|
||||||
|
const currentSupplierName =
|
||||||
|
partners.find((p) => p.id === currentSession?.supplier_id)?.name || currentSession?.supplier_id || '-';
|
||||||
|
// 현재 세션의 상품(이미지·규격 등 상세 + 카드 변수 치환용 상품명).
|
||||||
|
const currentProduct = products.find((p) => p.id === currentSession?.item_id);
|
||||||
|
|
||||||
|
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
|
||||||
|
const quotationCardViews = serverCards.map(mapServerCardView);
|
||||||
|
|
||||||
|
// 헤더 상단바·중지 버튼에 필요한 최소 표시값만 (나머지 견적 표시값은 DrawerHeaderCards 내부 계산).
|
||||||
|
const q_name = quotation.name || '미지정';
|
||||||
|
const q_number = quotation.number || 'EST-000000-0000';
|
||||||
|
const statusKey = normalizeQuotationStatus(quotation.status);
|
||||||
|
|
||||||
|
const goToChat = (sessionId: string) => {
|
||||||
|
setSelectedSessionId(sessionId);
|
||||||
|
setActiveTab('chat');
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { id: DrawerTab; label: string; icon: typeof UserCheck }[] = [
|
||||||
|
{ id: 'status', label: '협상 현황', icon: UserCheck },
|
||||||
|
{ id: 'chat', label: '협상 대화', icon: MessageSquare },
|
||||||
|
{ id: 'cards', label: `협상 카드 (${serverCards.length})`, icon: Layers },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-xs flex justify-end animate-fade-in">
|
||||||
|
<div className="flex-1 cursor-pointer" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="w-full max-w-5xl bg-card border-l border-border h-full flex flex-col justify-between shadow-2xl overflow-hidden animate-slide-left">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-border bg-muted/30">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-[10px] font-mono tracking-widest uppercase">
|
||||||
|
<span>견적 상세 // {q_number}</span>
|
||||||
|
</div>
|
||||||
|
<Typography variant="h3" className="mt-1">{q_name}</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHeaderCards(!showHeaderCards)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted hover:bg-muted-foreground/15 text-foreground border border-border rounded text-xs font-semibold cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<span>{showHeaderCards ? '견적 상세 정보 접기 ▲' : '견적 상세 정보 펼치기 ▼'}</span>
|
||||||
|
</button>
|
||||||
|
{statusKey === '견적진행중' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onStop(quotation.qt_id ?? '', q_name)}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 bg-red-600 hover:bg-rose-700 text-white rounded text-xs font-semibold cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<StopCircle size={14} />
|
||||||
|
<span>중지</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-full text-muted-foreground hover:bg-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB mapping info cards */}
|
||||||
|
{showHeaderCards && (
|
||||||
|
<DrawerHeaderCards
|
||||||
|
quotation={quotation}
|
||||||
|
partners={partners}
|
||||||
|
quotationSettings={quotationSettings}
|
||||||
|
currentProduct={currentProduct}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-border bg-background px-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(tab.id);
|
||||||
|
if (tab.id === 'chat') {
|
||||||
|
// 협상 대화 탭에선 대화 영역을 넓게 쓰도록 견적 상세 정보를 접는다.
|
||||||
|
setShowHeaderCards(false);
|
||||||
|
if (serverSessions.length > 0 && !selectedSessionId) {
|
||||||
|
setSelectedSessionId(serverSessions[0].session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 py-4 px-3 text-xs tracking-tight font-semibold border-b-2 transition-all cursor-pointer ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary text-primary font-bold'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto bg-background/50">
|
||||||
|
{activeTab === 'status' && (
|
||||||
|
<SessionsStatusTab sessionViews={sessionViews} onOpenChat={goToChat} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'cards' && <QuotationCardsTab quotationCardViews={quotationCardViews} />}
|
||||||
|
|
||||||
|
{activeTab === 'chat' && (
|
||||||
|
<ChatTab
|
||||||
|
serverSessions={serverSessions}
|
||||||
|
partners={partners}
|
||||||
|
effectiveSessionId={effectiveSessionId}
|
||||||
|
onSelectSession={setSelectedSessionId}
|
||||||
|
chatMessages={chatMessages}
|
||||||
|
currentSupplierName={currentSupplierName}
|
||||||
|
currentProduct={currentProduct}
|
||||||
|
serverCards={serverCards}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { type Estimate, normalizeQuotationStatus } from '../types';
|
|
||||||
|
|
||||||
// 견적 목록의 검색/상태/유형 필터 state + 파생 결과.
|
|
||||||
export function useQuotationFilters(quotations: Estimate[]) {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('ALL');
|
|
||||||
const [typeFilter, setTypeFilter] = useState('ALL');
|
|
||||||
|
|
||||||
const filtered = quotations.filter((est) => {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
const matchesSearch =
|
|
||||||
(est.title || '').toLowerCase().includes(q) || (est.number || '').toLowerCase().includes(q);
|
|
||||||
const matchesStatus =
|
|
||||||
statusFilter === 'ALL' || normalizeQuotationStatus(est.status) === statusFilter;
|
|
||||||
const matchesType = typeFilter === 'ALL' || est.type === typeFilter;
|
|
||||||
return matchesSearch && matchesStatus && matchesType;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
search,
|
|
||||||
setSearch,
|
|
||||||
statusFilter,
|
|
||||||
setStatusFilter,
|
|
||||||
typeFilter,
|
|
||||||
setTypeFilter,
|
|
||||||
filtered,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useListItems } from '@/api/generated/item/item';
|
import { useListItems } from '@/api/generated/item/item';
|
||||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
import { useListCards } from '@/api/generated/card/card';
|
import { useListCards } from '@/api/generated/card/card';
|
||||||
@ -14,8 +14,8 @@ import {
|
|||||||
useListQuotations,
|
useListQuotations,
|
||||||
useCreateQuotation,
|
useCreateQuotation,
|
||||||
useStopQuotation,
|
useStopQuotation,
|
||||||
getListQuotationsQueryKey,
|
|
||||||
} from '@/api/generated/quotation/quotation';
|
} from '@/api/generated/quotation/quotation';
|
||||||
|
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
||||||
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
|
import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotation';
|
||||||
import type { ItemData } from '@/api/generated/model/itemData';
|
import type { ItemData } from '@/api/generated/model/itemData';
|
||||||
import type { SupplierData } from '@/api/generated/model/supplierData';
|
import type { SupplierData } from '@/api/generated/model/supplierData';
|
||||||
@ -46,20 +46,22 @@ export type SettingInput = {
|
|||||||
// 견적 화면 데이터 허브.
|
// 견적 화면 데이터 허브.
|
||||||
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
|
||||||
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
|
||||||
export function useQuotations() {
|
export function useQuotations(params: ListQuotationsParams) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const itemsQuery = useListItems({ size: 100 });
|
const itemsQuery = useListItems({ size: 100 });
|
||||||
const suppliersQuery = useListSuppliers({ size: 100 });
|
const suppliersQuery = useListSuppliers({ size: 100 });
|
||||||
const cardsQuery = useListCards({ size: 100 });
|
const cardsQuery = useListCards({ size: 100 });
|
||||||
const settingsQuery = useListSettings();
|
const settingsQuery = useListSettings();
|
||||||
const quotationsQuery = useListQuotations(undefined);
|
// 견적 목록은 서버 검색/상태·유형 필터/페이지네이션. 페이지 이동 시 이전 데이터 유지(깜빡임 방지).
|
||||||
|
const quotationsQuery = useListQuotations(params, { query: { placeholderData: keepPreviousData } });
|
||||||
const createSettingMutation = useCreateSetting();
|
const createSettingMutation = useCreateSetting();
|
||||||
const deleteSettingMutation = useDeleteSetting();
|
const deleteSettingMutation = useDeleteSetting();
|
||||||
const createQuotationMutation = useCreateQuotation();
|
const createQuotationMutation = useCreateQuotation();
|
||||||
const stopQuotationMutation = useStopQuotation();
|
const stopQuotationMutation = useStopQuotation();
|
||||||
|
|
||||||
|
// 파라미터별 목록 쿼리 키 전부 재조회(prefix 무효화).
|
||||||
const invalidateQuotations = () =>
|
const invalidateQuotations = () =>
|
||||||
queryClient.invalidateQueries({ queryKey: getListQuotationsQueryKey(undefined) });
|
queryClient.invalidateQueries({ queryKey: ['/v1/quotation/list'] });
|
||||||
|
|
||||||
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
|
||||||
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
|
||||||
@ -74,6 +76,8 @@ export function useQuotations() {
|
|||||||
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
|
const qs = unwrap<{ quotations?: QuotationData[] }>(quotationsQuery.data)?.quotations;
|
||||||
if (qs) setQuotations(qs.map(mapQuotation));
|
if (qs) setQuotations(qs.map(mapQuotation));
|
||||||
}, [quotationsQuery.data]);
|
}, [quotationsQuery.data]);
|
||||||
|
// 서버 전체 건수(선택 필터 반영) — 페이지네이션용.
|
||||||
|
const total = unwrap<{ total?: number }>(quotationsQuery.data)?.total ?? 0;
|
||||||
|
|
||||||
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
|
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다.
|
||||||
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
|
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
|
||||||
@ -194,6 +198,7 @@ export function useQuotations() {
|
|||||||
partners,
|
partners,
|
||||||
cards,
|
cards,
|
||||||
quotations,
|
quotations,
|
||||||
|
total,
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
stopNegotiation,
|
stopNegotiation,
|
||||||
addSetting,
|
addSetting,
|
||||||
|
|||||||
@ -131,6 +131,18 @@ export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOT
|
|||||||
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
|
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI 필터값 → 서버 코드(SMALLINT). 서버 목록 필터(status/type 쿼리)로 보낼 때 사용.
|
||||||
|
export const QUOTATION_STATUS_CODE: Record<string, number> = {
|
||||||
|
견적생성: 1,
|
||||||
|
견적진행중: 2,
|
||||||
|
견적마감: 3,
|
||||||
|
협상보류: 4,
|
||||||
|
};
|
||||||
|
export const QUOTATION_TYPE_CODE: Record<string, number> = {
|
||||||
|
RE_NEGOTIATION: 1,
|
||||||
|
RE_ESTIMATE: 2,
|
||||||
|
};
|
||||||
|
|
||||||
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
|
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
|
||||||
|
|
||||||
export type BidSummaryView = {
|
export type BidSummaryView = {
|
||||||
@ -169,8 +181,8 @@ export type QuotationCardView = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
|
// 견적당 1개의 입찰 요약(bid_summary). est-1~3은 데모용 정적 매핑, 그 외는 견적 데이터에서 산출.
|
||||||
export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryView {
|
export function buildBidSummary(q: QuotationData, partners: Partner[]): BidSummaryView {
|
||||||
if (est.id === 'est-1') {
|
if (q.qt_id === 'est-1') {
|
||||||
return {
|
return {
|
||||||
bid_summary_id: 'bid-summary-111-uuid',
|
bid_summary_id: 'bid-summary-111-uuid',
|
||||||
status: '입찰진행중 (ACTIVE)',
|
status: '입찰진행중 (ACTIVE)',
|
||||||
@ -181,7 +193,7 @@ export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryV
|
|||||||
equal_data: '-',
|
equal_data: '-',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (est.id === 'est-2') {
|
if (q.qt_id === 'est-2') {
|
||||||
return {
|
return {
|
||||||
bid_summary_id: 'bid-summary-222-uuid',
|
bid_summary_id: 'bid-summary-222-uuid',
|
||||||
status: '입찰종료 (COMPLETED)',
|
status: '입찰종료 (COMPLETED)',
|
||||||
@ -192,7 +204,7 @@ export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryV
|
|||||||
equal_data: JSON.stringify({ 'part-2': 730000, 'part-3': 730000 }),
|
equal_data: JSON.stringify({ 'part-2': 730000, 'part-3': 730000 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (est.id === 'est-3') {
|
if (q.qt_id === 'est-3') {
|
||||||
return {
|
return {
|
||||||
bid_summary_id: 'bid-summary-333-uuid',
|
bid_summary_id: 'bid-summary-333-uuid',
|
||||||
status: '입찰활성화 (ACTIVE)',
|
status: '입찰활성화 (ACTIVE)',
|
||||||
@ -203,16 +215,15 @@ export function buildBidSummary(est: Estimate, partners: Partner[]): BidSummaryV
|
|||||||
equal_data: '-',
|
equal_data: '-',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const winnerId = q.preferred_sp_id ?? null;
|
||||||
return {
|
return {
|
||||||
bid_summary_id: `bid-summary-${est.id}`,
|
bid_summary_id: `bid-summary-${q.qt_id}`,
|
||||||
status: est.status === 'COMPLETED' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
|
status: normalizeQuotationStatus(q.status) === '견적마감' ? '입찰종료 (COMPLETED)' : '입찰활성화 (ACTIVE)',
|
||||||
qt_iteration: 1,
|
qt_iteration: q.iteration ?? 1,
|
||||||
has_preferred: !!est.winnerPartnerId,
|
has_preferred: !!winnerId,
|
||||||
preferred_sp_id: est.winnerPartnerId || null,
|
preferred_sp_id: winnerId,
|
||||||
preferred_sp_name: est.winnerPartnerId
|
preferred_sp_name: q.preferred_sp_name || (winnerId ? partners.find((p) => p.id === winnerId)?.name || '-' : '-'),
|
||||||
? partners.find((p) => p.id === est.winnerPartnerId)?.name || '-'
|
equal_data: typeof q.equal_bid_data === 'string' ? q.equal_bid_data : '-',
|
||||||
: '-',
|
|
||||||
equal_data: '-',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import { useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처.
|
|
||||||
// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다.
|
|
||||||
// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거).
|
|
||||||
//
|
|
||||||
// const overlay = useOverlayParams(['edit', 'new', 'modal']);
|
|
||||||
// overlay.get('edit') // ?edit=<id> 의 값(없으면 null) — 값 있는 오버레이
|
|
||||||
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
|
|
||||||
// overlay.open('edit', id) // ?edit=<id> (다른 오버레이 키는 지움)
|
|
||||||
// overlay.open('new') // ?new (값 생략 시 '1')
|
|
||||||
// overlay.close() // 그룹 내 모든 오버레이 키 제거
|
|
||||||
export function useOverlayParams<K extends string>(keys: readonly K[]) {
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const get = (key: K) => searchParams.get(key);
|
|
||||||
const has = (key: K) => searchParams.has(key);
|
|
||||||
|
|
||||||
const open = (key: K, value = '1') =>
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
keys.forEach((k) => next.delete(k));
|
|
||||||
next.set(key, value);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = () =>
|
|
||||||
setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
keys.forEach((k) => next.delete(k));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { get, has, open, close };
|
|
||||||
}
|
|
||||||
50
negodata/front/src/lib/useOverlayRouter.ts
Normal file
50
negodata/front/src/lib/useOverlayRouter.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
// 시트/드로어/모달 같은 "오버레이" 열림 상태를 쿼리스트링으로 표현하는 단일 출처.
|
||||||
|
// 로컬 useState 대신 URL 에 담아 딥링크·뒤로가기·새로고침을 지원한다.
|
||||||
|
// 같은 그룹(keys) 안에서는 한 번에 하나만 연다(상호배타: 열 때 나머지 키 제거).
|
||||||
|
//
|
||||||
|
// const overlay = useOverlayRouter(['detail', 'new', 'modal']);
|
||||||
|
// overlay.get('detail') // ?detail=<id> 의 값(없으면 null) — 값 있는 오버레이
|
||||||
|
// overlay.has('new') // ?new 존재 여부 — 플래그 오버레이
|
||||||
|
// overlay.open('detail', id) // ?detail=<id> (다른 오버레이 키는 지움) — 히스토리 push
|
||||||
|
// overlay.open('new') // ?new (값 생략 시 '1')
|
||||||
|
// overlay.close() // 그룹 내 모든 오버레이 키 제거
|
||||||
|
const OVERLAY_PUSHED = '__overlayPushed';
|
||||||
|
|
||||||
|
export function useOverlayRouter<K extends string>(keys: readonly K[]) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const get = (key: K) => searchParams.get(key);
|
||||||
|
const has = (key: K) => searchParams.has(key);
|
||||||
|
|
||||||
|
// 현재 쿼리에서 그룹 키를 모두 지운 뒤 mutate 를 적용해 search 문자열을 만든다.
|
||||||
|
const buildSearch = (mutate: (params: URLSearchParams) => void) => {
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
keys.forEach((k) => next.delete(k));
|
||||||
|
mutate(next);
|
||||||
|
const s = next.toString();
|
||||||
|
return s ? `?${s}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (key: K, value = '1') =>
|
||||||
|
navigate(
|
||||||
|
{ pathname: location.pathname, search: buildSearch((p) => p.set(key, value)) },
|
||||||
|
{ state: { ...(location.state ?? {}), [OVERLAY_PUSHED]: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (location.state?.[OVERLAY_PUSHED]) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(
|
||||||
|
{ pathname: location.pathname, search: buildSearch(() => {}) },
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { get, has, open, close };
|
||||||
|
}
|
||||||
@ -1,13 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
|
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
|
||||||
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
|
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
|
||||||
// 쿼리 파라미터를 만들어 수행한다 — 이 훅은 패칭을 하지 않고 상태만 관리한다.
|
|
||||||
//
|
|
||||||
// - search: 입력 즉시 반영(controlled) + debouncedSearch(쿼리용, 기본 300ms)로 분리해
|
|
||||||
// 키 입력마다 서버를 때리지 않는다.
|
|
||||||
// - filters: 임의 키-값(category/priority 등). 'ALL' 같은 "전체" 값의 의미는
|
|
||||||
// 호출부가 파라미터를 만들 때 결정한다(여기선 단순 보관).
|
|
||||||
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
|
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
|
||||||
export type ServerListControls = {
|
export type ServerListControls = {
|
||||||
page: number;
|
page: number;
|
||||||
@ -15,6 +9,7 @@ export type ServerListControls = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
search: string; // input value (controlled)
|
search: string; // input value (controlled)
|
||||||
setSearch: (v: string) => void;
|
setSearch: (v: string) => void;
|
||||||
|
submitSearch: () => void; // 엔터/즉시 검색용 (디바운스·최소길이 무시하고 바로 발사)
|
||||||
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
|
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
|
||||||
filters: Record<string, string>;
|
filters: Record<string, string>;
|
||||||
setFilter: (key: string, value: string) => void;
|
setFilter: (key: string, value: string) => void;
|
||||||
@ -25,25 +20,39 @@ export function useServerList(opts?: {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
initialFilters?: Record<string, string>;
|
initialFilters?: Record<string, string>;
|
||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
|
minSearchLength?: number;
|
||||||
}): ServerListControls {
|
}): ServerListControls {
|
||||||
const pageSize = opts?.pageSize ?? 10;
|
const pageSize = opts?.pageSize ?? 10;
|
||||||
const debounceMs = opts?.debounceMs ?? 300;
|
const debounceMs = opts?.debounceMs ?? 500;
|
||||||
|
const minSearchLength = opts?.minSearchLength ?? 2;
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearchInput] = useState('');
|
const [search, setSearchInput] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
|
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
// 입력 디바운스 → 쿼리용 검색어
|
// 입력 디바운스 → 쿼리용 검색어.
|
||||||
|
// 최소 길이 미만은 빈 검색(전체)으로 둬서 1글자 스캔 요청이 서버로 나가지 않게 막는다.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebouncedSearch(search.trim()), debounceMs);
|
timerRef.current = setTimeout(() => {
|
||||||
return () => clearTimeout(t);
|
const q = search.trim();
|
||||||
}, [search, debounceMs]);
|
setDebouncedSearch(q.length >= minSearchLength ? q : '');
|
||||||
|
}, debounceMs);
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [search, debounceMs, minSearchLength]);
|
||||||
|
|
||||||
const setSearch = (v: string) => {
|
const setSearch = (v: string) => {
|
||||||
setSearchInput(v);
|
setSearchInput(v);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 엔터: 대기 중인 디바운스 타이머를 버리고 최소길이 무시하고 즉시 1회 발사(의도적 검색).
|
||||||
|
const submitSearch = () => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
setDebouncedSearch(search.trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
const setFilter = (key: string, value: string) => {
|
const setFilter = (key: string, value: string) => {
|
||||||
setFilters((f) => ({ ...f, [key]: value }));
|
setFilters((f) => ({ ...f, [key]: value }));
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@ -51,5 +60,5 @@ export function useServerList(opts?: {
|
|||||||
|
|
||||||
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
return { page, setPage, pageSize, search, setSearch, debouncedSearch, filters, setFilter, totalPages };
|
return { page, setPage, pageSize, search, setSearch, submitSearch, debouncedSearch, filters, setFilter, totalPages };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,43 @@
|
|||||||
import { Plus, BookOpen } from 'lucide-react';
|
import { Plus, BookOpen } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
import { SearchInput } from '@/components/layout/PageToolbar';
|
import { SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { useClientPagination } from '@/lib/useClientPagination';
|
import { useServerList } from '@/lib/useServerList';
|
||||||
import { useCards } from '@/features/cards/hooks/useCards';
|
import { useCards } from '@/features/cards/hooks/useCards';
|
||||||
import { useCardFilters } from '@/features/cards/hooks/useCardFilters';
|
import { useGetCard } from '@/api/generated/card/card';
|
||||||
import { CardTable } from '@/features/cards/components/CardTable';
|
import { CardTable } from '@/features/cards/components/CardTable';
|
||||||
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
|
import { CardFormSheet } from '@/features/cards/components/CardFormSheet';
|
||||||
import type { CardTab, NegotiationCard } from '@/features/cards/types';
|
import { mapCardData, type CardTab, type NegotiationCard } from '@/features/cards/types';
|
||||||
|
import type { ListCardsParams } from '@/api/generated/model/listCardsParams';
|
||||||
|
|
||||||
export default function CardsPage() {
|
export default function CardsPage() {
|
||||||
const { cards, createCard, updateCard, deleteCard } = useCards();
|
// 검색/탭/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
|
||||||
const { search, setSearch, activeTab, setActiveTab, filtered, counts } = useCardFilters(cards);
|
const list = useServerList({ pageSize: 10, initialFilters: { tab: 'ALL' } });
|
||||||
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
|
const activeTab = list.filters.tab as CardTab;
|
||||||
|
const params: ListCardsParams = {
|
||||||
|
search: list.debouncedSearch || undefined,
|
||||||
|
is_wildcard: activeTab === 'ALL' ? undefined : activeTab === 'WILD',
|
||||||
|
page: list.page,
|
||||||
|
size: list.pageSize,
|
||||||
|
};
|
||||||
|
const { cards, total, totalNego, totalWild, createCard, updateCard, deleteCard } = useCards(params);
|
||||||
|
const totalPages = list.totalPages(total);
|
||||||
|
|
||||||
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
// 오버레이(폼)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
||||||
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
// ?detail=<id> 직접 접근 시 단건 API 로 받아 수정 폼을 연다(현재 페이지에 없어도 동작).
|
||||||
const overlay = useOverlayParams(['new', 'edit']);
|
const overlay = useOverlayRouter(['new', 'detail']);
|
||||||
const editId = overlay.get('edit');
|
const editId = overlay.get('detail');
|
||||||
const editing = editId ? cards.find((c) => c.id === editId) ?? null : null;
|
const editQuery = useGetCard(editId ?? '', { query: { enabled: !!editId } });
|
||||||
|
const editing: NegotiationCard | null = editQuery.data?.card ? mapCardData(editQuery.data.card) : null;
|
||||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
const openCreate = () => overlay.open('new');
|
const openCreate = () => overlay.open('new');
|
||||||
const openEdit = (card: NegotiationCard) => overlay.open('edit', card.id);
|
const openEdit = (card: NegotiationCard) => overlay.open('detail', card.id);
|
||||||
|
|
||||||
const handleDeleteCard = async (id: string, cardName: string) => {
|
const handleDeleteCard = async (id: string, cardName: string) => {
|
||||||
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
if (await confirm({ title: '카드 삭제', description: `[${cardName}]을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||||
@ -41,9 +51,9 @@ export default function CardsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabs: { id: CardTab; label: string; count: number }[] = [
|
const tabs: { id: CardTab; label: string; count: number }[] = [
|
||||||
{ id: 'ALL', label: '전체', count: counts.all },
|
{ id: 'ALL', label: '전체', count: totalNego + totalWild },
|
||||||
{ id: 'CARD', label: '협상카드', count: counts.card },
|
{ id: 'CARD', label: '협상카드', count: totalNego },
|
||||||
{ id: 'WILD', label: '와일드카드', count: counts.wild },
|
{ id: 'WILD', label: '와일드카드', count: totalWild },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -76,7 +86,7 @@ export default function CardsPage() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
id={`card-tab-${tab.id.toLowerCase()}`}
|
id={`card-tab-${tab.id.toLowerCase()}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => list.setFilter('tab', tab.id)}
|
||||||
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
|
className={`py-2 px-6 font-bold text-xs tracking-tight border-b-2 transition-all cursor-pointer ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
@ -91,21 +101,22 @@ export default function CardsPage() {
|
|||||||
{/* Filtering Search Bar */}
|
{/* Filtering Search Bar */}
|
||||||
<SearchInput
|
<SearchInput
|
||||||
id="card-search"
|
id="card-search"
|
||||||
value={search}
|
value={list.search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
placeholder="전체 카드이름, 카드번호, 코드 및 핵심멘트 검색..."
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
placeholder="전체 카드이름, 카드번호, 코드 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardTable
|
<CardTable
|
||||||
data={pageItems}
|
data={cards}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
page={page}
|
page={list.page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalCount={totalCount}
|
totalCount={total}
|
||||||
pageSize={pageSize}
|
pageSize={list.pageSize}
|
||||||
onPageChange={setPage}
|
onPageChange={list.setPage}
|
||||||
label="전체 카드"
|
label="전체 카드"
|
||||||
unit="개"
|
unit="개"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Plus, Upload } from 'lucide-react';
|
import { Plus, Upload } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
@ -30,16 +30,16 @@ export default function PartnersPage() {
|
|||||||
const totalPages = list.totalPages(total);
|
const totalPages = list.totalPages(total);
|
||||||
|
|
||||||
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
// 오버레이(폼/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
||||||
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
// ?detail=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
||||||
const overlay = useOverlayParams(['new', 'edit', 'modal']);
|
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
|
||||||
const editId = overlay.get('edit');
|
const editId = overlay.get('detail');
|
||||||
const modal = overlay.get('modal'); // 'excel' | null
|
const modal = overlay.get('modal'); // 'excel' | null
|
||||||
const editing = editId ? allPartners.find((p) => p.supplier_id === editId) ?? null : null;
|
const editing = editId ? allPartners.find((p) => p.supplier_id === editId) ?? null : null;
|
||||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
const openCreate = () => overlay.open('new');
|
const openCreate = () => overlay.open('new');
|
||||||
const openEdit = (part: Partner) => overlay.open('edit', part.supplier_id);
|
const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id);
|
||||||
|
|
||||||
const handleDeletePartner = async (id: string, partnerName: string) => {
|
const handleDeletePartner = async (id: string, partnerName: string) => {
|
||||||
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
if (await confirm({ title: '협력사 삭제', description: `[${partnerName}] 파트너사를 협력사 목록에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||||
@ -73,6 +73,7 @@ export default function PartnersPage() {
|
|||||||
id="partner-search"
|
id="partner-search"
|
||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus, Upload, TrendingDown } from 'lucide-react';
|
import { Plus, Upload, TrendingDown } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { confirm } from '@/lib/confirm';
|
import { confirm } from '@/lib/confirm';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
@ -45,17 +45,16 @@ export default function ProductsPage() {
|
|||||||
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
|
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 오버레이(폼/최저가/엑셀)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
// 딥링크·뒤로가기·새로고침 지원
|
||||||
// ?edit=<id> 직접 접근 시 데이터 로드 후 수정 폼이 자동으로 열린다.
|
const overlay = useOverlayRouter(['new', 'detail', 'modal']);
|
||||||
const overlay = useOverlayParams(['new', 'edit', 'modal']);
|
const editId = overlay.get('detail');
|
||||||
const editId = overlay.get('edit');
|
|
||||||
const modal = overlay.get('modal'); // 'price' | 'excel' | null
|
const modal = overlay.get('modal'); // 'price' | 'excel' | null
|
||||||
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
|
const editing = editId ? allProducts.find((p) => p.item_id === editId) ?? null : null;
|
||||||
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
const formMode: 'create' | 'edit' = editId ? 'edit' : 'create';
|
||||||
const isFormOpen = overlay.has('new') || !!editing;
|
const isFormOpen = overlay.has('new') || !!editing;
|
||||||
|
|
||||||
const openCreate = () => overlay.open('new');
|
const openCreate = () => overlay.open('new');
|
||||||
const openEdit = (prod: Product) => overlay.open('edit', prod.item_id);
|
const openEdit = (prod: Product) => overlay.open('detail', prod.item_id);
|
||||||
|
|
||||||
const handleDeleteProduct = async (id: string, prodName: string) => {
|
const handleDeleteProduct = async (id: string, prodName: string) => {
|
||||||
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) {
|
||||||
@ -99,6 +98,7 @@ export default function ProductsPage() {
|
|||||||
id="product-search"
|
id="product-search"
|
||||||
value={list.search}
|
value={list.search}
|
||||||
onChange={(e) => list.setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
placeholder="상품명 또는 상품 코드로 통합 검색..."
|
placeholder="상품명 또는 상품 코드로 통합 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +1,54 @@
|
|||||||
import { Settings, Plus } from 'lucide-react';
|
import { Settings, Plus } from 'lucide-react';
|
||||||
import { useOverlayParams } from '@/lib/useOverlayParams';
|
import { useOverlayRouter } from '@/lib/useOverlayRouter';
|
||||||
import { PageContainer } from '@/components/layout/PageContainer';
|
import { PageContainer } from '@/components/layout/PageContainer';
|
||||||
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
|
||||||
import { TablePagination } from '@/components/ui/table-pagination';
|
import { TablePagination } from '@/components/ui/table-pagination';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useClientPagination } from '@/lib/useClientPagination';
|
import { useServerList } from '@/lib/useServerList';
|
||||||
import { useQuotations } from '@/features/quotations/hooks/useQuotations';
|
import { useQuotations } from '@/features/quotations/hooks/useQuotations';
|
||||||
import { useQuotationFilters } from '@/features/quotations/hooks/useQuotationFilters';
|
import { useGetQuotation } from '@/api/generated/quotation/quotation';
|
||||||
import { QuotationTable } from '@/features/quotations/components/QuotationTable';
|
import { QuotationTable } from '@/features/quotations/components/QuotationTable';
|
||||||
import { QuotationDetailDrawer } from '@/features/quotations/components/QuotationDetailDrawer';
|
import { QuotationDetailSheet } from '@/features/quotations/components/QuotationDetailSheet';
|
||||||
import { CreateQuotationWizard } from '@/features/quotations/components/CreateQuotationWizard';
|
import { QuotationCreateModal } from '@/features/quotations/components/QuotationCreateModal';
|
||||||
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
|
import { QuotationSettingsModal } from '@/features/quotations/components/QuotationSettingsModal';
|
||||||
import { QUOTATION_STATUS_FILTERS } from '@/features/quotations/types';
|
import { QUOTATION_STATUS_FILTERS, QUOTATION_STATUS_CODE, QUOTATION_TYPE_CODE } from '@/features/quotations/types';
|
||||||
|
import type { ListQuotationsParams } from '@/api/generated/model/listQuotationsParams';
|
||||||
|
|
||||||
export default function QuotationPage() {
|
export default function QuotationPage() {
|
||||||
|
// 검색/상태·유형 필터/페이지 상태 → 서버 쿼리 파라미터로 변환.
|
||||||
|
const list = useServerList({ pageSize: 10, initialFilters: { status: 'ALL', type: 'ALL' } });
|
||||||
|
const statusFilter = list.filters.status;
|
||||||
|
const typeFilter = list.filters.type;
|
||||||
|
const params: ListQuotationsParams = {
|
||||||
|
search: list.debouncedSearch || undefined,
|
||||||
|
status: statusFilter !== 'ALL' ? String(QUOTATION_STATUS_CODE[statusFilter] ?? '') : undefined,
|
||||||
|
type: typeFilter !== 'ALL' ? String(QUOTATION_TYPE_CODE[typeFilter] ?? '') : undefined,
|
||||||
|
page: list.page,
|
||||||
|
size: list.pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
products,
|
products,
|
||||||
partners,
|
partners,
|
||||||
cards,
|
cards,
|
||||||
quotations,
|
quotations,
|
||||||
|
total,
|
||||||
quotationSettings,
|
quotationSettings,
|
||||||
stopNegotiation,
|
stopNegotiation,
|
||||||
addSetting,
|
addSetting,
|
||||||
deleteSetting,
|
deleteSetting,
|
||||||
createQuotation,
|
createQuotation,
|
||||||
} = useQuotations();
|
} = useQuotations(params);
|
||||||
|
const totalPages = list.totalPages(total);
|
||||||
|
|
||||||
const { search, setSearch, statusFilter, setStatusFilter, typeFilter, setTypeFilter, filtered } =
|
const overlay = useOverlayRouter(['detail', 'create', 'settings']);
|
||||||
useQuotationFilters(quotations);
|
|
||||||
const { page, setPage, pageSize, totalPages, totalCount, pageItems } = useClientPagination(filtered);
|
|
||||||
|
|
||||||
// 오버레이(상세/생성/세팅)를 쿼리스트링으로 → 딥링크·뒤로가기·새로고침 지원.
|
|
||||||
// ?detail=<id> 로 직접 접근하면 데이터 로드 후 상세가 자동으로 열린다.
|
|
||||||
const overlay = useOverlayParams(['detail', 'create', 'settings']);
|
|
||||||
const detailId = overlay.get('detail');
|
const detailId = overlay.get('detail');
|
||||||
const isCreateOpen = overlay.has('create');
|
const isCreateOpen = overlay.has('create');
|
||||||
const isSettingsOpen = overlay.has('settings');
|
const isSettingsOpen = overlay.has('settings');
|
||||||
|
|
||||||
const activeQuotation = quotations.find((e) => e.id === detailId) ?? null;
|
// 상세 요약은 리스트에서 find 하지 않고 단건 API 로 받아온다(딥링크 시 리스트 의존 제거).
|
||||||
|
const detailQuery = useGetQuotation(detailId ?? '', { query: { enabled: !!detailId } });
|
||||||
|
const activeQuotation = detailQuery.data?.quotation ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@ -66,13 +77,14 @@ export default function QuotationPage() {
|
|||||||
>
|
>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
id="quotation-search"
|
id="quotation-search"
|
||||||
value={search}
|
value={list.search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => list.setSearch(e.target.value)}
|
||||||
placeholder="견적명 또는 견적 번호로 실시간 서치..."
|
onKeyDown={(e) => e.key === 'Enter' && list.submitSearch()}
|
||||||
|
placeholder="견적명 또는 견적 번호로 검색..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as string)}>
|
<Select value={statusFilter} onValueChange={(v) => list.setFilter('status', v as string)}>
|
||||||
<SelectTrigger id="quotation-status-filter" className="font-bold">
|
<SelectTrigger id="quotation-status-filter" className="font-bold">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -84,7 +96,7 @@ export default function QuotationPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as string)}>
|
<Select value={typeFilter} onValueChange={(v) => list.setFilter('type', v as string)}>
|
||||||
<SelectTrigger id="quotation-type-filter">
|
<SelectTrigger id="quotation-type-filter">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
|
{(value) => (value === 'ALL' ? '전체 유형' : value === 'RE_NEGOTIATION' ? '재협상' : '재견적')}
|
||||||
@ -100,16 +112,16 @@ export default function QuotationPage() {
|
|||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
<QuotationTable
|
<QuotationTable
|
||||||
data={pageItems}
|
data={quotations}
|
||||||
products={products}
|
products={products}
|
||||||
onOpenDetail={(id) => overlay.open('detail', id)}
|
onOpenDetail={(id) => overlay.open('detail', id)}
|
||||||
footer={
|
footer={
|
||||||
<TablePagination
|
<TablePagination
|
||||||
page={page}
|
page={list.page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalCount={totalCount}
|
totalCount={total}
|
||||||
pageSize={pageSize}
|
pageSize={list.pageSize}
|
||||||
onPageChange={setPage}
|
onPageChange={list.setPage}
|
||||||
label="전체 견적"
|
label="전체 견적"
|
||||||
unit="건"
|
unit="건"
|
||||||
/>
|
/>
|
||||||
@ -117,19 +129,16 @@ export default function QuotationPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{activeQuotation && (
|
{activeQuotation && (
|
||||||
<QuotationDetailDrawer
|
<QuotationDetailSheet
|
||||||
key={activeQuotation.id}
|
key={activeQuotation.qt_id}
|
||||||
estimate={activeQuotation}
|
quotation={activeQuotation}
|
||||||
products={products}
|
|
||||||
partners={partners}
|
|
||||||
quotationSettings={quotationSettings}
|
|
||||||
onStop={stopNegotiation}
|
onStop={stopNegotiation}
|
||||||
onClose={overlay.close}
|
onClose={overlay.close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCreateOpen && (
|
{isCreateOpen && (
|
||||||
<CreateQuotationWizard
|
<QuotationCreateModal
|
||||||
open
|
open
|
||||||
products={products}
|
products={products}
|
||||||
partners={partners}
|
partners={partners}
|
||||||
|
|||||||
@ -187,7 +187,7 @@ export interface Session extends BaseEntity {
|
|||||||
|
|
||||||
// 12. Bid Summary (견적 입찰 정보)
|
// 12. Bid Summary (견적 입찰 정보)
|
||||||
// ⚠ DB 없음: 별도 테이블이 없고 quotation.quotations 의 preferred_sp_* / equal_bid_* 컬럼으로 흡수됨.
|
// ⚠ DB 없음: 별도 테이블이 없고 quotation.quotations 의 preferred_sp_* / equal_bid_* 컬럼으로 흡수됨.
|
||||||
// UI(QuotationDetailDrawer) 의 입찰 요약 표시용 파생 모델로만 존재.
|
// UI(QuotationDetailSheet) 의 입찰 요약 표시용 파생 모델로만 존재.
|
||||||
export interface BidSummary {
|
export interface BidSummary {
|
||||||
bid_summary_id: string; // (파생) UI 식별자
|
bid_summary_id: string; // (파생) UI 식별자
|
||||||
qt_id: string; // 견적 아이디 (quotation.quotations.qt_id)
|
qt_id: string; // 견적 아이디 (quotation.quotations.qt_id)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user