Files
prosapp-migration/admin/src/app/settings/page.tsx
T
Lizandro GuarnizoandClaude Sonnet 4.6 f66b1a9bc1 fix: agregar campos soporte faltantes en admin settings
- support_title, support_description, support_days, support_hours
  (los que lee la app Flutter para la pantalla de soporte)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:54:16 -05:00

338 lines
14 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';
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import {
Save, ExternalLink, Copy, FileText, Shield, Map,
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail,
} from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface GlobalSettings {
app_version_android?: string;
app_version_ios?: string;
support_title?: string;
support_description?: string;
support_days?: string;
support_hours?: string;
support_email?: string;
support_phone?: string;
maintenance_mode?: boolean;
[key: string]: any;
}
const POLICY_ITEMS = [
{ key: 'privacy', label: 'Política de Privacidad', icon: Shield, description: 'Cómo se recopilan, usan y protegen los datos personales.' },
{ key: 'terms', label: 'Términos y Condiciones', icon: FileText, description: 'Condiciones de uso de la plataforma ProsApp.' },
];
export default function SettingsPage() {
const [loading, setLoading] = useState(true);
// Global
const [global, setGlobal] = useState<GlobalSettings>({});
const [savingGlobal, setSavingGlobal] = useState(false);
// Maps
const [mapsConfigured, setMapsConfigured] = useState(false);
const [mapsKey, setMapsKey] = useState('');
const [showMapsKey, setShowMapsKey] = useState(false);
const [savingMaps, setSavingMaps] = useState(false);
// Policies
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
const [savingPolicy, setSavingPolicy] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
Promise.all([
api.get<GlobalSettings>('/settings'),
api.get<{ configured: boolean }>('/settings/maps-key'),
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
])
.then(([globalData, mapsData, policiesData]) => {
setGlobal(globalData || {});
setMapsConfigured(mapsData.configured);
setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' });
})
.catch(() => toast.error('Error al cargar configuración'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const saveGlobal = async () => {
setSavingGlobal(true);
try {
await api.patch('/settings', global);
toast.success('Configuración guardada');
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSavingGlobal(false);
}
};
const saveMapsKey = async () => {
if (!mapsKey.trim()) return toast.error('Ingresa una API Key');
setSavingMaps(true);
try {
await api.patch('/settings/maps', { api_key: mapsKey.trim() });
toast.success('Google Maps API Key guardada');
setMapsKey('');
setMapsConfigured(true);
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSavingMaps(false);
}
};
const savePolicy = async (key: string) => {
setSavingPolicy(key);
try {
await api.patch(`/settings/policies/${key}`, { content: policies[key] });
toast.success('Política guardada');
} catch (e: any) {
toast.error(e?.message || 'Error al guardar');
} finally {
setSavingPolicy(null);
}
};
const copyLink = (key: string) => {
navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`);
toast.success('Enlace copiado');
};
const setG = (field: keyof GlobalSettings, value: any) =>
setGlobal((g) => ({ ...g, [field]: value }));
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">Configuración</h1>
<p className="text-muted-foreground text-sm mt-1">Ajustes globales, integraciones y documentos legales de ProsApp.</p>
</div>
{/* ── Ajustes globales ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Ajustes generales</h2>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Smartphone size={18} className="text-muted-foreground" />
Versión de la app
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium">Android</label>
<Input
placeholder="1.0.0"
value={global.app_version_android || ''}
onChange={(e) => setG('app_version_android', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">iOS</label>
<Input
placeholder="1.0.0"
value={global.app_version_ios || ''}
onChange={(e) => setG('app_version_ios', e.target.value)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock size={18} className="text-muted-foreground" />
Soporte
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Título</label>
<Input
placeholder="Soporte ProsApp"
value={global.support_title || ''}
onChange={(e) => setG('support_title', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Descripción</label>
<Input
placeholder="Estamos aquí para ayudarte..."
value={global.support_description || ''}
onChange={(e) => setG('support_description', e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium">Días de atención</label>
<Input
placeholder="Lunes a Viernes"
value={global.support_days || ''}
onChange={(e) => setG('support_days', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Horas de atención</label>
<Input
placeholder="8:00 am 6:00 pm"
value={global.support_hours || ''}
onChange={(e) => setG('support_hours', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium flex items-center gap-1"><Mail size={13} /> Email</label>
<Input
placeholder="soporte@prosapp.co"
value={global.support_email || ''}
onChange={(e) => setG('support_email', e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium flex items-center gap-1"><Phone size={13} /> Teléfono / WhatsApp</label>
<Input
placeholder="+57 300 000 0000"
value={global.support_phone || ''}
onChange={(e) => setG('support_phone', e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Modo mantenimiento</CardTitle>
<CardDescription>Cuando está activo, la app muestra un mensaje de mantenimiento a los usuarios.</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-4">
<button
onClick={() => setG('maintenance_mode', !global.maintenance_mode)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${global.maintenance_mode ? 'bg-destructive' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${global.maintenance_mode ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<Badge variant={global.maintenance_mode ? 'destructive' : 'secondary'}>
{global.maintenance_mode ? 'Activo' : 'Inactivo'}
</Badge>
</CardContent>
</Card>
<Button onClick={saveGlobal} disabled={savingGlobal}>
<Save className="mr-1 h-4 w-4" />
{savingGlobal ? 'Guardando...' : 'Guardar ajustes generales'}
</Button>
</section>
{/* ── Google Maps ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Integraciones</h2>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Map size={18} className="text-muted-foreground" />
Google Maps API Key
</CardTitle>
<CardDescription>
Requerida para mostrar mapas y detectar ubicación. Habilita <strong>Maps JavaScript API</strong> y <strong>Geocoding API</strong> en Google Cloud Console.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
{mapsConfigured
? <><CheckCircle2 className="text-green-500 h-5 w-5" /><span className="text-sm font-medium">Configurada</span></>
: <><XCircle className="text-destructive h-5 w-5" /><span className="text-sm font-medium text-destructive">No configurada</span></>}
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showMapsKey ? 'text' : 'password'}
placeholder="AIzaSy..."
value={mapsKey}
onChange={(e) => setMapsKey(e.target.value)}
className="pr-10 font-mono"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowMapsKey(!showMapsKey)}
>
{showMapsKey ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<Button onClick={saveMapsKey} disabled={savingMaps || !mapsKey.trim()}>
<Save className="mr-1 h-4 w-4" />
{savingMaps ? 'Guardando...' : 'Guardar'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Restricción HTTP recomendada: <code className="bg-muted px-1 rounded">https://app.prosapp.co/*</code>
</p>
</CardContent>
</Card>
</section>
{/* ── Documentos legales ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Documentos legales</h2>
{POLICY_ITEMS.map(({ key, label, icon: Icon, description }) => {
const publicUrl = `${API_BASE}/settings/policy/${key}`;
return (
<Card key={key}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Icon size={18} className="text-muted-foreground" />
{label}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<textarea
className="w-full min-h-[220px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-y"
placeholder={`Escribe el contenido de ${label.toLowerCase()}...`}
value={policies[key]}
onChange={(e) => setPolicies((p) => ({ ...p, [key]: e.target.value }))}
/>
<div className="flex items-center gap-2 flex-wrap">
<Button onClick={() => savePolicy(key)} disabled={savingPolicy === key}>
<Save className="mr-1 h-4 w-4" />
{savingPolicy === key ? 'Guardando...' : 'Guardar'}
</Button>
<div className="flex items-center gap-1 flex-1 min-w-0">
<code className="text-xs bg-muted px-2 py-1.5 rounded border truncate flex-1 min-w-0">{publicUrl}</code>
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0" onClick={() => copyLink(key)}>
<Copy size={14} />
</Button>
<a href={publicUrl} target="_blank" rel="noopener noreferrer">
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0">
<ExternalLink size={14} />
</Button>
</a>
</div>
</div>
</CardContent>
</Card>
);
})}
</section>
</div>
);
}