diff --git a/admin/package-lock.json b/admin/package-lock.json index d4791b4..6d88548 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -1104,9 +1104,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1123,9 +1120,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1142,9 +1136,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1161,9 +1152,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1180,9 +1168,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1199,9 +1184,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1218,9 +1200,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1237,9 +1216,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1256,9 +1232,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1281,9 +1254,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1306,9 +1276,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1331,9 +1298,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1356,9 +1320,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1381,9 +1342,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1406,9 +1364,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1431,9 +1386,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1811,9 +1763,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1830,9 +1779,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1849,9 +1795,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1868,9 +1811,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2185,9 +2125,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2205,9 +2142,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2225,9 +2159,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2245,9 +2176,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2455,7 +2383,7 @@ "version": "19.2.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2893,9 +2821,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2910,9 +2835,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2927,9 +2849,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2944,9 +2863,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2961,9 +2877,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2978,9 +2891,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2995,9 +2905,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3012,9 +2919,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3029,9 +2933,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3046,9 +2947,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3978,7 +3876,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6792,9 +6690,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6816,9 +6711,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6840,9 +6732,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6864,9 +6753,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9506,7 +9392,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/admin/src/app/locations/page.tsx b/admin/src/app/locations/page.tsx new file mode 100644 index 0000000..c79103f --- /dev/null +++ b/admin/src/app/locations/page.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { Card, CardContent, CardHeader, CardTitle } 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 { Plus, Pencil, Trash2, ChevronDown, ChevronRight, Globe, Map, MapPin } from 'lucide-react'; + +interface City { id: string; name: string; latitude?: number; longitude?: number; } +interface Region { id: string; name: string; cities: City[]; } +interface Country { id: string; name: string; regions: Region[]; } + +type EditingItem = { type: 'country' | 'region' | 'city'; id: string; name: string } | null; +type AddingItem = { type: 'country' } | { type: 'region'; countryId: string } | { type: 'city'; regionId: string } | null; + +export default function LocationsPage() { + const [countries, setCountries] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedCountries, setExpandedCountries] = useState>(new Set()); + const [expandedRegions, setExpandedRegions] = useState>(new Set()); + const [editing, setEditing] = useState(null); + const [adding, setAdding] = useState(null); + const [inputValue, setInputValue] = useState(''); + + const load = useCallback(() => { + setLoading(true); + api.get('/locations/countries') + .then(setCountries) + .catch(() => toast.error('Error al cargar ubicaciones')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const startAdd = (item: AddingItem) => { setAdding(item); setEditing(null); setInputValue(''); }; + const startEdit = (item: EditingItem) => { setEditing(item); setAdding(null); setInputValue(item?.name || ''); }; + const cancel = () => { setAdding(null); setEditing(null); setInputValue(''); }; + + const saveAdd = async () => { + if (!inputValue.trim() || !adding) return; + try { + if (adding.type === 'country') { + await api.post('/locations/countries', { name: inputValue.trim() }); + } else if (adding.type === 'region') { + await api.post('/locations/regions', { country_id: adding.countryId, name: inputValue.trim() }); + } else if (adding.type === 'city') { + await api.post('/locations/cities', { region_id: adding.regionId, name: inputValue.trim() }); + } + toast.success('Creado correctamente'); + cancel(); + load(); + } catch (e: any) { toast.error(e?.message || 'Error al crear'); } + }; + + const saveEdit = async () => { + if (!inputValue.trim() || !editing) return; + try { + if (editing.type === 'country') { + await api.patch(`/locations/countries/${editing.id}`, { name: inputValue.trim() }); + } else if (editing.type === 'region') { + await api.patch(`/locations/regions/${editing.id}`, { name: inputValue.trim() }); + } else if (editing.type === 'city') { + await api.patch(`/locations/cities/${editing.id}`, { name: inputValue.trim() }); + } + toast.success('Actualizado correctamente'); + cancel(); + load(); + } catch (e: any) { toast.error(e?.message || 'Error al actualizar'); } + }; + + const remove = async (type: 'country' | 'region' | 'city', id: string, name: string) => { + if (!confirm(`¿Eliminar "${name}"? Se eliminarán todos los datos anidados.`)) return; + try { + if (type === 'country') await api.delete(`/locations/countries/${id}`); + else if (type === 'region') await api.delete(`/locations/regions/${id}`); + else await api.delete(`/locations/cities/${id}`); + toast.success('Eliminado correctamente'); + load(); + } catch (e: any) { toast.error(e?.message || 'Error al eliminar'); } + }; + + const InlineForm = ({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }) => ( +
+ setInputValue(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') onSave(); if (e.key === 'Escape') onCancel(); }} + placeholder="Nombre..." + className="h-8 text-sm" + /> + + +
+ ); + + if (loading) return
Cargando...
; + + const totalRegions = countries.reduce((a, c) => a + c.regions.length, 0); + const totalCities = countries.reduce((a, c) => a + c.regions.reduce((b, r) => b + r.cities.length, 0), 0); + + return ( +
+
+
+

Ubicaciones

+

+ {countries.length} países · {totalRegions} regiones · {totalCities} ciudades +

+
+ +
+ + {adding?.type === 'country' && ( + + +

Nuevo país

+ +
+
+ )} + + {countries.length === 0 && ( + No hay países. Agrega uno para comenzar. + )} + + {countries.map((country) => { + const isCountryOpen = expandedCountries.has(country.id); + return ( + + + {/* Country row */} +
+ + {!(editing?.type === 'country' && editing.id === country.id) && ( +
+ + +
+ )} +
+ + {editing?.type === 'country' && editing.id === country.id && ( + + )} + + {isCountryOpen && ( +
+ {country.regions.map((region) => { + const isRegionOpen = expandedRegions.has(region.id); + return ( +
+ {/* Region row */} +
+ + {!(editing?.type === 'region' && editing.id === region.id) && ( +
+ + +
+ )} +
+ + {editing?.type === 'region' && editing.id === region.id && ( + + )} + + {isRegionOpen && ( +
+
+ {region.cities.map((city) => ( +
+ {editing?.type === 'city' && editing.id === city.id ? null : ( + <> + + {city.name} + + + + )} +
+ ))} +
+ {editing?.type === 'city' && region.cities.some(c => c.id === editing.id) && ( + + )} + {adding?.type === 'city' && adding.regionId === region.id ? ( + + ) : ( + + )} +
+ )} +
+ ); + })} + + {adding?.type === 'region' && adding.countryId === country.id ? ( +
+

Nueva región

+ +
+ ) : ( + + )} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/admin/src/app/settings/page.tsx b/admin/src/app/settings/page.tsx index e18ac93..8edcdba 100644 --- a/admin/src/app/settings/page.tsx +++ b/admin/src/app/settings/page.tsx @@ -2,79 +2,125 @@ import { useEffect, useState, useCallback } from 'react'; import { api } from '@/lib/api'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { toast } from 'sonner'; -import { Save } from 'lucide-react'; +import { Save, ExternalLink, Copy, FileText, Shield } from 'lucide-react'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1'; +const PUBLIC_BASE = API_BASE.replace('/api/v1', ''); + +const POLICY_ITEMS = [ + { + key: 'privacy', + label: 'Política de Privacidad', + icon: Shield, + description: 'Cómo se recopilan, usan y protegen los datos personales de los usuarios.', + }, + { + key: 'terms', + label: 'Términos y Condiciones', + icon: FileText, + description: 'Condiciones de uso de la plataforma ProsApp.', + }, +]; export default function SettingsPage() { - const [settings, setSettings] = useState | null>(null); - const [editValue, setEditValue] = useState(''); + const [policies, setPolicies] = useState>({ privacy: '', terms: '' }); + const [saving, setSaving] = useState(null); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); const load = useCallback(() => { setLoading(true); - setError(null); - api.get>('/settings') - .then((data) => { - setSettings(data); - setEditValue(JSON.stringify(data, null, 2)); - }) - .catch(() => setError('Error al cargar configuración')) + api.get<{ privacy: string | null; terms: string | null }>('/settings/policies') + .then((data) => setPolicies({ privacy: data.privacy || '', terms: data.terms || '' })) + .catch(() => toast.error('Error al cargar políticas')) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); - const save = async () => { - setSaving(true); + const savePolicy = async (key: string) => { + setSaving(key); try { - const parsed = JSON.parse(editValue); - await api.patch('/settings', parsed); - setSettings(parsed); - toast.success('Configuración guardada'); + 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 { - setSaving(false); + setSaving(null); } }; - if (loading) { - return
Cargando...
; - } + const copyLink = (key: string) => { + const url = `${API_BASE}/settings/policy/${key}`; + navigator.clipboard.writeText(url); + toast.success('Enlace copiado'); + }; - if (error) { - return ( -
-

{error}

- -
- ); - } + if (loading) return
Cargando...
; return ( -
-

Configuración

- - - Configuración global (JSON) - - -