[feat] negodata/front: 상품·협력사 목록 서버 페이지네이션·검색 연동(재사용 useServerList) + 카드·견적 UI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-18 11:03:11 +09:00
parent 7c244bca27
commit dd03c2aecd
28 changed files with 1111 additions and 561 deletions

View File

@ -9,12 +9,52 @@
// baseURL — compose 의 VITE_API_BASE_URL 로 주입, 없으면 9400 폴백.
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9400';
// 토큰 보관소 — 인증 연동 시 로그인 후 setAccessToken()으로 주입한다(현재 목업 단계에선 미사용).
// 토큰 보관소 — 로그인 후 setAccessToken()으로 주입. localStorage(auth/service 와 동일 키)와 동기화.
const ACCESS_KEY = 'negodata.accessToken';
const REFRESH_KEY = 'negodata.refreshToken';
let accessToken: string | null = null;
export const setAccessToken = (token: string | null) => {
accessToken = token;
};
// 동시 다발 434(액세스 토큰 만료)를 단 한 번의 refresh 로 합치기 위한 in-flight 프라미스.
let refreshInFlight: Promise<string | null> | null = null;
// refresh 토큰으로 access 재발급. 성공 시 새 access 토큰, 실패 시 null.
async function refreshAccessToken(): Promise<string | null> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const refresh = localStorage.getItem(REFRESH_KEY);
if (!refresh) return null;
// refresh 토큰은 Authorization 헤더(Bearer)로 보낸다(auth/service restore() 와 동일 규약).
const res = await fetch(`${BASE_URL}/v1/auth/refresh_token`, {
method: 'POST',
headers: { Authorization: `Bearer ${refresh}` },
});
if (!res.ok) return null;
const refreshed = await res.json().catch(() => null);
const newToken: string | undefined = refreshed?.access_token;
if (!newToken || refreshed?.result?.success === false) return null;
localStorage.setItem(ACCESS_KEY, newToken);
accessToken = newToken;
return newToken;
} catch {
return null;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
function clearTokens() {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
accessToken = null;
}
export class ApiError extends Error {
constructor(
public status: number,
@ -42,19 +82,6 @@ export const customFetch = async <T>(
): Promise<T> => {
const { url, method, params, data, signal } = config;
// [요청 인터셉터] 헤더 병합: config → options 순으로 덮어쓴다.
const headers = new Headers(config.headers);
if (options?.headers) {
new Headers(options.headers).forEach((v, k) => headers.set(k, v));
}
if (data != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
// 토큰이 있으면 Authorization 자동 주입
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
// 쿼리스트링 직렬화 (null/undefined 값은 제외)
const queryString = params
? new URLSearchParams(
@ -67,13 +94,37 @@ export const customFetch = async <T>(
(url.startsWith('http') ? url : `${BASE_URL}${url}`) +
(queryString ? `?${queryString}` : '');
const response = await fetch(requestUrl, {
...options,
method,
headers,
body: data != null ? JSON.stringify(data) : options?.body,
signal,
});
// [요청 인터셉터] 토큰을 받아 헤더를 구성하고 한 번 호출(만료 재시도 시 새 토큰으로 재구성).
const doFetch = (token: string | null) => {
const headers = new Headers(config.headers);
if (options?.headers) {
new Headers(options.headers).forEach((v, k) => headers.set(k, v));
}
if (data != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token) headers.set('Authorization', `Bearer ${token}`);
return fetch(requestUrl, {
...options,
method,
headers,
body: data != null ? JSON.stringify(data) : options?.body,
signal,
});
};
let response = await doFetch(accessToken);
// 434(액세스 토큰 만료) → refresh 로 재발급 후 1회 재시도. auth 엔드포인트 자신은 제외(무한루프 방지).
const isAuthEndpoint = url.includes('/v1/auth/refresh_token') || url.includes('/v1/auth/login');
if ((response.status === 434 || response.status === 401) && !isAuthEndpoint) {
const newToken = await refreshAccessToken();
if (newToken) {
response = await doFetch(newToken);
} else {
clearTokens(); // refresh 실패 → 토큰 폐기(다음 로드/가드에서 로그인으로 유도)
}
}
// [응답 인터셉터] 본문 파싱 (204/빈 응답 대응)
const isJson = response.headers.get('content-type')?.includes('application/json');
@ -82,7 +133,6 @@ export const customFetch = async <T>(
// fetch는 4xx/5xx도 resolve하므로 직접 throw → React Query가 error로 처리
if (!response.ok) {
// TODO: 401이면 refresh 토큰으로 재발급 후 재시도 로직 추가 위치
throw new ApiError(response.status, response.statusText, body);
}

View File

@ -150,7 +150,6 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
<Typography as="div" variant="small" className="font-bold">
{pageLabelMap[currentPage]}
</Typography>
<Badge variant="outline">B2B 시스템</Badge>
</div>
<HeaderMeta user={user} today={today} />

View File

@ -26,7 +26,7 @@ function toAuthUser(me: ResMe): AuthUser {
loginId: me.id ?? '',
email: me.email ?? '',
contact: me.contact_number ?? '',
role: (me.role as UserRole) ?? '일반',
role: (me.role_label as UserRole) || '일반',
};
}

View File

@ -5,6 +5,7 @@ import { X, Layers } from 'lucide-react';
import { showToast } from '@/lib/notify';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type NegotiationCard, type CardTab, generateCardCode } from '../types';
import type { CardInput } from '../hooks/useCards';
@ -25,8 +26,8 @@ type CardFormModalProps = {
mode: 'create' | 'edit';
card: NegotiationCard | null; // edit 모드 초기값 출처
activeTab: CardTab; // create 시 기본 카드 종류 결정
onCreate: (input: CardInput) => void;
onUpdate: (id: string, input: CardInput) => void;
onCreate: (input: CardInput) => Promise<void>;
onUpdate: (id: string, input: CardInput) => Promise<void>;
onClose: () => void;
};
@ -85,7 +86,7 @@ export function CardFormModal({
if (!open) return null;
const onValid = (v: FormValues) => {
const onValid = async (v: FormValues) => {
const input: CardInput = {
title: v.title,
code: v.code,
@ -95,14 +96,19 @@ export function CardFormModal({
triggerCondition: v.triggerCondition,
memo: v.memo,
};
if (mode === 'create') {
onCreate(input);
showToast(`${v.isWildcard ? '와일드카드' : '협상카드'}가 추가되었습니다.`, 'success');
} else if (card) {
onUpdate(card.id, input);
showToast('정보가 수정되었습니다.', 'success');
const kind = v.isWildcard ? '와일드카드' : '협상카드';
try {
if (mode === 'create') {
await onCreate(input);
showToast(`${kind}가 추가되었습니다.`, 'success');
} else if (card) {
await onUpdate(card.id, input);
showToast('정보가 수정되었습니다.', 'success');
}
onClose();
} catch (err) {
showToast(err instanceof Error ? err.message : `${kind} 저장 실패`, 'error');
}
onClose();
};
return (
@ -139,21 +145,26 @@ export function CardFormModal({
control={control}
name="isWildcard"
render={({ field }) => (
<select
id="form-card-is-wildcard"
<Select
value={field.value ? 'WILD' : 'CARD'}
onChange={(e) => {
const wild = e.target.value === 'WILD';
onValueChange={(v) => {
// 카드 종류는 등록 시에만 정한다(수정 시 테이블 이동 불가 → 고정).
if (mode !== 'create') return;
const wild = v === 'WILD';
field.onChange(wild);
if (mode === 'create') {
setValue('code', generateCardCode(wild));
}
setValue('code', generateCardCode(wild));
}}
className="w-full p-2 bg-background border border-border rounded text-[11px] cursor-pointer focus:outline-none"
>
<option value="CARD">일반 협상카드</option>
<option value="WILD">와일드카드</option>
</select>
<SelectTrigger id="form-card-is-wildcard" className="w-full" disabled={mode === 'edit'}>
<SelectValue>
{(value) => (value === 'WILD' ? '와일드카드' : '일반 협상카드')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="CARD">일반 협상카드</SelectItem>
<SelectItem value="WILD">와일드카드</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>

View File

@ -1,6 +1,17 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
useListCards,
createCard,
updateCard,
deleteCard,
getListCardsQueryKey,
} from '@/api/generated/card/card';
import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
import type { ResCard } from '@/api/generated/model/resCard';
import type { NegotiationCard } from '@/types';
import { generateSlateJSON } from '../types';
import { generateSlateJSON, mapCardData, toCardStatusCode } from '../types';
const LIST_PARAMS = { size: 100 };
// 카드 폼이 넘기는 입력값(편집/생성 공통).
export type CardInput = {
@ -13,34 +24,60 @@ export type CardInput = {
memo?: string;
};
// 협상카드 카탈로그 — 백엔드 미연동이라 로컬 state로만 CRUD(목업 제거됨, 빈 상태로 시작).
// 와일드카드 전용 필드(triggerCondition/memo)는 isWildcard일 때만 보존한다.
export function useCards() {
const [cards, setCards] = useState<NegotiationCard[]>([]);
const toCard = (id: string, input: CardInput): NegotiationCard => ({
id,
isWildcard: input.isWildcard,
code: input.code,
title: input.title,
scriptPreview: input.scriptPreview,
editorScript: generateSlateJSON(input.scriptPreview),
status: input.status,
triggerCondition: input.isWildcard ? input.triggerCondition : undefined,
memo: input.isWildcard ? input.memo : undefined,
});
const createCard = (input: CardInput) => {
setCards((prev) => [...prev, toCard(`card-${Date.now()}`, input)]);
};
const updateCard = (id: string, input: CardInput) => {
setCards((prev) => prev.map((c) => (c.id === id ? { ...c, ...toCard(id, input) } : c)));
};
const deleteCard = (id: string) => {
setCards((prev) => prev.filter((c) => c.id !== id));
};
return { cards, createCard, updateCard, deleteCard };
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
function cardError(res: ResCard): string | null {
const r = res.result;
if (!r || r.success !== false) return null;
return r.desc || '카드 저장에 실패했습니다.';
}
// UI 입력 → 서버 요청 본문. 와일드카드 전용 필드(condition/memo)는 isWildcard 일 때만 전송.
function toReq(input: CardInput): ReqCreateCard {
return {
is_wildcard: input.isWildcard,
name: input.title,
number: input.code,
script: input.scriptPreview,
edit_script: generateSlateJSON(input.scriptPreview),
status: toCardStatusCode(input.status),
condition: input.isWildcard ? input.triggerCondition : undefined,
memo: input.isWildcard ? input.memo : undefined,
};
}
// 협상카드 카탈로그 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
// 실패 시 throw → 호출부(폼/페이지)에서 toast 처리.
export function useCards() {
const queryClient = useQueryClient();
const cardsQuery = useListCards(LIST_PARAMS);
const refresh = () =>
queryClient.invalidateQueries({ queryKey: getListCardsQueryKey(LIST_PARAMS) });
const createCardFn = async (input: CardInput) => {
const msg = cardError(await createCard(toReq(input)));
if (msg) throw new Error(msg);
await refresh();
};
const updateCardFn = async (id: string, input: CardInput) => {
const msg = cardError(await updateCard(id, toReq(input)));
if (msg) throw new Error(msg);
await refresh();
};
const deleteCardFn = async (id: string) => {
await deleteCard(id);
await refresh();
};
// customFetch 가 본문을 그대로 주므로 cardsQuery.data 가 곧 ResCardList → .cards.
const cards: NegotiationCard[] = (cardsQuery.data?.cards ?? []).map(mapCardData);
return {
cards,
createCard: createCardFn,
updateCard: updateCardFn,
deleteCard: deleteCardFn,
refresh,
cardsQuery,
};
}

View File

@ -1,10 +1,34 @@
import type { NegotiationCard } from '@/types';
import type { CardData } from '@/api/generated/model/cardData';
export type { NegotiationCard };
// 카드 목록 탭. 'ALL' 전체 / 'CARD' 일반 협상카드 / 'WILD' 와일드카드.
export type CardTab = 'ALL' | 'CARD' | 'WILD';
// 카드 status 코드(서버 CardStatus enum) ↔ UI 문자열. ACTIVE=1 / INACTIVE=2.
export const CARD_STATUS_ACTIVE = 1;
export const CARD_STATUS_INACTIVE = 2;
export const toCardStatusCode = (s: 'ACTIVE' | 'INACTIVE') =>
s === 'ACTIVE' ? CARD_STATUS_ACTIVE : CARD_STATUS_INACTIVE;
export const toCardStatusLabel = (code?: number): 'ACTIVE' | 'INACTIVE' =>
code === CARD_STATUS_INACTIVE ? 'INACTIVE' : 'ACTIVE';
// 서버 CardData(nego_cards/wild_cards 통합) → UI NegotiationCard.
export function mapCardData(c: CardData): NegotiationCard {
return {
id: c.nego_card_id,
isWildcard: c.is_wildcard ?? false,
code: c.number ?? '',
title: c.name ?? '',
scriptPreview: c.script ?? '',
editorScript: c.edit_script,
status: toCardStatusLabel(c.status),
triggerCondition: c.condition ?? undefined,
memo: c.memo ?? undefined,
};
}
// 평문 스크립트를 Slate JSON 노드로 변환(카드 저장 시 editorScript 생성).
export function generateSlateJSON(previewText: string): unknown[] {
return [

View File

@ -1,8 +1,9 @@
import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Download } from 'lucide-react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react';
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv } from '@/lib/excel';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
import { customFetch } from '@/api/mutator/custom-fetch';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -27,12 +28,17 @@ type TemplateRow = { name: string; code: string; managerName: string; managerEma
type ExcelUploadModalProps = {
open: boolean;
partners: Partner[]; // 코드 중복 검사용
onConfirm: (rows: SupplierCreate[]) => Promise<void>;
onConfirm: (rows: SupplierCreate[]) => Promise<BulkFailure[]>;
onClose: () => void;
};
// 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다.
function validateRows(rows: RawRow[], partners: Partner[]): ValidatedRow[] {
// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다.
function validateRows(
rows: RawRow[],
partners: Partner[],
serverErrors: Record<string, string>,
): ValidatedRow[] {
return rows.map((row) => {
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
@ -47,6 +53,9 @@ function validateRows(rows: RawRow[], partners: Partner[]): ValidatedRow[] {
return fail('이메일 형식 규격 외 - 올바른 이메일 주소(@ 포함)가 필요합니다.');
}
// 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리.
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
return { ...row, status: '정상', message: '등록 적격 - 정합성 검증 통과' };
});
}
@ -68,10 +77,14 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {
const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validated = useMemo(() => validateRows(rows, partners), [rows, partners]);
const validated = useMemo(
() => validateRows(rows, partners, serverErrors),
[rows, partners, serverErrors],
);
const validRows = validated.filter((r) => r.status === '정상');
const validCount = validRows.length;
const errorCount = validated.length - validCount;
@ -81,6 +94,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
const close = () => {
setExcelFile(null);
setRows([]);
setServerErrors({});
onClose();
};
@ -113,6 +127,23 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
}));
setExcelFile(file.name);
setRows(loaded);
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
// 업로드 즉시 DB 중복코드 사전검사 → 미리보기에서 바로 빨강 처리.
const codes = [...new Set(loaded.map((r) => r.code.trim()).filter(Boolean))];
if (codes.length === 0) return;
try {
const res = await customFetch<{ existing?: string[] }>({
url: '/v1/supplier/check-codes',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: { codes },
});
const dup: Record<string, string> = {};
(res.existing ?? []).forEach((c) => { dup[c] = '코드 중복 — 이미 등록된 협력사코드입니다(DB).'; });
setServerErrors(dup);
} catch {
// 사전검사 호출 실패는 조용히 무시 — 제출 시 서버가 최종 차단한다.
}
};
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리)
@ -120,15 +151,31 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
setRows((cur) => cur.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
};
// 행 삭제 — 정상/오류 무관하게 미리보기에서 제거(서버 등록 전 단계).
const handleRemoveRow = (id: string) => {
setRows((cur) => cur.filter((row) => row.id !== id));
};
const handleConfirm = async () => {
if (validRows.length === 0) {
showToast('정합성이 무결한 파트너 행이 존재하지 않습니다.', 'error');
return;
}
try {
await onConfirm(validRows.map(toSupplierCreate));
showToast(`총 ${validRows.length}개 협력사가 서버에 일괄 등록되었습니다.`, 'success');
close();
const failures = await onConfirm(validRows.map(toSupplierCreate));
const okCount = validRows.length - failures.length;
if (failures.length === 0) {
showToast(`총 ${okCount}개 협력사가 서버에 일괄 등록되었습니다.`, 'success');
close();
return;
}
// 부분 성공: 등록 성공한 행만 제거하고, 서버(DB)가 거부한 행은 사유와 함께 남긴다.
const failMap: Record<string, string> = {};
failures.forEach((f) => { failMap[f.code] = f.message; });
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
setServerErrors(failMap);
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error');
} catch (err) {
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
}
@ -136,7 +183,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
{/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border">
@ -227,18 +274,41 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-2 font-semibold w-12 text-center">행</TableHead>
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
<TableHead className="p-2 font-semibold text-center w-16">자격</TableHead>
<TableHead className="p-2 font-semibold">진단 내용</TableHead>
<TableHead className="p-2 font-semibold">협력사명 *</TableHead>
<TableHead className="p-2 font-semibold">협력사코드 *</TableHead>
<TableHead className="p-2 font-semibold">담당자명 *</TableHead>
<TableHead className="p-2 font-semibold">담당자 이메일 *</TableHead>
<TableHead className="p-2 font-semibold text-center w-16">자격</TableHead>
<TableHead className="p-2 font-semibold">진단 내용 (직접 수정가능)</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{validated.map((row) => (
<TableRow key={row.id} className={row.status === '오류' ? 'bg-red-500/5 hover:bg-red-500/10' : 'bg-emerald-500/5 hover:bg-emerald-500/10'}>
<TableCell className="p-2 text-center text-muted-foreground">{row.rowNum}</TableCell>
<TableCell className="p-2 text-center">
<button
type="button"
onClick={() => handleRemoveRow(row.id)}
title="이 행 삭제"
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
>
<Trash2 size={14} />
</button>
</TableCell>
<TableCell className="p-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block ${
row.status === '정상'
? 'bg-emerald-100 text-emerald-800 border border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/80'
: 'bg-red-100 text-red-800 border border-red-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-950'
}`}>
{row.status}
</span>
</TableCell>
<TableCell className={`p-2 font-mono text-[10px] ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
{row.message}
</TableCell>
<TableCell className="p-2">
<Input
type="text"
@ -271,18 +341,6 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
onChange={(e) => handleUpdateField(row.id, 'managerEmail', e.target.value)}
/>
</TableCell>
<TableCell className="p-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block ${
row.status === '정상'
? 'bg-emerald-100 text-emerald-800 border border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/80'
: 'bg-red-100 text-red-800 border border-red-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-950'
}`}>
{row.status}
</span>
</TableCell>
<TableCell className={`p-2 font-mono text-[10px] ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
{row.message}
</TableCell>
</TableRow>
))}
</TableBody>
@ -313,7 +371,7 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
onClick={handleConfirm}
className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs"
>
적격 협력사 추가 (총 {validCount}개사)
적격 협력사 등록 (총 {validCount}개사)
</button>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useForm } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Trash2 } from 'lucide-react';
@ -9,6 +9,7 @@ import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type Partner, priorityOptions } from '../types';
const schema = z.object({
@ -67,6 +68,7 @@ export function PartnerFormSheet({
}: PartnerFormSheetProps) {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
@ -139,15 +141,24 @@ export function PartnerFormSheet({
{/* Priority */}
<div className="space-y-1">
<Typography as="label" variant="label">우선 선정 대상자</Typography>
<select
id="form-partner-priority"
{...register('priority')}
className={`w-full p-2 bg-background border border-border rounded ${inputClass} cursor-pointer`}
>
{priorityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<Controller
control={control}
name="priority"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="form-partner-priority" className="w-full">
<SelectValue>
{(value) => priorityOptions.find((o) => o.value === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{priorityOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>

View File

@ -1,48 +0,0 @@
import { useState } from 'react';
import type { Partner } from '../types';
const ITEMS_PER_PAGE = 5;
// 협력사 목록에 대한 검색/우선순위/페이지네이션 UI state + 파생 결과.
// 검색·우선순위 변경 시 1페이지로 리셋한다.
export function usePartnerFilters(partners: Partner[]) {
const [search, setSearchRaw] = useState('');
const [priorityFilter, setPriorityFilterRaw] = useState('ALL');
const [page, setPage] = useState(1);
const setSearch = (v: string) => {
setSearchRaw(v);
setPage(1);
};
const setPriorityFilter = (v: string) => {
setPriorityFilterRaw(v);
setPage(1);
};
const filtered = partners.filter((part) => {
if (part.deleted) return false; // soft-deleted 제외
const q = search.toLowerCase();
const matchesSearch =
(part.name ?? '').toLowerCase().includes(q) ||
(part.code ? part.code.toLowerCase().includes(q) : false) ||
(part.manager_name ? part.manager_name.toLowerCase().includes(q) : false);
const matchesPriority = priorityFilter === 'ALL' || part.priority === priorityFilter;
return matchesSearch && matchesPriority;
});
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE) || 1;
const paginated = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
return {
search,
setSearch,
priorityFilter,
setPriorityFilter,
page,
setPage,
paginated,
totalPages,
totalCount: filtered.length,
itemsPerPage: ITEMS_PER_PAGE,
};
}

View File

@ -1,28 +1,49 @@
import { useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListSuppliers,
createSupplier,
updateSupplier,
deleteSupplier,
getListSuppliersQueryKey,
} from '@/api/generated/supplier/supplier';
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
import type { ReqCreateSupplier } from '@/api/generated/model/reqCreateSupplier';
import type { ReqUpdateSupplier } from '@/api/generated/model/reqUpdateSupplier';
import type { ResSupplier } from '@/api/generated/model/resSupplier';
import type { SupplierData } from '@/api/generated/model/supplierData';
import type { BulkFailure } from '@/lib/excel';
import type { Partner } from '../types';
const LIST_PARAMS = { size: 100 };
// 엑셀 중복검사 모달이 참조하는 "전체 협력사"용 메타 쿼리(최대 100건).
const META_PARAMS: ListSuppliersParams = { size: 100 };
// 협력사 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
// 실패 시 throw → 호출부에서 toast 처리.
export function usePartners() {
// 서버 SupplierData → UI Partner (그대로 + id 별칭만).
const toPartner = (sp: SupplierData): Partner => ({ ...sp, id: sp.supplier_id });
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
function supplierError(res: ResSupplier): string | null {
const r = res.result;
if (!r || r.success !== false) return null;
if (r.desc === 'SUPPLIER_CODE_DUPLICATE') return '코드 중복 — 이미 등록된 협력사코드입니다(DB 검증).';
return r.desc || '협력사 등록에 실패했습니다.';
}
// 협력사 서버 데이터 + CRUD.
// - params: 테이블용 서버 페이지네이션/검색/우선순위 (useServerList 가 만든다)
// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리.
export function usePartners(params: ListSuppliersParams) {
const queryClient = useQueryClient();
const suppliersQuery = useListSuppliers(LIST_PARAMS);
const refresh = () =>
queryClient.invalidateQueries({ queryKey: getListSuppliersQueryKey(LIST_PARAMS) });
// 테이블용(현재 페이지) — 페이지 이동 시 이전 데이터 유지(깜빡임 방지)
const suppliersQuery = useListSuppliers(params, { query: { placeholderData: keepPreviousData } });
// 메타용(엑셀 모달이 참조하는 전체 목록)
const metaQuery = useListSuppliers(META_PARAMS);
// /v1/supplier/list 로 시작하는 모든 쿼리(페이지·메타)를 prefix 매칭으로 재조회.
const refresh = () => queryClient.invalidateQueries({ queryKey: ['/v1/supplier/list'] });
const createPartner = async (data: ReqCreateSupplier) => {
await createSupplier(data);
const msg = supplierError(await createSupplier(data));
if (msg) throw new Error(msg);
await refresh();
};
const updatePartner = async (supplierId: string, data: ReqUpdateSupplier) => {
@ -33,18 +54,38 @@ export function usePartners() {
await deleteSupplier(supplierId);
await refresh();
};
// 엑셀 일괄 등록 — 검증된 행들을 순차 생성 후 한 번만 재조회.
const bulkCreate = async (rows: ReqCreateSupplier[]) => {
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
const bulkCreate = async (rows: ReqCreateSupplier[]): Promise<BulkFailure[]> => {
const failures: BulkFailure[] = [];
for (const row of rows) {
await createSupplier(row);
try {
const msg = supplierError(await createSupplier(row));
if (msg) failures.push({ code: row.code ?? '', message: msg });
} catch (err) {
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
}
}
await refresh();
return failures;
};
// 서버 SupplierData → UI Partner (그대로 + id 별칭만).
// customFetch 가 본문을 그대로 주므로 suppliersQuery.data 가 곧 ResSupplierList → .suppliers.
const suppliers = suppliersQuery.data?.suppliers ?? [];
const partners: Partner[] = suppliers.map((sp) => ({ ...sp, id: sp.supplier_id }));
// 테이블(현재 페이지) 협력사 + 서버 전체 건수.
const partners: Partner[] = (suppliersQuery.data?.suppliers ?? []).map(toPartner);
const total = suppliersQuery.data?.total ?? 0;
return { partners, createPartner, updatePartner, deletePartner, bulkCreate, refresh, suppliersQuery };
// 전체(메타) 협력사 — 엑셀 중복검사 모달용(현재 페이지에 없을 수 있어 전체 기준).
const allPartners: Partner[] = (metaQuery.data?.suppliers ?? []).map(toPartner);
return {
partners,
total,
allPartners,
createPartner,
updatePartner,
deletePartner,
bulkCreate,
refresh,
isLoading: suppliersQuery.isLoading,
};
}

View File

@ -1,8 +1,9 @@
import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Download } from 'lucide-react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react';
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv } from '@/lib/excel';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
import { customFetch } from '@/api/mutator/custom-fetch';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -14,25 +15,95 @@ type RawRow = {
rowNum: number;
name: string;
code: string;
model_name: string;
category: string;
spec: string;
manufacturer: string;
made_in: string;
price: number;
minPrice: number;
image_url: string;
moq: string;
lead_time: number;
quantity_unit: string;
delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환
vat_yn: string; // Y/N
delivery_fee_yn: string; // Y/N
};
// 검증 결과가 붙은 행. UI는 이걸 그린다.
type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string };
// 업로드 양식 한 줄(예시 행)
type TemplateRow = { name: string; code: string; price: number; minPrice: number };
// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합)
const DELIVERY_LABEL_TO_CODE: Record<string, number> = {
협력사배송: 1,
지정택배배송: 2,
픽업배송: 3,
};
// 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true)
const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim());
// Y/N 으로 인식 가능한 토큰(참/거짓 양쪽). 검증에서 '해석 가능한 값'인지 판단한다.
const isYnToken = (s: string): boolean =>
/^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim());
// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다.
const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [
{ header: '상품명', key: 'name' },
{ header: '상품코드', key: 'code' },
{ header: '모델번호', key: 'model_name' },
{ header: '카테고리', key: 'category' },
{ header: '규격', key: 'spec' },
{ header: '제조사', key: 'manufacturer' },
{ header: '원산지', key: 'made_in' },
{ header: '상품 단가', key: 'price' },
{ header: '최저한도', key: 'minPrice' },
{ header: '이미지URL', key: 'image_url' },
{ header: '최소주문수량', key: 'moq' },
{ header: '리드타임(일)', key: 'lead_time' },
{ header: '단위', key: 'quantity_unit' },
{ header: '배송형태', key: 'delivery_type' },
{ header: '부가세포함(Y/N)', key: 'vat_yn' },
{ header: '배송비포함(Y/N)', key: 'delivery_fee_yn' },
];
// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기)
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'lead_time']);
const REQUIRED_KEYS = new Set<keyof RawRow>(['name', 'code', 'price']);
// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다.
const EXAMPLE_ROWS: Record<string, string | number>[] = [
{
name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A',
category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국',
price: 1250000, minPrice: 1037500, image_url: 'https://example.com/img/lfp-100a.jpg',
moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송',
vat_yn: 'Y', delivery_fee_yn: 'N',
},
{
name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A',
category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본',
price: 18900000, minPrice: 16065000, image_url: 'https://example.com/img/rx-6a.jpg',
moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송',
vat_yn: 'Y', delivery_fee_yn: 'N',
},
];
type ExcelUploadModalProps = {
open: boolean;
products: Product[]; // 코드 중복 검사용
onConfirm: (rows: ItemCreate[]) => Promise<void>;
onConfirm: (rows: ItemCreate[]) => Promise<BulkFailure[]>;
onClose: () => void;
};
// 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. (상태에 저장하지 않고 렌더에서 파생)
function validateRows(rows: RawRow[], products: Product[]): ValidatedRow[] {
// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다.
function validateRows(
rows: RawRow[],
products: Product[],
serverErrors: Record<string, string>,
): ValidatedRow[] {
return rows.map((row) => {
const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message });
@ -46,6 +117,23 @@ function validateRows(rows: RawRow[], products: Product[]): ValidatedRow[] {
if (row.price <= 0) return fail('유효성 위반 - 상품 단가는 0보다 커야 합니다.');
if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.');
// 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL.
if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) {
return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.');
}
if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) {
return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
}
if (row.delivery_fee_yn.trim() && !isYnToken(row.delivery_fee_yn)) {
return fail('배송비포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.');
}
if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) {
return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.');
}
// 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리.
if (serverErrors[row.code]) return fail(serverErrors[row.code]);
return { ...row, status: '정상', message: '등록 적격 - 정합성 검증 통과' };
});
}
@ -55,18 +143,19 @@ function toItemCreate(row: RawRow): ItemCreate {
return {
name: row.name,
code: row.code,
category: '자동화설비',
model_name: row.model_name || undefined,
category: row.category || undefined,
spec: row.spec || undefined,
manufacturer: row.manufacturer || undefined,
made_in: row.made_in || undefined,
price: row.price,
model_name: 'EXCEL-LOADED',
spec: '엑셀 일괄 업로드',
manufacturer: '벌크 대리 수급처',
made_in: '미지정',
quantity_unit: 'EA',
delivery_type: 1, // 협력사배송 (delivery_type 코드)
moq: '10 EA',
lead_time: 14,
vat_yn: true,
delivery_fee_yn: false,
image_url: row.image_url || undefined,
moq: row.moq || undefined,
lead_time: row.lead_time || undefined,
quantity_unit: row.quantity_unit || undefined,
delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined,
vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined,
delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined,
internet_lowest_price_yn: false,
};
}
@ -76,11 +165,15 @@ function toItemCreate(row: RawRow): ItemCreate {
export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) {
const [excelFile, setExcelFile] = useState<string | null>(null);
const [rows, setRows] = useState<RawRow[]>([]);
const [serverErrors, setServerErrors] = useState<Record<string, string>>({}); // 서버(DB) 거부 code→사유
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 파생: 검증 결과 + 카운트 (state로 저장하지 않음)
const validated = useMemo(() => validateRows(rows, products), [rows, products]);
const validated = useMemo(
() => validateRows(rows, products, serverErrors),
[rows, products, serverErrors],
);
const validRows = validated.filter((r) => r.status === '정상');
const validCount = validRows.length;
const errorCount = validated.length - validCount;
@ -90,20 +183,16 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
const close = () => {
setExcelFile(null);
setRows([]);
setServerErrors({});
onClose();
};
// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행 (lib/excel 재사용)
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유.
const handleDownloadTemplate = () => {
downloadExcel<TemplateRow>(
downloadExcel<Record<string, string | number>>(
'상품_업로드_양식',
[
{ header: '상품명', value: (r) => r.name },
{ header: '상품코드', value: (r) => r.code },
{ header: '상품 단가', value: (r) => r.price },
{ header: '최저한도', value: (r) => r.minPrice },
],
[{ name: '예시) 고용량 배터리 팩', code: 'PROD-EXAMPLE-001', price: 1000000, minPrice: 830000 }],
UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })),
EXAMPLE_ROWS,
);
};
@ -115,19 +204,48 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
rowNum: i + 2,
name: r['상품명'] ?? '',
code: r['상품코드'] ?? '',
model_name: r['모델번호'] ?? '',
category: r['카테고리'] ?? '',
spec: r['규격'] ?? '',
manufacturer: r['제조사'] ?? '',
made_in: r['원산지'] ?? '',
price: Number(r['상품 단가']) || 0,
minPrice: Number(r['최저한도']) || 0,
image_url: r['이미지URL'] ?? '',
moq: r['최소주문수량'] ?? '',
lead_time: Number(r['리드타임(일)']) || 0,
quantity_unit: r['단위'] ?? '',
delivery_type: r['배송형태'] ?? '',
vat_yn: r['부가세포함(Y/N)'] ?? '',
delivery_fee_yn: r['배송비포함(Y/N)'] ?? '',
}));
setExcelFile(file.name);
setRows(loaded);
setServerErrors({}); // 새 파일 → 직전 서버사유 초기화
// 업로드 즉시 DB 중복코드 사전검사 → 미리보기에서 바로 빨강 처리.
const codes = [...new Set(loaded.map((r) => r.code.trim()).filter(Boolean))];
if (codes.length === 0) return;
try {
const res = await customFetch<{ existing?: string[] }>({
url: '/v1/item/check-codes',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: { codes },
});
const dup: Record<string, string> = {};
(res.existing ?? []).forEach((c) => { dup[c] = '코드 중복 — 이미 등록된 상품코드입니다(DB).'; });
setServerErrors(dup);
} catch {
// 사전검사 호출 실패는 조용히 무시 — 제출 시 서버가 최종 차단한다.
}
};
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리)
const handleUpdateField = (id: string, field: 'name' | 'code' | 'price' | 'minPrice', value: string) => {
// 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 숫자 컬럼은 정수화.
const handleUpdateField = (id: string, field: keyof RawRow, value: string) => {
setRows((cur) =>
cur.map((row) => {
if (row.id !== id) return row;
if (field === 'price' || field === 'minPrice') {
if (NUMERIC_KEYS.has(field)) {
return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) };
}
return { ...row, [field]: value };
@ -135,15 +253,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
);
};
// 행 삭제 — 정상/오류 무관하게 미리보기에서 제거(서버 전송 전 단계).
const handleRemoveRow = (id: string) => {
setRows((cur) => cur.filter((row) => row.id !== id));
};
const handleConfirm = async () => {
if (validRows.length === 0) {
showToast('정상으로 분류된 행이 존재하지 않아 업로드가 불가합니다.', 'error');
return;
}
try {
await onConfirm(validRows.map(toItemCreate));
showToast(`총 ${validRows.length}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
close();
const failures = await onConfirm(validRows.map(toItemCreate));
const okCount = validRows.length - failures.length;
if (failures.length === 0) {
showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success');
close();
return;
}
// 부분 성공: 등록 성공한 행만 제거하고, 서버(DB)가 거부한 행은 사유와 함께 남긴다.
const failMap: Record<string, string> = {};
failures.forEach((f) => { failMap[f.code] = f.message; });
const okCodes = new Set(validRows.map((r) => r.code).filter((c) => failMap[c] === undefined));
setServerErrors(failMap);
setRows((cur) => cur.filter((r) => !okCodes.has(r.code)));
showToast(`${okCount}건 등록 완료 · ${failures.length}건 서버 검증 실패(중복코드 등)`, 'error');
} catch (err) {
showToast(err instanceof Error ? err.message : '엑셀 일괄 등록 실패', 'error');
}
@ -151,7 +285,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs">
<div className="w-full max-w-2xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
<div className="w-full max-w-5xl bg-card border border-border rounded-lg shadow-2xl p-6 overflow-hidden animate-scale-up font-mono">
{/* Modal Title */}
<div className="flex items-center justify-between pb-4 border-b border-border">
@ -237,57 +371,40 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
<div className="space-y-1.5">
<span className="text-xs font-bold text-foreground block">정합성 검출 미리보기</span>
<div className="border border-border rounded overflow-hidden max-h-60 overflow-y-auto">
<Table className="w-full text-left font-mono text-[11px] border-collapse bg-background">
<div className="border border-border rounded overflow-auto max-h-72">
<Table className="text-left font-mono text-[11px] border-collapse bg-background min-w-max">
<TableHeader className="bg-muted text-muted-foreground text-[10px] border-b border-border">
<TableRow>
<TableHead className="p-2 font-semibold">행(Row)</TableHead>
<TableHead className="p-2 font-semibold">상품명 *</TableHead>
<TableHead className="p-2 font-semibold">상품코드 *</TableHead>
<TableHead className="p-2 font-semibold text-right">상품 단가 (₩) *</TableHead>
<TableHead className="p-2 font-semibold text-right">최저한도 (₩) *</TableHead>
<TableHead className="p-2 font-semibold text-center">자격</TableHead>
<TableHead className="p-2 font-semibold">진단 내용 (직접 수정가능)</TableHead>
<TableHead className="p-2 font-semibold whitespace-nowrap">행</TableHead>
<TableHead className="p-2 font-semibold text-center w-12">삭제</TableHead>
<TableHead className="p-2 font-semibold text-center whitespace-nowrap">자격</TableHead>
<TableHead className="p-2 font-semibold whitespace-nowrap">진단 내용</TableHead>
{UPLOAD_COLUMNS.map((c) => (
<TableHead
key={c.key}
className={`p-2 font-semibold whitespace-nowrap ${NUMERIC_KEYS.has(c.key) ? 'text-right' : ''}`}
>
{c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{validated.map((row) => (
<TableRow key={row.id} className={row.status === '오류' ? 'bg-red-500/5 hover:bg-red-500/10' : 'bg-emerald-500/5 hover:bg-emerald-500/10'}>
<TableCell className="p-2 text-muted-foreground">{row.rowNum}</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground font-semibold"
value={row.name}
onChange={(e) => handleUpdateField(row.id, 'name', e.target.value)}
/>
</TableCell>
<TableCell className="p-2">
<Input
type="text"
className="bg-muted/20 hover:bg-muted/50 text-foreground font-mono"
value={row.code}
onChange={(e) => handleUpdateField(row.id, 'code', e.target.value)}
/>
</TableCell>
<TableCell className="p-2 text-right">
<Input
type="number"
className="w-24 bg-muted/20 hover:bg-muted/50 text-right font-mono"
value={row.price}
onChange={(e) => handleUpdateField(row.id, 'price', e.target.value)}
/>
</TableCell>
<TableCell className="p-2 text-right">
<Input
type="number"
className="w-24 bg-muted/20 hover:bg-muted/50 text-right font-mono"
value={row.minPrice}
onChange={(e) => handleUpdateField(row.id, 'minPrice', e.target.value)}
/>
<TableCell className="p-2 text-center">
<button
type="button"
onClick={() => handleRemoveRow(row.id)}
title="이 행 삭제"
className="p-1 rounded text-muted-foreground hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
>
<Trash2 size={14} />
</button>
</TableCell>
<TableCell className="p-2 text-center">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block ${
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold block whitespace-nowrap ${
row.status === '정상'
? 'bg-emerald-100 text-emerald-800 border border-emerald-300 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/80'
: 'bg-red-100 text-red-800 border border-red-300 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-950'
@ -295,9 +412,23 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
{row.status}
</span>
</TableCell>
<TableCell className={`p-2 font-mono text-[10px] ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
<TableCell className={`p-2 font-mono text-[10px] whitespace-nowrap ${row.status === '오류' ? 'text-rose-500' : 'text-emerald-600'}`}>
{row.message}
</TableCell>
{UPLOAD_COLUMNS.map((c) => {
const isNum = NUMERIC_KEYS.has(c.key);
return (
<TableCell key={c.key} className={`p-2 ${isNum ? 'text-right' : ''}`}>
<Input
type={isNum ? 'number' : 'text'}
placeholder={c.header}
className={`bg-muted/20 hover:bg-muted/50 ${isNum ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`}
value={String(row[c.key] ?? '')}
onChange={(e) => handleUpdateField(row.id, c.key, e.target.value)}
/>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
@ -328,7 +459,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
onClick={handleConfirm}
className="py-1.5 px-4 bg-primary text-primary-foreground font-bold rounded hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer text-xs"
>
적격 상품(총 {validCount}개) 최종 전송
적격 상품(총 {validCount}개) 등록
</button>
</div>
</div>

View File

@ -10,13 +10,14 @@ import { Typography } from '@/components/ui/typography';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Sheet } from '@/components/ui/sheet';
import { type Product, categoriesList, toMinPrice } from '../types';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type Product, toMinPrice } from '../types';
// 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택.
const schema = z.object({
name: z.string().trim().min(1, '상품명을 작성하여 주십시오.'),
code: z.string().trim().min(1, '상품코드를 입력해 주십시오.'),
category: z.string(),
category: z.string().trim().min(1, '분류 카테고리를 선택하거나 새로 입력해 주십시오.'),
price: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '단가는 0 이상이어야 합니다.'),
minPrice: z.number({ message: '숫자를 입력해 주십시오.' }).min(0, '최저가는 0 이상이어야 합니다.'),
modelName: z.string(),
@ -39,6 +40,9 @@ type ProductFormSheetProps = {
open: boolean;
mode: 'create' | 'edit';
product: Product | null; // edit 모드일 때 초기값 출처
categories: string[]; // 서버 items 에서 distinct 로 뽑은 카테고리 목록(선택지)
categoryTypeByName: Record<string, number>; // 카테고리명 → category_type(id) 매핑
nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1)
onCreate: (data: ItemCreate) => Promise<void>;
onUpdate: (itemId: string, data: ItemUpdate) => Promise<void>;
onDelete: (itemId: string, name: string) => void;
@ -51,7 +55,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa
return {
name: product.name ?? '',
code: product.code || '',
category: product.category || '에너지/배터리',
category: product.category || '',
price: product.price || 0,
minPrice: toMinPrice(product.price),
modelName: product.model_name || '',
@ -71,7 +75,7 @@ function buildDefaults(mode: 'create' | 'edit', product: Product | null): FormVa
return {
name: '',
code: `PROD-BAT-${Math.floor(100 + Math.random() * 900)}`,
category: '에너지/배터리',
category: '',
price: 1000000,
minPrice: 800000,
modelName: '',
@ -98,6 +102,9 @@ export function ProductFormSheet({
open,
mode,
product,
categories,
categoryTypeByName,
nextCategoryType,
onCreate,
onUpdate,
onDelete,
@ -119,10 +126,14 @@ export function ProductFormSheet({
// minPrice는 화면 전용(서버 미전송). 검증된 값만 payload로.
const onValid = async (v: FormValues) => {
// 기존 카테고리면 그 category_type(id) 재사용, 처음 쓰는 카테고리면 max+1 부여.
const categoryName = v.category.trim();
const category_type = categoryTypeByName[categoryName] ?? nextCategoryType;
const common = {
name: v.name,
code: v.code,
category: v.category,
category: categoryName,
category_type,
price: v.price,
model_name: v.modelName,
spec: v.specification,
@ -194,18 +205,22 @@ export function ProductFormSheet({
/>
{errors.code && <p className="text-[10px] text-rose-500">{errors.code.message}</p>}
</div>
{/* Category */}
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
<div className="space-y-1">
<Typography as="label" variant="label">분류 카테고리</Typography>
<select
<Input
id="form-product-category"
list="form-product-category-options"
{...register('category')}
className={`${inputClass} cursor-pointer`}
>
{categoriesList.filter((c) => c !== 'ALL').map((c) => (
<option key={c} value={c}>{c}</option>
className={inputClass}
placeholder="기존 카테고리 선택 또는 새 카테고리 입력"
/>
<datalist id="form-product-category-options">
{categories.map((c) => (
<option key={c} value={c} />
))}
</select>
</datalist>
{errors.category && <p className="text-[10px] text-rose-500">{errors.category.message}</p>}
</div>
</div>
@ -319,16 +334,18 @@ export function ProductFormSheet({
control={control}
name="shippingType"
render={({ field }) => (
<select
id="form-product-delivery-type"
className={`${inputClass} cursor-pointer`}
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
>
{deliveryTypes.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="form-product-delivery-type" className="w-full">
<SelectValue>
{(value) => deliveryTypes.find((d) => d.value === value)?.label ?? ''}
</SelectValue>
</SelectTrigger>
<SelectContent>
{deliveryTypes.map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.shippingType && <p className="text-[10px] text-rose-500">{errors.shippingType.message}</p>}

View File

@ -1,47 +0,0 @@
import { useState } from 'react';
import type { Product } from '../types';
const ITEMS_PER_PAGE = 5;
// 상품 목록에 대한 검색/카테고리/페이지네이션 UI state + 파생 결과.
// 검색·카테고리 변경 시 1페이지로 리셋한다.
export function useProductFilters(products: Product[]) {
const [search, setSearchRaw] = useState('');
const [categoryFilter, setCategoryFilterRaw] = useState('ALL');
const [page, setPage] = useState(1);
const setSearch = (v: string) => {
setSearchRaw(v);
setPage(1);
};
const setCategoryFilter = (v: string) => {
setCategoryFilterRaw(v);
setPage(1);
};
const filtered = products.filter((prod) => {
if (prod.deleted) return false; // soft-deleted 제외
const q = search.toLowerCase();
const matchesSearch =
(prod.name ?? '').toLowerCase().includes(q) ||
(prod.code ? prod.code.toLowerCase().includes(q) : false);
const matchesCategory = categoryFilter === 'ALL' || prod.category === categoryFilter;
return matchesSearch && matchesCategory;
});
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE) || 1;
const paginated = filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE);
return {
search,
setSearch,
categoryFilter,
setCategoryFilter,
page,
setPage,
paginated,
totalPages,
totalCount: filtered.length,
itemsPerPage: ITEMS_PER_PAGE,
};
}

View File

@ -1,28 +1,61 @@
import { useQueryClient } from '@tanstack/react-query';
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
import {
useListItems,
useListItemCategories,
createItem,
updateItem,
deleteItem,
getListItemsQueryKey,
} from '@/api/generated/item/item';
import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
import type { ReqCreateItem } from '@/api/generated/model/reqCreateItem';
import type { ReqUpdateItem } from '@/api/generated/model/reqUpdateItem';
import type { ResItem } from '@/api/generated/model/resItem';
import type { ItemData } from '@/api/generated/model/itemData';
import type { BulkFailure } from '@/lib/excel';
import { type Product, toMinPrice } from '../types';
const LIST_PARAMS = { size: 100 };
// 엑셀 중복검사 + 최저가 모달은 "현재 페이지 밖"의 상품도 코드/ID로 조회해야 해서
// 전체 목록(최대 100건)을 따로 받는다. (카테고리 목록은 더 이상 여기서 만들지 않는다 — 아래 categoriesQuery.)
const META_PARAMS: ListItemsParams = { size: 100 };
// 상품 서버 데이터 + CRUD. orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회).
// 실패 시 throw → 호출부에서 toast 처리.
export function useProducts() {
// 서버 ItemData → UI Product (화면 전용 파생 필드 부여).
function toProduct(it: ItemData): Product {
return { ...it, id: it.item_id, minPrice: toMinPrice(it.price), status: 'ACTIVE' };
}
// 서버 공통응답(result.success=false)을 한글 사유로 변환. 정상이면 null.
// (HTTP 4xx 는 customFetch 가 throw, 앱레벨 거부는 200+result 로 오므로 여기서 본다.)
function itemError(res: ResItem): string | null {
const r = res.result;
if (!r || r.success !== false) return null;
if (r.desc === 'ITEM_CODE_DUPLICATE') return '코드 중복 — 이미 등록된 상품코드입니다(DB 검증).';
return r.desc || '상품 등록에 실패했습니다.';
}
// 상품 서버 데이터 + CRUD.
// - params: 테이블용 서버 페이지네이션/검색/카테고리 (useServerList 가 만든다)
// - 페이지 이동 시 placeholderData 로 이전 데이터 유지(빈 화면 깜빡임 방지)
// orval 뮤테이션 호출 후 목록 쿼리 무효화(재조회). 실패 시 throw → 호출부에서 toast 처리.
export function useProducts(params: ListItemsParams) {
const queryClient = useQueryClient();
const itemsQuery = useListItems(LIST_PARAMS);
// 테이블용(현재 페이지)
const itemsQuery = useListItems(params, { query: { placeholderData: keepPreviousData } });
// 모달(엑셀 중복검사·최저가)이 참조하는 전체 목록
const metaQuery = useListItems(META_PARAMS);
// 카테고리 목록 — 백엔드가 전체 상품에서 distinct 로 돌려준다(프론트가 상품을 긁지 않는다).
const categoriesQuery = useListItemCategories();
// 변경 후 목록(/list·메타) + 카테고리(/categories) 둘 다 재조회.
const refresh = () =>
queryClient.invalidateQueries({ queryKey: getListItemsQueryKey(LIST_PARAMS) });
Promise.all([
queryClient.invalidateQueries({ queryKey: ['/v1/item/list'] }),
queryClient.invalidateQueries({ queryKey: ['/v1/item/categories'] }),
]);
const createProduct = async (data: ReqCreateItem) => {
await createItem(data);
const msg = itemError(await createItem(data));
if (msg) throw new Error(msg);
await refresh();
};
const updateProduct = async (itemId: string, data: ReqUpdateItem) => {
@ -33,23 +66,54 @@ export function useProducts() {
await deleteItem(itemId);
await refresh();
};
// 엑셀 일괄 등록 — 검증된 행들을 순차 생성 후 한 번만 재조회.
const bulkCreate = async (rows: ReqCreateItem[]) => {
// 엑셀 일괄 등록 — 행별로 순차 생성하되 실패해도 멈추지 않고 사유를 모은다.
// 서버 DB 검증(중복코드 등)에 걸린 행은 BulkFailure 로 반환 → 모달이 해당 행만 사유와 함께 남긴다.
const bulkCreate = async (rows: ReqCreateItem[]): Promise<BulkFailure[]> => {
const failures: BulkFailure[] = [];
for (const row of rows) {
await createItem(row);
try {
const msg = itemError(await createItem(row));
if (msg) failures.push({ code: row.code ?? '', message: msg });
} catch (err) {
failures.push({ code: row.code ?? '', message: err instanceof Error ? err.message : '등록 실패' });
}
}
await refresh();
return failures;
};
// 서버 ItemData → UI Product (화면 전용 파생 필드 부여).
// customFetch 가 본문을 그대로 주므로 itemsQuery.data 가 곧 ResItemList → .items.
const items = itemsQuery.data?.items ?? [];
const products: Product[] = items.map((it) => ({
...it,
id: it.item_id,
minPrice: toMinPrice(it.price),
status: 'ACTIVE',
}));
// 테이블(현재 페이지) 상품 + 서버 전체 건수.
const products: Product[] = (itemsQuery.data?.items ?? []).map(toProduct);
const total = itemsQuery.data?.total ?? 0;
return { products, createProduct, updateProduct, deleteProduct, bulkCreate, refresh, itemsQuery };
// 전체(메타) 상품 — 엑셀 중복검사/최저가 모달이 코드·id 로 조회한다(현재 페이지에 없을 수 있어 전체 기준).
const allProducts: Product[] = (metaQuery.data?.items ?? []).map(toProduct);
// 카테고리: 백엔드 distinct 결과를 그대로 쓴다(프론트가 상품을 긁어 만들지 않는다).
// category_type(카테고리 id)은 이름→코드로 보존하고, 신규 카테고리는 max+1 을 부여한다(폼에서 사용).
const categoryTypeByName: Record<string, number> = {};
let maxCategoryType = 0;
for (const c of categoriesQuery.data?.categories ?? []) {
const type = c.category_type ?? 1;
if (type > maxCategoryType) maxCategoryType = type;
const name = c.name.trim();
if (name && !(name in categoryTypeByName)) categoryTypeByName[name] = type;
}
const categories = Object.keys(categoryTypeByName).sort((a, b) => a.localeCompare(b, 'ko'));
const nextCategoryType = maxCategoryType + 1;
return {
products,
total,
allProducts,
categories,
categoryTypeByName,
nextCategoryType,
createProduct,
updateProduct,
deleteProduct,
bulkCreate,
refresh,
isLoading: itemsQuery.isLoading,
};
}

View File

@ -8,8 +8,7 @@ export type Product = ItemData & {
status?: string;
};
// 분류 카테고리. 'ALL'은 필터 전용(폼에서는 제외).
export const categoriesList = ['ALL', '에너지/배터리', '자동화설비', '반도체소자/센서', '신소재', '광학/통신'];
// 분류 카테고리는 서버 items 에서 distinct 로 파생한다(useProducts). 하드코딩 상수 제거됨.
// 인터넷 최저가 데모 산정(표준 단가의 83%). 서버 미연동 — 표시/초기값 용도.
export const toMinPrice = (price?: number | null) => Math.round((price || 0) * 0.83);

View File

@ -3,6 +3,7 @@ import { X, PlusSquare, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { Product, Partner, QuotationSetting, NegotiationCard } from '../types';
import type { CreateQuotationInput } from '../hooks/useQuotations';
@ -102,15 +103,20 @@ export function CreateQuotationWizard({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Typography as="label" variant="label">유형</Typography>
<select
id="wizard-type"
className="w-full p-2 bg-background border border-border rounded text-xs cursor-pointer"
<Select
value={type}
onChange={(e) => setType(e.target.value as 'RE_NEGOTIATION' | 'RE_ESTIMATE')}
onValueChange={(v) => setType(v as 'RE_NEGOTIATION' | 'RE_ESTIMATE')}
>
<option value="RE_NEGOTIATION">재협상</option>
<option value="RE_ESTIMATE">재견적</option>
</select>
<SelectTrigger id="wizard-type" className="w-full">
<SelectValue>
{(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="RE_NEGOTIATION">재협상</SelectItem>
<SelectItem value="RE_ESTIMATE">재견적</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
@ -127,19 +133,25 @@ export function CreateQuotationWizard({
<div className="space-y-1">
<Typography as="label" variant="label">상품</Typography>
<select
id="wizard-product"
className="w-full p-2 bg-background border border-border rounded text-xs cursor-pointer"
value={productId}
onChange={(e) => setProductId(e.target.value)}
>
<option value="">협상 대상 상품을 고르세요...</option>
{products.filter((p) => p.status === 'ACTIVE').map((p) => (
<option key={p.id} value={p.id}>
{p.name} [{p.code}] (기준가: ₩{(p.price ?? 0).toLocaleString()})
</option>
))}
</select>
<Select value={productId} onValueChange={setProductId}>
<SelectTrigger id="wizard-product" className="w-full">
<SelectValue>
{(value) => {
const p = products.find((pp) => pp.id === value);
return p
? `${p.name} [${p.code}] (기준가: ₩${(p.price ?? 0).toLocaleString()})`
: '협상 대상 상품을 고르세요...';
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{products.filter((p) => p.status === 'ACTIVE').map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} [{p.code}] (기준가: ₩{(p.price ?? 0).toLocaleString()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
@ -185,18 +197,25 @@ export function CreateQuotationWizard({
<div className="space-y-4">
<div className="space-y-1 font-mono text-xs">
<Typography as="label" variant="label">적용할 견적 세팅 지정</Typography>
<select
id="wizard-setting-select"
className="w-full p-2 bg-background border border-border rounded text-xs focus:outline-none cursor-pointer"
value={settingId}
onChange={(e) => setSettingId(e.target.value)}
>
{quotationSettings.map((qs) => (
<option key={qs.qt_setting_id} value={qs.qt_setting_id}>
[목표 마진: {qs.target_margin}] {qs.anchoring_value} ({qs.card_use_count})
</option>
))}
</select>
<Select value={settingId} onValueChange={setSettingId}>
<SelectTrigger id="wizard-setting-select" className="w-full">
<SelectValue>
{(value) => {
const qs = quotationSettings.find((s) => s.qt_setting_id === value);
return qs
? `[목표 마진: ${qs.target_margin}] ${qs.anchoring_value} (${qs.card_use_count})`
: '';
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{quotationSettings.map((qs) => (
<SelectItem key={qs.qt_setting_id} value={qs.qt_setting_id}>
[목표 마진: {qs.target_margin}] {qs.anchoring_value} ({qs.card_use_count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">

View File

@ -6,22 +6,25 @@ import {
MessageSquare,
Layers,
Info,
Sparkles,
} from 'lucide-react';
import SlateRenderer from '@/components/SlateRenderer';
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Typography } from '@/components/ui/typography';
import { Input } from '@/components/ui/input';
import {
useGetQuotationSessions,
useGetSessionChat,
useGetQuotationCards,
} from '@/api/generated/quotation/quotation';
import {
type Estimate,
type Product,
type Partner,
type QuotationSetting,
type ChatSession,
type NegotiationCard,
normalizeQuotationStatus,
buildBidSummary,
buildSessions,
buildQuotationCards,
mapServerSessionView,
mapServerCardView,
} from '../types';
type DrawerTab = 'status' | 'cards' | 'chat';
@ -30,9 +33,7 @@ type QuotationDetailDrawerProps = {
estimate: Estimate;
products: Product[];
partners: Partner[];
cards: NegotiationCard[];
quotationSettings: QuotationSetting[];
sessions: ChatSession[];
onStop: (id: string, name: string) => void;
onClose: () => void;
};
@ -41,25 +42,36 @@ export function QuotationDetailDrawer({
estimate,
products,
partners,
cards,
quotationSettings,
sessions,
onStop,
onClose,
}: QuotationDetailDrawerProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('status');
const [selectedChatPartnerId, setSelectedChatPartnerId] = useState<string | null>(
sessions[0]?.id ?? null,
);
const [showHeaderCards, setShowHeaderCards] = useState(true);
const activeProduct = products.find((p) => p.id === estimate.productId) ?? null;
const currentChatSession = sessions.find((s) => s.id === selectedChatPartnerId) || sessions[0];
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 selectedSettingObj = quotationSettings.find((qs) => qs.qt_setting_id === estimate.settingApplied);
const bidSummaryObj = buildBidSummary(estimate, partners);
const sessionViews = buildSessions(estimate, sessions, partners, products);
const quotationCardViews = buildQuotationCards(estimate, cards);
const sessionViews = serverSessions.map((sd) => mapServerSessionView(sd, partners, products));
const quotationCardViews = serverCards.map(mapServerCardView);
// Quotations DDL 표시값
const q_name = estimate.name || estimate.title || '미지정';
@ -73,8 +85,8 @@ export function QuotationDetailDrawer({
const q_memo = estimate.memo || '안내사항 없음';
const statusKey = normalizeQuotationStatus(q_status);
const goToChat = (partnerId: string) => {
setSelectedChatPartnerId(partnerId);
const goToChat = (sessionId: string) => {
setSelectedSessionId(sessionId);
setActiveTab('chat');
};
@ -107,7 +119,7 @@ export function QuotationDetailDrawer({
>
<span>{showHeaderCards ? '데이터베이스 매핑 정보 접기 ▲' : '데이터베이스 매핑 정보 펼치기 ▼'}</span>
</button>
{estimate.status === 'ACTIVE' && (
{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"
@ -287,8 +299,8 @@ export function QuotationDetailDrawer({
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
if (tab.id === 'chat' && sessions.length > 0 && !selectedChatPartnerId) {
setSelectedChatPartnerId(sessions[0].id);
if (tab.id === 'chat' && 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 ${
@ -329,6 +341,13 @@ export function QuotationDetailDrawer({
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border">
{sessionViews.length === 0 && (
<TableRow>
<TableCell colSpan={11} 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>
@ -336,7 +355,7 @@ export function QuotationDetailDrawer({
<div className="flex items-center gap-2">
<span>{sess.supplier_name}</span>
<button
onClick={() => goToChat(sess.supplier_id)}
onClick={() => goToChat(sess.session_id)}
title="협상 대화방으로 이동"
className="p-1 hover:bg-primary/10 rounded text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
@ -416,7 +435,7 @@ export function QuotationDetailDrawer({
) : (
<TableRow>
<TableCell colSpan={3} className="p-12 text-center text-muted-foreground">
해당 견적에 사용할 수 있는 카드가 매핑되지 않습니다.
사용된 협상 카드가 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
@ -430,38 +449,47 @@ export function QuotationDetailDrawer({
{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 justify-between">
<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">
{sessions.map((sess) => {
const isSelected = sess.id === selectedChatPartnerId;
{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 = sd.status === 2 ? '협상종료' : sd.status === 3 ? '협상거부' : '협상중';
return (
<button
key={sess.id}
onClick={() => setSelectedChatPartnerId(sess.id)}
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">{sess.partnerName}</span>
<span className="font-bold text-foreground text-xs">{name}</span>
<span
className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
sess.status === 'REJECTED' || sess.status === '협상거부'
statusLabel === '협상거부'
? 'bg-rose-100 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300'
: sess.status === 'COMPLETED' || sess.status === '협상종료'
: 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'
}`}
>
{sess.status}
{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">₩{sess.currentBid.toLocaleString()}</span>
<span className="font-bold text-foreground">
{sd.bid_price ? `₩${Number(sd.bid_price).toLocaleString()}` : '-'}
</span>
</div>
</button>
);
@ -473,32 +501,31 @@ export function QuotationDetailDrawer({
<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">{currentChatSession?.partnerName}</strong>
채널: <strong className="text-foreground">{currentSupplierName}</strong>
</div>
<div className="text-xs text-muted-foreground">
기록: <span className="font-semibold text-foreground">{currentChatSession?.messages.length}</span> 세션전화
기록: <span className="font-semibold text-foreground">{chatMessages.length}</span> 메시지
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4">
{currentChatSession ? (
currentChatSession.messages.map((msg) => {
const isBot = msg.sender === 'BOT';
const isSystem = msg.sender === 'SYSTEM';
if (isSystem) {
return (
<div key={msg.id} className="flex justify-center my-3">
<span className="px-3 py-1 bg-muted border border-border text-[10px] text-muted-foreground rounded font-mono uppercase tracking-wider">
{msg.content}
</span>
</div>
);
}
{!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;
const cardName = m.card_used_yn
? serverCards.find((c) => c.session_card_id === m.chat_id)?.name
: null;
return (
<div
key={msg.id}
key={m.chat_id}
className={`flex items-start gap-2.5 max-w-[85%] ${isBot ? 'mr-auto' : 'ml-auto flex-row-reverse'}`}
>
<div
@ -511,9 +538,9 @@ export function QuotationDetailDrawer({
<div className="space-y-1">
<div className={`text-[10px] text-muted-foreground font-mono flex items-center gap-1.5 ${isBot ? '' : 'justify-end'}`}>
<span>{isBot ? 'Negosium AI Bot' : currentChatSession.partnerName}</span>
<span>{isBot ? 'Negosium AI Bot' : currentSupplierName}</span>
<span>·</span>
<span>{msg.timestamp}</span>
<span>#{m.index}</span>
</div>
<div
@ -521,27 +548,21 @@ export function QuotationDetailDrawer({
isBot ? 'bg-secondary border-border text-foreground' : 'bg-primary border-transparent text-primary-foreground'
}`}
>
{msg.editorScript ? (
<SlateRenderer
nodes={msg.editorScript}
variables={{
partner_name: currentChatSession.partnerName,
target_price: activeProduct ? Math.round((activeProduct.price ?? 0) * 0.92) : 1000000,
product_name: activeProduct?.name ?? '해당 배터리 조달품',
}}
/>
) : (
<Typography variant="body" className="whitespace-pre-line leading-relaxed">{msg.content}</Typography>
<div className="font-bold">제시 단가 ₩{Number(m.target_price).toLocaleString()}</div>
{cardName && (
<div
className={`mt-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold ${
isBot ? 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300' : 'bg-white/20'
}`}
>
<Sparkles size={10} /> 협상카드: {cardName}
</div>
)}
</div>
</div>
</div>
);
})
) : (
<div className="h-full flex items-center justify-center text-muted-foreground font-mono text-xs">
선택된 대화 채널 데이터가 기록되지 않았습니다.
</div>
)}
</div>

View File

@ -61,12 +61,19 @@ export function QuotationSettingsModal({
<TableHeader className="bg-muted text-muted-foreground">
<TableRow>
<TableHead className="p-2">목표 마진율</TableHead>
<TableHead className="p-2">앵커링 설정 방식</TableHead>
<TableHead className="p-2">앵커링 값</TableHead>
<TableHead className="p-2">카드 사용 횟수</TableHead>
<TableHead className="p-2 text-center w-12">삭제</TableHead>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-border bg-background">
{settings.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="p-6 text-center text-muted-foreground">
등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다)
</TableCell>
</TableRow>
)}
{settings.map((qs) => (
<TableRow key={qs.qt_setting_id} className="hover:bg-muted/30">
<TableCell className="p-2 font-bold text-primary">{qs.target_margin}</TableCell>
@ -95,15 +102,15 @@ export function QuotationSettingsModal({
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">목표 마진율 (%)</Typography>
<Input type="text" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12%" />
<Input type="number" step="0.1" value={targetMargin} onChange={(e) => setTargetMargin(e.target.value)} placeholder="예: 12" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링 설정 방식</Typography>
<Input type="text" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 최초 즉각 앵커링" />
<Typography as="label" variant="muted" className="text-[10px] font-semibold">앵커링 값</Typography>
<Input type="number" step="0.01" value={anchoringValue} onChange={(e) => setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
</div>
<div className="space-y-1">
<Typography as="label" variant="muted" className="text-[10px] font-semibold">카드 사용 횟수</Typography>
<Input type="text" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3회 제한" />
<Input type="number" step="1" value={cardUseCount} onChange={(e) => setCardUseCount(e.target.value)} placeholder="예: 3" />
</div>
</div>

View File

@ -118,7 +118,7 @@ export function QuotationTable({ data, products, onOpenDetail, onStop }: Quotati
<span>상세 정보</span>
</button>
{est.status === 'ACTIVE' && (
{normalizeQuotationStatus(est.status) === '견적진행중' && (
<button
onClick={(e) => {
e.stopPropagation();

View File

@ -1,14 +1,23 @@
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useListItems } from '@/api/generated/item/item';
import { useListSuppliers } from '@/api/generated/supplier/supplier';
import { useListSettings } from '@/api/generated/quotation-setting/quotation-setting';
import { useListCards } from '@/api/generated/card/card';
import { mapCardData } from '@/features/cards/types';
import {
useListSettings,
useCreateSetting,
useDeleteSetting,
getListSettingsQueryKey,
} from '@/api/generated/quotation-setting/quotation-setting';
import { useListQuotations } from '@/api/generated/quotation/quotation';
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 type { CardData } from '@/api/generated/model/cardData';
import { showToast } from '@/lib/notify';
import type { Estimate, QuotationSetting, ChatSession, NegotiationCard, Product } from '@/types';
import type { Estimate, ChatSession, Product } from '@/types';
import { unwrap, mapItem, mapSupplier, mapSetting, mapQuotation } from '../types';
export type CreateQuotationInput = {
@ -31,19 +40,22 @@ export type SettingInput = {
// 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다.
// (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태)
export function useQuotations() {
const queryClient = useQueryClient();
const itemsQuery = useListItems({ size: 100 });
const suppliersQuery = useListSuppliers({ size: 100 });
const cardsQuery = useListCards({ size: 100 });
const settingsQuery = useListSettings();
const quotationsQuery = useListQuotations(undefined);
const createSettingMutation = useCreateSetting();
const deleteSettingMutation = useDeleteSetting();
const products = (unwrap<{ items?: ItemData[] }>(itemsQuery.data)?.items ?? []).map(mapItem);
const partners = (unwrap<{ suppliers?: SupplierData[] }>(suppliersQuery.data)?.suppliers ?? []).map(mapSupplier);
const [quotationSettings, setQuotationSettings] = useState<QuotationSetting[]>([]);
useEffect(() => {
const s = unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings;
if (s) setQuotationSettings(s.map(mapSetting));
}, [settingsQuery.data]);
// 견적 세팅은 서버가 정본 — 목록 쿼리에서 바로 파생하고, 추가/삭제 후 쿼리를 무효화해 재조회한다.
const quotationSettings = (
unwrap<{ settings?: QuotationSettingData[] }>(settingsQuery.data)?.settings ?? []
).map(mapSetting);
const [quotations, setQuotations] = useState<Estimate[]>([]);
useEffect(() => {
@ -51,30 +63,10 @@ export function useQuotations() {
if (qs) setQuotations(qs.map(mapQuotation));
}, [quotationsQuery.data]);
// 협상카드 카탈로그/채팅은 아직 백엔드 미연동 → 빈 상태.
const [cards] = useState<NegotiationCard[]>([]);
// 협상카드 카탈로그는 서버(orval)에서 읽어 단계 3/3 카드 선택지로 쓴다. 채팅은 아직 미연동.
const cards = (unwrap<{ cards?: CardData[] }>(cardsQuery.data)?.cards ?? []).map(mapCardData);
const [chatSessions, setChatSessions] = useState<Record<string, ChatSession[]>>({});
// '견적생성(PROCESSING)' 견적을 3초 후 '견적진행중'으로 전이시키는 폴링 시뮬레이션.
useEffect(() => {
const processingItem = quotations.find(
(est) => est.status === 'PROCESSING' || est.status === '견적생성',
);
if (!processingItem) return;
const timer = setTimeout(() => {
setQuotations((prev) =>
prev.map((est) => {
if (est.status === 'PROCESSING' || est.status === '견적생성') {
showToast(`[${est.title}] 견적 절차가 개시되었습니다. 파트너 입찰 채널이 활성화되었습니다.`, 'success');
return { ...est, status: '견적진행중' };
}
return est;
}),
);
}, 3000);
return () => clearTimeout(timer);
}, [quotations]);
// 협상 강제중단 → '협상보류'.
const stopNegotiation = (id: string, name: string) => {
if (!confirm(`현재 입찰 중인 [${name}] 단가 협상 절차를 즉시 조기 중단(강제종료)하시겠습니까?`)) return;
@ -82,36 +74,49 @@ export function useQuotations() {
showToast("해당 협상이 관리자에 의하여 '협상보류' 상태로 지정되었습니다.", 'info');
};
// 견적 세팅 추가. 검증 실패 시 toast 후 false.
const invalidateSettings = () =>
queryClient.invalidateQueries({ queryKey: getListSettingsQueryKey() });
// 견적 세팅 추가 — 서버 등록. 입력은 목표 마진율(%) / 앵커링 값 / 카드 사용 횟수(정수).
// 백엔드는 target_margin_rate 를 비율(0.12)로 저장하므로 % 입력을 100 으로 나눠 보낸다.
const addSetting = (input: SettingInput): boolean => {
if (!input.targetMargin.trim() || !input.anchoringValue.trim() || !input.cardUseCount.trim()) {
showToast('모든 세팅 항목을 입력해야 합니다.', 'error');
const marginPct = Number(String(input.targetMargin).replace('%', '').trim());
const anchoring = Number(String(input.anchoringValue).trim());
const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10);
if (!Number.isFinite(marginPct) || !Number.isFinite(anchoring) || !Number.isInteger(cardCount)) {
showToast('목표 마진율·앵커링 값·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error');
return false;
}
const newSetting: QuotationSetting = {
qt_setting_id: `set-${Date.now()}`,
user_id: 'user-777-uuid',
target_margin: input.targetMargin.includes('%') ? input.targetMargin : `${input.targetMargin}%`,
anchoring_value: input.anchoringValue,
card_use_count: input.cardUseCount.includes('회') ? input.cardUseCount : `${input.cardUseCount}회`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
};
setQuotationSettings((prev) => [...prev, newSetting]);
showToast('새 견적 세팅 템플릿이 추가되었습니다.', 'success');
createSettingMutation.mutate(
{ data: { target_margin_rate: marginPct / 100, anchoring_value: anchoring, card_count: cardCount } },
{
onSuccess: () => {
invalidateSettings();
showToast('새 견적 세팅이 등록되었습니다.', 'success');
},
onError: () => showToast('견적 세팅 등록에 실패했습니다.', 'error'),
},
);
return true;
};
// 견적 세팅 삭제(최소 1개 유지).
// 견적 세팅 삭제(서버 soft-delete, 최소 1개 유지).
const deleteSetting = (id: string) => {
if (quotationSettings.length <= 1) {
showToast('최소 한 개의 세팅은 유지되어야 합니다.', 'error');
return;
}
if (!confirm('선택한 견적 세팅을 삭제하시겠습니까?')) return;
setQuotationSettings((prev) => prev.filter((qs) => qs.qt_setting_id !== id));
showToast('세팅이 삭제되었습니다.', 'info');
deleteSettingMutation.mutate(
{ qtSettingId: id },
{
onSuccess: () => {
invalidateSettings();
showToast('세팅이 삭제되었습니다.', 'info');
},
onError: () => showToast('세팅 삭제에 실패했습니다.', 'error'),
},
);
};
// 신규 견적 발의 — 견적 레코드 + 협력사별 채팅 세션을 생성. 검증 실패 시 toast 후 false.
@ -157,7 +162,7 @@ export function useQuotations() {
setChatSessions((prev) => ({ ...prev, [estId]: generatedSessions }));
setQuotations((prev) => [newQuotationRecord, ...prev]);
showToast(`신규 견정 제안서[${input.title}]가 발행되었습니다. 협상 세션 수립 중... (폴링 가동)`, 'info');
showToast(`신규 견적 제안서[${input.title}]가 발행되었습니다.`, 'info');
return true;
};

View File

@ -2,6 +2,8 @@ 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 type { SessionData } from '@/api/generated/model/sessionData';
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
import type {
Product,
Partner,
@ -45,12 +47,14 @@ export function mapSupplier(sp: SupplierData): Partner {
}
export function mapSetting(s: QuotationSettingData): QuotationSetting {
// 서버는 target_margin_rate 를 비율(0.12)로 저장 → UI 는 퍼센트(12%)로 표기.
const ratePct = Number(s.target_margin_rate ?? 0) * 100;
return {
qt_setting_id: s.qt_setting_id,
user_id: s.user_id || '',
target_margin: String(s.target_margin_rate ?? ''),
target_margin: `${Number.isFinite(ratePct) ? +ratePct.toFixed(2) : 0}%`,
anchoring_value: String(s.anchoring_value ?? ''),
card_use_count: String(s.card_count ?? ''),
card_use_count: `${s.card_count ?? 0}회`,
created_at: s.created_at ?? '',
updated_at: s.updated_at ?? '',
deleted: false,
@ -62,14 +66,27 @@ export function mapQuotation(q: QuotationData): Estimate {
...(q as unknown as Partial<Estimate>),
id: q.qt_id,
title: q.name,
dueDate: q.end_time,
winnerPartnerId: q.preferred_sp_id ?? null,
type: normalizeQuotationType(q.type),
status: normalizeQuotationStatus(q.status) || String(q.status ?? ''),
settingApplied: q.qt_setting_id, // 드로어 견적세팅 카드가 qt_setting_id 로 매칭
dueDate: formatDueDate(q.end_time),
participationCount: 0,
winnerPartnerId: q.preferred_sp_id ?? q.preferred_sp_name ?? null,
isEqualPrice: !!q.equal_bid_yn,
partnerIds: [],
usedCardIds: [],
};
}
// 서버 end_time(ISO) → 'YYYY-MM-DD HH:mm' 표기. 파싱 실패 시 원본 유지.
function formatDueDate(end?: string | null): string {
if (!end) return '미지정';
const d = new Date(end);
if (Number.isNaN(d.getTime())) return end;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ── 견적상태 정규화(영문 enum / 한글 DDL 혼용 대응) ──────────────────────
export type QtStatusKey = '견적생성' | '견적진행중' | '견적마감' | '협상보류';
@ -81,17 +98,22 @@ export const QUOTATION_STATUS_FILTERS: QtStatusKey[] = [
'협상보류',
];
export function normalizeQuotationStatus(status?: string | null): QtStatusKey | '' {
// QuotationStatus 코드(SMALLINT) ↔ 한글 상태키. 영문 enum/한글 DDL/숫자 코드 혼용을 모두 흡수.
export function normalizeQuotationStatus(status?: string | number | null): QtStatusKey | '' {
switch (status) {
case 1:
case 'PROCESSING':
case '견적생성':
return '견적생성';
case 2:
case 'ACTIVE':
case '견적진행중':
return '견적진행중';
case 3:
case 'COMPLETED':
case '견적마감':
return '견적마감';
case 4:
case 'STOPPED':
case '협상보류':
return '협상보류';
@ -100,6 +122,13 @@ export function normalizeQuotationStatus(status?: string | null): QtStatusKey |
}
}
// QuotationType 코드(1=재협상, 2=재견적) ↔ UI 유형값. 이미 문자열이면 그대로 통과.
export function normalizeQuotationType(type?: string | number | null): 'RE_NEGOTIATION' | 'RE_ESTIMATE' {
if (type === 1 || type === 'RE_NEGOTIATION') return 'RE_NEGOTIATION';
if (type === 2 || type === 'RE_ESTIMATE') return 'RE_ESTIMATE';
return type === '재협상' ? 'RE_NEGOTIATION' : 'RE_ESTIMATE';
}
// ── 상세 드로어용 파생 뷰 모델(서버 미연동 영역의 목업 보강 포함) ────────
export type BidSummaryView = {
@ -223,6 +252,54 @@ export function buildSessions(
});
}
// ── 서버 연동 매퍼(negotiation.sessions / chats / 사용 카드) ──────────────
const SESSION_STATUS_LABEL: Record<number, string> = { 1: '협상중', 2: '협상종료', 3: '협상거부' };
const DELIVERY_TYPE_LABEL: Record<number, string> = { 1: '협력사배송', 2: '지정택배배송', 3: '픽업배송' };
// ISO 문자열 → 'YYYY-MM-DD HH:mm'. 빈 값/파싱 실패는 '-'.
export function fmtDateTime(s?: string | null): string {
if (!s) return '-';
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
// 서버 SessionData → 협상현황 테이블 뷰. 협력사명/상품명은 이미 로드된 목록에서 해석.
// (SessionData 필드는 생성 타입 그대로 사용 — number|null / string|null, 캐스팅 없음.)
export function mapServerSessionView(sd: SessionData, partners: Partner[], products: Product[]): SessionView {
const supplier = partners.find((p) => p.id === sd.supplier_id);
const product = products.find((p) => p.id === sd.item_id);
return {
session_id: sd.session_id,
qt_id: sd.qt_id,
supplier_id: sd.supplier_id,
supplier_name: supplier?.name || sd.supplier_id,
item_id: sd.item_id,
item_name: product?.name || '부품',
status: SESSION_STATUS_LABEL[sd.status] || String(sd.status),
target_price: sd.target_price ?? 0,
bid_price: sd.bid_price ?? null,
bid_at: sd.bid_at ? fmtDateTime(sd.bid_at) : '-',
reject_reason: sd.reject_reason ?? null,
reject_price: sd.reject_price ?? null,
reject_delivery_type: sd.reject_delivery_type
? DELIVERY_TYPE_LABEL[sd.reject_delivery_type] || String(sd.reject_delivery_type)
: null,
end_time: fmtDateTime(sd.end_time),
};
}
// 서버 QuotationCardData → 사용 카드 뷰. type 2=와일드, 그 외 협상카드.
export function mapServerCardView(c: QuotationCardData): QuotationCardView {
return {
session_card_id: c.session_card_id,
card_name: c.name || '-',
type: c.type === 2 ? '와일드 카드' : '협상 카드',
};
}
// 견적에 매핑된 협상카드(quotation_cards N:M).
export function buildQuotationCards(est: Estimate, cards: NegotiationCard[]): QuotationCardView[] {
return (est.usedCardIds ?? []).map((cId) => {

View File

@ -7,6 +7,9 @@ export type ExcelColumn<T> = {
value: (row: T) => string | number | null | undefined;
};
// 일괄(엑셀) 등록 중 서버가 거부한 행. code 로 원본 행과 매칭해 사유를 표시한다.
export type BulkFailure = { code: string; message: string };
function escapeCell(v: string | number | null | undefined): string {
const s = v == null ? '' : String(v);
// 쉼표/따옴표/줄바꿈 포함 시 따옴표로 감싸고 내부 따옴표는 두 개로 이스케이프

View File

@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
// 서버사이드 리스트(검색·필터·페이지네이션)의 UI 상태 단일 출처.
// 실제 데이터 패칭은 각 도메인 훅(useProducts/usePartners 등)이 이 상태로
// 쿼리 파라미터를 만들어 수행한다 — 이 훅은 패칭을 하지 않고 상태만 관리한다.
//
// - search: 입력 즉시 반영(controlled) + debouncedSearch(쿼리용, 기본 300ms)로 분리해
// 키 입력마다 서버를 때리지 않는다.
// - filters: 임의 키-값(category/priority 등). 'ALL' 같은 "전체" 값의 의미는
// 호출부가 파라미터를 만들 때 결정한다(여기선 단순 보관).
// - 검색/필터가 바뀌면 page 를 1 로 리셋한다(다른 결과셋의 동일 페이지로 점프 방지).
export type ServerListControls = {
page: number;
setPage: (p: number) => void;
pageSize: number;
search: string; // input value (controlled)
setSearch: (v: string) => void;
debouncedSearch: string; // 쿼리 파라미터용 (디바운스 적용)
filters: Record<string, string>;
setFilter: (key: string, value: string) => void;
totalPages: (total: number) => number; // 서버 total → 페이지 수
};
export function useServerList(opts?: {
pageSize?: number;
initialFilters?: Record<string, string>;
debounceMs?: number;
}): ServerListControls {
const pageSize = opts?.pageSize ?? 10;
const debounceMs = opts?.debounceMs ?? 300;
const [page, setPage] = useState(1);
const [search, setSearchInput] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filters, setFilters] = useState<Record<string, string>>(() => opts?.initialFilters ?? {});
// 입력 디바운스 → 쿼리용 검색어
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search.trim()), debounceMs);
return () => clearTimeout(t);
}, [search, debounceMs]);
const setSearch = (v: string) => {
setSearchInput(v);
setPage(1);
};
const setFilter = (key: string, value: string) => {
setFilters((f) => ({ ...f, [key]: value }));
setPage(1);
};
const totalPages = (total: number) => Math.max(1, Math.ceil(total / pageSize));
return { page, setPage, pageSize, search, setSearch, debouncedSearch, filters, setFilter, totalPages };
}

View File

@ -29,10 +29,14 @@ export default function CardsPage() {
setIsFormOpen(true);
};
const handleDeleteCard = (id: string, cardName: string) => {
const handleDeleteCard = async (id: string, cardName: string) => {
if (confirm(`[${cardName}]을 삭제하시겠습니까?`)) {
deleteCard(id);
showToast('삭제되었습니다.', 'info');
try {
await deleteCard(id);
showToast('삭제되었습니다.', 'info');
} catch (err) {
showToast(err instanceof Error ? err.message : '카드 삭제 실패', 'error');
}
}
};

View File

@ -5,27 +5,28 @@ import { PageContainer } from '@/components/layout/PageContainer';
import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { useServerList } from '@/lib/useServerList';
import { usePartners } from '@/features/partners/hooks/usePartners';
import { usePartnerFilters } from '@/features/partners/hooks/usePartnerFilters';
import type { ListSuppliersParams } from '@/api/generated/model/listSuppliersParams';
import { PartnerTable } from '@/features/partners/components/PartnerTable';
import { PartnerFormSheet } from '@/features/partners/components/PartnerFormSheet';
import { ExcelUploadModal } from '@/features/partners/components/ExcelUploadModal';
import { prioritiesList, type Partner } from '@/features/partners/types';
export default function PartnersPage() {
const { partners, createPartner, updatePartner, deletePartner, bulkCreate } = usePartners();
const {
search,
setSearch,
priorityFilter,
setPriorityFilter,
page,
setPage,
paginated,
totalPages,
totalCount,
itemsPerPage,
} = usePartnerFilters(partners);
// 검색/우선순위/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { priority: 'ALL' } });
const priorityFilter = list.filters.priority;
const params: ListSuppliersParams = {
search: list.debouncedSearch || undefined,
priority: priorityFilter !== 'ALL' ? priorityFilter : undefined,
page: list.page,
size: list.pageSize,
};
const { partners, total, allPartners, createPartner, updatePartner, deletePartner, bulkCreate } =
usePartners(params);
const totalPages = list.totalPages(total);
// 모달 제어 + 폼 컨텍스트
const [openModal, setOpenModal] = useState<null | 'form' | 'excel'>(null);
@ -73,12 +74,12 @@ export default function PartnersPage() {
>
<SearchInput
id="partner-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
placeholder="협력사명, 코드 또는 담당자명으로 추적 검색..."
/>
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as string)}>
<Select value={priorityFilter} onValueChange={(v) => list.setFilter('priority', v as string)}>
<SelectTrigger id="partner-priority-filter" className="w-full sm:w-48">
<SelectValue>
{(value) => (value === 'ALL' ? '우선순위 가중치 (전체)' : `우선도: ${value}`)}
@ -95,13 +96,13 @@ export default function PartnersPage() {
</PageToolbar>
<PartnerTable
data={paginated}
data={partners}
onRowClick={openEdit}
page={page}
page={list.page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={itemsPerPage}
onPageChange={setPage}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
/>
{openModal === 'form' && (
@ -120,7 +121,7 @@ export default function PartnersPage() {
{openModal === 'excel' && (
<ExcelUploadModal
open
partners={partners}
partners={allPartners}
onConfirm={bulkCreate}
onClose={() => setOpenModal(null)}
/>

View File

@ -6,28 +6,39 @@ import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useServerList } from '@/lib/useServerList';
import { useProducts } from '@/features/products/hooks/useProducts';
import { useProductFilters } from '@/features/products/hooks/useProductFilters';
import type { ListItemsParams } from '@/api/generated/model/listItemsParams';
import { ProductTable } from '@/features/products/components/ProductTable';
import { ProductFormSheet } from '@/features/products/components/ProductFormSheet';
import { PriceUpdateModal } from '@/features/products/components/PriceUpdateModal';
import { ExcelUploadModal } from '@/features/products/components/ExcelUploadModal';
import { categoriesList, type Product } from '@/features/products/types';
import { type Product } from '@/features/products/types';
export default function ProductsPage() {
const { products, createProduct, updateProduct, deleteProduct, bulkCreate } = useProducts();
// 검색/카테고리/페이지 상태(재사용 훅) → 서버 쿼리 파라미터로 변환.
const list = useServerList({ pageSize: 10, initialFilters: { category: 'ALL' } });
const categoryFilter = list.filters.category;
const params: ListItemsParams = {
search: list.debouncedSearch || undefined,
category: categoryFilter !== 'ALL' ? categoryFilter : undefined,
page: list.page,
size: list.pageSize,
};
const {
search,
setSearch,
categoryFilter,
setCategoryFilter,
page,
setPage,
paginated,
totalPages,
totalCount,
itemsPerPage,
} = useProductFilters(products);
products,
total,
allProducts,
categories,
categoryTypeByName,
nextCategoryType,
createProduct,
updateProduct,
deleteProduct,
bulkCreate,
} = useProducts(params);
const totalPages = list.totalPages(total);
// 행 선택(테이블 체크박스 + 최저가 모달 대상)
const [selectedIds, setSelectedIds] = useState<string[]>([]);
@ -88,19 +99,19 @@ export default function ProductsPage() {
>
<SearchInput
id="product-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
value={list.search}
onChange={(e) => list.setSearch(e.target.value)}
placeholder="상품명 또는 상품 코드로 통합 검색..."
/>
<Select value={categoryFilter} onValueChange={(v) => setCategoryFilter(v as string)}>
<Select value={categoryFilter} onValueChange={(v) => list.setFilter('category', v as string)}>
<SelectTrigger id="product-category-filter" className="w-full sm:w-48">
<SelectValue>
{(value) => (value === 'ALL' ? '품목 카테고리 (전체)' : value)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categoriesList.map((cat) => (
{['ALL', ...categories].map((cat) => (
<SelectItem key={cat} value={cat}>
{cat === 'ALL' ? '품목 카테고리 (전체)' : cat}
</SelectItem>
@ -110,15 +121,15 @@ export default function ProductsPage() {
</PageToolbar>
<ProductTable
data={paginated}
data={products}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onRowClick={openEdit}
page={page}
page={list.page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={itemsPerPage}
onPageChange={setPage}
totalCount={total}
pageSize={list.pageSize}
onPageChange={list.setPage}
/>
{openModal === 'form' && (
@ -127,6 +138,9 @@ export default function ProductsPage() {
open
mode={formMode}
product={editing}
categories={categories}
categoryTypeByName={categoryTypeByName}
nextCategoryType={nextCategoryType}
onCreate={createProduct}
onUpdate={updateProduct}
onDelete={handleDeleteProduct}
@ -137,7 +151,7 @@ export default function ProductsPage() {
{openModal === 'price' && (
<PriceUpdateModal
open
products={products}
products={allProducts}
selectedIds={selectedIds}
onDone={() => setSelectedIds([])}
onClose={() => setOpenModal(null)}
@ -147,7 +161,7 @@ export default function ProductsPage() {
{openModal === 'excel' && (
<ExcelUploadModal
open
products={products}
products={allProducts}
onConfirm={bulkCreate}
onClose={() => setOpenModal(null)}
/>

View File

@ -18,7 +18,6 @@ export default function QuotationPage() {
cards,
quotations,
quotationSettings,
chatSessions,
stopNegotiation,
addSetting,
deleteSetting,
@ -107,9 +106,7 @@ export default function QuotationPage() {
estimate={activeQuotation}
products={products}
partners={partners}
cards={cards}
quotationSettings={quotationSettings}
sessions={chatSessions[activeQuotation.id ?? ''] ?? []}
onStop={stopNegotiation}
onClose={() => setDetailId(null)}
/>

View File

@ -1,6 +1,6 @@
import {create} from 'zustand';
export type UserRole = '매니져' | '일반';
export type UserRole = '관리자' | '일반';
export interface AuthUser {
company: string;