diff options
Diffstat (limited to 'lib/items')
| -rw-r--r-- | lib/items/service.ts | 84 | ||||
| -rw-r--r-- | lib/items/table/import-excel-button.tsx | 266 | ||||
| -rw-r--r-- | lib/items/table/import-item-handler.tsx | 118 | ||||
| -rw-r--r-- | lib/items/table/item-excel-template.tsx | 94 | ||||
| -rw-r--r-- | lib/items/table/items-table-columns.tsx | 3 | ||||
| -rw-r--r-- | lib/items/table/items-table-toolbar-actions.tsx | 155 |
6 files changed, 672 insertions, 48 deletions
diff --git a/lib/items/service.ts b/lib/items/service.ts index ef14a5f0..226742ca 100644 --- a/lib/items/service.ts +++ b/lib/items/service.ts @@ -9,7 +9,7 @@ import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or ,eq} from "drizzle-orm"; import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations"; import { Item, items } from "@/db/schema/items"; import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository"; @@ -102,32 +102,86 @@ export async function getItems(input: GetItemsSchema) { /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ +export interface ItemCreateData { + itemCode: string + itemName: string + description: string | null +} /** * Item 생성 후, (가장 오래된 Item 1개) 삭제로 * 전체 Item 개수를 고정 */ -export async function createItem(input: CreateItemSchema) { - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 +export async function createItem(input: ItemCreateData) { + unstable_noStore() // Next.js 서버 액션 캐싱 방지 + try { - await db.transaction(async (tx) => { - // 새 Item 생성 - const [newTask] = await insertItem(tx, { - itemCode: input.itemCode, - itemName: input.itemName, - description: input.description, - }); - return newTask; + if (!input.itemCode || !input.itemName) { + return { + success: false, + message: "아이템 코드와 아이템 명은 필수입니다", + data: null, + error: "필수 필드 누락" + } + } - }); + // result 변수에 명시적으로 타입과 초기값 할당 + let result: any[] = [] + + // 트랜잭션 결과를 result에 할당 + result = await db.transaction(async (tx) => { + // 기존 아이템 확인 (itemCode는 unique) + const existingItem = await tx.query.items.findFirst({ + where: eq(items.itemCode, input.itemCode), + }) + + let txResult + if (existingItem) { + // 기존 아이템 업데이트 + txResult = await updateItem(tx, existingItem.id, { + itemName: input.itemName, + description: input.description, + }) + } else { + // 새 아이템 생성 + txResult = await insertItem(tx, { + itemCode: input.itemCode, + itemName: input.itemName, + description: input.description, + }) + } + + return txResult + }) // 캐시 무효화 - revalidateTag("items"); + revalidateTag("items") - return { data: null, error: null }; + return { + success: true, + data: result[0] || null, + error: null + } } catch (err) { - return { data: null, error: getErrorMessage(err) }; + console.error("아이템 생성/업데이트 오류:", err) + + // 중복 키 오류 처리 + if (err instanceof Error && err.message.includes("unique constraint")) { + return { + success: false, + message: "이미 존재하는 아이템 코드입니다", + data: null, + error: "중복 키 오류" + } + } + + return { + success: false, + message: getErrorMessage(err), + data: null, + error: getErrorMessage(err) + } } } diff --git a/lib/items/table/import-excel-button.tsx b/lib/items/table/import-excel-button.tsx new file mode 100644 index 00000000..484fd778 --- /dev/null +++ b/lib/items/table/import-excel-button.tsx @@ -0,0 +1,266 @@ +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { processFileImport } from "./import-item-handler" // 별도 파일로 분리 + +interface ImportItemButtonProps { + onSuccess?: () => void +} + +export function ImportItemButton({ onSuccess }: ImportItemButtonProps) { + const [open, setOpen] = React.useState(false) + const [file, setFile] = React.useState<File | null>(null) + const [isUploading, setIsUploading] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const [error, setError] = React.useState<string | null>(null) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (!selectedFile) return + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.") + return + } + + setFile(selectedFile) + setError(null) + } + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요.") + return + } + + try { + setIsUploading(true) + setProgress(0) + setError(null) + + // 파일을 ArrayBuffer로 읽기 + const arrayBuffer = await file.arrayBuffer(); + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "아이템 코드" || v === "itemCode" || v === "item_code")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record<string, number> = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 + const requiredHeaders = ["아이템 코드", "아이템 명", "설명"]; + const alternativeHeaders = { + "아이템 코드": ["itemCode", "item_code"], + "아이템 명": ["itemName", "item_name"], + "설명": ["description"] + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 (헤더 이후 행부터) + const dataRows: Record<string, any>[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record<string, any> = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 실제 데이터 처리는 별도 함수에서 수행 + const result = await processFileImport( + dataRows, + updateProgress + ); + + // 처리 완료 + toast.success(`${result.successCount}개의 아이템이 성공적으로 가져와졌습니다.`); + + if (result.errorCount > 0) { + toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null) + setError(null) + setProgress(0) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + setOpen(newOpen) + } + + return ( + <> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setOpen(true)} + disabled={isUploading} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>아이템 가져오기</DialogTitle> + <DialogDescription> + 아이템을 Excel 파일에서 가져옵니다. + <br /> + 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="flex items-center gap-4"> + <input + type="file" + ref={fileInputRef} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isUploading} + /> + </div> + + {file && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB) + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <Progress value={progress} /> + <p className="text-sm text-muted-foreground text-center"> + {progress}% 완료 + </p> + </div> + )} + + {error && ( + <div className="text-sm font-medium text-destructive"> + {error} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!file || isUploading} + > + {isUploading ? "처리 중..." : "가져오기"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/items/table/import-item-handler.tsx b/lib/items/table/import-item-handler.tsx new file mode 100644 index 00000000..541d6fe1 --- /dev/null +++ b/lib/items/table/import-item-handler.tsx @@ -0,0 +1,118 @@ +"use client" + +import { z } from "zod" +import { createItem } from "../service" // 아이템 생성 서버 액션 + +// 아이템 데이터 검증을 위한 Zod 스키마 +const itemSchema = z.object({ + itemCode: z.string().min(1, "아이템 코드는 필수입니다"), + itemName: z.string().min(1, "아이템 명은 필수입니다"), + description: z.string().nullable().optional(), +}); + +interface ProcessResult { + successCount: number; + errorCount: number; + errors?: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 아이템 데이터 처리하는 함수 + */ +export async function processFileImport( + jsonData: any[], + progressCallback?: (current: number, total: number) => void +): Promise<ProcessResult> { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 빈 행 등 필터링 + const dataRows = jsonData.filter(row => { + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0 }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 필드 매핑 (한글/영문 필드명 모두 지원) + const itemCode = row["아이템 코드"] || row["itemCode"] || row["item_code"] || ""; + const itemName = row["아이템 명"] || row["itemName"] || row["item_name"] || ""; + const description = row["설명"] || row["description"] || null; + + // 데이터 정제 + const cleanedRow = { + itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), + itemName: typeof itemName === 'string' ? itemName.trim() : String(itemName).trim(), + description: description ? (typeof description === 'string' ? description : String(description)) : null, + }; + + // 데이터 유효성 검사 + const validationResult = itemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ row: rowIndex, message: errorMessage }); + errorCount++; + continue; + } + + // 아이템 생성 서버 액션 호출 + const result = await createItem({ + itemCode: cleanedRow.itemCode, + itemName: cleanedRow.itemName, + description: cleanedRow.description, + }); + + if (result.success || !result.error) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: result.message || result.error || "알 수 없는 오류" + }); + errorCount++; + } + } catch (error) { + console.error(`${rowIndex}행 처리 오류:`, error); + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "알 수 없는 오류" + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors: errors.length > 0 ? errors : undefined + }; +}
\ No newline at end of file diff --git a/lib/items/table/item-excel-template.tsx b/lib/items/table/item-excel-template.tsx new file mode 100644 index 00000000..75338168 --- /dev/null +++ b/lib/items/table/item-excel-template.tsx @@ -0,0 +1,94 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportItemTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '아이템 코드', key: 'itemCode', width: 15 }, + { header: '아이템 명', key: 'itemName', width: 30 }, + { header: '설명', key: 'description', width: 50 } + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { itemCode: 'ITEM001', itemName: '샘플 아이템 1', description: '이것은 샘플 아이템 1의 설명입니다.' }, + { itemCode: 'ITEM002', itemName: '샘플 아이템 2', description: '이것은 샘플 아이템 2의 설명입니다.' } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'item-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/items/table/items-table-columns.tsx b/lib/items/table/items-table-columns.tsx index 60043e8e..8dd84c58 100644 --- a/lib/items/table/items-table-columns.tsx +++ b/lib/items/table/items-table-columns.tsx @@ -26,9 +26,6 @@ import { } from "@/components/ui/dropdown-menu" import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" -import { modifiTask } from "@/lib/tasks/service" - - import { itemsColumnsConfig } from "@/config/itemsColumnsConfig" import { Item } from "@/db/schema/items" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" diff --git a/lib/items/table/items-table-toolbar-actions.tsx b/lib/items/table/items-table-toolbar-actions.tsx index 3444daab..b3178ce1 100644 --- a/lib/items/table/items-table-toolbar-actions.tsx +++ b/lib/items/table/items-table-toolbar-actions.tsx @@ -2,37 +2,119 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" +import { Download, FileDown } from "lucide-react" +import * as ExcelJS from 'exceljs' +import { saveAs } from "file-saver" -import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" - -// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import -import { importTasksExcel } from "@/lib/tasks/service" // 예시 import { Item } from "@/db/schema/items" import { DeleteItemsDialog } from "./delete-items-dialog" import { AddItemDialog } from "./add-items-dialog" +import { exportItemTemplate } from "./item-excel-template" +import { ImportItemButton } from "./import-excel-button" interface ItemsTableToolbarActionsProps { table: Table<Item> } export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - + const [refreshKey, setRefreshKey] = React.useState(0) + // 가져오기 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() + // Excel 내보내기 함수 + const exportTableToExcel = async ( + table: Table<any>, + options: { + filename: string; + excludeColumns?: string[]; + sheetName?: string; + } + ) => { + const { filename, excludeColumns = [], sheetName = "아이템 목록" } = options; + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet(sheetName); + + // 테이블 데이터 가져오기 + const data = table.getFilteredRowModel().rows.map(row => row.original); + + // 테이블 헤더 가져오기 + const headers = table.getAllColumns() + .filter(column => !excludeColumns.includes(column.id)) + .map(column => ({ + key: column.id, + header: column.columnDef.header?.toString() || column.id + })); + + // 컬럼 정의 + worksheet.columns = headers.map(header => ({ + header: header.header, + key: header.key, + width: 20 // 기본 너비 + })); + + // 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 데이터 행 추가 + data.forEach(item => { + const row: Record<string, any> = {}; + headers.forEach(header => { + row[header.key] = item[header.key]; + }); + worksheet.addRow(row); + }); + + // 전체 셀에 테두리 추가 + worksheet.eachRow((row, rowNumber) => { + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, `${filename}.xlsx`); + return true; + } catch (error) { + console.error("Excel 내보내기 오류:", error); + return false; + } } return ( <div className="flex items-center gap-2"> - {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {/* 선택된 로우가 있으면 삭제 다이얼로그 */} {table.getFilteredSelectedRowModel().rows.length > 0 ? ( <DeleteItemsDialog items={table @@ -42,26 +124,39 @@ export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProp /> ) : null} - {/** 2) 새 Task 추가 다이얼로그 */} + {/* 새 아이템 추가 다이얼로그 */} <AddItemDialog /> - + {/* Import 버튼 */} + <ImportItemButton onSuccess={handleImportSuccess} /> - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> + {/* Export 드롭다운 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "items", + excludeColumns: ["select", "actions"], + sheetName: "아이템 목록" + }) + } + > + <FileDown className="mr-2 h-4 w-4" /> + <span>현재 데이터 내보내기</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => exportItemTemplate()}> + <FileDown className="mr-2 h-4 w-4" /> + <span>템플릿 다운로드</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file |
