feat: full locations CRUD + legal policies in settings
Backend: - CRUD completo para countries, regions y cities - GET /settings/policy/:key — página HTML pública para políticas - GET/PATCH /settings/policies/:key — admin endpoints protegidos Admin: - /locations: árbol interactivo País → Región → Ciudad con add/edit/delete - /settings: editor de Política de Privacidad y Términos con enlace público copiable - Sidebar: Ciudades → Ubicaciones Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5c740420e9
commit
39c4301d1b
Generated
+3
-117
@@ -1104,9 +1104,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1123,9 +1120,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1142,9 +1136,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1161,9 +1152,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1180,9 +1168,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1199,9 +1184,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1218,9 +1200,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1237,9 +1216,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1256,9 +1232,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1281,9 +1254,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1306,9 +1276,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1331,9 +1298,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1356,9 +1320,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1381,9 +1342,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1406,9 +1364,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1431,9 +1386,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1811,9 +1763,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1830,9 +1779,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1849,9 +1795,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1868,9 +1811,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2185,9 +2125,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2205,9 +2142,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2225,9 +2159,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2245,9 +2176,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2455,7 +2383,7 @@
|
|||||||
"version": "19.2.16",
|
"version": "19.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz",
|
||||||
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
|
"integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2893,9 +2821,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2910,9 +2835,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2927,9 +2849,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2944,9 +2863,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2961,9 +2877,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2978,9 +2891,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2995,9 +2905,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3012,9 +2919,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3029,9 +2933,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3046,9 +2947,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3978,7 +3876,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@@ -6792,9 +6690,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6816,9 +6711,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6840,9 +6732,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6864,9 +6753,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -9506,7 +9392,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -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<Country[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedCountries, setExpandedCountries] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
|
||||||
|
const [editing, setEditing] = useState<EditingItem>(null);
|
||||||
|
const [adding, setAdding] = useState<AddingItem>(null);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.get<Country[]>('/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 }) => (
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') onSave(); if (e.key === 'Escape') onCancel(); }}
|
||||||
|
placeholder="Nombre..."
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={onSave} className="h-8">Guardar</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onCancel} className="h-8">Cancelar</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Ubicaciones</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
{countries.length} países · {totalRegions} regiones · {totalCities} ciudades
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => startAdd({ type: 'country' })}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />Añadir País
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding?.type === 'country' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<p className="text-sm font-medium mb-1 flex items-center gap-1"><Globe size={14} /> Nuevo país</p>
|
||||||
|
<InlineForm onSave={saveAdd} onCancel={cancel} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{countries.length === 0 && (
|
||||||
|
<Card><CardContent className="py-12 text-center text-muted-foreground">No hay países. Agrega uno para comenzar.</CardContent></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{countries.map((country) => {
|
||||||
|
const isCountryOpen = expandedCountries.has(country.id);
|
||||||
|
return (
|
||||||
|
<Card key={country.id}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{/* Country row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 flex-1 text-left hover:opacity-80"
|
||||||
|
onClick={() => setExpandedCountries(prev => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
s.has(country.id) ? s.delete(country.id) : s.add(country.id);
|
||||||
|
return s;
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isCountryOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
<Globe size={16} className="text-blue-500" />
|
||||||
|
{editing?.type === 'country' && editing.id === country.id ? null : (
|
||||||
|
<span className="font-semibold">{country.name}</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-xs">{country.regions.length} regiones</Badge>
|
||||||
|
</button>
|
||||||
|
{!(editing?.type === 'country' && editing.id === country.id) && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => startEdit({ type: 'country', id: country.id, name: country.name })}>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="h-7 w-7 text-destructive" onClick={() => remove('country', country.id, country.name)}>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing?.type === 'country' && editing.id === country.id && (
|
||||||
|
<InlineForm onSave={saveEdit} onCancel={cancel} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCountryOpen && (
|
||||||
|
<div className="ml-6 mt-3 space-y-2">
|
||||||
|
{country.regions.map((region) => {
|
||||||
|
const isRegionOpen = expandedRegions.has(region.id);
|
||||||
|
return (
|
||||||
|
<div key={region.id} className="border rounded-lg p-3">
|
||||||
|
{/* Region row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 flex-1 text-left hover:opacity-80"
|
||||||
|
onClick={() => setExpandedRegions(prev => {
|
||||||
|
const s = new Set(prev);
|
||||||
|
s.has(region.id) ? s.delete(region.id) : s.add(region.id);
|
||||||
|
return s;
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isRegionOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<Map size={14} className="text-green-500" />
|
||||||
|
{!(editing?.type === 'region' && editing.id === region.id) && (
|
||||||
|
<span className="font-medium text-sm">{region.name}</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-xs">{region.cities.length} ciudades</Badge>
|
||||||
|
</button>
|
||||||
|
{!(editing?.type === 'region' && editing.id === region.id) && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEdit({ type: 'region', id: region.id, name: region.name })}>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6 text-destructive" onClick={() => remove('region', region.id, region.name)}>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing?.type === 'region' && editing.id === region.id && (
|
||||||
|
<InlineForm onSave={saveEdit} onCancel={cancel} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRegionOpen && (
|
||||||
|
<div className="ml-5 mt-2">
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{region.cities.map((city) => (
|
||||||
|
<div key={city.id} className="group flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
|
{editing?.type === 'city' && editing.id === city.id ? null : (
|
||||||
|
<>
|
||||||
|
<MapPin size={10} className="text-orange-400" />
|
||||||
|
<span>{city.name}</span>
|
||||||
|
<button className="opacity-0 group-hover:opacity-100 ml-1" onClick={() => startEdit({ type: 'city', id: city.id, name: city.name })}>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
<button className="opacity-0 group-hover:opacity-100 text-destructive" onClick={() => remove('city', city.id, city.name)}>
|
||||||
|
<Trash2 size={10} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing?.type === 'city' && region.cities.some(c => c.id === editing.id) && (
|
||||||
|
<InlineForm onSave={saveEdit} onCancel={cancel} />
|
||||||
|
)}
|
||||||
|
{adding?.type === 'city' && adding.regionId === region.id ? (
|
||||||
|
<InlineForm onSave={saveAdd} onCancel={cancel} />
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" className="h-6 text-xs mt-1" onClick={() => startAdd({ type: 'city', regionId: region.id })}>
|
||||||
|
<Plus size={11} className="mr-1" />Ciudad
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{adding?.type === 'region' && adding.countryId === country.id ? (
|
||||||
|
<div className="border rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1"><Map size={12} /> Nueva región</p>
|
||||||
|
<InlineForm onSave={saveAdd} onCancel={cancel} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => startAdd({ type: 'region', countryId: country.id })}>
|
||||||
|
<Plus size={13} className="mr-1" />Región
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,79 +2,125 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { api } from '@/lib/api';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { toast } from 'sonner';
|
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() {
|
export default function SettingsPage() {
|
||||||
const [settings, setSettings] = useState<Record<string, any> | null>(null);
|
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
|
||||||
const [editValue, setEditValue] = useState('');
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies')
|
||||||
api.get<Record<string, any>>('/settings')
|
.then((data) => setPolicies({ privacy: data.privacy || '', terms: data.terms || '' }))
|
||||||
.then((data) => {
|
.catch(() => toast.error('Error al cargar políticas'))
|
||||||
setSettings(data);
|
|
||||||
setEditValue(JSON.stringify(data, null, 2));
|
|
||||||
})
|
|
||||||
.catch(() => setError('Error al cargar configuración'))
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const save = async () => {
|
const savePolicy = async (key: string) => {
|
||||||
setSaving(true);
|
setSaving(key);
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(editValue);
|
await api.patch(`/settings/policies/${key}`, { content: policies[key] });
|
||||||
await api.patch('/settings', parsed);
|
toast.success('Política guardada');
|
||||||
setSettings(parsed);
|
|
||||||
toast.success('Configuración guardada');
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e?.message || 'Error al guardar');
|
toast.error(e?.message || 'Error al guardar');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const copyLink = (key: string) => {
|
||||||
return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
const url = `${API_BASE}/settings/policy/${key}`;
|
||||||
}
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success('Enlace copiado');
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
|
||||||
<p>{error}</p>
|
|
||||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">Reintentar</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-8">
|
||||||
<h1 className="text-2xl font-bold">Configuración</h1>
|
<div>
|
||||||
<Card>
|
<h1 className="text-2xl font-bold">Configuración</h1>
|
||||||
<CardHeader>
|
<p className="text-muted-foreground text-sm mt-1">Gestiona las políticas legales y ajustes globales de ProsApp.</p>
|
||||||
<CardTitle>Configuración global (JSON)</CardTitle>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
{/* Policies */}
|
||||||
<textarea
|
<div className="space-y-6">
|
||||||
className="flex min-h-[300px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
<h2 className="text-lg font-semibold">Documentos legales</h2>
|
||||||
value={editValue}
|
{POLICY_ITEMS.map(({ key, label, icon: Icon, description }) => {
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
const publicUrl = `${API_BASE}/settings/policy/${key}`;
|
||||||
/>
|
return (
|
||||||
<Button onClick={save} disabled={saving}>
|
<Card key={key}>
|
||||||
<Save className="mr-1 h-4 w-4" />
|
<CardHeader>
|
||||||
{saving ? 'Guardando...' : 'Guardar'}
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
</Button>
|
<Icon size={18} className="text-muted-foreground" />
|
||||||
</CardContent>
|
{label}
|
||||||
</Card>
|
</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
className="w-full min-h-[260px] 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 font-mono resize-y"
|
||||||
|
placeholder={`Escribe aquí el contenido de ${label.toLowerCase()}...\n\nPuedes usar texto plano. Se mostrará con formato en la página pública.`}
|
||||||
|
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={saving === key}
|
||||||
|
>
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
{saving === 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>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enlace público para compartir con usuarios o incluir en la app mobile/web.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const menu = [
|
|||||||
{ href: '/users', label: 'Usuarios', icon: Users },
|
{ href: '/users', label: 'Usuarios', icon: Users },
|
||||||
{ href: '/professionals', label: 'Profesionales', icon: Briefcase },
|
{ href: '/professionals', label: 'Profesionales', icon: Briefcase },
|
||||||
{ href: '/services', label: 'Servicios', icon: ClipboardList },
|
{ href: '/services', label: 'Servicios', icon: ClipboardList },
|
||||||
{ href: '/cities', label: 'Ciudades', icon: MapPin },
|
{ href: '/locations', label: 'Ubicaciones', icon: MapPin },
|
||||||
{ href: '/professions', label: 'Profesiones', icon: Wrench },
|
{ href: '/professions', label: 'Profesiones', icon: Wrench },
|
||||||
{ href: '/comments', label: 'Comentarios', icon: Star },
|
{ href: '/comments', label: 'Comentarios', icon: Star },
|
||||||
{ href: '/sms', label: 'SMS', icon: MessageSquare },
|
{ href: '/sms', label: 'SMS', icon: MessageSquare },
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = 'admin@prosapp.co';
|
||||||
|
const password = process.argv[2] || 'Admin2024!';
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await prisma.users.upsert({
|
||||||
|
where: { email },
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
name: 'Admin',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
password_hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Admin listo: ${user.email} | password: ${password}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { LocationsService } from './locations.service';
|
import { LocationsService } from './locations.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
@@ -8,25 +8,70 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
|||||||
export class LocationsController {
|
export class LocationsController {
|
||||||
constructor(private locations: LocationsService) {}
|
constructor(private locations: LocationsService) {}
|
||||||
|
|
||||||
|
// Public reads
|
||||||
@Get('countries')
|
@Get('countries')
|
||||||
getCountries() {
|
getCountries() { return this.locations.getCountries(); }
|
||||||
return this.locations.getCountries();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('countries/:countryId/regions')
|
@Get('countries/:countryId/regions')
|
||||||
getRegions(@Param('countryId') id: string) {
|
getRegions(@Param('countryId') id: string) { return this.locations.getRegions(id); }
|
||||||
return this.locations.getRegions(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('regions/:regionId/cities')
|
@Get('regions/:regionId/cities')
|
||||||
getCities(@Param('regionId') id: string) {
|
getCities(@Param('regionId') id: string) { return this.locations.getCities(id); }
|
||||||
return this.locations.getCities(id);
|
|
||||||
|
// Countries CRUD
|
||||||
|
@Post('countries')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
createCountry(@Body() body: { name: string }) {
|
||||||
|
return this.locations.createCountry(body.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('countries/:id')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
updateCountry(@Param('id') id: string, @Body() body: { name: string }) {
|
||||||
|
return this.locations.updateCountry(id, body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('countries/:id')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
deleteCountry(@Param('id') id: string) {
|
||||||
|
return this.locations.deleteCountry(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regions CRUD
|
||||||
|
@Post('regions')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
createRegion(@Body() body: { country_id: string; name: string }) {
|
||||||
|
return this.locations.createRegion(body.country_id, body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('regions/:id')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
updateRegion(@Param('id') id: string, @Body() body: { name: string }) {
|
||||||
|
return this.locations.updateRegion(id, body.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('regions/:id')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
deleteRegion(@Param('id') id: string) {
|
||||||
|
return this.locations.deleteRegion(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities CRUD
|
||||||
@Post('cities')
|
@Post('cities')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
@ApiBearerAuth()
|
|
||||||
createCity(@Body() body: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
createCity(@Body() body: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
||||||
return this.locations.createCity(body);
|
return this.locations.createCity(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch('cities/:id')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
updateCity(@Param('id') id: string, @Body() body: { name?: string; latitude?: number; longitude?: number }) {
|
||||||
|
return this.locations.updateCity(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('cities/:id')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
deleteCity(@Param('id') id: string) {
|
||||||
|
return this.locations.deleteCity(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,82 @@ export class LocationsService {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
getCountries() {
|
getCountries() {
|
||||||
return this.prisma.countries.findMany({ include: { regions: { include: { cities: true } } } });
|
return this.prisma.countries.findMany({
|
||||||
|
include: { regions: { include: { cities: true }, orderBy: { name: 'asc' } } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getRegions(countryId: string) {
|
getRegions(countryId: string) {
|
||||||
return this.prisma.regions.findMany({ where: { country_id: countryId }, include: { cities: true } });
|
return this.prisma.regions.findMany({
|
||||||
|
where: { country_id: countryId },
|
||||||
|
include: { cities: { orderBy: { name: 'asc' } } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getCities(regionId: string) {
|
getCities(regionId: string) {
|
||||||
return this.prisma.cities.findMany({ where: { region_id: regionId } });
|
return this.prisma.cities.findMany({ where: { region_id: regionId }, orderBy: { name: 'asc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Countries
|
||||||
|
async createCountry(name: string) {
|
||||||
|
const existing = await this.prisma.countries.findUnique({ where: { name } });
|
||||||
|
if (existing) throw new ConflictException('Ya existe este país');
|
||||||
|
return this.prisma.countries.create({ data: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCountry(id: string, name: string) {
|
||||||
|
const country = await this.prisma.countries.findUnique({ where: { id } });
|
||||||
|
if (!country) throw new NotFoundException('País no encontrado');
|
||||||
|
return this.prisma.countries.update({ where: { id }, data: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCountry(id: string) {
|
||||||
|
const country = await this.prisma.countries.findUnique({ where: { id } });
|
||||||
|
if (!country) throw new NotFoundException('País no encontrado');
|
||||||
|
return this.prisma.countries.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regions
|
||||||
|
async createRegion(countryId: string, name: string) {
|
||||||
|
const country = await this.prisma.countries.findUnique({ where: { id: countryId } });
|
||||||
|
if (!country) throw new NotFoundException('País no encontrado');
|
||||||
|
const existing = await this.prisma.regions.findFirst({ where: { country_id: countryId, name } });
|
||||||
|
if (existing) throw new ConflictException('Ya existe esta región en el país');
|
||||||
|
return this.prisma.regions.create({ data: { country_id: countryId, name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRegion(id: string, name: string) {
|
||||||
|
const region = await this.prisma.regions.findUnique({ where: { id } });
|
||||||
|
if (!region) throw new NotFoundException('Región no encontrada');
|
||||||
|
return this.prisma.regions.update({ where: { id }, data: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegion(id: string) {
|
||||||
|
const region = await this.prisma.regions.findUnique({ where: { id } });
|
||||||
|
if (!region) throw new NotFoundException('Región no encontrada');
|
||||||
|
return this.prisma.regions.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities
|
||||||
async createCity(data: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
async createCity(data: { region_id: string; name: string; latitude?: number; longitude?: number }) {
|
||||||
const region = await this.prisma.regions.findUnique({ where: { id: data.region_id } });
|
const region = await this.prisma.regions.findUnique({ where: { id: data.region_id } });
|
||||||
if (!region) throw new NotFoundException('Región no encontrada');
|
if (!region) throw new NotFoundException('Región no encontrada');
|
||||||
|
const existing = await this.prisma.cities.findFirst({ where: { region_id: data.region_id, name: data.name } });
|
||||||
const existing = await this.prisma.cities.findFirst({
|
|
||||||
where: { region_id: data.region_id, name: data.name },
|
|
||||||
});
|
|
||||||
if (existing) throw new ConflictException('Ya existe esta ciudad en la región');
|
if (existing) throw new ConflictException('Ya existe esta ciudad en la región');
|
||||||
|
return this.prisma.cities.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.cities.create({
|
async updateCity(id: string, data: { name?: string; latitude?: number; longitude?: number }) {
|
||||||
data: { region_id: data.region_id, name: data.name, latitude: data.latitude, longitude: data.longitude },
|
const city = await this.prisma.cities.findUnique({ where: { id } });
|
||||||
});
|
if (!city) throw new NotFoundException('Ciudad no encontrada');
|
||||||
|
return this.prisma.cities.update({ where: { id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCity(id: string) {
|
||||||
|
const city = await this.prisma.cities.findUnique({ where: { id } });
|
||||||
|
if (!city) throw new NotFoundException('Ciudad no encontrada');
|
||||||
|
return this.prisma.cities.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Patch, Body, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Patch, Body, UseGuards, Param, Res } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
|
||||||
@@ -9,14 +10,74 @@ export class SettingsController {
|
|||||||
constructor(private settings: SettingsService) {}
|
constructor(private settings: SettingsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getGlobal() {
|
getGlobal() { return this.settings.getGlobal(); }
|
||||||
return this.settings.getGlobal();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch()
|
@Patch()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
@ApiBearerAuth()
|
|
||||||
updateGlobal(@Body() body: Record<string, any>) {
|
updateGlobal(@Body() body: Record<string, any>) {
|
||||||
return this.settings.updateGlobal(body);
|
return this.settings.updateGlobal(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Policies admin (protected)
|
||||||
|
@Get('policies')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
getPolicies() { return this.settings.getPolicies(); }
|
||||||
|
|
||||||
|
@Patch('policies/:key')
|
||||||
|
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
|
||||||
|
updatePolicy(@Param('key') key: 'privacy' | 'terms', @Body() body: { content: string }) {
|
||||||
|
return this.settings.updatePolicy(key, body.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public policy pages
|
||||||
|
@Get('policy/:key')
|
||||||
|
async getPublicPolicy(@Param('key') key: string, @Res() res: Response) {
|
||||||
|
const content = await this.settings.getPolicy(key);
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
privacy: 'Politica de Privacidad',
|
||||||
|
terms: 'Terminos y Condiciones',
|
||||||
|
};
|
||||||
|
const title = titles[key] || 'Politica';
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return res.status(404).send(`<html><body><h1>${title}</h1><p>No disponible aun.</p></body></html>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${title} - ProsApp</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #1e293b; }
|
||||||
|
header { background: linear-gradient(135deg, #42A4EF, #1565C0); padding: 24px 0; text-align: center; }
|
||||||
|
header img { height: 40px; margin-bottom: 8px; }
|
||||||
|
header h1 { color: white; font-size: 1.5rem; font-weight: 700; }
|
||||||
|
.container { max-width: 800px; margin: 32px auto; padding: 0 16px 64px; }
|
||||||
|
.card { background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.content { white-space: pre-wrap; line-height: 1.8; font-size: 0.95rem; color: #374151; }
|
||||||
|
.content h1, .content h2, .content h3 { color: #1e293b; margin: 1.5em 0 0.5em; font-weight: 600; }
|
||||||
|
.content p { margin-bottom: 1em; }
|
||||||
|
footer { text-align: center; padding: 24px; color: #94a3b8; font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<img src="https://prosapp.co/img/logo_prosapp.png" alt="ProsApp" onerror="this.style.display='none'">
|
||||||
|
<h1>${title}</h1>
|
||||||
|
</header>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="content">${content.replace(/</g, '<').replace(/>/g, '>')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>© ${new Date().getFullYear()} ProsApp. Todos los derechos reservados.</footer>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
return res.send(html);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const POLICY_KEYS = ['privacy', 'terms'] as const;
|
||||||
|
type PolicyKey = typeof POLICY_KEYS[number];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -17,4 +20,25 @@ export class SettingsService {
|
|||||||
update: { value },
|
update: { value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPolicy(key: string): Promise<string | null> {
|
||||||
|
const setting = await this.prisma.settings.findUnique({ where: { key: `policy_${key}` } });
|
||||||
|
return (setting?.value as any)?.content ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePolicy(key: PolicyKey, content: string) {
|
||||||
|
return this.prisma.settings.upsert({
|
||||||
|
where: { key: `policy_${key}` },
|
||||||
|
create: { key: `policy_${key}`, value: { content } },
|
||||||
|
update: { value: { content } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPolicies() {
|
||||||
|
const [privacy, terms] = await Promise.all([
|
||||||
|
this.getPolicy('privacy'),
|
||||||
|
this.getPolicy('terms'),
|
||||||
|
]);
|
||||||
|
return { privacy, terms };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user