diff --git a/negodata/front/src/api/mutator/custom-fetch.ts b/negodata/front/src/api/mutator/custom-fetch.ts index 6d2ac59..5921c43 100644 --- a/negodata/front/src/api/mutator/custom-fetch.ts +++ b/negodata/front/src/api/mutator/custom-fetch.ts @@ -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 | null = null; + +// refresh 토큰으로 access 재발급. 성공 시 새 access 토큰, 실패 시 null. +async function refreshAccessToken(): Promise { + 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 ( ): Promise => { 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 ( (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 ( // fetch는 4xx/5xx도 resolve하므로 직접 throw → React Query가 error로 처리 if (!response.ok) { - // TODO: 401이면 refresh 토큰으로 재발급 후 재시도 로직 추가 위치 throw new ApiError(response.status, response.statusText, body); } diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index 930fc1a..9216071 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -150,7 +150,6 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {pageLabelMap[currentPage]} - B2B 시스템 diff --git a/negodata/front/src/features/auth/service.ts b/negodata/front/src/features/auth/service.ts index 2051c67..005f408 100644 --- a/negodata/front/src/features/auth/service.ts +++ b/negodata/front/src/features/auth/service.ts @@ -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) || '일반', }; } diff --git a/negodata/front/src/features/cards/components/CardFormModal.tsx b/negodata/front/src/features/cards/components/CardFormModal.tsx index 7dcd241..8203e38 100644 --- a/negodata/front/src/features/cards/components/CardFormModal.tsx +++ b/negodata/front/src/features/cards/components/CardFormModal.tsx @@ -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; + onUpdate: (id: string, input: CardInput) => Promise; 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 }) => ( - + + + {(value) => (value === 'WILD' ? '와일드카드' : '일반 협상카드')} + + + + 일반 협상카드 + 와일드카드 + + )} /> diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index 586324e..07c9a0b 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -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([]); - - 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, + }; } diff --git a/negodata/front/src/features/cards/types.ts b/negodata/front/src/features/cards/types.ts index ada17fc..16121f3 100644 --- a/negodata/front/src/features/cards/types.ts +++ b/negodata/front/src/features/cards/types.ts @@ -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 [ diff --git a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx index 89d09bb..541d027 100644 --- a/negodata/front/src/features/partners/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/partners/components/ExcelUploadModal.tsx @@ -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; + onConfirm: (rows: SupplierCreate[]) => Promise; onClose: () => void; }; // 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. -function validateRows(rows: RawRow[], partners: Partner[]): ValidatedRow[] { +// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다. +function validateRows( + rows: RawRow[], + partners: Partner[], + serverErrors: Record, +): 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(null); const [rows, setRows] = useState([]); + const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(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 = {}; + (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 = {}; + 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 (
-
+
{/* Modal Title */}
@@ -227,18 +274,41 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp 행 + 삭제 + 자격 + 진단 내용 협력사명 * 협력사코드 * 담당자명 * 담당자 이메일 * - 자격 - 진단 내용 (직접 수정가능) {validated.map((row) => ( {row.rowNum} + + + + + + {row.status} + + + + {row.message} + handleUpdateField(row.id, 'managerEmail', e.target.value)} /> - - - {row.status} - - - - {row.message} - ))} @@ -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}개사)
diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index c27d651..61269e9 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -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({ @@ -139,15 +141,24 @@ export function PartnerFormSheet({ {/* Priority */}
우선 선정 대상자 - + ( + + )} + />
diff --git a/negodata/front/src/features/partners/hooks/usePartnerFilters.ts b/negodata/front/src/features/partners/hooks/usePartnerFilters.ts deleted file mode 100644 index 21a6dca..0000000 --- a/negodata/front/src/features/partners/hooks/usePartnerFilters.ts +++ /dev/null @@ -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, - }; -} diff --git a/negodata/front/src/features/partners/hooks/usePartners.ts b/negodata/front/src/features/partners/hooks/usePartners.ts index 55600c7..88e172d 100644 --- a/negodata/front/src/features/partners/hooks/usePartners.ts +++ b/negodata/front/src/features/partners/hooks/usePartners.ts @@ -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 => { + 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, + }; } diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index 37a5e7d..de687cf 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -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 = { + 협력사배송: 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(['price', 'minPrice', 'lead_time']); +const REQUIRED_KEYS = new Set(['name', 'code', 'price']); + +// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다. +const EXAMPLE_ROWS: Record[] = [ + { + 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; + onConfirm: (rows: ItemCreate[]) => Promise; onClose: () => void; }; // 행 검증 — 순수 함수. 우선순위 순으로 첫 위반 메시지를 매긴다. (상태에 저장하지 않고 렌더에서 파생) -function validateRows(rows: RawRow[], products: Product[]): ValidatedRow[] { +// serverErrors: 서버(DB) 검증에서 거부된 code→사유. 프론트 검증을 통과한 행만 마지막에 덧씌운다. +function validateRows( + rows: RawRow[], + products: Product[], + serverErrors: Record, +): 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(null); const [rows, setRows] = useState([]); + const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(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( + downloadExcel>( '상품_업로드_양식', - [ - { 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 = {}; + (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 = {}; + 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 (
-
+
{/* Modal Title */}
@@ -237,57 +371,40 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
정합성 검출 미리보기 -
- +
+
- 행(Row) - 상품명 * - 상품코드 * - 상품 단가 (₩) * - 최저한도 (₩) * - 자격 - 진단 내용 (직접 수정가능) + 행 + 삭제 + 자격 + 진단 내용 + {UPLOAD_COLUMNS.map((c) => ( + + {c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''} + + ))} {validated.map((row) => ( {row.rowNum} - - handleUpdateField(row.id, 'name', e.target.value)} - /> - - - handleUpdateField(row.id, 'code', e.target.value)} - /> - - - handleUpdateField(row.id, 'price', e.target.value)} - /> - - - handleUpdateField(row.id, 'minPrice', e.target.value)} - /> + + - - + {row.message} + {UPLOAD_COLUMNS.map((c) => { + const isNum = NUMERIC_KEYS.has(c.key); + return ( + + handleUpdateField(row.id, c.key, e.target.value)} + /> + + ); + })} ))} @@ -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}개) 등록 diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 87f8f62..fec052a 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -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; // 카테고리명 → category_type(id) 매핑 + nextCategoryType: number; // 신규 카테고리에 부여할 id(= 기존 max + 1) onCreate: (data: ItemCreate) => Promise; onUpdate: (itemId: string, data: ItemUpdate) => Promise; 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 &&

