"use client" import { useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from "react" import { Printer, Download, MoreVertical, Trash2, Tag, Ban, Pencil, GitMerge } from "lucide-react" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsList } from "@/lib/contacts/use-contacts-list" import { useDeleteContact, useMergeManyContacts } from "@/lib/api/hooks/use-contact-mutations" 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 { ContactAvatar } from "@/components/gmail/contacts/contact-avatar" import type { FullContact } from "@/lib/contacts/types" import { contactsTableGridStyle, isContactsColumnVisible, useContactsTableColumns, type ContactsTableColumn, } from "@/hooks/use-contacts-table-columns" import type { ContactsPageView } from "./contacts-app-shell" import { CONTACTS_HEADING_TEXT, CONTACTS_ICON_BTN_CLASS, CONTACTS_MUTED_TEXT, CONTACTS_TABLE_HEADER_CLASS, CONTACTS_TABLE_ROW_CLASS, CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS, CONTACTS_TABLE_TOOLBAR_CLASS, CONTACTS_TABLE_STICKY_HEAD_CLASS, } from "@/lib/contacts-chrome-classes" import { CONTACTS_MENU_SURFACE_CLASS } from "@/lib/contacts-chrome-classes" import { cn } from "@/lib/utils" import { ContactsLoadState } from "@/components/gmail/contacts/contacts-load-state" import { ContactLabelPickerBlock } from "@/components/gmail/contacts-page/contact-label-picker-block" import { ContactsBulkEditDialog } from "@/components/gmail/contacts-page/contacts-bulk-edit-dialog" import { ContactsBulkMergeDialog } from "@/components/gmail/contacts-page/contacts-bulk-merge-dialog" import { useContactBulkActions } from "@/components/gmail/contacts-page/use-contact-bulk-actions" import { toast } from "sonner" const DATA_COLUMNS: Exclude[] = [ "name", "email", "phone", "job", "labels", ] const CONTACTS_ROW_CHECKBOX_HIT_CLASS = "flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-muted/60 -m-1" interface ContactsTableProps { view: ContactsPageView searchQuery: string activeLabelId?: string | null onOpenContact: (id: string) => void } export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) { const { visibleColumns, columnLabels } = useContactsTableColumns() const gridStyle = contactsTableGridStyle(visibleColumns) const { contacts, bookId, isLoading, isError, error, refetch } = useContactsList() const softDeleteContact = useContactsStore((s) => s.softDeleteContact) const deleteContactMutation = useDeleteContact() const mergeManyContactsMutation = useMergeManyContacts() const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [labelPickerQuery, setLabelPickerQuery] = useState("") const [bulkEditOpen, setBulkEditOpen] = useState(false) const [bulkMergeOpen, setBulkMergeOpen] = useState(false) const lastSelectionAnchorIdRef = useRef(null) const { getLabelPresence, toggleLabelOnContacts, createAndApplyLabel, blockContacts, applyBulkField, resolveLabelVisualById, isUpdating, } = useContactBulkActions() 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 filteredContactIds = useMemo( () => 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()) lastSelectionAnchorIdRef.current = null }, [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 availableLabelRows = useMemo( () => labelRows.filter((r) => r.enabled !== false), [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 }) lastSelectionAnchorIdRef.current = id } function toggleContactInSelection(id: string) { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) lastSelectionAnchorIdRef.current = id } function selectRangeInclusive(fromId: string, toId: string) { const i0 = filteredContactIds.indexOf(fromId) const i1 = filteredContactIds.indexOf(toId) if (i0 === -1 || i1 === -1) return const lo = Math.min(i0, i1) const hi = Math.max(i0, i1) setSelectedIds(new Set(filteredContactIds.slice(lo, hi + 1))) } function handleShiftSelection(id: string) { const anchor = lastSelectionAnchorIdRef.current if (anchor == null) { setSelectedIds(new Set([id])) } else { selectRangeInclusive(anchor, id) } lastSelectionAnchorIdRef.current = id } function handleContactRowClick( id: string, e: Pick, ) { if (e.shiftKey) { e.preventDefault() handleShiftSelection(id) return } if (e.metaKey || e.ctrlKey) { e.preventDefault() toggleContactInSelection(id) return } onOpenContact(id) } function handleContactCheckboxClickCapture(id: string, e: MouseEvent) { if (!e.shiftKey) return e.preventDefault() e.stopPropagation() handleShiftSelection(id) } function toggleSelectAll(checked: boolean) { if (checked) { setSelectedIds(new Set(filteredContacts.map((c) => c.id))) lastSelectionAnchorIdRef.current = filteredContacts[0]?.id ?? null } else { setSelectedIds(new Set()) lastSelectionAnchorIdRef.current = null } } function handleDeleteSelected() { if (selectionCount === 0) return for (const contact of selectedContacts) { softDeleteContact(contact, "Supprimé manuellement") deleteContactMutation.mutate({ path: contact.id }) } setSelectedIds(new Set()) } function handleMergeSelected(primaryId: string) { if (!bookId) { toast.error("Carnet de contacts introuvable") return } if (selectedContacts.length < 2) return mergeManyContactsMutation.mutate( { bookId, contacts: selectedContacts, primaryId, }, { onSuccess: () => { toast.success("Contacts fusionnés") setBulkMergeOpen(false) setSelectedIds(new Set()) }, onError: (err) => { const msg = err instanceof Error && err.message ? err.message : "Impossible de fusionner ces contacts" toast.error(msg) }, }, ) } 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 (

{viewTitle}

{selectionCount > 0 && ( )} {selectionCount > 0 && ( <> { if (!open) setLabelPickerQuery("") }} > getLabelPresence(selectedContacts, labelId) } onToggleLabel={(labelId) => toggleLabelOnContacts(selectedContacts, labelId) } onCreateLabel={(labelText) => { createAndApplyLabel(selectedContacts, labelText) setLabelPickerQuery("") }} /> )} Exporter au format vCard (.vcf) Exporter au format CSV (.csv) {selectionCount > 0 && ( <> Ajouter / Retirer des libellés getLabelPresence(selectedContacts, labelId) } onToggleLabel={(labelId) => toggleLabelOnContacts(selectedContacts, labelId) } onCreateLabel={(labelText) => { createAndApplyLabel(selectedContacts, labelText) setLabelPickerQuery("") }} /> setBulkEditOpen(true)}> Édition de masse setBulkMergeOpen(true)} disabled={selectionCount < 2} > Fusionner blockContacts(selectedContacts)}> Bloquer )} toggleSelectAll(true)} disabled={filteredContacts.length === 0} > Tout sélectionner setSelectedIds(new Set())} disabled={selectionCount === 0} > Désélectionner tout
{isContactsColumnVisible(visibleColumns, "checkbox") && ( toggleSelectAll(checked === true)} aria-label="Tout sélectionner" /> )} {DATA_COLUMNS.map((column) => isContactsColumnVisible(visibleColumns, column) ? ( {columnLabels[column]} ) : null )}
{filteredContacts.map((contact) => ( toggleContact(contact.id, checked)} onRowClick={(e) => handleContactRowClick(contact.id, e)} onCheckboxClickCapture={(e) => handleContactCheckboxClickCapture(contact.id, e)} /> ))} {(isLoading || isError) && ( )} {!isLoading && !isError && filteredContacts.length === 0 && (
Aucun contact trouvé
)} applyBulkField(selectedContacts, field, value)} isApplying={isUpdating} />
) } function sanitizeExportName(contact: FullContact): string { const name = fullContactDisplayName(contact) || contact.emails[0]?.value || "contact" return name.replace(/[/\\?%*:|"<>]/g, "-").trim() || "contact" } function ContactTableRow({ contact, visibleColumns, gridStyle, selected, onToggleSelect, onRowClick, onCheckboxClickCapture, }: { contact: FullContact visibleColumns: ContactsTableColumn[] gridStyle: CSSProperties selected: boolean onToggleSelect: (checked: boolean) => void onRowClick: (e: Pick) => void onCheckboxClickCapture: (e: MouseEvent) => void }) { const displayName = fullContactDisplayName(contact) const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?" const labelRows = useNavStore((s) => s.labelRows) return (
{ if (e.key !== "Enter" && e.key !== " ") return e.preventDefault() onRowClick(e) }} className={cn( CONTACTS_TABLE_ROW_CLASS, "cursor-pointer", selected && "bg-mail-nav-selected", )} style={gridStyle} > {isContactsColumnVisible(visibleColumns, "checkbox") && ( e.stopPropagation()} onClickCapture={onCheckboxClickCapture} onKeyDown={(e) => e.stopPropagation()} > onToggleSelect(checked === true)} aria-label={`Sélectionner ${name}`} /> )} {isContactsColumnVisible(visibleColumns, "name") && ( {name} {!isContactsColumnVisible(visibleColumns, "email") && contact.emails[0]?.value && ( {contact.emails[0].value} )} )} {isContactsColumnVisible(visibleColumns, "email") && ( {contact.emails[0]?.value || ""} )} {isContactsColumnVisible(visibleColumns, "phone") && ( {contact.phones[0]?.value || ""} )} {isContactsColumnVisible(visibleColumns, "job") && ( {[contact.jobTitle, contact.company].filter(Boolean).join(", ")} )} {isContactsColumnVisible(visibleColumns, "labels") && ( {contact.labels?.map((labelId) => { const row = labelRows.find((l) => l.id === labelId) return row ? ( {row.label} ) : null })} )}
) }