diff --git a/app/plugins/builtins/contacts/plugin.py b/app/plugins/builtins/contacts/plugin.py index 3e19729..fd9c9c0 100644 --- a/app/plugins/builtins/contacts/plugin.py +++ b/app/plugins/builtins/contacts/plugin.py @@ -96,11 +96,13 @@ class ContactsPlugin(BasePlugin): ], menu_items=[ FrontendMenuItem(label_key='nav.contacts', label='Kontakte', path='/contacts', icon='Users', order=10, permission='contacts:read'), + FrontendMenuItem(label_key='nav.companies', label='Firmen', path='/companies', icon='Building2', order=11, permission='contacts:read'), ], page_routes=[ FrontendPageRoute(path='/contacts', component='@/pages/ContactsList', protected=True, permission='contacts:read'), FrontendPageRoute(path='/contacts/:id', component='@/pages/ContactDetailPage', protected=True, permission='contacts:read'), FrontendPageRoute(path='/contacts/dedup', component='@/pages/DedupMerge', protected=True, permission='contacts:read'), + FrontendPageRoute(path='/companies', component='@/pages/Companies', protected=True, permission='contacts:read'), ], permissions=[ "contacts:read", diff --git a/frontend/src/__tests__/pages/Companies.test.tsx b/frontend/src/__tests__/pages/Companies.test.tsx new file mode 100644 index 0000000..3037f3f --- /dev/null +++ b/frontend/src/__tests__/pages/Companies.test.tsx @@ -0,0 +1,271 @@ +/** + * Companies page tests — firmen management UI (UI-Backlog module 12/16). + * + * Covers: rendering, permission gating, list with cards, search/industry + * filters, create form validation + payload, edit prefill, delete flow, + * detail modal with contact persons link/unlink. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { CompaniesPage } from '@/pages/Companies'; +import type { Company } from '@/api/companies'; + +const { createMut, updateMut, deleteMut, linkMut, unlinkMut } = vi.hoisted(() => ({ + createMut: vi.fn().mockResolvedValue({}), + updateMut: vi.fn().mockResolvedValue({}), + deleteMut: vi.fn().mockResolvedValue({}), + linkMut: vi.fn().mockResolvedValue({}), + unlinkMut: vi.fn().mockResolvedValue({}), +})); + +const makeCompany = (overrides: Partial = {}): Company => ({ + id: '11111111-1111-1111-1111-111111111111', + name: 'TechCorp GmbH', + displayname: 'TechCorp GmbH', + status: 'lead', + industry: 'IT', + description: null, + email_1: 'info@techcorp.de', + email_2: null, + phone_1: '+49 30 123456', + phone_2: null, + website: 'https://techcorp.de', + mailing_city: 'Berlin', + mailing_postalcode: null, + mailing_country: 'Deutschland', + tags: null, + custom: null, + ...overrides, +}); + +let mockItems: Company[] = []; +let mockTotal = 0; +let mockDetail: Company | null = null; +let mockCanRead = true; +let mockCanWrite = true; +let mockCanDelete = true; + +vi.mock('@/api/companies', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCompanies: () => ({ + data: { items: mockItems, total: mockTotal, page: 1, page_size: 20 }, + isLoading: false, + isError: false, + }), + useCompany: () => ({ + data: mockDetail, + isLoading: false, + isError: false, + }), + useCompanyEmails: () => ({ data: [], isLoading: false }), + useCreateCompany: () => ({ mutate: createMut, isPending: false }), + useUpdateCompany: () => ({ mutate: updateMut, isPending: false }), + useDeleteCompany: () => ({ mutate: deleteMut, isPending: false }), + useLinkContact: () => ({ mutate: linkMut, isPending: false }), + useUnlinkContact: () => ({ mutate: unlinkMut, isPending: false }), + }; +}); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => + (mockCanRead || perm !== 'contacts:read') && + (mockCanWrite || perm !== 'contacts:write') && + (mockCanDelete || perm !== 'contacts:delete'), + }), +})); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + , + ); +} + +beforeEach(() => { + createMut.mockClear(); + updateMut.mockClear(); + deleteMut.mockClear(); + linkMut.mockClear(); + unlinkMut.mockClear(); + mockItems = []; + mockTotal = 0; + mockDetail = null; + mockCanRead = true; + mockCanWrite = true; + mockCanDelete = true; + window.confirm = () => true; +}); + +describe('CompaniesPage', () => { + it('renders page title, export buttons and create button', () => { + renderPage(); + expect(screen.getByTestId('companies-page')).toBeInTheDocument(); + expect(screen.getByTestId('company-export-csv')).toBeInTheDocument(); + expect(screen.getByTestId('company-export-xlsx')).toBeInTheDocument(); + expect(screen.getByTestId('company-create-btn')).toBeInTheDocument(); + }); + + it('shows empty state when no companies exist', async () => { + renderPage(); + expect(await screen.findByTestId('companies-empty')).toBeInTheDocument(); + }); + + it('shows no-permission card without contacts:read', () => { + mockCanRead = false; + renderPage(); + expect(screen.getByTestId('companies-no-permission')).toBeInTheDocument(); + }); + + it('renders company cards with name, status badge and industry', () => { + mockItems = [makeCompany()]; + mockTotal = 1; + renderPage(); + const card = screen.getByTestId('company-card-11111111-1111-1111-1111-111111111111'); + expect(card).toBeInTheDocument(); + expect(screen.getByText('TechCorp GmbH')).toBeInTheDocument(); + expect(screen.getByTestId('company-status-11111111-1111-1111-1111-111111111111')).toHaveTextContent('lead'); + expect(screen.getByText('IT')).toBeInTheDocument(); + }); + + it('hides create/edit/delete buttons without write/delete permissions', () => { + mockItems = [makeCompany()]; + mockTotal = 1; + mockCanWrite = false; + mockCanDelete = false; + renderPage(); + expect(screen.queryByTestId('company-create-btn')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('company-edit-11111111-1111-1111-1111-111111111111'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('company-delete-11111111-1111-1111-1111-111111111111'), + ).not.toBeInTheDocument(); + }); + + it('opens the create form and submits a trimmed payload', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('company-create-btn')); + + fireEvent.change(screen.getByTestId('company-form-name'), { + target: { value: ' Neue Firma AG ' }, + }); + fireEvent.change(screen.getByTestId('company-form-industry'), { + target: { value: 'Handel' }, + }); + fireEvent.change(screen.getByTestId('company-form-email'), { + target: { value: 'info@neuefirma.de' }, + }); + fireEvent.click(screen.getByTestId('company-form-submit')); + + await waitFor(() => expect(createMut).toHaveBeenCalled()); + const payload = createMut.mock.calls[0][0]; + expect(payload.name).toBe('Neue Firma AG'); + expect(payload.status).toBe('lead'); + expect(payload.industry).toBe('Handel'); + expect(payload.email_1).toBe('info@neuefirma.de'); + }); + + it('create submit disabled without a name', () => { + renderPage(); + fireEvent.click(screen.getByTestId('company-create-btn')); + expect(screen.getByTestId('company-form-submit')).toBeDisabled(); + }); + + it('opens the edit form pre-filled and submits only changed fields', async () => { + mockItems = [makeCompany()]; + mockTotal = 1; + renderPage(); + fireEvent.click(screen.getByTestId('company-edit-11111111-1111-1111-1111-111111111111')); + + await waitFor(() => { + expect(screen.getByTestId('company-form-name')).toHaveValue('TechCorp GmbH'); + }); + expect(screen.getByTestId('company-form-industry')).toHaveValue('IT'); + + fireEvent.change(screen.getByTestId('company-form-name'), { + target: { value: 'TechCorp AG' }, + }); + fireEvent.click(screen.getByTestId('company-form-submit')); + + await waitFor(() => expect(updateMut).toHaveBeenCalled()); + const call = updateMut.mock.calls[0][0]; + expect(call.id).toBe('11111111-1111-1111-1111-111111111111'); + expect(call.data.name).toBe('TechCorp AG'); + // unchanged fields must not be part of the update payload + expect(call.data.industry).toBeUndefined(); + expect(call.data.email_1).toBeUndefined(); + }); + + it('deletes a company after confirm', async () => { + mockItems = [makeCompany()]; + mockTotal = 1; + renderPage(); + fireEvent.click(screen.getByTestId('company-delete-11111111-1111-1111-1111-111111111111')); + await waitFor(() => expect(deleteMut).toHaveBeenCalled()); + expect(deleteMut.mock.calls[0][0].id).toBe('11111111-1111-1111-1111-111111111111'); + }); + + it('opens the detail modal with contact persons and unlinks one', async () => { + mockItems = [makeCompany()]; + mockTotal = 1; + mockDetail = makeCompany({ + contacts: [ + { id: '22222222-2222-2222-2222-222222222222', firstname: 'Max', lastname: 'Mustermann', email: 'max@techcorp.de', phone: null }, + ], + }); + renderPage(); + fireEvent.click(screen.getByTestId('company-detail-trigger-11111111-1111-1111-1111-111111111111')); + + await waitFor(() => { + expect(screen.getByTestId('company-person-22222222-2222-2222-2222-222222222222')).toBeInTheDocument(); + }); + expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('company-unlink-22222222-2222-2222-2222-222222222222')); + await waitFor(() => expect(unlinkMut).toHaveBeenCalled()); + expect(unlinkMut.mock.calls[0][0].companyId).toBe('11111111-1111-1111-1111-111111111111'); + }); + + it('links a contact person via UUID input', async () => { + mockItems = [makeCompany()]; + mockTotal = 1; + mockDetail = makeCompany({ contacts: [] }); + renderPage(); + fireEvent.click(screen.getByTestId('company-detail-trigger-11111111-1111-1111-1111-111111111111')); + + await waitFor(() => { + expect(screen.getByTestId('company-link-contact-id')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByTestId('company-link-contact-id'), { + target: { value: '33333333-3333-3333-3333-333333333333' }, + }); + fireEvent.click(screen.getByTestId('company-link-submit')); + + await waitFor(() => expect(linkMut).toHaveBeenCalled()); + expect(linkMut.mock.calls[0][0]).toEqual({ + companyId: '11111111-1111-1111-1111-111111111111', + contactId: '33333333-3333-3333-3333-333333333333', + }); + }); + + it('search input submits on Enter', async () => { + renderPage(); + const input = screen.getByTestId('company-search-input'); + fireEvent.change(input, { target: { value: 'Tech' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + // no crash + the search button is still there + expect(screen.getByTestId('company-search-submit')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/companies.ts b/frontend/src/api/companies.ts new file mode 100644 index 0000000..aea17c1 --- /dev/null +++ b/frontend/src/api/companies.ts @@ -0,0 +1,173 @@ +/** + * Companies API client — CRUD, search, export and contact links for + * companies (UI-Backlog module 12/16). + * + * Backend: /api/v1/companies (contacts plugin). Companies are Contact + * entities with type='company'; industry and description live in the + * custom JSONB field. + * - GET / → paginated list (search, industry, sort) + * - POST / → create (201) + * - GET /export → CSV/XLSX download + * - GET /{id} → detail incl. linked contact persons + * - PUT /{id} → update + * - DELETE /{id} → soft-delete (?cascade=true: persons too) + * - POST /{id}/contacts/{cid} → link a contact person + * - DELETE /{id}/contacts/{cid} → unlink (removes all persons links) + * - GET /{id}/emails → emails mentioning the company + * Permissions: contacts:read (list/get/export) / contacts:write + * (create/update/link/unlink) / contacts:delete (delete). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPut, apiDelete } from '@/api/client'; + +export interface Company { + id: string; + name: string; + displayname: string | null; + status: string | null; + industry: string | null; + description: string | null; + email_1: string | null; + email_2: string | null; + phone_1: string | null; + phone_2: string | null; + website: string | null; + mailing_city: string | null; + mailing_postalcode: string | null; + mailing_country: string | null; + tags: string | null; + custom: Record | null; + /** Only present in the detail response: */ + contacts?: CompanyContactPerson[]; +} + +export interface CompanyContactPerson { + id: string; + firstname: string | null; + lastname: string | null; + email: string | null; + phone: string | null; +} + +export interface CompanyListResult { + items: Company[]; + total: number; + page: number; + page_size: number; +} + +export interface CompanyListParams { + page?: number; + page_size?: number; + search?: string | null; + industry?: string | null; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + +export interface CompanyCreatePayload { + name: string; + status?: string; + industry?: string; + description?: string; + email_1?: string; + phone_1?: string; + website?: string; + mailing_city?: string; + mailing_country?: string; + [key: string]: unknown; +} + +export type CompanyUpdatePayload = Partial; + +// ─── Query hooks ───────────────────────────────────────────── + +export function useCompanies(params: CompanyListParams = {}) { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set('page', String(params.page)); + if (params.page_size) searchParams.set('page_size', String(params.page_size)); + if (params.search) searchParams.set('search', params.search); + if (params.industry) searchParams.set('industry', params.industry); + if (params.sort_by) searchParams.set('sort_by', params.sort_by); + if (params.sort_order) searchParams.set('sort_order', params.sort_order); + const qs = searchParams.toString(); + return useQuery({ + queryKey: ['companies', params.page, params.page_size, params.search, params.industry, params.sort_by, params.sort_order], + queryFn: () => apiGet(`/companies${qs ? `?${qs}` : ''}`), + }); +} + +export function useCompany(companyId: string | null) { + return useQuery({ + queryKey: ['companies', 'detail', companyId], + queryFn: () => apiGet(`/companies/${companyId}`), + enabled: !!companyId, + }); +} + +export function useCompanyEmails(companyId: string | null) { + return useQuery({ + queryKey: ['companies', 'emails', companyId], + queryFn: () => apiGet(`/companies/${companyId}/emails`), + enabled: !!companyId, + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +function invalidateCompanies(qc: ReturnType) { + qc.invalidateQueries({ queryKey: ['companies'] }); +} + +export function useCreateCompany() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data) => apiPost('/companies', data), + onSuccess: () => invalidateCompanies(qc), + }); +} + +export function useUpdateCompany() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }) => apiPut(`/companies/${id}`, data), + onSuccess: (company) => { + invalidateCompanies(qc); + qc.invalidateQueries({ queryKey: ['companies', 'detail', company.id] }); + }, + }); +} + +export function useDeleteCompany() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, cascade }) => + apiDelete(`/companies/${id}${cascade ? '?cascade=true' : ''}`), + onSuccess: () => invalidateCompanies(qc), + }); +} + +export function useLinkContact() { + const qc = useQueryClient(); + return useMutation<{ company_id: string; contact_id: string }, Error, { companyId: string; contactId: string }>({ + mutationFn: ({ companyId, contactId }) => + apiPost(`/companies/${companyId}/contacts/${contactId}`), + onSuccess: (r) => { + invalidateCompanies(qc); + qc.invalidateQueries({ queryKey: ['companies', 'detail', r.company_id] }); + }, + }); +} + +export function useUnlinkContact() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ companyId, contactId }) => + apiDelete(`/companies/${companyId}/contacts/${contactId}`), + onSuccess: (_, vars) => { + invalidateCompanies(qc); + qc.invalidateQueries({ queryKey: ['companies', 'detail', vars.companyId] }); + }, + }); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c2ffe4d..68d65b6 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -14,7 +14,7 @@ import { Reply, Forward, Paperclip, MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2, Inbox, Send, - BookOpen, Lightbulb, Share2, + BookOpen, Lightbulb, Share2, Building2, } from 'lucide-react'; const ICON_MAP: Record> = { @@ -26,7 +26,7 @@ const ICON_MAP: Record> = { MoreVertical, RefreshCw, Download, Upload, Eye, EyeOff, Lock, Unlock, Clock, AlertCircle, CheckCircle, XCircle, Info, Loader2, Inbox, Send, ChevronRight, BookOpen, Lightbulb, - Brain, Store, Tags, ArrowRightLeft, Share2, + Brain, Store, Tags, ArrowRightLeft, Share2, Building2, }; import { useMenuOrder } from '@/api/users'; import { usePermission } from '@/hooks/usePermission'; diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index 8048802..ba3366e 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -36,6 +36,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/pages/Calendar': () => import('@/pages/Calendar').then(normalizeModule), '@/pages/CalendarKanban': () => import('@/pages/CalendarKanban').then(normalizeModule), '@/pages/Communication': () => import('@/pages/Communication').then(normalizeModule), + '@/pages/Companies': () => import('@/pages/Companies').then((m) => ({ default: m.CompaniesPage })), '@/pages/ContactDetailPage': () => import('@/pages/ContactDetailPage').then((m) => ({ default: m.ContactDetailPage })), '@/pages/ContactsList': () => import('@/pages/ContactsList').then((m) => ({ default: m.ContactsListPage })), '@/pages/DedupMerge': () => import('@/pages/DedupMerge').then((m) => ({ default: m.DedupMergePage })), diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 91d4013..e91d3b7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -202,7 +202,31 @@ "notFound": "Firma nicht gefunden.", "noContacts": "Diese Firma hat noch keine Kontakte.", "noFiles": "Keine Dateien vorhanden.", - "noActivity": "Keine Aktivität vorhanden." + "noActivity": "Keine Aktivität vorhanden.", + "createTitle": "Neue Firma", + "editTitle": "Firma bearbeiten: {{name}}", + "status": "Status", + "statusLead": "Lead", + "statusCustomer": "Kunde", + "statusInactive": "Inaktiv", + "city": "Stadt", + "country": "Land", + "save": "Speichern", + "createSubmit": "Erstellen", + "search": "Suchen", + "searchPlaceholder": "Firma suchen…", + "industryFilter": "Branche-Filter", + "sort": "Sortierung", + "contactPersons": "Ansprechpartner", + "noPersons": "Keine Ansprechpartner verknüpft.", + "linkContactLabel": "Kontakt verknüpfen (UUID)", + "link": "Verknüpfen", + "unlink": "Verknüpfung lösen", + "prev": "Zurück", + "next": "Weiter", + "empty": "Keine Firmen vorhanden.", + "noPermission": "Keine Berechtigung (contacts:read erforderlich).", + "loadError": "Firmen konnten nicht geladen werden." }, "contacts": { "title": "Kontakte", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 24229df..9ea6057 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -202,7 +202,31 @@ "notFound": "Company not found.", "noContacts": "This company has no contacts yet.", "noFiles": "No files available.", - "noActivity": "No activity available." + "noActivity": "No activity available.", + "createTitle": "New company", + "editTitle": "Edit company: {{name}}", + "status": "Status", + "statusLead": "Lead", + "statusCustomer": "Customer", + "statusInactive": "Inactive", + "city": "City", + "country": "Country", + "save": "Save", + "createSubmit": "Create", + "search": "Search", + "searchPlaceholder": "Search companies…", + "industryFilter": "Industry filter", + "sort": "Sort", + "contactPersons": "Contact persons", + "noPersons": "No contact persons linked.", + "linkContactLabel": "Link contact (UUID)", + "link": "Link", + "unlink": "Unlink", + "prev": "Previous", + "next": "Next", + "empty": "No companies yet.", + "noPermission": "No permission (contacts:read required).", + "loadError": "Failed to load companies." }, "contacts": { "title": "Contacts", diff --git a/frontend/src/pages/Companies.tsx b/frontend/src/pages/Companies.tsx new file mode 100644 index 0000000..87bceea --- /dev/null +++ b/frontend/src/pages/Companies.tsx @@ -0,0 +1,600 @@ +/** + * Companies page — firmen management (UI-Backlog module 12/16). + * + * Backend: /api/v1/companies (contacts plugin). Companies are Contact + * entities with type='company'; industry/description live in custom JSONB. + * Ops: list (search/industry/sort), create, export (csv/xlsx), get (with + * linked persons), update, delete (?cascade), link/unlink contacts. + * Permissions: contacts:read / contacts:write / contacts:delete. + */ + +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { + Building2, + Plus, + Pencil, + Trash2, + Download, + Inbox, + AlertTriangle, + Search, + Users, + UserPlus, + X, + Globe, + Mail, + Phone, + MapPin, +} from 'lucide-react'; +import { + useCompanies, + useCompany, + useCreateCompany, + useUpdateCompany, + useDeleteCompany, + useLinkContact, + useUnlinkContact, + type Company, +} from '@/api/companies'; +import { usePermission } from '@/hooks/usePermission'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; + +const PAGE_SIZE = 20; + +const STATUS_BADGE: Record = { + lead: 'bg-warning-100 text-warning-800 dark:bg-warning-900/30 dark:text-warning-300', + customer: 'bg-success-100 text-success-800 dark:bg-success-900/30 dark:text-success-300', + inactive: 'bg-secondary-200 text-secondary-700 dark:bg-secondary-800 dark:text-secondary-300', +}; + +function statusBadgeClass(status: string | null): string { + return STATUS_BADGE[status ?? ''] ?? 'bg-primary-100 text-primary-800 dark:bg-primary-900/30 dark:text-primary-300'; +} + +interface CompanyFormState { + name: string; + status: string; + industry: string; + description: string; + email_1: string; + phone_1: string; + website: string; + mailing_city: string; + mailing_country: string; +} + +const EMPTY_FORM: CompanyFormState = { + name: '', + status: 'lead', + industry: '', + description: '', + email_1: '', + phone_1: '', + website: '', + mailing_city: '', + mailing_country: '', +}; + +function formFromCompany(c: Company): CompanyFormState { + return { + name: c.name ?? '', + status: c.status ?? 'lead', + industry: c.industry ?? '', + description: c.description ?? '', + email_1: c.email_1 ?? '', + phone_1: c.phone_1 ?? '', + website: c.website ?? '', + mailing_city: c.mailing_city ?? '', + mailing_country: c.mailing_country ?? '', + }; +} + +function CompanyCard({ + company, + canWrite, + canDelete, + isMutating, + onEdit, + onDelete, + onDetail, +}: { + company: Company; + canWrite: boolean; + canDelete: boolean; + isMutating: boolean; + onEdit: (c: Company) => void; + onDelete: (c: Company) => void; + onDetail: (c: Company) => void; +}) { + return ( + +
+
onDetail(company)} data-testid={`company-detail-trigger-${company.id}`}> +
+
+
+ {company.industry && {company.industry}} + {company.mailing_city && ( + + + )} + {company.email_1 && ( + + + )} + {company.phone_1 && ( + + + )} + {company.website && ( + + + )} +
+
+
+ {canWrite && ( + + )} + {canDelete && ( + + )} +
+
+
+ ); +} + +function CompanyFormModal({ + open, + company, + onClose, + onSubmit, + isSubmitting, +}: { + open: boolean; + company: Company | null; + onClose: () => void; + onSubmit: (data: CompanyFormState) => void; + isSubmitting: boolean; +}) { + const { t } = useTranslation(); + const [form, setForm] = useState(EMPTY_FORM); + + React.useEffect(() => { + if (open) { + setForm(company ? formFromCompany(company) : EMPTY_FORM); + } + }, [open, company]); + + const set = (key: keyof CompanyFormState) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [key]: e.target.value })); + + const valid = form.name.trim().length > 0; + + return ( + +
+ +
+
+ + +
+ +
+
+ +