ultisuite-client/lib/contacts/contacts-store.ts
R3D347HR4Y 77f99d8d8a hehe
2026-05-19 00:48:20 +02:00

332 lines
10 KiB
TypeScript

"use client"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import {
findDuplicatePairs,
mergePairKey,
normalizePhone,
type DuplicateMatchReason,
} from "./duplicate-detection"
import { MOCK_FULL_CONTACTS } from "./mock-data"
import type { FullContact } from "./types"
type ContactsView = "list" | "view" | "create" | "edit"
/** Prefill for "Nouveau contact" opened from hover card / elsewhere. */
export type ContactCreateDraft = {
firstName?: string
lastName?: string
emails?: { value: string; label: string }[]
}
export interface DeletedContact {
contact: FullContact
deletedAt: number
reason: string
}
export interface MergeSuggestion {
contactA: FullContact
contactB: FullContact
reason: DuplicateMatchReason
}
export interface CoordinateSuggestion {
contact: FullContact
suggestedField: string
suggestedValue: string
}
interface ContactsState {
contacts: FullContact[]
deletedContacts: DeletedContact[]
ignoredMergePairs: string[]
panelOpen: boolean
view: ContactsView
activeContactId: string | null
searchQuery: string
searchMode: boolean
createDraft: ContactCreateDraft | null
}
interface ContactsActions {
togglePanel: () => void
openPanel: () => void
closePanel: () => void
openContactDetail: (contactId: string) => void
openCreateContact: (draft?: ContactCreateDraft | null) => void
clearCreateDraft: () => void
setView: (view: ContactsView, activeContactId?: string | null) => void
setSearchQuery: (q: string) => void
setSearchMode: (active: boolean) => void
addContact: (
contact: Omit<FullContact, "id" | "createdAt" | "updatedAt">
) => string
addContacts: (
contacts: Omit<FullContact, "id" | "createdAt" | "updatedAt">[]
) => number
updateContact: (id: string, patch: Partial<FullContact>) => void
deleteContact: (id: string) => void
softDeleteContact: (id: string, reason?: string) => void
restoreContact: (id: string) => void
emptyTrash: () => void
mergeContacts: (keepId: string, mergeId: string) => void
ignoreMergePair: (idA: string, idB: string) => void
getMergeSuggestions: () => MergeSuggestion[]
getCoordinateSuggestions: () => CoordinateSuggestion[]
}
export type ContactsStore = ContactsState & ContactsActions
function computeCoordinateSuggestions(contacts: FullContact[]): CoordinateSuggestion[] {
const suggestions: CoordinateSuggestion[] = []
const emailDomains = new Map<string, { company?: string; jobTitle?: string }>()
for (const c of contacts) {
if (c.company) {
for (const e of c.emails) {
const domain = e.value.split("@")[1]?.toLowerCase()
if (domain && !domain.includes("gmail") && !domain.includes("outlook") && !domain.includes("yahoo") && !domain.includes("proton")) {
emailDomains.set(domain, { company: c.company, jobTitle: c.jobTitle })
}
}
}
}
for (const c of contacts) {
if (c.company) continue
for (const e of c.emails) {
const domain = e.value.split("@")[1]?.toLowerCase()
if (domain && emailDomains.has(domain)) {
const info = emailDomains.get(domain)!
if (info.company) {
suggestions.push({ contact: c, suggestedField: "company", suggestedValue: info.company })
break
}
}
}
if (suggestions.length >= 20) break
}
return suggestions
}
export const useContactsStore = create<ContactsStore>()(
persist(
(set, get) => ({
contacts: MOCK_FULL_CONTACTS,
deletedContacts: [],
ignoredMergePairs: [],
panelOpen: false,
view: "list",
activeContactId: null,
searchQuery: "",
searchMode: false,
createDraft: null,
togglePanel: () =>
set((s) =>
s.panelOpen
? {
panelOpen: false,
view: "list",
activeContactId: null,
searchQuery: "",
searchMode: false,
createDraft: null,
}
: { panelOpen: true }
),
openPanel: () => set({ panelOpen: true }),
closePanel: () =>
set({
panelOpen: false,
view: "list",
activeContactId: null,
searchQuery: "",
searchMode: false,
createDraft: null,
}),
openContactDetail: (contactId) =>
set({
panelOpen: true,
view: "view",
activeContactId: contactId,
searchQuery: "",
searchMode: false,
createDraft: null,
}),
openCreateContact: (draft = null) =>
set({
panelOpen: true,
view: "create",
activeContactId: null,
searchQuery: "",
searchMode: false,
createDraft: draft,
}),
clearCreateDraft: () => set({ createDraft: null }),
setView: (view, activeContactId = null) =>
set({ view, activeContactId, createDraft: null }),
setSearchQuery: (searchQuery) => set({ searchQuery }),
setSearchMode: (searchMode) =>
set(searchMode ? { searchMode } : { searchMode, searchQuery: "" }),
addContact: (contact) => {
const id = `contact-${crypto.randomUUID()}`
const now = Date.now()
const full: FullContact = { ...contact, id, createdAt: now, updatedAt: now }
set((s) => ({ contacts: [...s.contacts, full] }))
return id
},
addContacts: (incoming) => {
if (incoming.length === 0) return 0
const now = Date.now()
const added = incoming.map((contact) => ({
...contact,
id: `contact-${crypto.randomUUID()}`,
createdAt: now,
updatedAt: now,
}))
set((s) => ({ contacts: [...s.contacts, ...added] }))
return added.length
},
updateContact: (id, patch) =>
set((s) => ({
contacts: s.contacts.map((c) =>
c.id === id ? { ...c, ...patch, updatedAt: Date.now() } : c
),
})),
deleteContact: (id) =>
set((s) => ({
contacts: s.contacts.filter((c) => c.id !== id),
activeContactId: s.activeContactId === id ? null : s.activeContactId,
view: s.activeContactId === id ? "list" : s.view,
})),
softDeleteContact: (id, reason = "Supprimé manuellement") =>
set((s) => {
const contact = s.contacts.find((c) => c.id === id)
if (!contact) return s
return {
contacts: s.contacts.filter((c) => c.id !== id),
deletedContacts: [
...s.deletedContacts,
{ contact, deletedAt: Date.now(), reason },
],
activeContactId: s.activeContactId === id ? null : s.activeContactId,
view: s.activeContactId === id ? "list" : s.view,
}
}),
restoreContact: (id) =>
set((s) => {
const entry = s.deletedContacts.find((d) => d.contact.id === id)
if (!entry) return s
return {
contacts: [...s.contacts, entry.contact],
deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id),
}
}),
emptyTrash: () => set({ deletedContacts: [] }),
mergeContacts: (keepId, mergeId) =>
set((s) => {
const keep = s.contacts.find((c) => c.id === keepId)
const merge = s.contacts.find((c) => c.id === mergeId)
if (!keep || !merge) return s
const mergedEmails = [...keep.emails]
for (const e of merge.emails) {
if (!mergedEmails.some((me) => me.value.toLowerCase() === e.value.toLowerCase())) {
mergedEmails.push(e)
}
}
const mergedPhones = [...keep.phones]
for (const p of merge.phones) {
const norm = normalizePhone(p.value)
if (
!mergedPhones.some(
(mp) => normalizePhone(mp.value) === norm && norm.length > 0
)
) {
mergedPhones.push(p)
}
}
const mergedLabels = [
...new Set([...(keep.labels ?? []), ...(merge.labels ?? [])]),
]
const merged: FullContact = {
...keep,
firstName: keep.firstName || merge.firstName,
lastName: keep.lastName || merge.lastName,
emails: mergedEmails,
phones: mergedPhones,
labels: mergedLabels.length ? mergedLabels : undefined,
company: keep.company || merge.company,
jobTitle: keep.jobTitle || merge.jobTitle,
department: keep.department || merge.department,
birthday: keep.birthday || merge.birthday,
avatarUrl: keep.avatarUrl || merge.avatarUrl,
notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || undefined,
updatedAt: Date.now(),
}
const pairKey = mergePairKey(keepId, mergeId)
return {
contacts: s.contacts
.filter((c) => c.id !== mergeId)
.map((c) => (c.id === keepId ? merged : c)),
ignoredMergePairs: s.ignoredMergePairs.includes(pairKey)
? s.ignoredMergePairs
: [...s.ignoredMergePairs, pairKey],
}
}),
ignoreMergePair: (idA, idB) =>
set((s) => {
const key = mergePairKey(idA, idB)
if (s.ignoredMergePairs.includes(key)) return s
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
}),
getMergeSuggestions: () => {
const s = get()
const ignored = new Set(s.ignoredMergePairs)
return findDuplicatePairs(s.contacts, ignored).map((p) => ({
contactA: p.contactA,
contactB: p.contactB,
reason: p.reason,
}))
},
getCoordinateSuggestions: () => computeCoordinateSuggestions(get().contacts),
}),
{
name: "contacts-store",
storage: debouncedPersistJSONStorage,
partialize: (state) => ({
contacts: state.contacts,
deletedContacts: state.deletedContacts,
ignoredMergePairs: state.ignoredMergePairs,
}),
}
)
)