Files
vexplor/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx
kjs 28ef7e1226 fix: Enhance error handling and validation messages in form data operations
- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations.
- Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context.
- Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations.
- Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions.

These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
2026-03-10 14:47:05 +09:00

783 lines
28 KiB
TypeScript

"use client";
import React, { useEffect, useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2, X, Plus } from "lucide-react";
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types";
import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation";
import { apiClient } from "@/lib/api/client";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
config?: SimpleRepeaterTableProps;
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
value?: any[];
onChange?: (newData: any[]) => void;
columns?: SimpleRepeaterColumnConfig[];
calculationRules?: any[];
readOnly?: boolean;
showRowNumber?: boolean;
allowDelete?: boolean;
allowAdd?: boolean;
maxHeight?: string;
}
export function SimpleRepeaterTableComponent({
// ComponentRendererProps (자동 전달)
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
className,
formData,
onFormDataChange,
// SimpleRepeaterTable 전용 props
config,
value: propValue,
onChange: propOnChange,
columns: propColumns,
calculationRules: propCalculationRules,
readOnly: propReadOnly,
showRowNumber: propShowRowNumber,
allowDelete: propAllowDelete,
allowAdd: propAllowAdd,
maxHeight: propMaxHeight,
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
_initialData,
_originalData,
_groupedData,
// 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음)
initialData: legacyInitialData,
originalData: legacyOriginalData,
groupedData: legacyGroupedData,
...props
}: SimpleRepeaterTableComponentProps & {
_initialData?: any;
_originalData?: any;
_groupedData?: any;
initialData?: any;
originalData?: any;
groupedData?: any;
}) {
// 실제 사용할 데이터 (새 props 우선, 레거시 fallback)
const effectiveInitialData = _initialData || legacyInitialData;
const effectiveOriginalData = _originalData || legacyOriginalData;
const effectiveGroupedData = _groupedData || legacyGroupedData;
// config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
...component?.config,
};
// config prop 우선, 없으면 개별 prop 사용
const columns = componentConfig?.columns || propColumns || [];
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
const allowAdd = componentConfig?.allowAdd ?? propAllowAdd ?? false;
const addButtonText = componentConfig?.addButtonText || "행 추가";
const addButtonPosition = componentConfig?.addButtonPosition || "bottom";
const minRows = componentConfig?.minRows ?? 0;
const maxRows = componentConfig?.maxRows ?? Infinity;
const newRowDefaults = componentConfig?.newRowDefaults || {};
const summaryConfig = componentConfig?.summaryConfig;
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
// 🆕 컴포넌트 레벨의 저장 테이블 설정
const componentTargetTable = componentConfig?.targetTable || componentConfig?.saveTable;
const componentFkColumn = componentConfig?.fkColumn;
// value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 로딩 상태
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
const handleChange = (newData: any[]) => {
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(newData);
}
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newData);
}
};
// 계산 hook
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 🆕 초기 데이터 로드
useEffect(() => {
const loadInitialData = async () => {
const initialConfig = componentConfig?.initialDataConfig;
if (!initialConfig || !initialConfig.sourceTable) {
return; // 초기 데이터 설정이 없으면 로드하지 않음
}
setIsLoading(true);
setLoadError(null);
try {
// 필터 조건 생성
const filters: Record<string, any> = {};
if (initialConfig.filterConditions) {
for (const condition of initialConfig.filterConditions) {
let filterValue = condition.value;
// formData에서 값 가져오기
if (condition.valueFromField && formData) {
filterValue = formData[condition.valueFromField];
}
filters[condition.field] = filterValue;
}
}
// API 호출
const response = await apiClient.post(`/table-management/tables/${initialConfig.sourceTable}/data`, {
search: filters,
page: 1,
size: 1000, // 대량 조회
});
if (response.data.success && response.data.data?.data) {
const loadedData = response.data.data.data;
// 1. 기본 데이터 매핑 (Direct & Manual)
const baseMappedData = loadedData.map((row: any) => {
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
for (const col of columns) {
if (col.sourceConfig) {
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
} else if (col.sourceConfig.type === "manual") {
mappedRow[col.field] = col.defaultValue;
}
// Join은 2단계에서 처리
} else {
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
}
}
return mappedRow;
});
// 2. 조인 데이터 처리
const joinColumns = columns.filter(
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey,
);
if (joinColumns.length > 0) {
// 조인 테이블별로 그룹화
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
joinColumns.forEach((col) => {
const table = col.sourceConfig!.joinTable!;
const key = col.sourceConfig!.joinKey!;
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
const refKey = col.sourceConfig!.joinRefKey || key;
const groupKey = `${table}:${key}:${refKey}`;
if (!joinGroups.has(groupKey)) {
joinGroups.set(groupKey, { key, refKey, cols: [] });
}
joinGroups.get(groupKey)!.cols.push(col);
});
// 각 그룹별로 데이터 조회 및 병합
await Promise.all(
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
const [tableName] = groupKey.split(":");
// 조인 키 값 수집 (중복 제거)
const keyValues = Array.from(
new Set(baseMappedData.map((row: any) => row[key]).filter((v: any) => v !== undefined && v !== null)),
);
if (keyValues.length === 0) return;
try {
// 조인 테이블 조회
// refKey(타겟 테이블 컬럼)로 검색
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
page: 1,
size: 1000,
});
if (response.data.success && response.data.data?.data) {
const joinedRows = response.data.data.data;
// 조인 데이터 맵 생성 (refKey -> row)
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
// 데이터 병합
baseMappedData.forEach((row: any) => {
const keyValue = row[key];
const joinedRow = joinMap.get(keyValue);
if (joinedRow) {
cols.forEach((col) => {
if (col.sourceConfig?.joinColumn) {
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
}
});
}
});
}
} catch (error) {
console.error(`조인 실패 (${tableName}):`, error);
// 실패 시 무시하고 진행 (값은 undefined)
}
}),
);
}
const mappedData = baseMappedData;
// 계산 필드 적용
const calculatedData = calculateAll(mappedData);
handleChange(calculatedData);
}
} catch (error: any) {
console.error("초기 데이터 로드 실패:", error);
setLoadError(error.message || "데이터를 불러올 수 없습니다");
} finally {
setIsLoading(false);
}
};
loadInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentConfig?.initialDataConfig]);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
handleChange(calculated);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
if (value.length === 0) {
// console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
return;
}
// 🆕 컴포넌트 레벨의 targetTable이 설정되어 있으면 우선 사용
if (componentTargetTable) {
console.log("✅ [SimpleRepeaterTable] 컴포넌트 레벨 저장 테이블 사용:", componentTargetTable);
// 모든 행을 해당 테이블에 저장
const dataToSave = value.map((row: any) => {
// 메타데이터 필드 제외 (_, _rowIndex 등)
const cleanRow: Record<string, any> = {};
Object.keys(row).forEach((key) => {
if (!key.startsWith("_")) {
cleanRow[key] = row[key];
}
});
return {
...cleanRow,
_targetTable: componentTargetTable,
};
});
// CustomEvent의 detail에 데이터 추가
if (event instanceof CustomEvent && event.detail) {
const key = columnName || component?.id || "repeater_data";
event.detail.formData[key] = dataToSave;
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
key,
targetTable: componentTargetTable,
itemCount: dataToSave.length,
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) {
onFormDataChange(columnName, dataToSave);
}
return;
}
// 🆕 컬럼별 targetConfig가 있는 경우 기존 로직 사용
const dataByTable: Record<string, any[]> = {};
for (const row of value) {
// 각 행의 데이터를 테이블별로 분리
for (const col of columns) {
// 저장 설정이 있고 저장이 활성화된 경우에만
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
const targetTable = col.targetConfig.targetTable;
const targetColumn = col.targetConfig.targetColumn || col.field;
// 테이블 그룹 초기화
if (!dataByTable[targetTable]) {
dataByTable[targetTable] = [];
}
// 해당 테이블의 데이터 찾기 또는 생성
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
if (!tableRow) {
tableRow = { _rowIndex: row._rowIndex };
dataByTable[targetTable].push(tableRow);
}
// 컬럼 값 저장
tableRow[targetColumn] = row[col.field];
}
}
}
// 컬럼별 설정도 없으면 기본 동작 (formData에 직접 추가)
if (Object.keys(dataByTable).length === 0) {
console.log("⚠️ [SimpleRepeaterTable] targetTable 설정 없음 - 기본 저장");
if (event instanceof CustomEvent && event.detail) {
const key = columnName || component?.id || "repeater_data";
event.detail.formData[key] = value;
}
return;
}
// _rowIndex 제거
Object.keys(dataByTable).forEach((tableName) => {
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
const { _rowIndex, ...rest } = row;
return rest;
});
});
// console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
// CustomEvent의 detail에 테이블별 데이터 추가
if (event instanceof CustomEvent && event.detail) {
// 각 테이블별로 데이터 전달
Object.entries(dataByTable).forEach(([tableName, rows]) => {
const key = `${columnName || component?.id}_${tableName}`;
event.detail.formData[key] = rows.map((row: any) => ({
...row,
_targetTable: tableName,
}));
});
// console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
// tables: Object.keys(dataByTable),
// totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
// });
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) {
// 테이블별 데이터를 통합하여 전달
onFormDataChange(
columnName,
Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table })),
),
);
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [value, columns, columnName, component?.id, onFormDataChange, componentTargetTable]);
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
const newRow = { ...value[rowIndex], [field]: cellValue };
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
const newData = [...value];
newData[rowIndex] = calculatedRow;
handleChange(newData);
};
const handleRowDelete = (rowIndex: number) => {
// 최소 행 수 체크
if (value.length <= minRows) {
return;
}
const newData = value.filter((_, i) => i !== rowIndex);
handleChange(newData);
};
// 행 추가 함수
const handleAddRow = () => {
// 최대 행 수 체크
if (value.length >= maxRows) {
return;
}
// 새 행 생성 (기본값 적용)
const newRow: Record<string, any> = { ...newRowDefaults };
// 각 컬럼의 기본값 설정
columns.forEach((col) => {
if (newRow[col.field] === undefined) {
if (col.defaultValue !== undefined) {
newRow[col.field] = col.defaultValue;
} else if (col.type === "number") {
newRow[col.field] = 0;
} else if (col.type === "date") {
newRow[col.field] = new Date().toISOString().split("T")[0];
} else {
newRow[col.field] = "";
}
}
});
// 계산 필드 적용
const calculatedRow = calculateRow(newRow);
const newData = [...value, calculatedRow];
handleChange(newData);
};
// 합계 계산
const summaryValues = useMemo(() => {
if (!summaryConfig?.enabled || !summaryConfig.fields || value.length === 0) {
return null;
}
const result: Record<string, number> = {};
// 먼저 기본 집계 함수 계산
summaryConfig.fields.forEach((field) => {
if (field.formula) return; // 수식 필드는 나중에 처리
const values = value.map((row) => {
const val = row[field.field];
return typeof val === "number" ? val : parseFloat(val) || 0;
});
switch (field.type || "sum") {
case "sum":
result[field.field] = values.reduce((a, b) => a + b, 0);
break;
case "avg":
result[field.field] = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case "count":
result[field.field] = values.length;
break;
case "min":
result[field.field] = Math.min(...values);
break;
case "max":
result[field.field] = Math.max(...values);
break;
default:
result[field.field] = values.reduce((a, b) => a + b, 0);
}
});
// 수식 필드 계산 (다른 합계 필드 참조)
summaryConfig.fields.forEach((field) => {
if (!field.formula) return;
let formula = field.formula;
// 다른 필드 참조 치환
Object.keys(result).forEach((key) => {
formula = formula.replace(new RegExp(`\\b${key}\\b`, "g"), result[key].toString());
});
try {
result[field.field] = new Function(`return ${formula}`)();
} catch {
result[field.field] = 0;
}
});
return result;
}, [value, summaryConfig]);
// 합계 값 포맷팅
const formatSummaryValue = (field: SummaryFieldConfig, value: number): string => {
const decimals = field.decimals ?? 0;
const formatted = value.toFixed(decimals);
switch (field.format) {
case "currency":
return Number(formatted).toLocaleString() + "원";
case "percent":
return formatted + "%";
default:
return Number(formatted).toLocaleString();
}
};
// 행 추가 버튼 컴포넌트
const AddRowButton = () => {
if (!allowAdd || readOnly || value.length >= maxRows) return null;
return (
<Button type="button" variant="outline" size="sm" onClick={handleAddRow} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
{addButtonText}
</Button>
);
};
const renderCell = (row: any, column: SimpleRepeaterColumnConfig, rowIndex: number) => {
const cellValue = row[column.field];
// 계산 필드는 편집 불가
if (column.calculated || !column.editable || readOnly) {
return (
<div className="px-2 py-1">
{column.type === "number"
? typeof cellValue === "number"
? cellValue.toLocaleString()
: cellValue || "0"
: cellValue || "-"}
</div>
);
}
// 편집 가능한 필드
switch (column.type) {
case "number":
return (
<Input
type="number"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)}
className="h-7 text-xs"
/>
);
case "date":
return (
<Input
type="date"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
case "select":
return (
<Select
value={cellValue || ""}
onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions
?.filter((option) => option.value && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default: // text
return (
<Input
type="text"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
}
};
// 로딩 중일 때
if (isLoading) {
return (
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<Loader2 className="text-primary mx-auto mb-2 h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
</div>
);
}
// 에러 발생 시
if (loadError) {
return (
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<div className="bg-destructive/10 mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
<X className="text-destructive h-6 w-6" />
</div>
<p className="text-destructive mb-1 text-sm font-medium"> </p>
<p className="text-muted-foreground text-xs">{loadError}</p>
</div>
</div>
</div>
);
}
// 테이블 컬럼 수 계산
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
return (
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
{/* 상단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "bottom" && (
<div className="bg-muted/50 border-b p-2">
<AddRowButton />
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight }}>
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
{showRowNumber && (
<th key="header-rownum" className="text-muted-foreground w-12 px-4 py-2 text-left font-medium">
#
</th>
)}
{columns.map((col) => (
<th
key={`header-${col.field}`}
className="text-muted-foreground px-4 py-2 text-left font-medium"
style={{ width: col.width }}
>
{col.label}{(col.required || isColumnRequiredByMeta(componentTargetTable, col.field)) && <span className="text-orange-500">*</span>}
</th>
))}
{!readOnly && allowDelete && (
<th key="header-delete" className="text-muted-foreground w-20 px-4 py-2 text-left font-medium">
</th>
)}
</tr>
</thead>
<tbody className="bg-background">
{value.length === 0 ? (
<tr key="empty-row">
<td key="empty-cell" colSpan={totalColumns} className="text-muted-foreground px-4 py-8 text-center">
{allowAdd ? (
<div className="flex flex-col items-center gap-2">
<span> </span>
<AddRowButton />
</div>
) : (
"표시할 데이터가 없습니다"
)}
</td>
</tr>
) : (
value.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="hover:bg-accent/50 border-t">
{showRowNumber && (
<td key={`rownum-${rowIndex}`} className="text-muted-foreground px-4 py-2 text-center">
{rowIndex + 1}
</td>
)}
{columns.map((col) => (
<td key={`${rowIndex}-${col.field}`} className="px-2 py-1">
{renderCell(row, col, rowIndex)}
</td>
))}
{!readOnly && allowDelete && (
<td key={`delete-${rowIndex}`} className="px-4 py-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRowDelete(rowIndex)}
disabled={value.length <= minRows}
className="text-destructive hover:text-destructive h-7 w-7 p-0 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{/* 합계 표시 */}
{summaryConfig?.enabled && summaryValues && (
<div
className={cn("bg-muted/30 border-t p-3", summaryConfig.position === "bottom-right" && "flex justify-end")}
>
<div className={cn(summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full")}>
{summaryConfig.title && (
<div className="text-muted-foreground mb-2 text-xs font-medium">{summaryConfig.title}</div>
)}
<div
className={cn(
"grid gap-2",
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{summaryConfig.fields.map((field) => (
<div
key={field.field}
className={cn(
"flex items-center justify-between rounded px-3 py-1.5",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background",
)}
>
<span className="text-muted-foreground text-xs">{field.label}</span>
<span className={cn("text-sm font-medium", field.highlight && "text-primary")}>
{formatSummaryValue(field, summaryValues[field.field] || 0)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* 하단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "top" && value.length > 0 && (
<div className="bg-muted/50 flex items-center justify-between border-t p-2">
<AddRowButton />
{maxRows !== Infinity && (
<span className="text-muted-foreground text-xs">
{value.length} / {maxRows}
</span>
)}
</div>
)}
</div>
);
}