332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* @file RelationSelectField.tsx
|
||
* @description A styled select field for relation fields that supports:
|
||
* - Standard single-click selection
|
||
* - Double-click to open QuickCreateModal for in-place record creation
|
||
* - Auto-update when a new record is injected via usePublicationStore
|
||
*
|
||
* Usage:
|
||
* ```tsx
|
||
* <RelationSelectField
|
||
* name="tipo_publicacion_id"
|
||
* label="Tipo de Publicación"
|
||
* options={publicationTypes}
|
||
* labelKey="descripcion"
|
||
* relationCollection="publication_types"
|
||
* required
|
||
* />
|
||
* ```
|
||
*/
|
||
|
||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { PlusCircle, ChevronDown, MousePointerClick, Image as ImageIcon } from 'lucide-react';
|
||
import { RelationSelectFieldProps } from '@/types/publications';
|
||
import { usePublicationStore } from '@/store/usePublicationStore';
|
||
import { collectionMap } from '@/lib/schema';
|
||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||
|
||
// ─────────────────────────────────────────────
|
||
// QUICK CREATE MODAL (Portal-based)
|
||
// ─────────────────────────────────────────────
|
||
|
||
interface QuickCreatePortalProps {
|
||
collection: string;
|
||
fieldName: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function QuickCreatePortal({ collection, fieldName, onClose }: QuickCreatePortalProps) {
|
||
const { injectRelationValue } = usePublicationStore();
|
||
const [fields, setFields] = useState<Record<string, string>>({});
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const res = await fetch('/api/publications/quick-create', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ collection, data: fields }),
|
||
});
|
||
|
||
const json = await res.json();
|
||
|
||
if (!res.ok || !json.success) {
|
||
throw new Error(json.error || 'Error al crear el registro.');
|
||
}
|
||
|
||
// Inject the new record into the store so the parent select updates
|
||
injectRelationValue(fieldName, collection, json.data);
|
||
onClose();
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : 'Error desconocido.');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
// Determine simple fields to show for quick creation
|
||
const quickFields: { name: string; label: string }[] = (() => {
|
||
const schemaMap: Record<string, Array<{ name: string; label: string; type: string }>> = {
|
||
publication_types: [
|
||
{ name: 'codigo', label: 'Código', type: 'text' },
|
||
{ name: 'descripcion', label: 'Descripción', type: 'text' },
|
||
],
|
||
clients: [
|
||
{ name: 'nombre_entidad', label: 'Nombre de la Empresa', type: 'text' },
|
||
{ name: 'numero_whatsapp', label: 'WhatsApp', type: 'text' },
|
||
{ name: 'slug', label: 'Slug', type: 'text' },
|
||
],
|
||
categories: [
|
||
{ name: 'nombre', label: 'Nombre', type: 'text' },
|
||
{ name: 'slug', label: 'Slug', type: 'text' },
|
||
],
|
||
};
|
||
return schemaMap[collection] ?? [{ name: 'descripcion', label: 'Descripción', type: 'text' }];
|
||
})();
|
||
|
||
const modal = (
|
||
<div
|
||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Crear registro rápido"
|
||
>
|
||
{/* Backdrop */}
|
||
<div
|
||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||
onClick={onClose}
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
{/* Modal Card */}
|
||
<div className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||
{/* Header */}
|
||
<div className="px-6 py-5 border-b border-slate-800 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-cyan-500/10 rounded-lg text-cyan-400">
|
||
<PlusCircle size={18} />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-black text-white uppercase tracking-widest">Crear Nuevo</h3>
|
||
<p className="text-[10px] text-slate-500 font-medium mt-0.5 capitalize">{collection.replace(/_/g, ' ')}</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="text-slate-500 hover:text-white transition-colors text-xl leading-none"
|
||
aria-label="Cerrar modal"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Form */}
|
||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||
{quickFields.map((field) => (
|
||
<div key={field.name}>
|
||
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 block mb-1.5">
|
||
{field.label}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={fields[field.name] || ''}
|
||
onChange={(e) => setFields((prev) => ({ ...prev, [field.name]: e.target.value }))}
|
||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm focus:border-cyan-500 focus:outline-none transition-colors"
|
||
/>
|
||
</div>
|
||
))}
|
||
|
||
{error && (
|
||
<p className="text-red-400 text-xs font-bold bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-2">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex gap-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 py-2.5 rounded-xl border border-slate-700 text-slate-400 text-sm font-bold hover:border-slate-600 hover:text-white transition-all"
|
||
>
|
||
Cancelar
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
className="flex-1 py-2.5 rounded-xl bg-cyan-500 text-slate-950 text-sm font-black hover:bg-cyan-400 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{saving ? 'Guardando…' : 'Crear'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// Use a Portal to render outside the form DOM tree
|
||
return createPortal(modal, document.body);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// MEDIA PREVIEW THUMBNAIL
|
||
// ─────────────────────────────────────────────
|
||
|
||
function MediaThumbnail({ fileId }: { fileId: string }) {
|
||
if (!fileId) return null;
|
||
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://nyc.cloud.appwrite.io/v1';
|
||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '6aef63d9002c07371b40';
|
||
|
||
const url = `${endpoint}/storage/buckets/media/files/${fileId}/preview?project=${projectId}&width=400&height=250&quality=80`;
|
||
|
||
return (
|
||
<div className="mt-3 rounded-xl overflow-hidden border border-slate-800 bg-slate-900 aspect-video relative group animate-fade-in shadow-lg">
|
||
<img src={url} alt="Recurso Seleccionado" className="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" />
|
||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none">
|
||
<div className="flex items-center gap-2 text-cyan-400">
|
||
<ImageIcon size={14} />
|
||
<span className="text-[9px] font-black uppercase tracking-widest">Previsualización Activa</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// RELATION SELECT FIELD
|
||
// ─────────────────────────────────────────────
|
||
|
||
export const RelationSelectField: React.FC<RelationSelectFieldProps> = ({
|
||
name,
|
||
label,
|
||
options: initialOptions,
|
||
labelKey,
|
||
value: externalValue,
|
||
onChange,
|
||
relationCollection,
|
||
required = false,
|
||
disabled = false,
|
||
}) => {
|
||
const {
|
||
relationCache,
|
||
pendingRelationField,
|
||
openQuickCreate,
|
||
closeQuickCreate,
|
||
} = usePublicationStore();
|
||
|
||
// Merge initial options with any newly injected ones from the store
|
||
const cachedOptions = relationCache[name] || relationCache[relationCollection] || [];
|
||
const mergedOptions = cachedOptions.length > 0 ? cachedOptions : initialOptions;
|
||
|
||
const [selectedValue, setSelectedValue] = useState(externalValue || '');
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const doubleClickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// Auto-select the newly injected option
|
||
useEffect(() => {
|
||
if (pendingRelationField === name && cachedOptions.length > 0) {
|
||
const newest = cachedOptions[0];
|
||
if (newest) {
|
||
setSelectedValue(newest.$id);
|
||
onChange?.(newest.$id);
|
||
}
|
||
}
|
||
}, [pendingRelationField, cachedOptions, name, onChange]);
|
||
|
||
const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
const val = e.target.value;
|
||
setSelectedValue(val);
|
||
onChange?.(val);
|
||
}, [onChange]);
|
||
|
||
const handleDoubleClick = useCallback(() => {
|
||
if (doubleClickTimer.current) clearTimeout(doubleClickTimer.current);
|
||
openQuickCreate(name, relationCollection);
|
||
setIsModalOpen(true);
|
||
}, [name, relationCollection, openQuickCreate]);
|
||
|
||
const handleCloseModal = useCallback(() => {
|
||
setIsModalOpen(false);
|
||
closeQuickCreate();
|
||
}, [closeQuickCreate]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5 w-full">
|
||
{/* Label with Explicit Quick Create Action */}
|
||
<div className="flex items-center justify-between">
|
||
<label
|
||
htmlFor={`field-${name}`}
|
||
className="text-[10px] font-black uppercase tracking-widest text-slate-500"
|
||
>
|
||
{label}
|
||
{required && <span className="text-cyan-400 ml-1">*</span>}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.preventDefault(); handleDoubleClick(); }}
|
||
className="flex items-center gap-1.5 text-[10px] bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 px-2 py-1 rounded-md font-bold uppercase tracking-wider transition-colors border border-cyan-500/20"
|
||
title="Añadir una nueva opción a la base de datos"
|
||
>
|
||
<MousePointerClick size={10} />
|
||
Crear Nuevo
|
||
</button>
|
||
</div>
|
||
|
||
{/* Select Wrapper */}
|
||
<div className="relative group">
|
||
<select
|
||
id={`field-${name}`}
|
||
name={name}
|
||
value={selectedValue}
|
||
onChange={handleChange}
|
||
onDoubleClick={handleDoubleClick}
|
||
required={required}
|
||
disabled={disabled}
|
||
className="w-full appearance-none bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white text-sm
|
||
focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500/30
|
||
group-hover:border-slate-700 transition-all pr-10
|
||
disabled:opacity-50 disabled:cursor-not-allowed
|
||
cursor-pointer"
|
||
aria-label={label}
|
||
>
|
||
<option value="">— Seleccionar —</option>
|
||
{mergedOptions.map((opt) => (
|
||
<option key={opt.$id} value={opt.$id}>
|
||
{String(opt[labelKey] ?? opt.$id)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown
|
||
size={16}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none group-hover:text-slate-400 transition-colors"
|
||
/>
|
||
</div>
|
||
|
||
{/* Media Preview Thumbnail */}
|
||
{relationCollection === 'media' && selectedValue && (
|
||
<MediaThumbnail fileId={selectedValue} />
|
||
)}
|
||
|
||
{/* Quick Create Portal */}
|
||
{isModalOpen && (
|
||
<QuickCreatePortal
|
||
collection={relationCollection}
|
||
fieldName={name}
|
||
onClose={handleCloseModal}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default RelationSelectField;
|