diff --git a/src/app/dashboard/[entity]/page.tsx b/src/app/dashboard/[entity]/page.tsx index d2173c2..c38dd12 100644 --- a/src/app/dashboard/[entity]/page.tsx +++ b/src/app/dashboard/[entity]/page.tsx @@ -1,5 +1,5 @@ import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite'; -import { createSessionClient } from '@/lib/appwrite.server'; +import { createSessionClient, getLoggedInUser } from '@/lib/appwrite.server'; import { Query } from 'node-appwrite'; import Link from 'next/link'; import { collectionMap, SCHEMA_MAP } from '@/lib/schema'; @@ -42,7 +42,8 @@ export default async function EntityPage({ ); } - // Fetch preferences + // Fetch user and preferences + const user = await getLoggedInUser(); const prefs = await getUserPreferences(); const hiddenColumns = prefs.hiddenColumns?.[entity] || []; @@ -126,6 +127,7 @@ export default async function EntityPage({ initialHiddenColumns={hiddenColumns} currentSortField={sort} currentSortDir={dir} + user={user} /> {/* Pagination */} diff --git a/src/components/dashboard/DataTable.tsx b/src/components/dashboard/DataTable.tsx index 2ba1c21..eef26a0 100644 --- a/src/components/dashboard/DataTable.tsx +++ b/src/components/dashboard/DataTable.tsx @@ -17,6 +17,7 @@ interface DataTableProps { initialHiddenColumns: string[]; currentSortField: string; currentSortDir: 'asc' | 'desc'; + user: any; } export const DataTable = ({ @@ -26,7 +27,8 @@ export const DataTable = ({ fieldsConfig, initialHiddenColumns, currentSortField, - currentSortDir + currentSortDir, + user }: DataTableProps) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -117,8 +119,19 @@ export const DataTable = ({ - {documents.map((doc) => ( - + {documents.map((doc) => { + const canEdit = user?.$id === 'admin-emergency' || user?.labels?.includes('admin') || user?.labels?.includes('editor'); + + return ( + { + if (canEdit) { + router.push(`/dashboard/${entity}/${doc.$id}`); + } + }} + > {fieldsConfig.map(field => { if (!visibleColumns.includes(field.name)) return null; @@ -153,7 +166,7 @@ export const DataTable = ({ - ))} + )})} diff --git a/src/components/dashboard/EntityForm.tsx b/src/components/dashboard/EntityForm.tsx index 9f49076..6e9d26c 100644 --- a/src/components/dashboard/EntityForm.tsx +++ b/src/components/dashboard/EntityForm.tsx @@ -7,6 +7,9 @@ import Link from 'next/link'; import { FieldDefinition } from '@/lib/schema'; import { MediaPickerMini } from './MediaPickerMini'; import { RichTextEditor } from './RichTextEditor'; +import { QuickCreateModal } from './QuickCreateModal'; +import { collectionMap } from '@/lib/schema'; +import { Client, Databases } from 'appwrite'; // Minimalistic image preview for relations with 'media' const MediaPreview = ({ fileId, bucketId, endpoint, projectId }: any) => { @@ -52,6 +55,11 @@ export const EntityForm = ({ const [isAutoSlug, setIsAutoSlug] = useState(!initialData?.slug); const [featuredImageId, setFeaturedImageId] = useState(initialData?.imagen_destacada_id || ''); const [editorValues, setEditorValues] = useState>({}); + const [localRelationData, setLocalRelationData] = useState(relationData); + const [activeTab, setActiveTab] = useState(''); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalEntity, setModalEntity] = useState(''); + const [activeRelationField, setActiveRelationField] = useState(''); // Initialize editor values useEffect(() => { @@ -77,6 +85,28 @@ export const EntityForm = ({ } }, [title, isAutoSlug]); + const sections = Array.from(new Set(fields.map(f => f.section || 'General'))); + + useEffect(() => { + if (sections.length > 0 && !activeTab) { + setActiveTab(sections[0]); + } + }, [sections, activeTab]); + + const refreshRelation = async (field: string, collectionId: string) => { + const client = new Client() + .setEndpoint(appwriteConfig.endpoint) + .setProject(appwriteConfig.projectId); + const databases = new Databases(client); + + try { + const res = await databases.listDocuments('almeira', collectionId, []); + setLocalRelationData(prev => ({ ...prev, [field]: res.documents })); + } catch (err) { + console.error("Error al refrescar relación:", err); + } + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.currentTarget); @@ -123,6 +153,7 @@ export const EntityForm = ({ }; return ( + <>
{/* Header Sticky */}
@@ -158,186 +189,129 @@ export const EntityForm = ({
-
- {/* Main Column */} -
-
+
+ {sections.map(section => ( + + ))} +
+ +
+
- {mainFields.map(field => { - const defaultValue = getDefValue(field); - - if (field.name === 'titulo' || field.name === 'nombre_entidad' || field.name === 'numero_contrato') { - return ( -
- - (field.name === 'titulo' || field.name === 'nombre_entidad') && setTitle(e.target.value)} - placeholder={`Ingresa el ${field.label.toLowerCase()}...`} - className="w-full bg-transparent border-b-2 border-slate-800 py-4 text-2xl md:text-3xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700" - /> -
- ); - } +
+ {fields.filter(f => (f.section || 'General') === activeTab).map(field => { + const defaultValue = getDefValue(field); + const gridSpanMap: Record = { + 1: 'col-span-1', 2: 'col-span-2', 3: 'col-span-3', 4: 'col-span-4', + 5: 'col-span-5', 6: 'col-span-6', 7: 'col-span-7', 8: 'col-span-8', + 9: 'col-span-9', 10: 'col-span-10', 11: 'col-span-11', 12: 'col-span-12' + }; + const gridSpan = gridSpanMap[field.gridSize || 12] || 'col-span-12'; + + return ( +
+
+ + {field.type === 'relation' && ( + Double click to create quick + + )} +
- if (field.type === 'textarea') { - return ( - setEditorValues(prev => ({ ...prev, [field.name]: val }))} - /> - ); - } - - return ( -
- - - {field.type === 'text' && ( - - )} - - {field.type === 'number' && ( - - )} - - {field.type === 'url' && ( - - )} - - {field.type === 'boolean' && ( - - )} - - {field.type === 'select' && field.options && ( - - )} - - {field.type === 'relation' && ( - - )} -
- ); - })} -
-
- - {/* Sidebar Column */} -
-
-

- Configuración de Publicación -

- - {sidebarFields.map(field => { - const defaultValue = getDefValue(field); - - return ( -
-
- - {field.name === 'slug' && ( - + {field.name === 'titulo' || field.name === 'nombre_entidad' ? ( + setTitle(e.target.value)} + placeholder={`Nombre de ${field.label}...`} + className="w-full bg-transparent border-b-2 border-slate-800 py-2 text-2xl font-bold text-white focus:outline-none focus:border-cyan-500 transition-all placeholder:text-slate-700" + /> + ) : field.type === 'textarea' ? ( + setEditorValues(prev => ({ ...prev, [field.name]: val }))} + /> + ) : field.type === 'relation' ? ( +
+ + {field.relationCollection === 'media' && ( + + )} +
+ ) : field.type === 'select' ? ( + + ) : field.type === 'date' ? ( + + ) : field.type === 'boolean' ? ( + + ) : ( + )}
- - {field.name === 'slug' ? ( - { setIsAutoSlug(false); setSlug(e.target.value); }} - required={field.required} - className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-cyan-100 font-mono focus:outline-none focus:border-cyan-500 transition-all font-bold" - /> - ) : field.name === 'imagen_destacada_id' ? ( - - ) : field.type === 'date' ? ( - - ) : field.type === 'select' ? ( - - ) : field.type === 'relation' ? ( - <> - - {field.relationCollection === 'media' && ( - - )} - - ) : ( - - )} -
- ); - })} -
- -
-
-
- Estado del Sistema -
-

- “Recuerda verificar el slug antes de publicar. Los cambios en el estado afectarán inmediatamente la visibilidad en el portal de noticias.” -

-
+ ); + })} +
+ + setIsModalOpen(false)} + onSuccess={async (newId) => { + const relField = fields.find(f => f.name === activeRelationField); + if (relField?.relationCollection) { + await refreshRelation(activeRelationField, collectionMap[relField.relationCollection]); + // Set wait to ensure state has updated options before selecting + setTimeout(() => { + const selectElement = document.querySelector(`select[name="${activeRelationField}"]`) as HTMLSelectElement; + if (selectElement) { + selectElement.value = newId; + } + }, 1000); + } + }} + /> + ); }; diff --git a/src/components/dashboard/QuickCreateModal.tsx b/src/components/dashboard/QuickCreateModal.tsx new file mode 100644 index 0000000..559a697 --- /dev/null +++ b/src/components/dashboard/QuickCreateModal.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; +import { X, Save, Plus } from 'lucide-react'; +import { FieldDefinition, SCHEMA_MAP, collectionMap } from '@/lib/schema'; +import { createDocumentServer } from '@/app/actions/appwrite'; + +interface QuickCreateModalProps { + entity: string; + isOpen: boolean; + onClose: () => void; + onSuccess: (newId: string) => void; +} + +export const QuickCreateModal = ({ entity, isOpen, onClose, onSuccess }: QuickCreateModalProps) => { + const [isPending, setIsPending] = useState(false); + const fields = SCHEMA_MAP[entity] || []; + const collectionId = collectionMap[entity]; + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsPending(true); + + try { + const formData = new FormData(e.currentTarget); + const data: Record = {}; + for (const [key, value] of formData.entries()) { + data[key] = value; + } + + // Simple type conversion + fields.forEach(f => { + if (f.type === 'number' && data[f.name]) data[f.name] = parseFloat(data[f.name]); + if (f.type === 'boolean') data[f.name] = data[f.name] === 'true'; + }); + + const res = await createDocumentServer(collectionId, data); + if (res.success && res.data?.$id) { + onSuccess(res.data.$id); + onClose(); + } else { + alert("Error al crear: " + (res.error || "Desconocido")); + } + } catch (error) { + console.error("QuickCreate error:", error); + } finally { + setIsPending(false); + } + }; + + return ( +
+
+
+
+
+ +
+

+ Creación Rápida: {entity} +

+
+ +
+ +
+
+ {fields.filter(f => f.name !== 'slug').map(field => ( +
+ + {field.type === 'textarea' ? ( + + ) : field.type === 'select' ? ( + + ) : ( + + )} +
+ ))} +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 1268963..e8cb394 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -8,24 +8,26 @@ export type FieldDefinition = { options?: string[]; // For 'select' type relationCollection?: string; // Appwrite collection alias relationLabel?: string; // Field to display in relation select + section?: string; // For grouping in the form + gridSize?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; // 12-column grid system }; export const SCHEMA_MAP: Record = { posts: [ - { name: 'titulo', label: 'Título', type: 'text', required: true }, - { name: 'slug', label: 'URL Amigable (Slug)', type: 'text', required: true }, - { name: 'tipo_publicacion_id', label: 'Tipo de Publicación', type: 'relation', relationCollection: 'publication_types', relationLabel: 'descripcion', required: true }, - { name: 'estado', label: 'Estado', type: 'select', options: ['borrador', 'pendiente', 'publicado'], required: true }, - { name: 'fecha_publicacion', label: 'Fecha de Publicación', type: 'date', required: true }, - { name: 'fecha_finalizacion', label: 'Expiración / Caducidad', type: 'date' }, - { name: 'cliente_id', label: 'Patrocinador Cliente', type: 'relation', relationCollection: 'clients', relationLabel: 'nombre_entidad' }, - { name: 'url_destino', label: 'URL Destino (Banner/Click)', type: 'url' }, - { name: 'resumen_redes', label: 'Resumen para Redes', type: 'textarea' }, - { name: 'contenido', label: 'Contenido Completo', type: 'textarea', required: true }, - { name: 'imagen_destacada_id', label: 'ID de Imagen (Relacional)', type: 'relation', relationCollection: 'media', relationLabel: 'titulo' }, - { name: 'video_url', label: 'URL de Video', type: 'url' }, - { name: 'seo_titulo', label: 'SEO Título', type: 'text' }, - { name: 'seo_descripcion', label: 'SEO Descripción', type: 'text' }, + { name: 'titulo', label: 'Título', type: 'text', required: true, section: 'Datos Básicos', gridSize: 12 }, + { name: 'slug', label: 'URL Amigable (Slug)', type: 'text', required: true, section: 'Metadatos', gridSize: 6 }, + { name: 'tipo_publicacion_id', label: 'Tipo de Publicación', type: 'relation', relationCollection: 'publication_types', relationLabel: 'descripcion', required: true, section: 'Metadatos', gridSize: 6 }, + { name: 'estado', label: 'Estado', type: 'select', options: ['borrador', 'pendiente', 'publicado'], required: true, section: 'Configuración', gridSize: 4 }, + { name: 'fecha_publicacion', label: 'Fecha de Publicación', type: 'date', required: true, section: 'Configuración', gridSize: 4 }, + { name: 'fecha_finalizacion', label: 'Expiración / Caducidad', type: 'date', section: 'Configuración', gridSize: 4 }, + { name: 'cliente_id', label: 'Patrocinador Cliente', type: 'relation', relationCollection: 'clients', relationLabel: 'nombre_entidad', section: 'Comercial', gridSize: 6 }, + { name: 'url_destino', label: 'URL Destino (Banner/Click)', type: 'url', section: 'Comercial', gridSize: 6 }, + { name: 'resumen_redes', label: 'Resumen para Redes', type: 'textarea', section: 'Datos Básicos', gridSize: 12 }, + { name: 'contenido', label: 'Contenido Completo', type: 'textarea', required: true, section: 'Datos Básicos', gridSize: 12 }, + { name: 'imagen_destacada_id', label: 'Imagen Destacada', type: 'relation', relationCollection: 'media', relationLabel: 'titulo', section: 'Multimedia', gridSize: 12 }, + { name: 'video_url', label: 'URL de Video (Youtube/Vimeo)', type: 'url', section: 'Multimedia', gridSize: 12 }, + { name: 'seo_titulo', label: 'SEO Título', type: 'text', section: 'SEO', gridSize: 6 }, + { name: 'seo_descripcion', label: 'SEO Descripción', type: 'text', section: 'SEO', gridSize: 6 }, ], categories: [ { name: 'nombre', label: 'Nombre de la Categoría', type: 'text', required: true },