ultisuite-client/components/gmail/contacts/contact-form-view.tsx
R3D347HR4Y ae54fa29e4 Hehe
2026-05-18 17:47:32 +02:00

887 lines
28 KiB
TypeScript

"use client"
import {
useState,
useEffect,
useId,
forwardRef,
useCallback,
useRef,
type InputHTMLAttributes,
} from "react"
import { useForm, useFieldArray, Controller } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
ArrowLeft,
Star,
X,
User,
Building2,
Mail,
Phone,
MapPin,
Cake,
FileText,
Plus,
ChevronDown,
ChevronUp,
Check,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store"
const FRENCH_MONTHS = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
] as const
const EMAIL_LABELS = ["Domicile", "Travail", "Autre"] as const
const PHONE_LABELS = ["Mobile", "Domicile", "Travail"] as const
const ADDRESS_LABELS = ["Domicile", "Travail", "Autre"] as const
const addressSchema = z.object({
street: z.string().optional().default(""),
city: z.string().optional().default(""),
region: z.string().optional().default(""),
postalCode: z.string().optional().default(""),
country: z.string().optional().default(""),
label: z.string().default("Domicile"),
})
const contactFormSchema = z.object({
namePrefix: z.string().optional().default(""),
firstName: z.string().optional().default(""),
middleName: z.string().optional().default(""),
lastName: z.string().optional().default(""),
nameSuffix: z.string().optional().default(""),
phoneticFirstName: z.string().optional().default(""),
phoneticLastName: z.string().optional().default(""),
company: z.string().optional().default(""),
department: z.string().optional().default(""),
jobTitle: z.string().optional().default(""),
emails: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
phones: z.array(
z.object({
value: z.string(),
label: z.string(),
}),
),
addresses: z.array(addressSchema),
birthday: z
.object({
day: z.any().optional(),
month: z.any().optional(),
year: z.any().optional(),
})
.optional(),
notes: z.string().optional().default(""),
labels: z.array(z.string()).optional().default([]),
})
type ContactFormValues = z.infer<typeof contactFormSchema>
interface ContactFormViewProps {
mode: "create" | "edit"
contactId?: string | null
}
export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
const {
contacts,
addContact,
updateContact,
setView,
closePanel,
createDraft,
clearCreateDraft,
} = useContactsStore()
const labelRows = useNavStore((s) => s.labelRows)
const [starred, setStarred] = useState(false)
const [nameExpanded, setNameExpanded] = useState(false)
const [companyExpanded, setCompanyExpanded] = useState(false)
const existingContact =
mode === "edit" ? contacts.find((c) => c.id === contactId) : null
const {
register,
handleSubmit,
control,
watch,
reset,
setValue,
formState: { isDirty },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
namePrefix: "",
firstName: "",
middleName: "",
lastName: "",
nameSuffix: "",
phoneticFirstName: "",
phoneticLastName: "",
company: "",
department: "",
jobTitle: "",
emails: [{ value: "", label: "Domicile" }],
phones: [{ value: "", label: "Mobile" }],
addresses: [],
birthday: { day: undefined, month: undefined, year: undefined },
notes: "",
labels: [],
},
})
const {
fields: emailFields,
append: appendEmail,
remove: removeEmail,
} = useFieldArray({ control, name: "emails" })
const {
fields: phoneFields,
append: appendPhone,
remove: removePhone,
} = useFieldArray({ control, name: "phones" })
const {
fields: addressFields,
append: appendAddress,
remove: removeAddress,
} = useFieldArray({ control, name: "addresses" })
useEffect(() => {
if (mode !== "create" || !createDraft) return
reset({
namePrefix: "",
firstName: createDraft.firstName ?? "",
middleName: "",
lastName: createDraft.lastName ?? "",
nameSuffix: "",
phoneticFirstName: "",
phoneticLastName: "",
company: "",
department: "",
jobTitle: "",
emails: createDraft.emails?.length
? createDraft.emails
: [{ value: "", label: "Domicile" }],
phones: [{ value: "", label: "Mobile" }],
addresses: [],
birthday: { day: undefined, month: undefined, year: undefined },
notes: "",
labels: [],
}, { shouldDirty: true })
clearCreateDraft()
}, [mode, createDraft, reset, clearCreateDraft])
useEffect(() => {
if (existingContact) {
const hasExtendedName = !!(
existingContact.namePrefix ||
existingContact.middleName ||
existingContact.nameSuffix ||
existingContact.phoneticFirstName ||
existingContact.phoneticLastName
)
if (hasExtendedName) setNameExpanded(true)
if (existingContact.department) setCompanyExpanded(true)
reset({
namePrefix: existingContact.namePrefix ?? "",
firstName: existingContact.firstName,
middleName: existingContact.middleName ?? "",
lastName: existingContact.lastName,
nameSuffix: existingContact.nameSuffix ?? "",
phoneticFirstName: existingContact.phoneticFirstName ?? "",
phoneticLastName: existingContact.phoneticLastName ?? "",
company: existingContact.company ?? "",
department: existingContact.department ?? "",
jobTitle: existingContact.jobTitle ?? "",
emails: existingContact.emails.length
? existingContact.emails
: [{ value: "", label: "Domicile" }],
phones: existingContact.phones.length
? existingContact.phones
: [{ value: "", label: "Mobile" }],
addresses: existingContact.addresses ?? [],
birthday: existingContact.birthday ?? {
day: undefined,
month: undefined,
year: undefined,
},
notes: existingContact.notes ?? "",
labels: existingContact.labels ?? [],
})
}
}, [existingContact, reset])
const firstName = watch("firstName")
const lastName = watch("lastName")
const watchedEmails = watch("emails")
const currentLabels = watch("labels") ?? []
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
const canSave =
isDirty ||
(mode === "create" &&
!!(
firstName?.trim() ||
lastName?.trim() ||
watchedEmails?.some((e) => e.value?.trim())
))
const toggleLabel = useCallback(
(labelId: string) => {
const next = currentLabels.includes(labelId)
? currentLabels.filter((l) => l !== labelId)
: [...currentLabels, labelId]
setValue("labels", next, { shouldDirty: true })
},
[currentLabels, setValue],
)
function onSubmit(data: ContactFormValues) {
const payload = {
namePrefix: data.namePrefix || undefined,
firstName: data.firstName ?? "",
middleName: data.middleName || undefined,
lastName: data.lastName ?? "",
nameSuffix: data.nameSuffix || undefined,
phoneticFirstName: data.phoneticFirstName || undefined,
phoneticLastName: data.phoneticLastName || undefined,
company: data.company || undefined,
department: data.department || undefined,
jobTitle: data.jobTitle || undefined,
emails: data.emails.filter((e) => e.value),
phones: data.phones.filter((p) => p.value),
addresses: data.addresses.filter(
(a) => a.street || a.city || a.region || a.postalCode || a.country,
),
birthday:
data.birthday?.day || data.birthday?.month || data.birthday?.year
? data.birthday
: undefined,
notes: data.notes || undefined,
labels: data.labels?.length ? data.labels : undefined,
}
if (mode === "create") {
const id = addContact(payload)
setView("view", id)
} else if (contactId) {
updateContact(contactId, payload)
setView("view", contactId)
}
}
const availableLabels = labelRows.filter((r) => r.enabled !== false)
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex h-full flex-col"
>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-2">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
onClick={() =>
mode === "edit" && contactId
? setView("view", contactId)
: setView("list")
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setStarred((s) => !s)}
>
<Star
className={`h-4 w-4 ${starred ? "fill-yellow-400 text-yellow-400" : "text-gray-400"}`}
/>
</Button>
<button
type="submit"
disabled={!canSave}
className="rounded-full bg-[#f1f3f4] px-5 h-9 text-sm font-medium text-[#3c4043] hover:bg-[#e8eaed] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Enregistrer
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
onClick={closePanel}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Avatar */}
<div className="flex flex-col items-center py-6">
{displayName ? (
<div
className="flex h-20 w-20 items-center justify-center rounded-full text-2xl font-medium text-white"
style={{ backgroundColor: avatarColor(displayName) }}
>
{senderInitial(displayName)}
</div>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-gray-500">
<User className="h-8 w-8" />
</div>
)}
</div>
{/* Labels */}
<div className="flex flex-wrap items-center justify-center gap-1.5 px-4 pb-4">
{currentLabels.map((labelId) => {
const row = labelRows.find((r) => r.id === labelId)
return (
<span
key={labelId}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs text-gray-700"
>
{row && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
)}
{row?.label ?? labelId}
<button
type="button"
onClick={() => toggleLabel(labelId)}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3 w-3" />
</button>
</span>
)
})}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-gray-300 px-2.5 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
<Plus className="h-3 w-3" />
Libellé
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="center">
<p className="px-2 py-1.5 text-xs font-medium text-gray-500">
Libellés
</p>
<div className="max-h-48 overflow-y-auto">
{availableLabels.map((row) => {
const active = currentLabels.includes(row.id)
return (
<button
key={row.id}
type="button"
onClick={() => toggleLabel(row.id)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-gray-100"
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
<span className="flex-1 truncate">{row.label}</span>
{active && <Check className="h-3.5 w-3.5 text-blue-600" />}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
</div>
{/* Name section */}
<FormSection icon={<User className="h-5 w-5 text-gray-400" />}>
{nameExpanded && (
<FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />
)}
<div className="flex items-center gap-1">
<div className="flex-1">
<FloatingInput label="Prénom" {...register("firstName")} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
onClick={() => setNameExpanded((e) => !e)}
>
{nameExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
{nameExpanded && (
<FloatingInput label="Deuxième prénom" {...register("middleName")} />
)}
<FloatingInput label="Nom" {...register("lastName")} />
{nameExpanded && (
<>
<FloatingInput label="Suffixe (Jr., Sr...)" {...register("nameSuffix")} />
<FloatingInput label="Prénom phonétique" {...register("phoneticFirstName")} />
<FloatingInput label="Nom phonétique" {...register("phoneticLastName")} />
</>
)}
</FormSection>
{/* Company section */}
<FormSection icon={<Building2 className="h-5 w-5 text-gray-400" />}>
<div className="flex items-center gap-1">
<div className="flex-1">
<FloatingInput label="Entreprise" {...register("company")} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
onClick={() => setCompanyExpanded((e) => !e)}
>
{companyExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
{companyExpanded && (
<FloatingInput label="Service" {...register("department")} />
)}
<FloatingInput label="Fonction" {...register("jobTitle")} />
</FormSection>
{/* Email section */}
<FormSection icon={<Mail className="h-5 w-5 text-gray-400" />}>
{emailFields.map((field, index) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-1">
<div className="flex-1">
<FloatingInput
label="E-mail"
type="email"
{...register(`emails.${index}.value`)}
/>
</div>
{emailFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
onClick={() => removeEmail(index)}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Controller
control={control}
name={`emails.${index}.label`}
render={({ field: f }) => (
<CompactSelect
value={f.value}
onValueChange={f.onChange}
options={EMAIL_LABELS.map((l) => ({ value: l, label: l }))}
/>
)}
/>
</div>
))}
<AddButton onClick={() => appendEmail({ value: "", label: "Domicile" })}>
Ajouter une adresse e-mail
</AddButton>
</FormSection>
{/* Phone section */}
<FormSection icon={<Phone className="h-5 w-5 text-gray-400" />}>
{phoneFields.map((field, index) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded text-sm">
🇫🇷
</span>
<div className="flex-1">
<FloatingInput
label="Téléphone"
type="tel"
{...register(`phones.${index}.value`)}
/>
</div>
{phoneFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400"
onClick={() => removePhone(index)}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Controller
control={control}
name={`phones.${index}.label`}
render={({ field: f }) => (
<CompactSelect
value={f.value}
onValueChange={f.onChange}
options={PHONE_LABELS.map((l) => ({ value: l, label: l }))}
/>
)}
/>
</div>
))}
<AddButton onClick={() => appendPhone({ value: "", label: "Mobile" })}>
Ajouter un numéro de téléphone
</AddButton>
</FormSection>
{/* Address section */}
<FormSection icon={<MapPin className="h-5 w-5 text-gray-400" />}>
{addressFields.map((field, index) => (
<div key={field.id} className="space-y-2 rounded-lg border border-gray-200 p-3">
<div className="flex items-center justify-between">
<Controller
control={control}
name={`addresses.${index}.label`}
render={({ field: f }) => (
<CompactSelect
value={f.value}
onValueChange={f.onChange}
options={ADDRESS_LABELS.map((l) => ({ value: l, label: l }))}
/>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 rounded-full text-gray-400"
onClick={() => removeAddress(index)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<FloatingInput label="Rue" {...register(`addresses.${index}.street`)} />
<div className="flex gap-2">
<div className="w-24">
<FloatingInput label="Code postal" {...register(`addresses.${index}.postalCode`)} />
</div>
<div className="flex-1">
<FloatingInput label="Ville" {...register(`addresses.${index}.city`)} />
</div>
</div>
<FloatingInput label="Région / Province" {...register(`addresses.${index}.region`)} />
<FloatingInput label="Pays" {...register(`addresses.${index}.country`)} />
</div>
))}
<AddButton
onClick={() =>
appendAddress({
street: "",
city: "",
region: "",
postalCode: "",
country: "",
label: "Domicile",
})
}
>
Ajouter une adresse
</AddButton>
</FormSection>
{/* Birthday section */}
<FormSection icon={<Cake className="h-5 w-5 text-gray-400" />}>
<div className="flex items-stretch gap-2">
<div className="w-[72px]">
<FloatingInput
label="Jour"
type="number"
min={1}
max={31}
{...register("birthday.day", { valueAsNumber: true })}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="birthday.month"
render={({ field: f }) => (
<CompactSelect
value={f.value ? String(f.value) : ""}
onValueChange={(v) => f.onChange(v ? Number(v) : undefined)}
options={FRENCH_MONTHS.map((name, i) => ({
value: String(i + 1),
label: name,
}))}
placeholder="Mois"
/>
)}
/>
</div>
<div className="w-24">
<FloatingInput
label="Année"
type="number"
min={1900}
max={2100}
{...register("birthday.year", { valueAsNumber: true })}
/>
</div>
</div>
</FormSection>
{/* Notes section */}
<FormSection icon={<FileText className="h-5 w-5 text-gray-400" />}>
<FloatingTextarea label="Notes" {...register("notes")} />
</FormSection>
<div className="h-8" />
</div>
</form>
)
}
/* ─── Layout helpers ─────────────────────────────────────────── */
function FormSection({
icon,
children,
}: {
icon: React.ReactNode
children: React.ReactNode
}) {
return (
<div className="flex gap-3 px-4 py-2">
<div className="flex w-5 shrink-0 pt-2">{icon}</div>
<div className="flex-1 space-y-2">{children}</div>
</div>
)
}
function AddButton({
onClick,
children,
}: {
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-2 py-1 text-sm text-[#1a73e8] hover:text-[#1557b0]"
>
<Plus className="h-4 w-4" />
{children}
</button>
)
}
/* ─── Floating label input (Material outlined style) ─────── */
interface FloatingInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string
}
const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
function FloatingInput({ label, className, defaultValue, ...props }, ref) {
const id = useId()
const [focused, setFocused] = useState(false)
const [filled, setFilled] = useState(() => !!defaultValue)
const innerRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (innerRef.current && innerRef.current.value) setFilled(true)
})
const setRefs = useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
if (node && node.value) setFilled(true)
},
[ref],
)
const floated = focused || filled
return (
<div className="relative">
<input
ref={setRefs}
id={id}
{...props}
defaultValue={defaultValue}
className={`peer h-[42px] w-full rounded border bg-white px-3 pt-4 pb-1 text-sm outline-none transition-colors ${
focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"
} ${className ?? ""}`}
onFocus={(e) => {
setFocused(true)
props.onFocus?.(e)
}}
onBlur={(e) => {
setFocused(false)
setFilled(!!e.target.value)
props.onBlur?.(e)
}}
onChange={(e) => {
setFilled(!!e.target.value)
props.onChange?.(e)
}}
/>
<label
htmlFor={id}
className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${
floated
? "top-0.5 px-0.5 text-[10px] leading-tight"
: "top-[11px] text-sm"
} ${focused ? "text-blue-600" : "text-gray-500"}`}
>
{label}
</label>
</div>
)
},
)
/* ─── Floating label textarea ────────────────────────────── */
interface FloatingTextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string
}
const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
function FloatingTextarea({ label, className, ...props }, ref) {
const id = useId()
const [focused, setFocused] = useState(false)
const [filled, setFilled] = useState(false)
const innerRef = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
if (innerRef.current && innerRef.current.value) setFilled(true)
})
const setRefs = useCallback(
(node: HTMLTextAreaElement | null) => {
innerRef.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
if (node && node.value) setFilled(true)
},
[ref],
)
const floated = focused || filled
return (
<div className="relative">
<textarea
ref={setRefs}
id={id}
rows={3}
{...props}
className={`peer w-full rounded border bg-white px-3 pt-5 pb-2 text-sm outline-none transition-colors resize-none ${
focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"
} ${className ?? ""}`}
onFocus={(e) => {
setFocused(true)
props.onFocus?.(e)
}}
onBlur={(e) => {
setFocused(false)
setFilled(!!e.target.value)
props.onBlur?.(e)
}}
onChange={(e) => {
setFilled(!!e.target.value)
props.onChange?.(e)
}}
/>
<label
htmlFor={id}
className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${
floated
? "top-1 px-0.5 text-[10px] leading-tight"
: "top-2.5 text-sm"
} ${focused ? "text-blue-600" : "text-gray-500"}`}
>
{label}
</label>
</div>
)
},
)
/* ─── Compact select (no floating label, just small) ─────── */
function CompactSelect({
value,
onValueChange,
options,
placeholder,
}: {
value: string
onValueChange: (v: string) => void
options: { value: string; label: string }[]
placeholder?: string
}) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="!h-[42px] !min-h-[42px] w-full rounded border border-gray-300 bg-white px-3 py-0 text-sm shadow-none data-[size=default]:!h-[42px] focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
<SelectValue placeholder={placeholder ?? "Choisir..."} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}