almeira-web/src/components/publications/RelationSelectField.tsx

332 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;