[feat] negodata/front: 상품·협력사 엑셀 업로드 양식 정비

업로드 모달/툴바를 드롭다운(일괄 업로드 + 양식 다운로드)으로 정리,
UPLOAD_COLUMNS 단일 정의 공유, CSV 템플릿(헤더+예시행) 추가. dropdown-menu UI 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-26 17:08:43 +09:00
parent 0c6a002e29
commit 21e9f71f53
5 changed files with 132 additions and 61 deletions

View File

@ -0,0 +1,55 @@
"use client"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
// shadcn 스타일 DropdownMenu(@base-ui Menu 기반). 액션 묶음용 — Select 와 달리 값 선택이 아니라 명령 실행.
// 사용: <DropdownMenu><DropdownMenuTrigger render={<Button/>}>...</DropdownMenuTrigger>
// <DropdownMenuContent><DropdownMenuItem onClick={...}>...</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
function DropdownMenu(props: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuTrigger(props: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
children,
...props
}: MenuPrimitive.Popup.Props) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner className="z-50 outline-none" sideOffset={4} align="end">
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"bg-popover text-popover-foreground relative z-50 min-w-40 overflow-hidden rounded-lg border border-border p-1 text-xs shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
</MenuPrimitive.Popup>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-muted data-highlighted:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
className
)}
{...props}
/>
)
}
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }

View File

