[feat] negodata/front: 상품·협력사 목록 서버 페이지네이션·검색 연동(재사용 useServerList) + 카드·견적 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c244bca27
commit
dd03c2aecd
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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) || '일반',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 [
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
// 쉼표/따옴표/줄바꿈 포함 시 따옴표로 감싸고 내부 따옴표는 두 개로 이스케이프
|
||||
|
||||
55
negodata/front/src/lib/useServerList.ts
Normal file
55
negodata/front/src/lib/useServerList.ts
Normal 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 };
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {create} from 'zustand';
|
||||
|
||||
export type UserRole = '매니져' | '일반';
|
||||
export type UserRole = '관리자' | '일반';
|
||||
|
||||
export interface AuthUser {
|
||||
company: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user