|
@@ -2,22 +2,36 @@
|
|
|
// Displays documents from a view in table, card, or kanban mode
|
|
// Displays documents from a view in table, card, or kanban mode
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
|
|
|
+import { useNavigate } from 'react-router-dom'
|
|
|
import apiClient from '@/api/client'
|
|
import apiClient from '@/api/client'
|
|
|
import { useWorkspace } from '@/contexts/WorkspaceContext'
|
|
import { useWorkspace } from '@/contexts/WorkspaceContext'
|
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
import { usePermissions } from '@/hooks/usePermissions'
|
|
|
import Button from '@/components/Button'
|
|
import Button from '@/components/Button'
|
|
|
|
|
+import ConfirmModal from '@/components/ConfirmModal'
|
|
|
import type { DocumentListConfig, Document } from '@/types'
|
|
import type { DocumentListConfig, Document } from '@/types'
|
|
|
|
|
|
|
|
|
|
+interface ViewField {
|
|
|
|
|
+ name: string
|
|
|
|
|
+ label: string
|
|
|
|
|
+ type: string
|
|
|
|
|
+ required?: boolean
|
|
|
|
|
+ options?: Array<{ value: string; label: string }> | string[]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
interface View {
|
|
interface View {
|
|
|
id: string
|
|
id: string
|
|
|
name: string
|
|
name: string
|
|
|
collection_name: string
|
|
collection_name: string
|
|
|
schema: {
|
|
schema: {
|
|
|
- fields?: Array<{
|
|
|
|
|
- name: string
|
|
|
|
|
- label: string
|
|
|
|
|
- type: string
|
|
|
|
|
- }>
|
|
|
|
|
|
|
+ fields?: ViewField[]
|
|
|
|
|
+ }
|
|
|
|
|
+ settings?: {
|
|
|
|
|
+ show_create_button?: boolean
|
|
|
|
|
+ show_edit_button?: boolean
|
|
|
|
|
+ quick_create_mode?: 'modal' | 'inline' | 'page'
|
|
|
|
|
+ quick_edit_mode?: 'modal' | 'inline' | 'page'
|
|
|
|
|
+ quick_create_fields?: string[]
|
|
|
|
|
+ quick_edit_fields?: string[]
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -28,12 +42,26 @@ interface DocumentListRendererProps {
|
|
|
onDeleteDocument?: (doc: Document) => void
|
|
onDeleteDocument?: (doc: Document) => void
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Normalize options to {value, label} format
|
|
|
|
|
+function normalizeOptions(options: Array<{ value: string; label: string }> | string[] | undefined): Array<{ value: string; label: string }> {
|
|
|
|
|
+ if (!options) return []
|
|
|
|
|
+ return options.map((opt): { value: string; label: string } => {
|
|
|
|
|
+ if (typeof opt === 'string') {
|
|
|
|
|
+ return { value: opt, label: opt }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { value: opt.value, label: opt.label }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function DocumentListRenderer({
|
|
export function DocumentListRenderer({
|
|
|
config,
|
|
config,
|
|
|
onCreateDocument,
|
|
onCreateDocument,
|
|
|
onEditDocument,
|
|
onEditDocument,
|
|
|
- onDeleteDocument,
|
|
|
|
|
|
|
+ // Note: delete is now handled internally with ConfirmModal
|
|
|
|
|
+ onDeleteDocument: _onDeleteDocument,
|
|
|
}: DocumentListRendererProps) {
|
|
}: DocumentListRendererProps) {
|
|
|
|
|
+ void _onDeleteDocument // Silence unused variable warning
|
|
|
|
|
+ const navigate = useNavigate()
|
|
|
const { currentWorkspace } = useWorkspace()
|
|
const { currentWorkspace } = useWorkspace()
|
|
|
const workspaceId = currentWorkspace?.id || ''
|
|
const workspaceId = currentWorkspace?.id || ''
|
|
|
const { canCreateInCollection, canWriteCollection, canDeleteInCollection } = usePermissions({
|
|
const { canCreateInCollection, canWriteCollection, canDeleteInCollection } = usePermissions({
|
|
@@ -47,6 +75,21 @@ export function DocumentListRenderer({
|
|
|
const [page, setPage] = useState(1)
|
|
const [page, setPage] = useState(1)
|
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
|
|
|
|
|
|
|
|
+ // Inline create form state
|
|
|
|
|
+ const [inlineFormData, setInlineFormData] = useState<Record<string, unknown>>({})
|
|
|
|
|
+ const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
|
+ const [submitError, setSubmitError] = useState<string | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ // Inline edit state
|
|
|
|
|
+ const [editingDoc, setEditingDoc] = useState<Document | null>(null)
|
|
|
|
|
+ const [editFormData, setEditFormData] = useState<Record<string, unknown>>({})
|
|
|
|
|
+ const [isUpdating, setIsUpdating] = useState(false)
|
|
|
|
|
+ const [updateError, setUpdateError] = useState<string | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ // Delete confirmation modal state
|
|
|
|
|
+ const [deleteDoc, setDeleteDoc] = useState<Document | null>(null)
|
|
|
|
|
+ const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
|
+
|
|
|
// Fetch view details
|
|
// Fetch view details
|
|
|
const fetchView = useCallback(async () => {
|
|
const fetchView = useCallback(async () => {
|
|
|
if (!workspaceId || !config.viewId) return null
|
|
if (!workspaceId || !config.viewId) return null
|
|
@@ -102,6 +145,357 @@ export function DocumentListRenderer({
|
|
|
const fields = view?.schema?.fields || []
|
|
const fields = view?.schema?.fields || []
|
|
|
const collectionName = view?.collection_name || ''
|
|
const collectionName = view?.collection_name || ''
|
|
|
|
|
|
|
|
|
|
+ // Get quick create/edit fields
|
|
|
|
|
+ const getQuickFields = (mode: 'create' | 'edit'): ViewField[] => {
|
|
|
|
|
+ const allFields = view?.schema?.fields || []
|
|
|
|
|
+ const quickFields = mode === 'create'
|
|
|
|
|
+ ? view?.settings?.quick_create_fields
|
|
|
|
|
+ : view?.settings?.quick_edit_fields
|
|
|
|
|
+
|
|
|
|
|
+ if (quickFields && quickFields.length > 0) {
|
|
|
|
|
+ return quickFields
|
|
|
|
|
+ .map(name => allFields.find(f => f.name === name))
|
|
|
|
|
+ .filter((f): f is ViewField => f !== undefined)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return allFields
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Handle inline create form submission
|
|
|
|
|
+ const handleInlineSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (!view || !workspaceId) return
|
|
|
|
|
+
|
|
|
|
|
+ setIsSubmitting(true)
|
|
|
|
|
+ setSubmitError(null)
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await apiClient.post(
|
|
|
|
|
+ `/workspaces/${workspaceId}/collections/${view.collection_name}/documents`,
|
|
|
|
|
+ { data: inlineFormData }
|
|
|
|
|
+ )
|
|
|
|
|
+ setInlineFormData({})
|
|
|
|
|
+ await fetchDocuments(view)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setSubmitError(err instanceof Error ? err.message : 'Failed to create document')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsSubmitting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Start inline edit
|
|
|
|
|
+ const startInlineEdit = (doc: Document) => {
|
|
|
|
|
+ setEditingDoc(doc)
|
|
|
|
|
+ setEditFormData({ ...doc.data })
|
|
|
|
|
+ setUpdateError(null)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Cancel inline edit
|
|
|
|
|
+ const cancelInlineEdit = () => {
|
|
|
|
|
+ setEditingDoc(null)
|
|
|
|
|
+ setEditFormData({})
|
|
|
|
|
+ setUpdateError(null)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Handle inline edit form submission
|
|
|
|
|
+ const handleInlineEditSubmit = async (e: React.FormEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (!view || !workspaceId || !editingDoc) return
|
|
|
|
|
+
|
|
|
|
|
+ setIsUpdating(true)
|
|
|
|
|
+ setUpdateError(null)
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await apiClient.patch(
|
|
|
|
|
+ `/workspaces/${workspaceId}/collections/${view.collection_name}/documents/${editingDoc.id}`,
|
|
|
|
|
+ { data: editFormData }
|
|
|
|
|
+ )
|
|
|
|
|
+ setEditingDoc(null)
|
|
|
|
|
+ setEditFormData({})
|
|
|
|
|
+ await fetchDocuments(view)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setUpdateError(err instanceof Error ? err.message : 'Failed to update document')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsUpdating(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Handle delete with confirmation modal
|
|
|
|
|
+ const handleDeleteClick = (doc: Document) => {
|
|
|
|
|
+ setDeleteDoc(doc)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleDeleteConfirm = async () => {
|
|
|
|
|
+ if (!view || !workspaceId || !deleteDoc) return
|
|
|
|
|
+
|
|
|
|
|
+ setIsDeleting(true)
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await apiClient.delete(
|
|
|
|
|
+ `/workspaces/${workspaceId}/collections/${view.collection_name}/documents/${deleteDoc.id}`
|
|
|
|
|
+ )
|
|
|
|
|
+ setDeleteDoc(null)
|
|
|
|
|
+ await fetchDocuments(view)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to delete document:', err)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setIsDeleting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Handle "page" mode - navigate to create/edit page
|
|
|
|
|
+ const handlePageCreate = () => {
|
|
|
|
|
+ if (view) {
|
|
|
|
|
+ navigate(`/views/${view.id}/create`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handlePageEdit = (doc: Document) => {
|
|
|
|
|
+ if (view) {
|
|
|
|
|
+ navigate(`/views/${view.id}/edit/${doc.id}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Render form field (used for both create and edit)
|
|
|
|
|
+ const renderFormField = (field: ViewField, formData: Record<string, unknown>, setFormData: React.Dispatch<React.SetStateAction<Record<string, unknown>>>, compact = false) => {
|
|
|
|
|
+ const value = formData[field.name] ?? ''
|
|
|
|
|
+
|
|
|
|
|
+ if (field.type === 'select' && field.options) {
|
|
|
|
|
+ const opts = normalizeOptions(field.options)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex-1 min-w-[120px]" : ""}>
|
|
|
|
|
+ {!compact && (
|
|
|
|
|
+ <label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
+ {field.label}
|
|
|
|
|
+ {field.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={String(value)}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
|
|
|
+ required={field.required}
|
|
|
|
|
+ title={compact ? field.label : undefined}
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="">{compact ? field.label : 'Select...'}</option>
|
|
|
|
|
+ {opts.map(opt => (
|
|
|
|
|
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (field.type === 'boolean') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex items-center" : ""}>
|
|
|
|
|
+ <label className="flex items-center gap-2">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="checkbox"
|
|
|
|
|
+ checked={Boolean(value)}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.checked }))}
|
|
|
|
|
+ className="h-4 w-4 rounded border-gray-300"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span className="text-sm text-gray-700">{field.label}</span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (field.type === 'date') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex-1 min-w-[140px]" : ""}>
|
|
|
|
|
+ {!compact && (
|
|
|
|
|
+ <label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
+ {field.label}
|
|
|
|
|
+ {field.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="date"
|
|
|
|
|
+ value={String(value)}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
|
|
|
+ required={field.required}
|
|
|
|
|
+ title={compact ? field.label : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (field.type === 'datetime') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex-1 min-w-[180px]" : ""}>
|
|
|
|
|
+ {!compact && (
|
|
|
|
|
+ <label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
+ {field.label}
|
|
|
|
|
+ {field.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="datetime-local"
|
|
|
|
|
+ value={String(value)}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
|
|
|
+ required={field.required}
|
|
|
|
|
+ title={compact ? field.label : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (field.type === 'number') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex-1 min-w-[80px]" : ""}>
|
|
|
|
|
+ {!compact && (
|
|
|
|
|
+ <label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
+ {field.label}
|
|
|
|
|
+ {field.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ value={String(value)}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: parseFloat(e.target.value) || 0 }))}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
|
|
|
+ placeholder={compact ? field.label : undefined}
|
|
|
|
|
+ required={field.required}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (field.type === 'color') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex-shrink-0" : ""}>
|
|
|
|
|
+ {!compact && (
|
|
|
|
|
+ <label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
+ {field.label}
|
|
|
|
|
+ {field.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="color"
|
|
|
|
|
+ value={String(value) || '#000000'}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
|
|
|
+ className="h-9 w-14 cursor-pointer rounded-lg border border-gray-300 p-1"
|
|
|
|
|
+ title={compact ? field.label : undefined}
|
|
|
|
|
+ required={field.required}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Default: text input
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={field.name} className={compact ? "flex-1 min-w-[120px]" : ""}>
|
|
|
|
|
+ {!compact && (
|
|
|
|
|
+ <label className="mb-1 block text-sm font-medium text-gray-700">
|
|
|
|
|
+ {field.label}
|
|
|
|
|
+ {field.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
|
|
|
|
|
+ value={String(value)}
|
|
|
|
|
+ onChange={(e) => setFormData(prev => ({ ...prev, [field.name]: e.target.value }))}
|
|
|
|
|
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
|
|
|
+ placeholder={compact ? field.label : undefined}
|
|
|
|
|
+ required={field.required}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Determine modes
|
|
|
|
|
+ const quickCreateMode = view?.settings?.quick_create_mode || 'modal'
|
|
|
|
|
+ const quickEditMode = view?.settings?.quick_edit_mode || 'modal'
|
|
|
|
|
+ const showCreateButton = view?.settings?.show_create_button !== false && canCreateInCollection(collectionName)
|
|
|
|
|
+ const showEditButton = view?.settings?.show_edit_button !== false && canWriteCollection(collectionName)
|
|
|
|
|
+
|
|
|
|
|
+ // Handle edit based on mode
|
|
|
|
|
+ const handleEdit = (doc: Document) => {
|
|
|
|
|
+ if (quickEditMode === 'inline') {
|
|
|
|
|
+ startInlineEdit(doc)
|
|
|
|
|
+ } else if (quickEditMode === 'page') {
|
|
|
|
|
+ handlePageEdit(doc)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Modal mode - use parent handler
|
|
|
|
|
+ onEditDocument?.(doc)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Render create button or inline form
|
|
|
|
|
+ const renderCreateArea = () => {
|
|
|
|
|
+ if (!showCreateButton) return null
|
|
|
|
|
+
|
|
|
|
|
+ if (quickCreateMode === 'inline') {
|
|
|
|
|
+ const quickFields = getQuickFields('create')
|
|
|
|
|
+ return (
|
|
|
|
|
+ <form onSubmit={handleInlineSubmit} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
|
|
|
+ {submitError && (
|
|
|
|
|
+ <div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
|
|
|
+ {submitError}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="flex flex-wrap items-end gap-3">
|
|
|
|
|
+ {quickFields.map(f => renderFormField(f, inlineFormData, setInlineFormData, true))}
|
|
|
|
|
+ <Button type="submit" size="sm" isLoading={isSubmitting}>
|
|
|
|
|
+ <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Add
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Modal or Page mode - show button
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={quickCreateMode === 'page' ? handlePageCreate : onCreateDocument}
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Add
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Render inline edit form for a document row
|
|
|
|
|
+ const renderInlineEditRow = (doc: Document) => {
|
|
|
|
|
+ if (editingDoc?.id !== doc.id) return null
|
|
|
|
|
+
|
|
|
|
|
+ const quickFields = getQuickFields('edit')
|
|
|
|
|
+ return (
|
|
|
|
|
+ <tr className="bg-blue-50">
|
|
|
|
|
+ <td colSpan={visibleColumns.length + 1} className="px-4 py-3">
|
|
|
|
|
+ <form onSubmit={handleInlineEditSubmit}>
|
|
|
|
|
+ {updateError && (
|
|
|
|
|
+ <div className="mb-3 rounded bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
|
|
|
+ {updateError}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="flex flex-wrap items-end gap-3">
|
|
|
|
|
+ {quickFields.map(f => renderFormField(f, editFormData, setEditFormData, true))}
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <Button type="submit" size="sm" isLoading={isUpdating}>
|
|
|
|
|
+ Save
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button type="button" variant="secondary" size="sm" onClick={cancelInlineEdit} disabled={isUpdating}>
|
|
|
|
|
+ Cancel
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Render loading state
|
|
// Render loading state
|
|
|
if (isLoading && documents.length === 0) {
|
|
if (isLoading && documents.length === 0) {
|
|
|
return (
|
|
return (
|
|
@@ -135,191 +529,225 @@ export function DocumentListRenderer({
|
|
|
// Render table mode
|
|
// Render table mode
|
|
|
if (config.displayMode === 'table' || !config.displayMode) {
|
|
if (config.displayMode === 'table' || !config.displayMode) {
|
|
|
return (
|
|
return (
|
|
|
- <div className="space-y-4">
|
|
|
|
|
- {/* Header with create button */}
|
|
|
|
|
- <div className="flex items-center justify-between">
|
|
|
|
|
- <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
|
|
|
|
|
- {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
|
|
|
|
|
- <Button size="sm" onClick={onCreateDocument}>
|
|
|
|
|
- <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- Add
|
|
|
|
|
- </Button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {/* Header with create button */}
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
|
|
|
|
|
+ {quickCreateMode !== 'inline' && renderCreateArea()}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Inline create form */}
|
|
|
|
|
+ {quickCreateMode === 'inline' && renderCreateArea()}
|
|
|
|
|
|
|
|
- {/* Table */}
|
|
|
|
|
- <div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
|
|
|
- <table className="min-w-full divide-y divide-gray-200">
|
|
|
|
|
- <thead className="bg-gray-50">
|
|
|
|
|
- <tr>
|
|
|
|
|
- {visibleColumns.map((colName) => {
|
|
|
|
|
- const field = fields.find((f) => f.name === colName)
|
|
|
|
|
- return (
|
|
|
|
|
- <th
|
|
|
|
|
- key={colName}
|
|
|
|
|
- className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
|
|
|
- >
|
|
|
|
|
- {field?.label || colName}
|
|
|
|
|
- </th>
|
|
|
|
|
- )
|
|
|
|
|
- })}
|
|
|
|
|
- <th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
|
|
|
- Actions
|
|
|
|
|
- </th>
|
|
|
|
|
- </tr>
|
|
|
|
|
- </thead>
|
|
|
|
|
- <tbody className="divide-y divide-gray-200 bg-white">
|
|
|
|
|
- {documents.length === 0 ? (
|
|
|
|
|
|
|
+ {/* Table */}
|
|
|
|
|
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
|
|
|
+ <table className="min-w-full divide-y divide-gray-200">
|
|
|
|
|
+ <thead className="bg-gray-50">
|
|
|
<tr>
|
|
<tr>
|
|
|
- <td colSpan={visibleColumns.length + 1} className="px-4 py-8 text-center text-gray-500">
|
|
|
|
|
- No documents found
|
|
|
|
|
- </td>
|
|
|
|
|
|
|
+ {visibleColumns.map((colName) => {
|
|
|
|
|
+ const field = fields.find((f) => f.name === colName)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <th
|
|
|
|
|
+ key={colName}
|
|
|
|
|
+ className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
|
|
|
+ >
|
|
|
|
|
+ {field?.label || colName}
|
|
|
|
|
+ </th>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ <th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
|
|
|
|
|
+ Actions
|
|
|
|
|
+ </th>
|
|
|
</tr>
|
|
</tr>
|
|
|
- ) : (
|
|
|
|
|
- documents.map((doc) => (
|
|
|
|
|
- <tr key={doc.id} className="hover:bg-gray-50">
|
|
|
|
|
- {visibleColumns.map((colName) => (
|
|
|
|
|
- <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
|
|
|
|
|
- {formatCellValue(doc.data[colName])}
|
|
|
|
|
- </td>
|
|
|
|
|
- ))}
|
|
|
|
|
- <td className="whitespace-nowrap px-4 py-3 text-right text-sm">
|
|
|
|
|
- <div className="flex justify-end gap-2">
|
|
|
|
|
- {canWriteCollection(collectionName) && (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => onEditDocument?.(doc)}
|
|
|
|
|
- className="text-primary-600 hover:text-primary-800"
|
|
|
|
|
- >
|
|
|
|
|
- Edit
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- {canDeleteInCollection(collectionName) && (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => onDeleteDocument?.(doc)}
|
|
|
|
|
- className="text-red-600 hover:text-red-800"
|
|
|
|
|
- >
|
|
|
|
|
- Delete
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ </thead>
|
|
|
|
|
+ <tbody className="divide-y divide-gray-200 bg-white">
|
|
|
|
|
+ {documents.length === 0 ? (
|
|
|
|
|
+ <tr>
|
|
|
|
|
+ <td colSpan={visibleColumns.length + 1} className="px-4 py-8 text-center text-gray-500">
|
|
|
|
|
+ No documents found
|
|
|
</td>
|
|
</td>
|
|
|
</tr>
|
|
</tr>
|
|
|
- ))
|
|
|
|
|
- )}
|
|
|
|
|
- </tbody>
|
|
|
|
|
- </table>
|
|
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ documents.map((doc) => (
|
|
|
|
|
+ editingDoc?.id === doc.id && quickEditMode === 'inline' ? (
|
|
|
|
|
+ renderInlineEditRow(doc)
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <tr key={doc.id} className="hover:bg-gray-50">
|
|
|
|
|
+ {visibleColumns.map((colName) => (
|
|
|
|
|
+ <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
|
|
|
|
|
+ {formatCellValue(doc.data[colName], fields.find(f => f.name === colName)?.type)}
|
|
|
|
|
+ </td>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <td className="whitespace-nowrap px-4 py-3 text-right text-sm">
|
|
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
|
|
+ {showEditButton && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleEdit(doc)}
|
|
|
|
|
+ className="text-primary-600 hover:text-primary-800"
|
|
|
|
|
+ >
|
|
|
|
|
+ Edit
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {canDeleteInCollection(collectionName) && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleDeleteClick(doc)}
|
|
|
|
|
+ className="text-red-600 hover:text-red-800"
|
|
|
|
|
+ >
|
|
|
|
|
+ Delete
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </td>
|
|
|
|
|
+ </tr>
|
|
|
|
|
+ )
|
|
|
|
|
+ ))
|
|
|
|
|
+ )}
|
|
|
|
|
+ </tbody>
|
|
|
|
|
+ </table>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Pagination */}
|
|
|
|
|
+ {(page > 1 || hasMore) && (
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
+ disabled={page === 1}
|
|
|
|
|
+ >
|
|
|
|
|
+ Previous
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <span className="text-sm text-gray-600">Page {page}</span>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() => setPage((p) => p + 1)}
|
|
|
|
|
+ disabled={!hasMore}
|
|
|
|
|
+ >
|
|
|
|
|
+ Next
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Pagination */}
|
|
|
|
|
- {(page > 1 || hasMore) && (
|
|
|
|
|
- <div className="flex items-center justify-between">
|
|
|
|
|
- <Button
|
|
|
|
|
- variant="secondary"
|
|
|
|
|
- size="sm"
|
|
|
|
|
- onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
- disabled={page === 1}
|
|
|
|
|
- >
|
|
|
|
|
- Previous
|
|
|
|
|
- </Button>
|
|
|
|
|
- <span className="text-sm text-gray-600">Page {page}</span>
|
|
|
|
|
- <Button
|
|
|
|
|
- variant="secondary"
|
|
|
|
|
- size="sm"
|
|
|
|
|
- onClick={() => setPage((p) => p + 1)}
|
|
|
|
|
- disabled={!hasMore}
|
|
|
|
|
- >
|
|
|
|
|
- Next
|
|
|
|
|
- </Button>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ {/* Delete confirmation modal */}
|
|
|
|
|
+ <ConfirmModal
|
|
|
|
|
+ isOpen={deleteDoc !== null}
|
|
|
|
|
+ title="Delete Item"
|
|
|
|
|
+ message="Are you sure you want to delete this item? This action cannot be undone."
|
|
|
|
|
+ confirmLabel="Delete"
|
|
|
|
|
+ variant="danger"
|
|
|
|
|
+ isLoading={isDeleting}
|
|
|
|
|
+ onConfirm={handleDeleteConfirm}
|
|
|
|
|
+ onCancel={() => setDeleteDoc(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Render card mode
|
|
|
|
|
|
|
+ // Render card mode - show all fields
|
|
|
if (config.displayMode === 'cards') {
|
|
if (config.displayMode === 'cards') {
|
|
|
- const titleField = config.cardTemplate?.titleField || visibleColumns[0]
|
|
|
|
|
- const subtitleField = config.cardTemplate?.subtitleField
|
|
|
|
|
- const badgeField = config.cardTemplate?.badgeField
|
|
|
|
|
|
|
+ // First visible column is title, rest are body fields
|
|
|
|
|
+ const titleField = visibleColumns[0]
|
|
|
|
|
+ const bodyFields = visibleColumns.slice(1)
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div className="space-y-4">
|
|
|
|
|
- {/* Header with create button */}
|
|
|
|
|
- <div className="flex items-center justify-between">
|
|
|
|
|
- <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
|
|
|
|
|
- {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
|
|
|
|
|
- <Button size="sm" onClick={onCreateDocument}>
|
|
|
|
|
- <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- Add
|
|
|
|
|
- </Button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* Card grid */}
|
|
|
|
|
- {documents.length === 0 ? (
|
|
|
|
|
- <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
|
|
|
|
|
- <p className="text-gray-500">No documents found</p>
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {/* Header with create button */}
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
|
|
|
|
|
+ {quickCreateMode !== 'inline' && renderCreateArea()}
|
|
|
</div>
|
|
</div>
|
|
|
- ) : (
|
|
|
|
|
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
|
- {documents.map((doc) => (
|
|
|
|
|
- <div
|
|
|
|
|
- key={doc.id}
|
|
|
|
|
- className="group rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
|
|
|
|
|
- >
|
|
|
|
|
- <div className="flex items-start justify-between">
|
|
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
|
|
- <h4 className="truncate font-medium text-gray-900">
|
|
|
|
|
- {formatCellValue(doc.data[titleField])}
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Inline create form */}
|
|
|
|
|
+ {quickCreateMode === 'inline' && renderCreateArea()}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Card grid */}
|
|
|
|
|
+ {documents.length === 0 ? (
|
|
|
|
|
+ <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
|
|
|
|
|
+ <p className="text-gray-500">No documents found</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
|
+ {documents.map((doc) => {
|
|
|
|
|
+ const field = fields.find(f => f.name === titleField)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={doc.id}
|
|
|
|
|
+ className="group rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Title */}
|
|
|
|
|
+ <h4 className="font-medium text-gray-900 truncate">
|
|
|
|
|
+ {formatCellValue(doc.data[titleField], field?.type)}
|
|
|
</h4>
|
|
</h4>
|
|
|
- {subtitleField && (
|
|
|
|
|
- <p className="mt-1 truncate text-sm text-gray-500">
|
|
|
|
|
- {formatCellValue(doc.data[subtitleField])}
|
|
|
|
|
- </p>
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Body fields */}
|
|
|
|
|
+ {bodyFields.length > 0 && (
|
|
|
|
|
+ <div className="mt-3 space-y-1.5">
|
|
|
|
|
+ {bodyFields.map(colName => {
|
|
|
|
|
+ const bodyField = fields.find(f => f.name === colName)
|
|
|
|
|
+ const val = doc.data[colName]
|
|
|
|
|
+ if (val === null || val === undefined) return null
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={colName} className="flex items-center text-sm">
|
|
|
|
|
+ <span className="text-gray-500 mr-2">{bodyField?.label || colName}:</span>
|
|
|
|
|
+ <span className="text-gray-900 truncate">
|
|
|
|
|
+ {formatCellValue(val, bodyField?.type)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Card actions */}
|
|
|
|
|
+ <div className="mt-4 flex justify-end gap-2 opacity-0 transition group-hover:opacity-100">
|
|
|
|
|
+ {showEditButton && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleEdit(doc)}
|
|
|
|
|
+ className="rounded px-2 py-1 text-xs text-primary-600 hover:bg-primary-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ Edit
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {canDeleteInCollection(collectionName) && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleDeleteClick(doc)}
|
|
|
|
|
+ className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ Delete
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- {badgeField && Boolean(doc.data[badgeField]) && (
|
|
|
|
|
- <span className="ml-2 inline-flex flex-shrink-0 items-center rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-800">
|
|
|
|
|
- {formatCellValue(doc.data[badgeField])}
|
|
|
|
|
- </span>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- {/* Card actions */}
|
|
|
|
|
- <div className="mt-4 flex justify-end gap-2 opacity-0 transition group-hover:opacity-100">
|
|
|
|
|
- {canWriteCollection(collectionName) && (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => onEditDocument?.(doc)}
|
|
|
|
|
- className="rounded px-2 py-1 text-xs text-primary-600 hover:bg-primary-50"
|
|
|
|
|
- >
|
|
|
|
|
- Edit
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- {canDeleteInCollection(collectionName) && (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => onDeleteDocument?.(doc)}
|
|
|
|
|
- className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
|
|
|
|
- >
|
|
|
|
|
- Delete
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ {/* Delete confirmation modal */}
|
|
|
|
|
+ <ConfirmModal
|
|
|
|
|
+ isOpen={deleteDoc !== null}
|
|
|
|
|
+ title="Delete Item"
|
|
|
|
|
+ message="Are you sure you want to delete this item? This action cannot be undone."
|
|
|
|
|
+ confirmLabel="Delete"
|
|
|
|
|
+ variant="danger"
|
|
|
|
|
+ isLoading={isDeleting}
|
|
|
|
|
+ onConfirm={handleDeleteConfirm}
|
|
|
|
|
+ onCancel={() => setDeleteDoc(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Render kanban mode (simplified version)
|
|
|
|
|
|
|
+ // Render kanban mode - show all fields in cards
|
|
|
if (config.displayMode === 'kanban') {
|
|
if (config.displayMode === 'kanban') {
|
|
|
const groupField = config.kanbanGroupField || visibleColumns[0]
|
|
const groupField = config.kanbanGroupField || visibleColumns[0]
|
|
|
- const titleField = config.cardTemplate?.titleField || visibleColumns[0]
|
|
|
|
|
|
|
+ const titleField = visibleColumns.find(c => c !== groupField) || visibleColumns[0]
|
|
|
|
|
+ const bodyFields = visibleColumns.filter(c => c !== groupField && c !== titleField)
|
|
|
|
|
|
|
|
// Group documents by the kanban field
|
|
// Group documents by the kanban field
|
|
|
const groups: Record<string, Document[]> = {}
|
|
const groups: Record<string, Document[]> = {}
|
|
@@ -332,61 +760,108 @@ export function DocumentListRenderer({
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div className="space-y-4">
|
|
|
|
|
- {/* Header with create button */}
|
|
|
|
|
- <div className="flex items-center justify-between">
|
|
|
|
|
- <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
|
|
|
|
|
- {config.showCreateButton !== false && canCreateInCollection(collectionName) && (
|
|
|
|
|
- <Button size="sm" onClick={onCreateDocument}>
|
|
|
|
|
- <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- Add
|
|
|
|
|
- </Button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {/* Header with create button */}
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <h3 className="text-lg font-medium text-gray-900">{view?.name || 'Documents'}</h3>
|
|
|
|
|
+ {quickCreateMode !== 'inline' && renderCreateArea()}
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- {/* Kanban columns */}
|
|
|
|
|
- <div className="flex gap-4 overflow-x-auto pb-4">
|
|
|
|
|
- {Object.entries(groups).map(([groupName, groupDocs]) => (
|
|
|
|
|
- <div
|
|
|
|
|
- key={groupName}
|
|
|
|
|
- className="flex w-72 flex-shrink-0 flex-col rounded-lg bg-gray-100"
|
|
|
|
|
- >
|
|
|
|
|
- <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
|
|
|
|
- <h4 className="font-medium text-gray-900">{groupName}</h4>
|
|
|
|
|
- <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">
|
|
|
|
|
- {groupDocs.length}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className="flex-1 space-y-2 overflow-y-auto p-2">
|
|
|
|
|
- {groupDocs.map((doc) => (
|
|
|
|
|
- <div
|
|
|
|
|
- key={doc.id}
|
|
|
|
|
- className="group rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
|
|
|
|
- >
|
|
|
|
|
- <p className="font-medium text-gray-900">
|
|
|
|
|
- {formatCellValue(doc.data[titleField])}
|
|
|
|
|
- </p>
|
|
|
|
|
- <div className="mt-2 flex justify-end gap-1 opacity-0 transition group-hover:opacity-100">
|
|
|
|
|
- {canWriteCollection(collectionName) && (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => onEditDocument?.(doc)}
|
|
|
|
|
- className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- ))}
|
|
|
|
|
|
|
+ {/* Inline create form */}
|
|
|
|
|
+ {quickCreateMode === 'inline' && renderCreateArea()}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Kanban columns */}
|
|
|
|
|
+ <div className="flex gap-4 overflow-x-auto pb-4">
|
|
|
|
|
+ {Object.entries(groups).map(([groupName, groupDocs]) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={groupName}
|
|
|
|
|
+ className="flex w-72 flex-shrink-0 flex-col rounded-lg bg-gray-100"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
|
|
|
|
+ <h4 className="font-medium text-gray-900">{groupName}</h4>
|
|
|
|
|
+ <span className="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">
|
|
|
|
|
+ {groupDocs.length}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 space-y-2 overflow-y-auto p-2">
|
|
|
|
|
+ {groupDocs.map((doc) => {
|
|
|
|
|
+ const field = fields.find(f => f.name === titleField)
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={doc.id}
|
|
|
|
|
+ className="group rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Title */}
|
|
|
|
|
+ <p className="font-medium text-gray-900">
|
|
|
|
|
+ {formatCellValue(doc.data[titleField], field?.type)}
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Body fields */}
|
|
|
|
|
+ {bodyFields.length > 0 && (
|
|
|
|
|
+ <div className="mt-2 space-y-1">
|
|
|
|
|
+ {bodyFields.map(colName => {
|
|
|
|
|
+ const bodyField = fields.find(f => f.name === colName)
|
|
|
|
|
+ const val = doc.data[colName]
|
|
|
|
|
+ if (val === null || val === undefined) return null
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={colName} className="text-xs text-gray-500">
|
|
|
|
|
+ <span>{bodyField?.label || colName}: </span>
|
|
|
|
|
+ <span className="text-gray-700">
|
|
|
|
|
+ {formatCellValue(val, bodyField?.type)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Actions */}
|
|
|
|
|
+ <div className="mt-2 flex justify-end gap-1 opacity-0 transition group-hover:opacity-100">
|
|
|
|
|
+ {showEditButton && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleEdit(doc)}
|
|
|
|
|
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
|
|
|
|
+ title="Edit"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {canDeleteInCollection(collectionName) && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleDeleteClick(doc)}
|
|
|
|
|
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
|
|
|
|
+ title="Delete"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- ))}
|
|
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Delete confirmation modal */}
|
|
|
|
|
+ <ConfirmModal
|
|
|
|
|
+ isOpen={deleteDoc !== null}
|
|
|
|
|
+ title="Delete Item"
|
|
|
|
|
+ message="Are you sure you want to delete this item? This action cannot be undone."
|
|
|
|
|
+ confirmLabel="Delete"
|
|
|
|
|
+ variant="danger"
|
|
|
|
|
+ isLoading={isDeleting}
|
|
|
|
|
+ onConfirm={handleDeleteConfirm}
|
|
|
|
|
+ onCancel={() => setDeleteDoc(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -394,13 +869,16 @@ export function DocumentListRenderer({
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Helper function to format cell values for display
|
|
// Helper function to format cell values for display
|
|
|
-function formatCellValue(value: unknown): string {
|
|
|
|
|
|
|
+function formatCellValue(value: unknown, fieldType?: string): string {
|
|
|
if (value === null || value === undefined) {
|
|
if (value === null || value === undefined) {
|
|
|
return '-'
|
|
return '-'
|
|
|
}
|
|
}
|
|
|
if (typeof value === 'boolean') {
|
|
if (typeof value === 'boolean') {
|
|
|
return value ? 'Yes' : 'No'
|
|
return value ? 'Yes' : 'No'
|
|
|
}
|
|
}
|
|
|
|
|
+ if (fieldType === 'color' && typeof value === 'string' && value.startsWith('#')) {
|
|
|
|
|
+ return value // Could render as color swatch in future
|
|
|
|
|
+ }
|
|
|
if (value instanceof Date) {
|
|
if (value instanceof Date) {
|
|
|
return value.toLocaleDateString()
|
|
return value.toLocaleDateString()
|
|
|
}
|
|
}
|