life-manager/codebase/features/projects/frontend/ProjectContactsPage.tsx
2026-03-17 17:51:06 -07:00

182 lines
6.3 KiB
TypeScript

/** @jsxImportSource react */
import { useState } from 'react';
import type { ReactElement } from 'react';
import styled from 'styled-components';
import { useTheme } from '@lilith/ui-theme';
import { useProjectContext } from './useProjectContext';
import { useContacts, useCreateContact, useDeleteContact } from '@features/contacts/frontend/useContacts';
import { Modal } from '@/components/Modal';
import { Button, Input, Select } from '@lilith/ui-primitives';
import { ContactType } from '@life-platform/shared';
import type { Contact, CreateContactDto } from '@life-platform/shared';
import { Page, SectionTitle, PageHeader, EmptyState, FormField, Label, ModalActions } from '@features/domains/frontend/domain-styles';
import ContactCard from '@features/domains/frontend/components/ContactCard';
const FilterRow = styled.div`
display: flex;
gap: 8px;
flex-wrap: wrap;
`;
const FilterBtn = styled.button<{ active: boolean }>`
background: ${({ active, theme }) => (active ? `color-mix(in srgb, ${theme.colors.primary.main} 13%, transparent)` : 'transparent')};
border: 1px solid ${({ active, theme }) => (active ? theme.colors.primary.main : theme.colors.background.secondary)};
border-radius: 4px;
color: ${({ active, theme }) => (active ? theme.colors.primary.main : theme.colors.text.secondary)};
font-size: 12px;
cursor: pointer;
padding: 4px 10px;
&:hover { border-color: ${({ theme }) => theme.colors.primary.main}; color: ${({ theme }) => theme.colors.primary.main}; }
`;
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
`;
const SearchInput = styled(Input)`
max-width: 220px;
`;
const CONTACT_TYPE_LABELS: Record<ContactType, string> = {
[ContactType.Client]: 'Client',
[ContactType.Agency]: 'Agency',
[ContactType.Collaborator]: 'Collaborator',
[ContactType.Provider]: 'Provider',
[ContactType.Other]: 'Other',
};
export default function ProjectContactsPage(): ReactElement | null {
const { project } = useProjectContext();
const { theme } = useTheme();
const [typeFilter, setTypeFilter] = useState<ContactType | 'all'>('all');
const [activeFilter, setActiveFilter] = useState<boolean | 'all'>('all');
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState<Partial<CreateContactDto>>({
contactType: ContactType.Client,
});
const { data: contactsResult } = useContacts({
projectId: project.id,
contactType: typeFilter !== 'all' ? typeFilter : undefined,
isActive: activeFilter !== 'all' ? activeFilter : undefined,
search: search || undefined,
});
const createContact = useCreateContact();
const deleteContact = useDeleteContact();
const contacts = contactsResult?.data ?? [];
const accent = project.color || theme.colors.primary.main;
function handleSubmit(): void {
if (!form.name || !form.contactType) return;
createContact.mutate(
{ ...form, projectId: project.id } as CreateContactDto,
{
onSuccess: () => {
setShowCreate(false);
setForm({ contactType: ContactType.Client });
},
},
);
}
return (
<Page>
<PageHeader>
<SectionTitle>Contacts ({contactsResult?.meta?.total ?? 0})</SectionTitle>
<Button onClick={() => setShowCreate(true)} size="sm">
+ New Contact
</Button>
</PageHeader>
<FilterRow>
<SearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search contacts"
/>
<FilterBtn active={activeFilter === 'all'} onClick={() => setActiveFilter('all')}>All</FilterBtn>
<FilterBtn active={activeFilter === true} onClick={() => setActiveFilter(true)}>Active</FilterBtn>
<FilterBtn active={activeFilter === false} onClick={() => setActiveFilter(false)}>Inactive</FilterBtn>
{Object.entries(CONTACT_TYPE_LABELS).map(([v, l]) => (
<FilterBtn
key={v}
active={typeFilter === v}
onClick={() => setTypeFilter(typeFilter === v ? 'all' : v as ContactType)}
>
{l}
</FilterBtn>
))}
</FilterRow>
{contacts.length > 0 ? (
<Grid>
{contacts.map((contact: Contact) => (
<ContactCard
key={contact.id}
contact={contact}
accent={accent}
contactTypeLabels={CONTACT_TYPE_LABELS}
onDelete={() => deleteContact.mutate(contact.id)}
/>
))}
</Grid>
) : (
<EmptyState>No contacts found</EmptyState>
)}
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="New Contact">
<FormField>
<Label>Name</Label>
<Input
value={form.name ?? ''}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</FormField>
<FormField>
<Label>Type</Label>
<Select
value={form.contactType ?? ContactType.Client}
onChange={(e) => setForm((f) => ({ ...f, contactType: e.target.value as ContactType }))}
>
{Object.entries(CONTACT_TYPE_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</FormField>
<FormField>
<Label>Email</Label>
<Input
type="email"
value={form.email ?? ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
/>
</FormField>
<FormField>
<Label>Phone</Label>
<Input
value={form.phone ?? ''}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
</FormField>
<FormField>
<Label>Notes</Label>
<Input
value={form.notes ?? ''}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</FormField>
<ModalActions>
<Button variant="ghost" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={createContact.isPending}>
{createContact.isPending ? 'Saving...' : 'Create'}
</Button>
</ModalActions>
</Modal>
</Page>
);
}