[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:
parent
0c6a002e29
commit
21e9f71f53
55
negodata/front/src/components/ui/dropdown-menu.tsx
Normal file
55
negodata/front/src/components/ui/dropdown-menu.tsx
Normal 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 }
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user