{errors.code.message}

} - {/* Category */} + {/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
분류 카테고리 - + + {errors.category &&

{errors.category.message}

}
@@ -319,16 +334,18 @@ export function ProductFormSheet({ control={control} name="shippingType" render={({ field }) => ( - + )} /> {errors.shippingType &&

{errors.shippingType.message}

} diff --git a/negodata/front/src/features/products/hooks/useProductFilters.ts b/negodata/front/src/features/products/hooks/useProductFilters.ts deleted file mode 100644 index eb1c527..0000000 --- a/negodata/front/src/features/products/hooks/useProductFilters.ts +++ /dev/null @@ -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, - }; -} diff --git a/negodata/front/src/features/products/hooks/useProducts.ts b/negodata/front/src/features/products/hooks/useProducts.ts index a84f6ae..341db9a 100644 --- a/negodata/front/src/features/products/hooks/useProducts.ts +++ b/negodata/front/src/features/products/hooks/useProducts.ts @@ -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 => { + 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 = {}; + 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, + }; } diff --git a/negodata/front/src/features/products/types.ts b/negodata/front/src/features/products/types.ts index 9b5d9f3..59610e6 100644 --- a/negodata/front/src/features/products/types.ts +++ b/negodata/front/src/features/products/types.ts @@ -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); diff --git a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx b/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx index 121adbc..3264857 100644 --- a/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx +++ b/negodata/front/src/features/quotations/components/CreateQuotationWizard.tsx @@ -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({
유형 - + + + {(value) => (value === 'RE_ESTIMATE' ? '재견적' : '재협상')} + + + + 재협상 + 재견적 + +
@@ -127,19 +133,25 @@ export function CreateQuotationWizard({
상품 - +
)} @@ -185,18 +197,25 @@ export function CreateQuotationWizard({
적용할 견적 세팅 지정 - +
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx index 89e5f10..f1e642c 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailDrawer.tsx @@ -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('status'); - const [selectedChatPartnerId, setSelectedChatPartnerId] = useState( - 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(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({ > {showHeaderCards ? '데이터베이스 매핑 정보 접기 ▲' : '데이터베이스 매핑 정보 펼치기 ▼'} - {estimate.status === 'ACTIVE' && ( + {statusKey === '견적진행중' && ( ); @@ -473,32 +501,31 @@ export function QuotationDetailDrawer({
- 채널: {currentChatSession?.partnerName} + 채널: {currentSupplierName}
- 기록: {currentChatSession?.messages.length} 세션전화 + 기록: {chatMessages.length} 메시지
- {currentChatSession ? ( - currentChatSession.messages.map((msg) => { - const isBot = msg.sender === 'BOT'; - const isSystem = msg.sender === 'SYSTEM'; - - if (isSystem) { - return ( -
- - {msg.content} - -
- ); - } - + {!effectiveSessionId ? ( +
+ 선택된 대화 채널이 없습니다. +
+ ) : chatMessages.length === 0 ? ( +
+ 기록된 협상 대화가 없습니다. +
+ ) : ( + 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 (
- {isBot ? 'Negosium AI Bot' : currentChatSession.partnerName} + {isBot ? 'Negosium AI Bot' : currentSupplierName} · - {msg.timestamp} + #{m.index}
- {msg.editorScript ? ( - - ) : ( - {msg.content} +
제시 단가 ₩{Number(m.target_price).toLocaleString()}
+ {cardName && ( +
+ 협상카드: {cardName} +
)}
); }) - ) : ( -
- 선택된 대화 채널 데이터가 기록되지 않았습니다. -
)}
diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index 25983ce..3fc9ece 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -61,12 +61,19 @@ export function QuotationSettingsModal({ 목표 마진율 - 앵커링 설정 방식 + 앵커링 값 카드 사용 횟수 삭제 + {settings.length === 0 && ( + + + 등록된 견적 세팅이 없습니다. (리스트가 비어 있습니다) + + + )} {settings.map((qs) => ( {qs.target_margin} @@ -95,15 +102,15 @@ export function QuotationSettingsModal({
목표 마진율 (%) - setTargetMargin(e.target.value)} placeholder="예: 12%" /> + setTargetMargin(e.target.value)} placeholder="예: 12" />
- 앵커링 설정 방식 - setAnchoringValue(e.target.value)} placeholder="예: 최초 즉각 앵커링" /> + 앵커링 값 + setAnchoringValue(e.target.value)} placeholder="예: 0.01" />
카드 사용 횟수 - setCardUseCount(e.target.value)} placeholder="예: 3회 제한" /> + setCardUseCount(e.target.value)} placeholder="예: 3" />
diff --git a/negodata/front/src/features/quotations/components/QuotationTable.tsx b/negodata/front/src/features/quotations/components/QuotationTable.tsx index e291504..33dd085 100644 --- a/negodata/front/src/features/quotations/components/QuotationTable.tsx +++ b/negodata/front/src/features/quotations/components/QuotationTable.tsx @@ -118,7 +118,7 @@ export function QuotationTable({ data, products, onOpenDetail, onStop }: Quotati 상세 정보 - {est.status === 'ACTIVE' && ( + {normalizeQuotationStatus(est.status) === '견적진행중' && (