ultisuite-client/components/gmail/contacts-page/contact-detail-page.tsx
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- 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.
2026-06-06 20:26:51 +02:00

291 lines
10 KiB
TypeScript

"use client"
import { useState } from "react"
import {
ArrowLeft,
Download,
Pencil,
Sparkles,
Star,
Trash2,
Mail,
Phone,
Building2,
MapPin,
Cake,
FileText,
MessageSquare,
Video,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useContactsList } from "@/lib/contacts/use-contacts-list"
import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { useNavStore } from "@/lib/stores/nav-store"
import { downloadContactVCard } from "@/lib/contacts/export-contacts"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_ICON_BTN_CLASS,
CONTACTS_PAGE_TAG_CLASS,
CONTACTS_PANEL_DIVIDER_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery"
import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils"
import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog"
const FRENCH_MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
function formatBirthday(b: { day?: number; month?: number; year?: number }): string {
const parts: string[] = []
if (b.day) parts.push(String(b.day))
if (b.month) parts.push(FRENCH_MONTHS[b.month - 1] ?? "")
if (b.year) parts.push(String(b.year))
return parts.join(" ")
}
interface ContactDetailPageProps {
contactId: string
onBack: () => void
onEdit: (id: string) => void
}
export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPageProps) {
const { contacts } = useContactsList()
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const deleteContactMutation = useDeleteContact()
const labelRows = useNavStore((s) => s.labelRows)
const { data: llmSettings } = useLLMSettings()
const [improveOpen, setImproveOpen] = useState(false)
const contact = contacts.find((c) => c.id === contactId)
const llmReady = isLLMConfigured(llmSettings)
if (!contact) {
return (
<div className={cn("flex h-full items-center justify-center text-sm", CONTACTS_MUTED_TEXT)}>
Contact introuvable
</div>
)
}
const displayName = fullContactDisplayName(contact)
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
const primaryEmail = contact.emails[0]?.value
function handleDelete() {
if (contact) softDeleteContact(contact, "Supprimé manuellement")
deleteContactMutation.mutate({ path: contactId })
onBack()
}
return (
<div className="mx-auto max-w-3xl px-6 py-8 text-foreground">
<div className="mb-6 flex items-center justify-between">
<Button
variant="ghost"
size="icon"
className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={onBack}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="mr-1 hidden rounded-full sm:inline-flex"
onClick={() => setImproveOpen(true)}
disabled={!llmReady}
title={
llmReady
? undefined
: "Configurez un fournisseur LLM dans les réglages contacts"
}
>
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
Amélioration IA
</Button>
<Button variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS}>
<Star className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={() => downloadContactVCard(contact)}
aria-label="Télécharger la fiche contact"
>
<Download className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={() => onEdit(contactId)}
>
<Pencil className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={handleDelete}
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
</div>
<div className="flex items-center gap-6 pb-6">
<ContactAvatar contact={contact} name={name} size="xl" />
<div>
<h1 className={cn("text-3xl", CONTACTS_HEADING_TEXT)}>{name}</h1>
{contact.company && (
<p className={cn("mt-1 text-base", CONTACTS_MUTED_TEXT)}>
{contact.jobTitle ? `${contact.jobTitle}` : ""}
{contact.company}
</p>
)}
{contact.labels && contact.labels.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{contact.labels.map((labelId) => {
const row = labelRows.find((r) => r.id === labelId)
return row ? (
<span key={labelId} className={CONTACTS_PAGE_TAG_CLASS}>
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
{row.label}
</span>
) : null
})}
</div>
)}
</div>
</div>
<div className="mb-4 sm:hidden">
<Button
type="button"
variant="outline"
size="sm"
className="w-full rounded-full"
onClick={() => setImproveOpen(true)}
disabled={!llmReady}
title={
llmReady
? undefined
: "Configurez un fournisseur LLM dans les réglages contacts"
}
>
<Sparkles className="mr-1.5 h-4 w-4 text-amber-500" />
Amélioration IA
</Button>
</div>
{primaryEmail && (
<div className={cn("flex items-center gap-2 py-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
<button type="button" className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}>
<Mail className="h-4 w-4" />
Envoyer un e-mail
</button>
<button type="button" className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}>
<MessageSquare className="h-4 w-4" />
</button>
<button type="button" className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}>
<Video className="h-4 w-4" />
</button>
</div>
)}
<div className={cn("space-y-1 pt-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
{contact.emails.length > 0 && (
<DetailRow icon={<Mail className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.emails.map((e, i) => (
<div key={i}>
<p className="text-sm text-primary">{e.value}</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{e.label}</p>
</div>
))}
</DetailRow>
)}
{contact.phones.length > 0 && (
<DetailRow icon={<Phone className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.phones.map((p, i) => (
<div key={i}>
<p className="text-sm text-primary">{p.value}</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{p.label}</p>
</div>
))}
</DetailRow>
)}
{contact.company && (
<DetailRow icon={<Building2 className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div>
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{contact.company}</p>
{contact.department && (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.department}</p>
)}
{contact.jobTitle && (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.jobTitle}</p>
)}
</div>
</DetailRow>
)}
{contact.addresses && contact.addresses.length > 0 && (
<DetailRow icon={<MapPin className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.addresses.map((addr, i) => (
<div key={i}>
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>
{[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country]
.filter(Boolean)
.join(", ")}
</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{addr.label}</p>
</div>
))}
</DetailRow>
)}
{contact.birthday && (contact.birthday.day || contact.birthday.month) && (
<DetailRow icon={<Cake className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{formatBirthday(contact.birthday)}</p>
</DetailRow>
)}
{contact.notes && (
<DetailRow icon={<FileText className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className={cn("whitespace-pre-wrap text-sm", CONTACTS_HEADING_TEXT)}>{contact.notes}</p>
</DetailRow>
)}
</div>
<ContactImproveDialog
contact={contact}
open={improveOpen}
onOpenChange={setImproveOpen}
/>
</div>
)
}
function DetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex gap-4 py-3">
<div className="flex w-6 shrink-0 pt-0.5">{icon}</div>
<div className="flex-1 space-y-2">{children}</div>
</div>
)
}