|
@@ -1,8 +1,401 @@
|
|
|
|
|
+import { useState } from 'react'
|
|
|
|
|
+import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
|
|
+import { Plus, Trash2 } from 'lucide-react'
|
|
|
|
|
+import { api } from '@/api/client'
|
|
|
|
|
+import { useAuthStore } from '@/stores/authStore'
|
|
|
|
|
+import { useToastStore } from '@/stores/toastStore'
|
|
|
|
|
+import { Modal } from '@/components/Modal'
|
|
|
|
|
+import { ConfirmDialog } from '@/components/ConfirmDialog'
|
|
|
|
|
+import { ApiError } from '@/types'
|
|
|
|
|
+import type { CollectionMeta } from '@/types'
|
|
|
|
|
+
|
|
|
|
|
+// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function humanizeBytes(bytes: number): string {
|
|
|
|
|
+ if (bytes < 1024) return `${bytes} B`
|
|
|
|
|
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
|
|
|
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
|
|
|
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── new collection form ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface NewCollectionFormProps {
|
|
|
|
|
+ project: string
|
|
|
|
|
+ onSuccess: () => void
|
|
|
|
|
+ onCancel: () => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type CollectionKind = 'json' | 'vector'
|
|
|
|
|
+
|
|
|
|
|
+function NewCollectionForm({ project, onSuccess, onCancel }: NewCollectionFormProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
|
|
+
|
|
|
|
|
+ const [name, setName] = useState('')
|
|
|
|
|
+ const [kind, setKind] = useState<CollectionKind>('json')
|
|
|
|
|
+ const [vectorDimension, setVectorDimension] = useState('')
|
|
|
|
|
+ const [embeddingModel, setEmbeddingModel] = useState('')
|
|
|
|
|
+ const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const handleSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (!name.trim()) return
|
|
|
|
|
+
|
|
|
|
|
+ if (kind === 'vector') {
|
|
|
|
|
+ const dim = parseInt(vectorDimension, 10)
|
|
|
|
|
+ if (!vectorDimension || isNaN(dim) || dim <= 0) {
|
|
|
|
|
+ push('Vector dimension must be a positive integer.', 'error')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setSubmitting(true)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const body: {
|
|
|
|
|
+ name: string
|
|
|
|
|
+ kind: CollectionKind
|
|
|
|
|
+ vector_dimension?: number
|
|
|
|
|
+ embedding_model?: string
|
|
|
|
|
+ } = { name: name.trim(), kind }
|
|
|
|
|
+
|
|
|
|
|
+ if (kind === 'vector') {
|
|
|
|
|
+ body.vector_dimension = parseInt(vectorDimension, 10)
|
|
|
|
|
+ if (embeddingModel.trim()) {
|
|
|
|
|
+ body.embedding_model = embeddingModel.trim()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await api.createCollection(project, body)
|
|
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['collections', project] })
|
|
|
|
|
+ push(`Collection "${name.trim()}" created.`, 'success')
|
|
|
|
|
+ onSuccess()
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Failed to create collection: ${msg}`, 'error')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSubmitting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
|
|
|
+ {/* Name */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Name <span className="text-red-400">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={name}
|
|
|
|
|
+ onChange={(e) => setName(e.target.value)}
|
|
|
|
|
+ required
|
|
|
|
|
+ placeholder="my_collection"
|
|
|
|
|
+ className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Kind */}
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-2">
|
|
|
|
|
+ Kind <span className="text-red-400">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <div className="flex gap-4">
|
|
|
|
|
+ {(['json', 'vector'] as CollectionKind[]).map((k) => (
|
|
|
|
|
+ <label key={k} className="flex items-center gap-2 cursor-pointer">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="radio"
|
|
|
|
|
+ name="kind"
|
|
|
|
|
+ value={k}
|
|
|
|
|
+ checked={kind === k}
|
|
|
|
|
+ onChange={() => setKind(k)}
|
|
|
|
|
+ className="accent-indigo-500"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="text-sm text-slate-300 capitalize">{k}</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Vector-only fields */}
|
|
|
|
|
+ {kind === 'vector' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Vector Dimension <span className="text-red-400">*</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ value={vectorDimension}
|
|
|
|
|
+ onChange={(e) => setVectorDimension(e.target.value)}
|
|
|
|
|
+ required
|
|
|
|
|
+ min={1}
|
|
|
|
|
+ placeholder="1536"
|
|
|
|
|
+ className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <label className="block text-xs font-medium text-slate-400 mb-1">
|
|
|
|
|
+ Embedding Model <span className="text-slate-500">(optional)</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={embeddingModel}
|
|
|
|
|
+ onChange={(e) => setEmbeddingModel(e.target.value)}
|
|
|
|
|
+ placeholder="text-embedding-3-small"
|
|
|
|
|
+ className="w-full rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Actions */}
|
|
|
|
|
+ <div className="flex items-center justify-end gap-3 pt-1">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onCancel}
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-slate-300 hover:text-slate-100 bg-slate-700/50 hover:bg-slate-700 transition disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ disabled={submitting}
|
|
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ {submitting ? 'Creating…' : 'Create'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── kind badge ───────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function KindBadge({ kind }: { kind: CollectionMeta['kind'] }) {
|
|
|
|
|
+ const styles =
|
|
|
|
|
+ kind === 'vector'
|
|
|
|
|
+ ? 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/30'
|
|
|
|
|
+ : 'bg-slate-600/30 text-slate-300 border border-slate-600/30'
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${styles}`}>
|
|
|
|
|
+ {kind}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── collections table ────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface CollectionsTableProps {
|
|
|
|
|
+ collections: CollectionMeta[]
|
|
|
|
|
+ project: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function CollectionsTable({ collections, project }: CollectionsTableProps) {
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+ const queryClient = useQueryClient()
|
|
|
|
|
+
|
|
|
|
|
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const handleDelete = async (name: string) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await api.deleteCollection(project, name)
|
|
|
|
|
+ await queryClient.invalidateQueries({ queryKey: ['collections', project] })
|
|
|
|
|
+ push(`Collection "${name}" deleted.`, 'success')
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
|
|
+ push(`Failed to delete collection: ${msg}`, 'error')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (collections.length === 0) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-12 text-center">
|
|
|
|
|
+ <p className="text-slate-400 text-sm">No collections yet. Create one to get started.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 overflow-hidden">
|
|
|
|
|
+ <table className="w-full text-sm">
|
|
|
|
|
+ <thead>
|
|
|
|
|
+ <tr className="border-b border-slate-700/40 bg-slate-800/60">
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Name</th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Kind</th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Dimension</th>
|
|
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Embedding Model</th>
|
|
|
|
|
+ <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Documents</th>
|
|
|
|
|
+ <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
|
|
|
|
+ <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Actions</th>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody>
|
|
|
|
|
+ {collections.map((col, idx) => (
|
|
|
|
|
+ <tr
|
|
|
|
|
+ key={col.name}
|
|
|
|
|
+ className={[
|
|
|
|
|
+ 'border-b border-slate-700/20 transition hover:bg-slate-800/40',
|
|
|
|
|
+ idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
|
|
|
|
|
+ ].join(' ')}
|
|
|
|
|
+ >
|
|
|
|
|
+ <td className="px-4 py-3 font-mono text-slate-200 text-xs">{col.name}</td>
|
|
|
|
|
+ <td className="px-4 py-3">
|
|
|
|
|
+ <KindBadge kind={col.kind} />
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-slate-300">
|
|
|
|
|
+ {col.kind === 'vector' ? col.vector_dimension : <span className="text-slate-600">—</span>}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-slate-300 text-xs font-mono">
|
|
|
|
|
+ {col.embedding_model || <span className="text-slate-600">—</span>}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-right text-slate-300">
|
|
|
|
|
+ {col.document_count !== undefined ? col.document_count.toLocaleString() : <span className="text-slate-600">—</span>}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-right text-slate-300">
|
|
|
|
|
+ {col.size_bytes !== undefined ? humanizeBytes(col.size_bytes) : <span className="text-slate-600">—</span>}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ <td className="px-4 py-3 text-right">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setDeleteTarget(col.name)}
|
|
|
|
|
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
|
|
|
|
|
+ aria-label={`Delete ${col.name}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 size={13} />
|
|
|
|
|
+ Delete
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <ConfirmDialog
|
|
|
|
|
+ open={deleteTarget !== null}
|
|
|
|
|
+ title="Delete Collection"
|
|
|
|
|
+ message={`Are you sure you want to delete "${deleteTarget ?? ''}"? This will permanently remove all documents in this collection.`}
|
|
|
|
|
+ confirmLabel="Delete"
|
|
|
|
|
+ onConfirm={() => {
|
|
|
|
|
+ if (deleteTarget) handleDelete(deleteTarget)
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClose={() => setDeleteTarget(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── skeleton ─────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function TableSkeleton() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 overflow-hidden animate-pulse">
|
|
|
|
|
+ <div className="bg-slate-800/60 px-4 py-3 flex gap-8">
|
|
|
|
|
+ {[80, 60, 60, 120, 60, 60, 60].map((w, i) => (
|
|
|
|
|
+ <div key={i} className="h-3 rounded bg-slate-700/60" style={{ width: w }} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {[1, 2, 3].map((i) => (
|
|
|
|
|
+ <div key={i} className="border-t border-slate-700/20 bg-slate-900/20 px-4 py-4 flex gap-8">
|
|
|
|
|
+ {[80, 60, 60, 120, 60, 60, 60].map((w, j) => (
|
|
|
|
|
+ <div key={j} className="h-3 rounded bg-slate-700/30" style={{ width: w }} />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── page ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
export default function Collections() {
|
|
export default function Collections() {
|
|
|
|
|
+ const currentProject = useAuthStore((s) => s.currentProject)
|
|
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
|
|
+
|
|
|
|
|
+ const [newModalOpen, setNewModalOpen] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ const { data, isLoading, isError, error } = useQuery({
|
|
|
|
|
+ queryKey: ['collections', currentProject],
|
|
|
|
|
+ queryFn: () => api.listCollections(currentProject!),
|
|
|
|
|
+ enabled: !!currentProject,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Surface query errors as toasts
|
|
|
|
|
+ if (isError && error instanceof Error) {
|
|
|
|
|
+ push(`Collections error: ${error.message}`, 'error')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!currentProject) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-8">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">Collections</h1>
|
|
|
|
|
+ <p className="text-slate-400 text-sm mt-1">Manage collections in the current project.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
|
|
|
|
|
+ <p className="text-slate-400 text-sm">
|
|
|
|
|
+ No project selected — choose a project from the selector above.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const collections = data?.collections ?? []
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
- <div>
|
|
|
|
|
- <h1 className="text-2xl font-semibold text-slate-100 mb-2">Collections</h1>
|
|
|
|
|
- <p className="text-slate-400">Collection management — coming in W6.</p>
|
|
|
|
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
|
|
+ {/* Page header */}
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">Collections</h1>
|
|
|
|
|
+ <p className="text-slate-400 text-sm mt-1">
|
|
|
|
|
+ Project: <span className="text-slate-300 font-mono">{currentProject}</span>
|
|
|
|
|
+ {!isLoading && !isError && (
|
|
|
|
|
+ <span className="ml-2 text-slate-500">
|
|
|
|
|
+ ({collections.length} {collections.length === 1 ? 'collection' : 'collections'})
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setNewModalOpen(true)}
|
|
|
|
|
+ className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
|
|
|
|
|
+ data-testid="new-collection-btn"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Plus size={16} />
|
|
|
|
|
+ New collection
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Table */}
|
|
|
|
|
+ {isLoading && <TableSkeleton />}
|
|
|
|
|
+ {isError && (
|
|
|
|
|
+ <div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
|
|
|
|
|
+ <p className="text-red-400 text-sm">
|
|
|
|
|
+ Failed to load collections: {error instanceof Error ? error.message : 'Unknown error'}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!isLoading && !isError && (
|
|
|
|
|
+ <CollectionsTable collections={collections} project={currentProject} />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* New collection modal */}
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ open={newModalOpen}
|
|
|
|
|
+ title="New Collection"
|
|
|
|
|
+ onClose={() => setNewModalOpen(false)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <NewCollectionForm
|
|
|
|
|
+ project={currentProject}
|
|
|
|
|
+ onSuccess={() => setNewModalOpen(false)}
|
|
|
|
|
+ onCancel={() => setNewModalOpen(false)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Modal>
|
|
|
</div>
|
|
</div>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|