Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs 2025-12-17 15:00:30 +09:00
commit 857e46eab6
3 changed files with 405 additions and 77 deletions

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react"; import { Plus, Columns } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal"; import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable"; import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types"; import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
@ -328,6 +328,12 @@ export function ModalRepeaterTableComponent({
const companyCode = componentConfig?.companyCode || propCompanyCode; const companyCode = componentConfig?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
// 체크박스 선택 상태
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// 균등 분배 트리거 (값이 변경되면 RepeaterTable에서 균등 분배 실행)
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행) // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false); const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
@ -794,6 +800,18 @@ export function ModalRepeaterTableComponent({
handleChange(newData); handleChange(newData);
}; };
// 선택된 항목 일괄 삭제 핸들러
const handleBulkDelete = () => {
if (selectedRows.size === 0) return;
// 선택되지 않은 항목만 남김
const newData = localValue.filter((_, index) => !selectedRows.has(index));
// 데이터 업데이트 및 선택 상태 초기화
handleChange(newData);
setSelectedRows(new Set());
};
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴) // 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
const columnLabels = columns.reduce((acc, col) => { const columnLabels = columns.reduce((acc, col) => {
// sourceColumnLabels에 정의된 라벨 우선 사용 // sourceColumnLabels에 정의된 라벨 우선 사용
@ -805,16 +823,42 @@ export function ModalRepeaterTableComponent({
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
{/* 추가 버튼 */} {/* 추가 버튼 */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground"> <div className="flex items-center gap-2">
{localValue.length > 0 && `${localValue.length}개 항목`} <span className="text-sm text-muted-foreground">
{localValue.length > 0 && `${localValue.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
{columns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2"
title="컬럼 너비 균등 분배"
>
<Columns className="h-3.5 w-3.5 mr-1" />
</Button>
)}
</div>
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button
variant="destructive"
onClick={handleBulkDelete}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
({selectedRows.size})
</Button>
)}
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{modalButtonText}
</Button>
</div> </div>
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{modalButtonText}
</Button>
</div> </div>
{/* Repeater 테이블 */} {/* Repeater 테이블 */}
@ -826,6 +870,9 @@ export function ModalRepeaterTableComponent({
onRowDelete={handleRowDelete} onRowDelete={handleRowDelete}
activeDataSources={activeDataSources} activeDataSources={activeDataSources}
onDataSourceChange={handleDataSourceChange} onDataSourceChange={handleDataSourceChange}
selectedRows={selectedRows}
onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={equalizeWidthsTrigger}
/> />
{/* 항목 선택 모달 */} {/* 항목 선택 모달 */}

View File

@ -1,14 +1,68 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Trash2, ChevronDown, Check } from "lucide-react"; import { ChevronDown, Check, GripVertical } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { RepeaterColumnConfig } from "./types"; import { RepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// @dnd-kit imports
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
interface SortableRowProps {
id: string;
children: (props: {
attributes: React.HTMLAttributes<HTMLElement>;
listeners: React.HTMLAttributes<HTMLElement> | undefined;
isDragging: boolean;
}) => React.ReactNode;
className?: string;
}
function SortableRow({ id, children, className }: SortableRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: isDragging ? "#f0f9ff" : undefined,
};
return (
<tr ref={setNodeRef} style={style} className={className}>
{children({ attributes, listeners, isDragging })}
</tr>
);
}
interface RepeaterTableProps { interface RepeaterTableProps {
columns: RepeaterColumnConfig[]; columns: RepeaterColumnConfig[];
data: any[]; data: any[];
@ -18,6 +72,11 @@ interface RepeaterTableProps {
// 동적 데이터 소스 관련 // 동적 데이터 소스 관련
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백 onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
// 체크박스 선택 관련
selectedRows: Set<number>; // 선택된 행 인덱스
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
// 균등 분배 트리거
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
} }
export function RepeaterTable({ export function RepeaterTable({
@ -28,7 +87,60 @@ export function RepeaterTable({
onRowDelete, onRowDelete,
activeDataSources = {}, activeDataSources = {},
onDataSourceChange, onDataSourceChange,
selectedRows,
onSelectionChange,
equalizeWidthsTrigger,
}: RepeaterTableProps) { }: RepeaterTableProps) {
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef<HTMLDivElement>(null);
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
const [isEqualizedMode, setIsEqualizedMode] = useState(false);
// DnD 센서 설정
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px 이동해야 드래그 시작 (클릭과 구분)
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// 드래그 종료 핸들러
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = data.findIndex((_, idx) => `row-${idx}` === active.id);
const newIndex = data.findIndex((_, idx) => `row-${idx}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newData = arrayMove(data, oldIndex, newIndex);
onDataChange(newData);
// 선택된 행 인덱스도 업데이트
if (selectedRows.size > 0) {
const newSelectedRows = new Set<number>();
selectedRows.forEach((oldIdx) => {
if (oldIdx === oldIndex) {
newSelectedRows.add(newIndex);
} else if (oldIdx > oldIndex && oldIdx <= newIndex) {
newSelectedRows.add(oldIdx - 1);
} else if (oldIdx < oldIndex && oldIdx >= newIndex) {
newSelectedRows.add(oldIdx + 1);
} else {
newSelectedRows.add(oldIdx);
}
});
onSelectionChange(newSelectedRows);
}
}
}
};
const [editingCell, setEditingCell] = useState<{ const [editingCell, setEditingCell] = useState<{
rowIndex: number; rowIndex: number;
field: string; field: string;
@ -66,15 +178,100 @@ export function RepeaterTable({
startX: e.clientX, startX: e.clientX,
startWidth: columnWidths[field] || 120, startWidth: columnWidths[field] || 120,
}); });
// 수동 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
}; };
// 더블클릭으로 기본 너비로 리셋 // 컬럼 확장 상태 추적 (토글용)
const handleDoubleClick = (field: string) => { const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
setColumnWidths((prev) => ({
...prev, // 데이터 기준 최적 너비 계산
[field]: defaultWidths[field] || 120, const calculateAutoFitWidth = (field: string): number => {
})); const column = columns.find(col => col.field === field);
if (!column) return 120;
// 헤더 텍스트 길이 (대략 8px per character + padding)
const headerWidth = (column.label?.length || field.length) * 8 + 40;
// 데이터 중 가장 긴 텍스트 찾기
let maxDataWidth = 0;
data.forEach(row => {
const value = row[field];
if (value !== undefined && value !== null) {
let displayText = String(value);
// 숫자는 천단위 구분자 포함
if (typeof value === 'number') {
displayText = value.toLocaleString();
}
// 날짜는 yyyy-mm-dd 형식
if (column.type === 'date' && displayText.includes('T')) {
displayText = displayText.split('T')[0];
}
// 대략적인 너비 계산 (8px per character + padding)
const textWidth = displayText.length * 8 + 32;
maxDataWidth = Math.max(maxDataWidth, textWidth);
}
});
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
const optimalWidth = Math.max(headerWidth, maxDataWidth);
return Math.min(Math.max(optimalWidth, 60), 400);
}; };
// 더블클릭으로 auto-fit / 기본 너비 토글
const handleDoubleClick = (field: string) => {
// 개별 컬럼 조정 시 균등 분배 모드 해제
setIsEqualizedMode(false);
setExpandedColumns(prev => {
const newSet = new Set(prev);
if (newSet.has(field)) {
// 확장 상태 → 기본 너비로 복구
newSet.delete(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: defaultWidths[field] || 120,
}));
} else {
// 기본 상태 → 데이터 기준 auto-fit
newSet.add(field);
const autoWidth = calculateAutoFitWidth(field);
setColumnWidths(prevWidths => ({
...prevWidths,
[field]: autoWidth,
}));
}
return newSet;
});
};
// 균등 분배 트리거 감지
useEffect(() => {
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
if (!containerRef.current) return;
// 실제 컨테이너 너비 측정
const containerWidth = containerRef.current.offsetWidth;
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
const checkboxColumnWidth = 40;
const borderWidth = 2;
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
newWidths[col.field] = equalWidth;
});
setColumnWidths(newWidths);
setExpandedColumns(new Set()); // 확장 상태 초기화
setIsEqualizedMode(true); // 균등 분배 모드 활성화
}, [equalizeWidthsTrigger, columns]);
useEffect(() => { useEffect(() => {
if (!resizing) return; if (!resizing) return;
@ -112,6 +309,33 @@ export function RepeaterTable({
onRowChange(rowIndex, newRow); onRowChange(rowIndex, newRow);
}; };
// 전체 선택 체크박스 핸들러
const handleSelectAll = (checked: boolean) => {
if (checked) {
// 모든 행 선택
const allIndices = new Set(data.map((_, index) => index));
onSelectionChange(allIndices);
} else {
// 전체 해제
onSelectionChange(new Set());
}
};
// 개별 행 선택 핸들러
const handleRowSelect = (rowIndex: number, checked: boolean) => {
const newSelection = new Set(selectedRows);
if (checked) {
newSelection.add(rowIndex);
} else {
newSelection.delete(rowIndex);
}
onSelectionChange(newSelection);
};
// 전체 선택 상태 계산
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
const renderCell = ( const renderCell = (
row: any, row: any,
column: RepeaterColumnConfig, column: RepeaterColumnConfig,
@ -144,7 +368,7 @@ export function RepeaterTable({
onChange={(e) => onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
} }
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
/> />
); );
@ -172,7 +396,7 @@ export function RepeaterTable({
type="date" type="date"
value={formatDateValue(value)} value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
/> />
); );
@ -184,7 +408,7 @@ export function RepeaterTable({
handleCellEdit(rowIndex, column.field, newValue) handleCellEdit(rowIndex, column.field, newValue)
} }
> >
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"> <SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -203,21 +427,49 @@ export function RepeaterTable({
type="text" type="text"
value={value || ""} value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)} onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none" className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
/> />
); );
} }
}; };
// 드래그 아이템 ID 목록
const sortableItems = data.map((_, idx) => `row-${idx}`);
return ( return (
<div className="border border-gray-200 bg-white"> <DndContext
<div className="overflow-x-auto max-h-[400px] overflow-y-auto"> sensors={sensors}
<table className="w-full text-xs border-collapse"> collisionDetection={closestCenter}
<thead className="bg-gray-50 sticky top-0 z-10"> onDragEnd={handleDragEnd}
<tr> >
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12"> <div ref={containerRef} className="border border-gray-200 bg-white">
# <div className="overflow-x-auto max-h-[400px] overflow-y-auto">
</th> <table
className={cn(
"text-xs border-collapse",
isEqualizedMode && "w-full"
)}
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
>
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
{/* 드래그 핸들 헤더 */}
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
<span className="sr-only"></span>
</th>
{/* 체크박스 헤더 */}
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
<Checkbox
checked={isAllSelected}
// @ts-ignore - indeterminate는 HTML 속성
data-indeterminate={isIndeterminate}
onCheckedChange={handleSelectAll}
className={cn(
"border-gray-400",
isIndeterminate && "data-[state=checked]:bg-primary"
)}
/>
</th>
{columns.map((col) => { {columns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0; const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId; const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
@ -225,13 +477,15 @@ export function RepeaterTable({
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0] ? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
: null; : null;
const isExpanded = expandedColumns.has(col.field);
return ( return (
<th <th
key={col.field} key={col.field}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none" className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
style={{ width: `${columnWidths[col.field]}px` }} style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)} onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 기본 너비로 되돌리기" title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
> >
<div className="flex items-center justify-between pointer-events-none"> <div className="flex items-center justify-between pointer-events-none">
<div className="flex items-center gap-1 pointer-events-auto"> <div className="flex items-center gap-1 pointer-events-auto">
@ -303,49 +557,74 @@ export function RepeaterTable({
</th> </th>
); );
})} })}
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
</th>
</tr>
</thead>
<tbody className="bg-white">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
>
</td>
</tr> </tr>
) : ( </thead>
data.map((row, rowIndex) => ( <SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors"> <tbody className="bg-white">
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200"> {data.length === 0 ? (
{rowIndex + 1} <tr>
</td> <td
{columns.map((col) => ( colSpan={columns.length + 2}
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200"> className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
> >
<Trash2 className="h-4 w-4" />
</Button> </td>
</td> </tr>
</tr> ) : (
)) data.map((row, rowIndex) => (
)} <SortableRow
</tbody> key={`row-${rowIndex}`}
</table> id={`row-${rowIndex}`}
className={cn(
"hover:bg-blue-50/50 transition-colors",
selectedRows.has(rowIndex) && "bg-blue-50"
)}
>
{({ attributes, listeners, isDragging }) => (
<>
{/* 드래그 핸들 */}
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
<button
type="button"
className={cn(
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
isDragging && "cursor-grabbing"
)}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4 text-gray-400" />
</button>
</td>
{/* 체크박스 */}
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
className="border-gray-400"
/>
</td>
{/* 데이터 컬럼들 */}
{columns.map((col) => (
<td
key={col.field}
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden"
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }}
>
{renderCell(row, col, rowIndex)}
</td>
))}
</>
)}
</SortableRow>
))
)}
</tbody>
</SortableContext>
</table>
</div>
</div> </div>
</div> </DndContext>
); );
} }

View File

@ -1744,11 +1744,13 @@ function RowNumberingConfigSection({
<SelectValue placeholder="컬럼 선택" /> <SelectValue placeholder="컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tableColumns.map((col, index) => ( {tableColumns
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs"> .filter((col) => col.field && col.field.trim() !== "")
{col.label || col.field} .map((col, index) => (
</SelectItem> <SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
))} {col.label || col.field}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[9px] text-muted-foreground"> <p className="text-[9px] text-muted-foreground">