feat(contacts): multi-valor (varios tel/email/direccion) + libretas + backend osint_db (flag)
- ContactIn + frontmatter + vCard multi-valor: emite N TEL, N EMAIL, N ADR; _vcard_to_json parsea ADR -> direcciones[] (y sigue leyendo X-OSINT-DIRECCION legacy). Los singulares telefono/email/direccion se mantienen por compat (= primer elemento de cada lista). - Libretas de contactos (addressbooks): endpoints GET/POST /api/addressbooks; en ContactsView un selector de libreta + boton 'Nueva libreta' (replica del patron de crear calendario) + filtro por libreta en la lista. - Frontend ContactsView: TagsInput para telefonos/emails/direcciones, cargando TODOS los valores al editar (antes solo el primero). - Feature flag OSINT_DB_BACKEND (dev/feature_flags.json, default off): con ON, osint_web lee/escribe contra el service osint_db (DuckDB = fuente de verdad) via server/osintdb_client.py; con OFF, el comportamiento historico (vault .md + vCard Xandikos) queda intacto byte a byte. Verificado: 52 tests backend (40 + 12 nuevos), tsc --noEmit limpio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+55
-3
@@ -108,7 +108,12 @@ export interface Contact {
|
||||
emails: ContactPhone[];
|
||||
telefonos: string[];
|
||||
correos: string[];
|
||||
// Direcciones multi-valor (varias ADR del vCard, o X-OSINT-DIRECCION legacy).
|
||||
direcciones?: string[];
|
||||
osint: Record<string, string>;
|
||||
// Libreta (addressbook) a la que pertenece el contacto, cuando el backend la
|
||||
// expone (camino osint_db). Sirve para filtrar la lista por libreta.
|
||||
collection?: string | null;
|
||||
href?: string;
|
||||
etag?: string;
|
||||
}
|
||||
@@ -223,14 +228,24 @@ export interface ContactInput {
|
||||
tipo: "persona" | "organizacion";
|
||||
nombre: string;
|
||||
aliases: string[];
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
// Multi-valor: listas completas de teléfonos, emails y direcciones. El backend
|
||||
// reconcilia con los campos singulares (compat) — el primer elemento de cada
|
||||
// lista se conserva en telefono/email/direccion para los lectores viejos.
|
||||
telefonos: string[];
|
||||
emails: string[];
|
||||
direcciones: string[];
|
||||
// Singulares (compat). Opcionales: el frontend nuevo envía solo las listas; el
|
||||
// backend rellena el singular con lista[0].
|
||||
telefono?: string | null;
|
||||
email?: string | null;
|
||||
direccion?: string | null;
|
||||
dni: string | null;
|
||||
direccion: string | null;
|
||||
pais: string | null;
|
||||
contexto: string | null;
|
||||
relaciones: string[];
|
||||
notas: string | null;
|
||||
// Libreta (addressbook) destino. Solo se honra con el flag OSINT_DB_BACKEND ON.
|
||||
collection?: string | null;
|
||||
}
|
||||
|
||||
export interface ContactWriteResult {
|
||||
@@ -280,6 +295,43 @@ export const deleteContact = (slug: string) =>
|
||||
"DELETE",
|
||||
);
|
||||
|
||||
// --- Libretas (addressbooks) de contactos ---------------------------------
|
||||
|
||||
export interface Addressbook {
|
||||
slug: string;
|
||||
display_name: string;
|
||||
collection_path: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface AddressbooksPayload {
|
||||
status: string;
|
||||
count?: number;
|
||||
addressbooks?: Addressbook[];
|
||||
default?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cuerpo de POST /addressbooks para crear una libreta nueva.
|
||||
export interface AddressbookInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface AddressbookWriteResult {
|
||||
status: string;
|
||||
slug?: string;
|
||||
existed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const fetchAddressbooks = () =>
|
||||
getJSON<AddressbooksPayload>("/addressbooks");
|
||||
|
||||
export const createAddressbook = (data: AddressbookInput) =>
|
||||
sendJSON<AddressbookWriteResult>("/addressbooks", "POST", data);
|
||||
|
||||
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
|
||||
|
||||
export const createCalendar = (data: CalendarInput) =>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
ColorInput,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -19,12 +21,15 @@ import {
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconAddressBook,
|
||||
IconAt,
|
||||
IconEdit,
|
||||
IconMapPin,
|
||||
IconNote,
|
||||
IconPhone,
|
||||
IconPlus,
|
||||
@@ -33,19 +38,23 @@ import {
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
createAddressbook,
|
||||
createContact,
|
||||
deleteContact,
|
||||
fetchAddressbooks,
|
||||
fetchContacts,
|
||||
updateContact,
|
||||
type Addressbook,
|
||||
type Contact,
|
||||
type ContactInput,
|
||||
} from "../api";
|
||||
import { slugify } from "../format";
|
||||
|
||||
// Agenda: lista de contactos del addressbook Xandikos a la izquierda (con
|
||||
// buscador) y la ficha del contacto seleccionado a la derecha. Mantiene la
|
||||
// vista de lectura y añade los controles de edición: alta ("Nuevo contacto"),
|
||||
// edición y borrado de la ficha del vault (con reflejo inmediato en Xandikos).
|
||||
// buscador, selector de libreta y filtro por libreta) y la ficha del contacto
|
||||
// seleccionado a la derecha. Soporta contactos multi-valor (varios teléfonos,
|
||||
// emails y direcciones) y la creación de libretas nuevas (análogo al patrón de
|
||||
// "Nuevo calendario" en CalendarView).
|
||||
|
||||
type FormTipo = "persona" | "organizacion";
|
||||
|
||||
@@ -53,61 +62,73 @@ interface FormState {
|
||||
tipo: FormTipo;
|
||||
nombre: string;
|
||||
aliases: string[];
|
||||
telefono: string;
|
||||
email: string;
|
||||
// Multi-valor: listas completas (antes eran campos singulares).
|
||||
telefonos: string[];
|
||||
emails: string[];
|
||||
direcciones: string[];
|
||||
dni: string;
|
||||
direccion: string;
|
||||
pais: string;
|
||||
contexto: string;
|
||||
notas: string;
|
||||
// Libreta destino (slug). "" → libreta por defecto.
|
||||
collection: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
tipo: "persona",
|
||||
nombre: "",
|
||||
aliases: [],
|
||||
telefono: "",
|
||||
email: "",
|
||||
telefonos: [],
|
||||
emails: [],
|
||||
direcciones: [],
|
||||
dni: "",
|
||||
direccion: "",
|
||||
pais: "",
|
||||
contexto: "",
|
||||
notas: "",
|
||||
collection: "",
|
||||
};
|
||||
|
||||
// Construye el estado del formulario a partir de un contacto existente (para
|
||||
// editar). Toma los campos del bloque osint (dni/direccion/pais/contexto) que el
|
||||
// backend expone tras parsear el vCard.
|
||||
// editar). Carga TODOS los valores multi-valor (antes solo cargaba el primero).
|
||||
function formFromContact(c: Contact): FormState {
|
||||
const osint = c.osint ?? {};
|
||||
return {
|
||||
tipo: "persona",
|
||||
nombre: c.nombre ?? "",
|
||||
aliases: c.alias ? c.alias.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
||||
telefono: c.telefonos?.[0] ?? "",
|
||||
email: c.correos?.[0] ?? "",
|
||||
telefonos: c.telefonos ?? [],
|
||||
emails: c.correos ?? [],
|
||||
// Direcciones desde el campo multi-valor; cae a osint.direccion (legacy).
|
||||
direcciones:
|
||||
c.direcciones && c.direcciones.length > 0
|
||||
? c.direcciones
|
||||
: osint.direccion
|
||||
? [osint.direccion]
|
||||
: [],
|
||||
dni: osint.dni ?? "",
|
||||
direccion: osint.direccion ?? "",
|
||||
pais: osint.pais ?? "",
|
||||
contexto: osint.contexto ?? "",
|
||||
notas: c.nota ?? "",
|
||||
collection: c.collection ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function formToInput(f: FormState): ContactInput {
|
||||
const t = (v: string) => (v.trim() ? v.trim() : null);
|
||||
const t = (vals: string[]) => vals.map((s) => s.trim()).filter(Boolean);
|
||||
const s = (v: string) => (v.trim() ? v.trim() : null);
|
||||
return {
|
||||
tipo: f.tipo,
|
||||
nombre: f.nombre.trim(),
|
||||
aliases: f.aliases.map((s) => s.trim()).filter(Boolean),
|
||||
telefono: t(f.telefono),
|
||||
email: t(f.email),
|
||||
dni: t(f.dni),
|
||||
direccion: t(f.direccion),
|
||||
pais: t(f.pais),
|
||||
contexto: t(f.contexto),
|
||||
aliases: t(f.aliases),
|
||||
telefonos: t(f.telefonos),
|
||||
emails: t(f.emails),
|
||||
direcciones: t(f.direcciones),
|
||||
dni: s(f.dni),
|
||||
pais: s(f.pais),
|
||||
contexto: s(f.contexto),
|
||||
relaciones: [],
|
||||
notas: t(f.notas),
|
||||
notas: s(f.notas),
|
||||
collection: f.collection.trim() ? f.collection.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +140,10 @@ export function ContactsView() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debQuery] = useDebouncedValue(query, 200);
|
||||
|
||||
// Libretas (addressbooks) + filtro por libreta de la lista.
|
||||
const [addressbooks, setAddressbooks] = useState<Addressbook[]>([]);
|
||||
const [filterBook, setFilterBook] = useState<string>(""); // "" = todas
|
||||
|
||||
// Estado del formulario (alta/edición). `editSlug` distingue alta (null) de
|
||||
// edición (slug del contacto). `saving` deshabilita el botón mientras escribe.
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
@@ -126,6 +151,13 @@ export function ContactsView() {
|
||||
const [editSlug, setEditSlug] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Modal "nueva libreta" (mismo patrón que "Nuevo calendario").
|
||||
const [newBookOpen, setNewBookOpen] = useState(false);
|
||||
const [newBookName, setNewBookName] = useState("");
|
||||
const [newBookColor, setNewBookColor] = useState("");
|
||||
const [newBookErr, setNewBookErr] = useState<string | null>(null);
|
||||
const [newBookSaving, setNewBookSaving] = useState(false);
|
||||
|
||||
function reload() {
|
||||
setLoading(true);
|
||||
fetchContacts()
|
||||
@@ -141,15 +173,35 @@ export function ContactsView() {
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
// Carga de libretas. `selectSlug` selecciona una recién creada en el form.
|
||||
const loadAddressbooks = useCallback((selectSlug?: string) => {
|
||||
return fetchAddressbooks()
|
||||
.then((d) => {
|
||||
if (d.status === "ok" && d.addressbooks) {
|
||||
setAddressbooks(d.addressbooks);
|
||||
if (selectSlug) {
|
||||
setForm((f) => ({ ...f, collection: selectSlug }));
|
||||
}
|
||||
}
|
||||
return d;
|
||||
})
|
||||
.catch(() => {
|
||||
/* el selector degrada a la libreta por defecto; no es fatal */
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
loadAddressbooks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debQuery.trim().toLowerCase();
|
||||
if (!q) return contacts;
|
||||
return contacts.filter((c) => {
|
||||
if (filterBook && (c.collection ?? "") !== filterBook) return false;
|
||||
if (!q) return true;
|
||||
const hay = [
|
||||
c.nombre,
|
||||
c.alias,
|
||||
@@ -162,7 +214,14 @@ export function ContactsView() {
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [contacts, debQuery]);
|
||||
}, [contacts, debQuery, filterBook]);
|
||||
|
||||
// Opciones del selector de libreta del formulario y del filtro.
|
||||
const bookOptions = useMemo(
|
||||
() =>
|
||||
addressbooks.map((b) => ({ value: b.slug, label: b.display_name })),
|
||||
[addressbooks],
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
setForm(EMPTY_FORM);
|
||||
@@ -218,6 +277,47 @@ export function ContactsView() {
|
||||
}
|
||||
}
|
||||
|
||||
// Crea una libreta nueva (mismo flujo que createNewCalendar): deriva el slug
|
||||
// del nombre, refresca la lista y selecciona la nueva en el formulario.
|
||||
const createNewBook = useCallback(async () => {
|
||||
const name = newBookName.trim();
|
||||
if (!name) {
|
||||
setNewBookErr("El nombre es obligatorio.");
|
||||
return;
|
||||
}
|
||||
const slug = slugify(name);
|
||||
if (!slug) {
|
||||
setNewBookErr("El nombre no produce un identificador válido.");
|
||||
return;
|
||||
}
|
||||
setNewBookSaving(true);
|
||||
setNewBookErr(null);
|
||||
try {
|
||||
const res = await createAddressbook({
|
||||
slug,
|
||||
name,
|
||||
color: newBookColor || null,
|
||||
});
|
||||
if (res.status !== "ok") {
|
||||
setNewBookErr(res.error || "No se pudo crear la libreta.");
|
||||
return;
|
||||
}
|
||||
await loadAddressbooks(res.slug || slug);
|
||||
notifications.show({
|
||||
color: "teal",
|
||||
title: "Libreta creada",
|
||||
message: name,
|
||||
});
|
||||
setNewBookOpen(false);
|
||||
setNewBookName("");
|
||||
setNewBookColor("");
|
||||
} catch (e) {
|
||||
setNewBookErr(String(e));
|
||||
} finally {
|
||||
setNewBookSaving(false);
|
||||
}
|
||||
}, [newBookName, newBookColor, loadAddressbooks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContactForm
|
||||
@@ -225,11 +325,67 @@ export function ContactsView() {
|
||||
editing={editSlug !== null}
|
||||
form={form}
|
||||
saving={saving}
|
||||
bookOptions={bookOptions}
|
||||
onChange={setForm}
|
||||
onNewBook={() => {
|
||||
setNewBookErr(null);
|
||||
setNewBookName("");
|
||||
setNewBookColor("");
|
||||
setNewBookOpen(true);
|
||||
}}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSave={onSave}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={newBookOpen}
|
||||
onClose={() => setNewBookOpen(false)}
|
||||
title="Nueva libreta"
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
placeholder="Trabajo, Familia, OSINT…"
|
||||
value={newBookName}
|
||||
onChange={(e) => setNewBookName(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
required
|
||||
/>
|
||||
<ColorInput
|
||||
label="Color"
|
||||
placeholder="Color de la libreta (opcional)"
|
||||
value={newBookColor}
|
||||
onChange={setNewBookColor}
|
||||
format="hex"
|
||||
swatches={[
|
||||
"#23bdfe",
|
||||
"#16a34a",
|
||||
"#dc2626",
|
||||
"#f59e0b",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#0891b2",
|
||||
"#64748b",
|
||||
]}
|
||||
/>
|
||||
{newBookErr && (
|
||||
<Text c="red" size="sm">
|
||||
{newBookErr}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<Button variant="default" onClick={() => setNewBookOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={createNewBook} loading={newBookSaving}>
|
||||
Crear
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{error ? (
|
||||
<Center h="100%" p="xl">
|
||||
<Alert color="orange" title="Agenda no disponible" maw={500}>
|
||||
@@ -258,6 +414,34 @@ export function ContactsView() {
|
||||
>
|
||||
Nuevo contacto
|
||||
</Button>
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap" mb="sm">
|
||||
<Select
|
||||
label="Libreta"
|
||||
placeholder="Todas"
|
||||
data={bookOptions}
|
||||
value={filterBook || null}
|
||||
onChange={(v) => setFilterBook(v || "")}
|
||||
leftSection={<IconAddressBook size={14} />}
|
||||
clearable
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Tooltip label="Nueva libreta">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Nueva libreta"
|
||||
onClick={() => {
|
||||
setNewBookErr(null);
|
||||
setNewBookName("");
|
||||
setNewBookColor("");
|
||||
setNewBookOpen(true);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<TextInput
|
||||
placeholder="Buscar contacto…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
@@ -339,7 +523,9 @@ function ContactForm({
|
||||
editing,
|
||||
form,
|
||||
saving,
|
||||
bookOptions,
|
||||
onChange,
|
||||
onNewBook,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
@@ -347,7 +533,9 @@ function ContactForm({
|
||||
editing: boolean;
|
||||
form: FormState;
|
||||
saving: boolean;
|
||||
bookOptions: { value: string; label: string }[];
|
||||
onChange: (f: FormState) => void;
|
||||
onNewBook: () => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
@@ -362,17 +550,42 @@ function ContactForm({
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Tipo"
|
||||
data={[
|
||||
{ value: "persona", label: "Persona" },
|
||||
{ value: "organizacion", label: "Organización" },
|
||||
]}
|
||||
value={form.tipo}
|
||||
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
|
||||
disabled={editing}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Tipo"
|
||||
data={[
|
||||
{ value: "persona", label: "Persona" },
|
||||
{ value: "organizacion", label: "Organización" },
|
||||
]}
|
||||
value={form.tipo}
|
||||
onChange={(v) => set("tipo", (v as FormTipo) || "persona")}
|
||||
disabled={editing}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
label="Libreta"
|
||||
placeholder="Por defecto"
|
||||
data={bookOptions}
|
||||
value={form.collection || null}
|
||||
onChange={(v) => set("collection", v || "")}
|
||||
leftSection={<IconAddressBook size={14} />}
|
||||
clearable
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Tooltip label="Nueva libreta">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Nueva libreta"
|
||||
onClick={onNewBook}
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
required
|
||||
@@ -386,18 +599,29 @@ function ContactForm({
|
||||
value={form.aliases}
|
||||
onChange={(v) => set("aliases", v)}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Teléfono"
|
||||
value={form.telefono}
|
||||
onChange={(e) => set("telefono", e.currentTarget.value)}
|
||||
<Group grow align="flex-start">
|
||||
<TagsInput
|
||||
label="Teléfonos"
|
||||
placeholder="añade un teléfono y pulsa Enter"
|
||||
value={form.telefonos}
|
||||
onChange={(v) => set("telefonos", v)}
|
||||
leftSection={<IconPhone size={14} />}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.currentTarget.value)}
|
||||
<TagsInput
|
||||
label="Emails"
|
||||
placeholder="añade un email y pulsa Enter"
|
||||
value={form.emails}
|
||||
onChange={(v) => set("emails", v)}
|
||||
leftSection={<IconAt size={14} />}
|
||||
/>
|
||||
</Group>
|
||||
<TagsInput
|
||||
label="Direcciones"
|
||||
placeholder="añade una dirección y pulsa Enter"
|
||||
value={form.direcciones}
|
||||
onChange={(v) => set("direcciones", v)}
|
||||
leftSection={<IconMapPin size={14} />}
|
||||
/>
|
||||
{form.tipo === "persona" && (
|
||||
<Group grow>
|
||||
<TextInput
|
||||
@@ -419,11 +643,6 @@ function ContactForm({
|
||||
onChange={(e) => set("pais", e.currentTarget.value)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="Dirección"
|
||||
value={form.direccion}
|
||||
onChange={(e) => set("direccion", e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Contexto"
|
||||
placeholder="origen / círculo"
|
||||
@@ -469,8 +688,16 @@ function ContactDetail({
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const osintEntries = Object.entries(contact.osint ?? {}).filter(
|
||||
([, v]) => v != null && v !== "",
|
||||
// direccion se muestra en su propio bloque multi-valor; no se duplica en OSINT.
|
||||
([k, v]) => v != null && v !== "" && k !== "direccion",
|
||||
);
|
||||
// Direcciones multi-valor: campo direcciones[] o, en su defecto, osint.direccion.
|
||||
const direcciones =
|
||||
contact.direcciones && contact.direcciones.length > 0
|
||||
? contact.direcciones
|
||||
: contact.osint?.direccion
|
||||
? [contact.osint.direccion]
|
||||
: [];
|
||||
return (
|
||||
<Stack p="xl" gap="lg" maw={720}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
@@ -553,6 +780,22 @@ function ContactDetail({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{direcciones.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<IconMapPin size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Direcciones
|
||||
</Text>
|
||||
</Group>
|
||||
{direcciones.map((d, i) => (
|
||||
<Text key={i} size="sm" pl="lg">
|
||||
{d}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{osintEntries.length > 0 && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fw={600} size="sm" mb="xs" c="brand">
|
||||
|
||||
Reference in New Issue
Block a user