@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
@ -72,6 +72,21 @@ function toSupplierCreate(row: RawRow): SupplierCreate {
};
}
// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행. 툴바·모달이 공유한다.
export function downloadPartnerTemplate() {
downloadExcel<TemplateRow>(
'협력사_업로드_양식',
[
{ header: '협력사명', value: (r) => r.name },
{ header: '식별코드', value: (r) => r.code },
{ header: '담당자명', value: (r) => r.managerName },
{ header: '담당자이메일', value: (r) => r.managerEmail },
{ header: '우선순위', value: (r) => r.priority },
],
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }],
);
}
// 협력사 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고,
// 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임.
export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUploadModalProps) {
@ -98,21 +113,6 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
onClose();
};
// 업로드 양식(.csv) 다운로드 — 채워 넣을 컬럼 헤더 + 예시 1행 (lib/excel 재사용)
const handleDownloadTemplate = () => {
downloadExcel<TemplateRow>(
'협력사_업로드_양식',
[
{ header: '협력사명', value: (r) => r.name },
{ header: '식별코드', value: (r) => r.code },
{ header: '담당자명', value: (r) => r.managerName },
{ header: '담당자이메일', value: (r) => r.managerEmail },
{ header: '우선순위', value: (r) => r.priority },
],
[{ name: '예시) (주)한빛정밀', code: 'PART-EXAMPLE-001', managerName: '김철수 과장', managerEmail: 'cs.kim@example.com', priority: 'HIGH' }],
);
};
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
const handleFile = async (file: File) => {
const parsed = parseCsv(await file.text());
@ -243,16 +243,6 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
>
</button>
<button
type="button"
id="excel-partners-template-download-button"
onClick={handleDownloadTemplate}
className="mt-2.5 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2 cursor-pointer"
>
<Download size={12} />
(.csv)
</button>
</div>
) : (
<div className="space-y-4">

View File

@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Download, Trash2 } from 'lucide-react';
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
import { showToast } from '@/lib/notify';
import { downloadExcel, parseCsv, type BulkFailure } from '@/lib/excel';
@ -22,6 +22,8 @@ type RawRow = {
made_in: string;
price: number;
minPrice: number;
purchase_price: number;
selling_price: number;
image_url: string;
moq: string;
lead_time: number;
@ -59,6 +61,8 @@ const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [
{ header: '원산지', key: 'made_in' },
{ header: '상품 단가', key: 'price' },
{ header: '최저한도', key: 'minPrice' },
{ header: '매입가', key: 'purchase_price' },
{ header: '판매가', key: 'selling_price' },
{ header: '이미지URL', key: 'image_url' },
{ header: '최소주문수량', key: 'moq' },
{ header: '리드타임(일)', key: 'lead_time' },
@ -69,7 +73,7 @@ const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [
];
// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기)
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'lead_time']);
const NUMERIC_KEYS = new Set<keyof RawRow>(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']);
const REQUIRED_KEYS = new Set<keyof RawRow>(['name', 'code', 'price']);
// 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다.
@ -77,19 +81,28 @@ 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',
price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, 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',
price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, 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',
},
];
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다.
export function downloadProductTemplate() {
downloadExcel<Record<string, string | number>>(
'상품_업로드_양식',
UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })),
EXAMPLE_ROWS,
);
}
type ExcelUploadModalProps = {
open: boolean;
products: Product[]; // 코드 중복 검사용
@ -149,6 +162,8 @@ function toItemCreate(row: RawRow): ItemCreate {
manufacturer: row.manufacturer || undefined,
made_in: row.made_in || undefined,
price: row.price,
purchase_price: row.purchase_price || undefined,
selling_price: row.selling_price || undefined,
image_url: row.image_url || undefined,
moq: row.moq || undefined,
lead_time: row.lead_time || undefined,
@ -187,15 +202,6 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
onClose();
};
// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유.
const handleDownloadTemplate = () => {
downloadExcel<Record<string, string | number>>(
'상품_업로드_양식',
UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })),
EXAMPLE_ROWS,
);
};
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
const handleFile = async (file: File) => {
const parsed = parseCsv(await file.text());
@ -211,6 +217,8 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
made_in: r['원산지'] ?? '',
price: Number(r['상품 단가']) || 0,
minPrice: Number(r['최저한도']) || 0,
purchase_price: Number(r['매입가']) || 0,
selling_price: Number(r['판매가']) || 0,
image_url: r['이미지URL'] ?? '',
moq: r['최소주문수량'] ?? '',
lead_time: Number(r['리드타임(일)']) || 0,
@ -345,16 +353,6 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
>
</button>
<button
type="button"
id="excel-template-download-button"
onClick={handleDownloadTemplate}
className="mt-2.5 flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2 cursor-pointer"
>
<Download size={12} />
(.csv)
</button>
</div>
) : (
<div className="space-y-4">

View File

@ -1,4 +1,4 @@
import { Plus, Upload } from 'lucide-react';
import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
@ -6,12 +6,13 @@ 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 { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useServerList } from '@/lib/useServerList';
import { usePartners } from '@/features/partners/hooks/usePartners';
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 { ExcelUploadModal, downloadPartnerTemplate } from '@/features/partners/components/ExcelUploadModal';
import { prioritiesList, type Partner } from '@/features/partners/types';
export default function PartnersPage() {
@ -57,10 +58,23 @@ export default function PartnersPage() {
<PageToolbar
actions={
<>
<Button variant="outline" onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</Button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadPartnerTemplate}>
<Download />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={openCreate}>
<Plus />

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Plus, Upload, TrendingDown } from 'lucide-react';
import { Plus, Upload, TrendingDown, Download, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { useOverlayRouter } from '@/lib/useOverlayRouter';
import { showToast } from '@/lib/notify';
import { confirm } from '@/lib/confirm';
@ -7,6 +7,7 @@ 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 { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { useServerList } from '@/lib/useServerList';
import { useProducts } from '@/features/products/hooks/useProducts';
@ -14,7 +15,7 @@ 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 { ExcelUploadModal, downloadProductTemplate } from '@/features/products/components/ExcelUploadModal';
import { type Product } from '@/features/products/types';
export default function ProductsPage() {
@ -82,10 +83,23 @@ export default function ProductsPage() {
{selectedIds.length > 0 && <Badge variant="destructive">{selectedIds.length}</Badge>}
</Button>
<Button variant="outline" onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</Button>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
<FileSpreadsheet />
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => overlay.open('modal', 'excel')}>
<Upload />
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadProductTemplate}>
<Download />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button onClick={openCreate}>
<Plus />