ultisuite-client/components/gmail/contacts-page/contacts-table.tsx
R3D347HR4Y 77f99d8d8a hehe
2026-05-19 00:48:20 +02:00

357 lines
12 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { Printer, Download, MoreVertical, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useNavStore } from "@/lib/stores/nav-store"
import { searchContacts } from "@/lib/contacts/fuzzy-search"
import { printContacts } from "@/lib/contacts/print-contacts"
import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/export-contacts"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import type { FullContact } from "@/lib/contacts/types"
import type { ContactsPageView } from "./contacts-app-shell"
const TABLE_GRID =
"grid grid-cols-[40px_minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,1.5fr)_minmax(0,1fr)] gap-2"
interface ContactsTableProps {
view: ContactsPageView
searchQuery: string
activeLabelId?: string | null
onOpenContact: (id: string) => void
}
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
const contacts = useContactsStore((s) => s.contacts)
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const filteredContacts = useMemo(() => {
let list = contacts
if (view === "frequent") {
list = [...contacts]
.filter((c) => (c.interactionCount ?? 0) > 0)
.sort((a, b) => (b.interactionCount ?? 0) - (a.interactionCount ?? 0))
} else if (view === "other") {
list = contacts.filter((c) => c.isOtherContact === true)
} else if (view === "label" && activeLabelId) {
list = contacts.filter((c) => c.labels?.includes(activeLabelId))
}
if (searchQuery.trim()) {
list = searchContacts(list, searchQuery)
} else if (view !== "frequent") {
list = [...list].sort((a, b) => {
const nameA = fullContactDisplayName(a) || a.emails[0]?.value || ""
const nameB = fullContactDisplayName(b) || b.emails[0]?.value || ""
return nameA.localeCompare(nameB, "fr")
})
}
return list
}, [contacts, view, searchQuery, activeLabelId])
const filteredIds = useMemo(
() => new Set(filteredContacts.map((c) => c.id)),
[filteredContacts]
)
const selectedContacts = useMemo(
() => filteredContacts.filter((c) => selectedIds.has(c.id)),
[filteredContacts, selectedIds]
)
const selectionCount = selectedContacts.length
const allFilteredSelected =
filteredContacts.length > 0 &&
filteredContacts.every((c) => selectedIds.has(c.id))
const someFilteredSelected =
filteredContacts.some((c) => selectedIds.has(c.id)) && !allFilteredSelected
useEffect(() => {
setSelectedIds(new Set())
}, [view, activeLabelId])
useEffect(() => {
setSelectedIds((prev) => {
const next = new Set([...prev].filter((id) => filteredIds.has(id)))
return next.size === prev.size ? prev : next
})
}, [filteredIds])
const labelRows = useNavStore((s) => s.labelRows)
const activeLabelName = activeLabelId
? labelRows.find((l) => l.id === activeLabelId)?.label
: null
const viewTitle = view === "frequent"
? "Contacts fréquents"
: view === "other"
? "Autres contacts"
: view === "label" && activeLabelName
? activeLabelName
: `Contacts (${contacts.length})`
function toggleContact(id: string, checked: boolean) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
function toggleSelectAll(checked: boolean) {
if (checked) {
setSelectedIds(new Set(filteredContacts.map((c) => c.id)))
} else {
setSelectedIds(new Set())
}
}
function handleDeleteSelected() {
if (selectionCount === 0) return
for (const contact of selectedContacts) {
softDeleteContact(contact.id, "Supprimé manuellement")
}
setSelectedIds(new Set())
}
function handleExportVcf() {
if (selectionCount === 0) return
const filename =
selectionCount === 1
? `${sanitizeExportName(selectedContacts[0])}.vcf`
: "contacts.vcf"
downloadContactsVCard(selectedContacts, filename)
}
function handleExportCsv() {
if (selectionCount === 0) return
const filename =
selectionCount === 1
? `${sanitizeExportName(selectedContacts[0])}.csv`
: "contacts.csv"
downloadContactsCsv(selectedContacts, filename)
}
return (
<div className="px-6 py-4">
<div className="mb-2 flex items-center justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-normal text-[#1f1f1f]">{viewTitle}</h1>
{selectionCount > 0 && (
<p className="mt-0.5 text-sm text-[#5f6368]">
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full text-[#5f6368]"
onClick={() => printContacts(filteredContacts, viewTitle)}
aria-label="Imprimer"
>
<Printer className="h-5 w-5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full text-[#5f6368] disabled:opacity-40"
disabled={selectionCount === 0}
aria-label="Exporter la sélection"
>
<Download className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={handleExportVcf}>
Exporter au format vCard (.vcf)
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportCsv}>
Exporter au format CSV (.csv)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full text-[#5f6368] disabled:opacity-40"
disabled={selectionCount === 0}
onClick={handleDeleteSelected}
aria-label="Supprimer la sélection"
>
<Trash2 className="h-5 w-5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full text-[#5f6368]"
aria-label="Plus d'actions"
>
<MoreVertical className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => toggleSelectAll(true)}
disabled={filteredContacts.length === 0}
>
Tout sélectionner
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setSelectedIds(new Set())}
disabled={selectionCount === 0}
>
Désélectionner tout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={`${TABLE_GRID} border-b border-gray-200 py-2 text-xs font-medium text-[#5f6368]`}
>
<span className="flex items-center justify-center">
<Checkbox
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
aria-label="Tout sélectionner"
/>
</span>
<span>Nom</span>
<span>E-mail</span>
<span>Numéro de téléphone</span>
<span>Fonction et entreprise</span>
<span>Libellés</span>
</div>
{filteredContacts.map((contact) => (
<ContactTableRow
key={contact.id}
contact={contact}
selected={selectedIds.has(contact.id)}
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
onOpen={() => onOpenContact(contact.id)}
/>
))}
{filteredContacts.length === 0 && (
<div className="py-12 text-center text-sm text-[#5f6368]">
Aucun contact trouvé
</div>
)}
</div>
)
}
function sanitizeExportName(contact: FullContact): string {
const name = fullContactDisplayName(contact) || contact.emails[0]?.value || "contact"
return name.replace(/[/\\?%*:|"<>]/g, "-").trim() || "contact"
}
function ContactTableRow({
contact,
selected,
onToggleSelect,
onOpen,
}: {
contact: FullContact
selected: boolean
onToggleSelect: (checked: boolean) => void
onOpen: () => void
}) {
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
const color = avatarColor(name)
const initial = senderInitial(name)
const labelRows = useNavStore((s) => s.labelRows)
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onOpen()
}
}}
className={`${TABLE_GRID} w-full cursor-pointer items-center border-b border-gray-100 py-2.5 text-left text-sm transition-colors hover:bg-[#f5f5f5] ${
selected ? "bg-[#e8f0fe]" : ""
}`}
>
<span
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onToggleSelect(checked === true)}
aria-label={`Sélectionner ${name}`}
/>
</span>
<span className="flex items-center gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 rounded-full object-cover" />
) : (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</span>
)}
<span className="truncate text-[#1f1f1f]">{name}</span>
</span>
<span className="truncate text-[#1f1f1f]">{contact.emails[0]?.value || ""}</span>
<span className="truncate text-[#1f1f1f]">{contact.phones[0]?.value || ""}</span>
<span className="truncate text-[#1f1f1f]">
{[contact.jobTitle, contact.company].filter(Boolean).join(", ")}
</span>
<span className="flex flex-wrap gap-1">
{contact.labels?.map((labelId) => {
const row = labelRows.find((l) => l.id === labelId)
return row ? (
<span
key={labelId}
className="inline-flex rounded border border-gray-300 px-1.5 py-0.5 text-[11px] text-[#3c4043]"
>
{row.label}
</span>
) : null
})}
</span>
</div>
)
}