Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views. - Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts. - Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface. - Added loading and error states in ContactsListView for better user feedback during data fetching. - Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list. - Updated package.json to include @formkit/auto-animate for improved UI animations.
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
"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 { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-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<ContactsTableColumn, "checkbox">[] = [
|
|
"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<Set<string>>(() => new Set())
|
|
const [labelPickerQuery, setLabelPickerQuery] = useState("")
|
|
const [bulkEditOpen, setBulkEditOpen] = useState(false)
|
|
const [bulkMergeOpen, setBulkMergeOpen] = useState(false)
|
|
const lastSelectionAnchorIdRef = useRef<string | null>(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<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey" | "preventDefault">,
|
|
) {
|
|
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 (
|
|
<div className="px-3 py-4 sm:px-6">
|
|
<div className={CONTACTS_TABLE_STICKY_HEAD_CLASS}>
|
|
<div className={CONTACTS_TABLE_TOOLBAR_CLASS}>
|
|
<h1 className={cn("min-w-0 flex-1 truncate text-xl font-normal sm:text-2xl", CONTACTS_HEADING_TEXT)}>
|
|
{viewTitle}
|
|
</h1>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{selectionCount > 0 && (
|
|
<span
|
|
aria-live="polite"
|
|
className={cn(
|
|
"mr-1 hidden text-sm whitespace-nowrap sm:inline",
|
|
CONTACTS_MUTED_TEXT,
|
|
)}
|
|
>
|
|
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
{selectionCount > 0 && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn("hidden h-9 rounded-full px-3 sm:inline-flex", CONTACTS_ICON_BTN_CLASS)}
|
|
onClick={() => setBulkEditOpen(true)}
|
|
>
|
|
<Pencil className="mr-1.5 h-4 w-4" />
|
|
Édition de masse
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn("hidden h-9 rounded-full px-3 sm:inline-flex", CONTACTS_ICON_BTN_CLASS)}
|
|
disabled={selectionCount < 2}
|
|
onClick={() => setBulkMergeOpen(true)}
|
|
>
|
|
<GitMerge className="mr-1.5 h-4 w-4" />
|
|
Fusionner
|
|
</Button>
|
|
|
|
<DropdownMenu
|
|
onOpenChange={(open) => {
|
|
if (!open) setLabelPickerQuery("")
|
|
}}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
|
|
aria-label="Ajouter ou retirer des libellés"
|
|
>
|
|
<Tag className="h-5 w-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className={cn(
|
|
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
|
|
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
|
)}
|
|
>
|
|
<ContactLabelPickerBlock
|
|
query={labelPickerQuery}
|
|
onQueryChange={setLabelPickerQuery}
|
|
labelRows={availableLabelRows}
|
|
resolveLabelVisual={resolveLabelVisualById}
|
|
Item={DropdownMenuItem}
|
|
getLabelPresence={(labelId) =>
|
|
getLabelPresence(selectedContacts, labelId)
|
|
}
|
|
onToggleLabel={(labelId) =>
|
|
toggleLabelOnContacts(selectedContacts, labelId)
|
|
}
|
|
onCreateLabel={(labelText) => {
|
|
createAndApplyLabel(selectedContacts, labelText)
|
|
setLabelPickerQuery("")
|
|
}}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
|
|
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={cn("h-9 w-9 rounded-full disabled:opacity-40", CONTACTS_ICON_BTN_CLASS)}
|
|
disabled={selectionCount === 0}
|
|
aria-label="Exporter la sélection"
|
|
>
|
|
<Download className="h-5 w-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className={cn("w-52", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
|
<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={cn("h-9 w-9 rounded-full disabled:opacity-40", CONTACTS_ICON_BTN_CLASS)}
|
|
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={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
|
|
aria-label="Plus d'actions"
|
|
>
|
|
<MoreVertical className="h-5 w-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className={cn("w-56 overflow-visible", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
|
|
{selectionCount > 0 && (
|
|
<>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
|
|
<Tag className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
Ajouter / Retirer des libellés
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent
|
|
className={cn(
|
|
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
|
|
"flex max-h-72 min-w-[260px] flex-col overflow-hidden p-0 py-0",
|
|
)}
|
|
>
|
|
<ContactLabelPickerBlock
|
|
query={labelPickerQuery}
|
|
onQueryChange={setLabelPickerQuery}
|
|
labelRows={availableLabelRows}
|
|
resolveLabelVisual={resolveLabelVisualById}
|
|
Item={DropdownMenuItem}
|
|
getLabelPresence={(labelId) =>
|
|
getLabelPresence(selectedContacts, labelId)
|
|
}
|
|
onToggleLabel={(labelId) =>
|
|
toggleLabelOnContacts(selectedContacts, labelId)
|
|
}
|
|
onCreateLabel={(labelText) => {
|
|
createAndApplyLabel(selectedContacts, labelText)
|
|
setLabelPickerQuery("")
|
|
}}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuItem onClick={() => setBulkEditOpen(true)}>
|
|
<Pencil className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
Édition de masse
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setBulkMergeOpen(true)}
|
|
disabled={selectionCount < 2}
|
|
>
|
|
<GitMerge className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
Fusionner
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => blockContacts(selectedContacts)}>
|
|
<Ban className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
Bloquer
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<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={CONTACTS_TABLE_HEADER_CLASS}
|
|
style={gridStyle}
|
|
>
|
|
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
|
<span className={CONTACTS_TABLE_HEADER_CHECKBOX_HIT_CLASS}>
|
|
<Checkbox
|
|
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
|
|
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
|
|
aria-label="Tout sélectionner"
|
|
/>
|
|
</span>
|
|
)}
|
|
{DATA_COLUMNS.map((column) =>
|
|
isContactsColumnVisible(visibleColumns, column) ? (
|
|
<span key={column} className="flex min-h-8 items-center">
|
|
{columnLabels[column]}
|
|
</span>
|
|
) : null
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{filteredContacts.map((contact) => (
|
|
<ContactTableRow
|
|
key={contact.id}
|
|
contact={contact}
|
|
visibleColumns={visibleColumns}
|
|
gridStyle={gridStyle}
|
|
selected={selectedIds.has(contact.id)}
|
|
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
|
|
onRowClick={(e) => handleContactRowClick(contact.id, e)}
|
|
onCheckboxClickCapture={(e) => handleContactCheckboxClickCapture(contact.id, e)}
|
|
/>
|
|
))}
|
|
|
|
{(isLoading || isError) && (
|
|
<ContactsLoadState
|
|
isLoading={isLoading}
|
|
isError={isError}
|
|
error={error}
|
|
onRetry={refetch}
|
|
/>
|
|
)}
|
|
|
|
{!isLoading && !isError && filteredContacts.length === 0 && (
|
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
|
Aucun contact trouvé
|
|
</div>
|
|
)}
|
|
|
|
<ContactsBulkEditDialog
|
|
open={bulkEditOpen}
|
|
onOpenChange={setBulkEditOpen}
|
|
contacts={selectedContacts}
|
|
onApply={(field, value) => applyBulkField(selectedContacts, field, value)}
|
|
isApplying={isUpdating}
|
|
/>
|
|
|
|
<ContactsBulkMergeDialog
|
|
open={bulkMergeOpen}
|
|
onOpenChange={setBulkMergeOpen}
|
|
contacts={selectedContacts}
|
|
onMerge={handleMergeSelected}
|
|
isMerging={mergeManyContactsMutation.isPending}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey" | "preventDefault">) => void
|
|
onCheckboxClickCapture: (e: MouseEvent<HTMLSpanElement>) => void
|
|
}) {
|
|
const displayName = fullContactDisplayName(contact)
|
|
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
|
const labelRows = useNavStore((s) => s.labelRows)
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onRowClick}
|
|
onKeyDown={(e) => {
|
|
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") && (
|
|
<span
|
|
className={CONTACTS_ROW_CHECKBOX_HIT_CLASS}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onClickCapture={onCheckboxClickCapture}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
>
|
|
<Checkbox
|
|
checked={selected}
|
|
onCheckedChange={(checked) => onToggleSelect(checked === true)}
|
|
aria-label={`Sélectionner ${name}`}
|
|
/>
|
|
</span>
|
|
)}
|
|
|
|
{isContactsColumnVisible(visibleColumns, "name") && (
|
|
<span className="flex min-w-0 items-center gap-2 sm:gap-3">
|
|
<ContactAvatar contact={contact} name={name} size="xs" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-foreground">{name}</span>
|
|
{!isContactsColumnVisible(visibleColumns, "email") && contact.emails[0]?.value && (
|
|
<span className="block truncate text-xs text-muted-foreground">{contact.emails[0].value}</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
)}
|
|
|
|
{isContactsColumnVisible(visibleColumns, "email") && (
|
|
<span className="truncate text-foreground">{contact.emails[0]?.value || ""}</span>
|
|
)}
|
|
|
|
{isContactsColumnVisible(visibleColumns, "phone") && (
|
|
<span className="truncate text-foreground">{contact.phones[0]?.value || ""}</span>
|
|
)}
|
|
|
|
{isContactsColumnVisible(visibleColumns, "job") && (
|
|
<span className="truncate text-foreground">
|
|
{[contact.jobTitle, contact.company].filter(Boolean).join(", ")}
|
|
</span>
|
|
)}
|
|
|
|
{isContactsColumnVisible(visibleColumns, "labels") && (
|
|
<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-border px-1.5 py-0.5 text-[11px] text-foreground"
|
|
>
|
|
{row.label}
|
|
</span>
|
|
) : null
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|