EMAILOUT / frontend /src /components /workspace /ContactActivityLogger.jsx
Seth
Claude Sonnet 5
Add Call/Visit activity logging and timeline for contacts and companies
41c19e3
Raw History Blame Contribute Delete
5.67 kB
import React, { useState } from 'react';
import { Phone, MapPin, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
/**
* "Call" / "Visit" icon buttons for a contact. Clicking one opens an inline textarea to note
* what was discussed; saving POSTs to /api/contacts/{contactId}/activities. Self-contained —
* manages its own open/saving state, calls `onLogged` after a successful save so the parent can
* refresh whatever activity timeline is showing.
*/
export default function ContactActivityLogger({ contactId, onLogged, className, iconSize = 'h-3.5 w-3.5' }) {
const [openType, setOpenType] = useState(null); // 'call' | 'visit' | null
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
if (!contactId) return null;
const toggle = (type) => (e) => {
e.stopPropagation();
setError('');
setOpenType((cur) => (cur === type ? null : type));
setNotes('');
};
const cancel = (e) => {
e.stopPropagation();
setOpenType(null);
setNotes('');
setError('');
};
const save = async (e) => {
e.stopPropagation();
if (!openType) return;
setSaving(true);
setError('');
try {
const res = await apiFetch(`/api/contacts/${contactId}/activities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ activity_type: openType, notes: notes.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(typeof data.detail === 'string' ? data.detail : 'Could not save activity');
return;
}
setOpenType(null);
setNotes('');
onLogged && onLogged(data);
} catch (err) {
console.error(err);
setError('Network error');
} finally {
setSaving(false);
}
};
return (
<div className={className} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-7 w-7 text-slate-500 hover:text-emerald-700 hover:bg-emerald-50',
openType === 'call' && 'bg-emerald-100 text-emerald-800 hover:bg-emerald-100'
)}
title="Log a call"
aria-label="Log a call"
onClick={toggle('call')}
>
<Phone className={iconSize} aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'h-7 w-7 text-slate-500 hover:text-violet-700 hover:bg-violet-50',
openType === 'visit' && 'bg-violet-100 text-violet-800 hover:bg-violet-100'
)}
title="Log a visit"
aria-label="Log a visit"
onClick={toggle('visit')}
>
<MapPin className={iconSize} aria-hidden />
</Button>
</div>
{openType ? (
<div className="mt-2 w-72 rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium text-slate-600">
{openType === 'call' ? 'What did you discuss on the call?' : 'What did you discuss on the visit?'}
</span>
<button
type="button"
className="text-slate-400 hover:text-slate-600"
onClick={cancel}
aria-label="Cancel"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
<textarea
autoFocus
rows={2}
className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-violet-400"
placeholder="Notes (optional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={saving}
onClick={(e) => e.stopPropagation()}
/>
{error ? <p className="mt-1 text-xs text-red-600">{error}</p> : null}
<div className="mt-1.5 flex justify-end gap-1.5">
<Button type="button" size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={cancel} disabled={saving}>
Cancel
</Button>
<Button type="button" size="sm" className="h-7 px-2 text-xs" onClick={save} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : 'Save'}
</Button>
</div>
</div>
) : null}
</div>
);
}