diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 0627dca..713f0f6 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -22,6 +22,10 @@ import type { CreateActivityPayload, UpdateActivityPayload, ActivitiesQueryParams, + Company, + CreateCompanyPayload, + UpdateCompanyPayload, + CompaniesQueryParams, PaginatedResponse, SingleResponse, } from './types'; @@ -166,3 +170,32 @@ export const activitiesApi = { .delete>(`/crm/activities/${id}`) .then((r) => r.data), }; + +// --- Companies --- + +export const companiesApi = { + list: (params: CompaniesQueryParams) => + api + .get>('/crm/companies', { params }) + .then((r) => r.data), + + getById: (id: string) => + api + .get>(`/crm/companies/${id}`) + .then((r) => r.data), + + create: (data: CreateCompanyPayload) => + api + .post>('/crm/companies', data) + .then((r) => r.data), + + update: (id: string, data: UpdateCompanyPayload) => + api + .patch>(`/crm/companies/${id}`, data) + .then((r) => r.data), + + delete: (id: string) => + api + .delete>(`/crm/companies/${id}`) + .then((r) => r.data), +}; diff --git a/packages/frontend/src/crm/companies/CompaniesPage.module.css b/packages/frontend/src/crm/companies/CompaniesPage.module.css new file mode 100644 index 0000000..e18f0be --- /dev/null +++ b/packages/frontend/src/crm/companies/CompaniesPage.module.css @@ -0,0 +1,56 @@ +/* ============================================================ + CompaniesPage – Filter & Paginierung + ============================================================ */ + +.filterBar { + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.searchInput { + flex: 1; + min-width: 200px; + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.875rem; + outline: none; + background: var(--color-bg-card); + color: var(--color-text); +} + +.searchInput:focus { + border-color: var(--color-primary); +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-top: 1px solid var(--color-border); + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.paginationButtons { + display: flex; + gap: 0.5rem; +} + +.paginationBtn { + padding: 0.375rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + color: var(--color-text); + font-size: 0.8125rem; + cursor: pointer; +} + +.paginationBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/packages/frontend/src/crm/companies/CompaniesPage.tsx b/packages/frontend/src/crm/companies/CompaniesPage.tsx new file mode 100644 index 0000000..47dbcea --- /dev/null +++ b/packages/frontend/src/crm/companies/CompaniesPage.tsx @@ -0,0 +1,385 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Modal } from '../../components/Modal'; +import { useCompanies, useDeleteCompany } from '../hooks'; +import { CompanyFormModal } from './CompanyFormModal'; +import type { Company, CompaniesQueryParams } from '../types'; +import styles from './CompaniesPage.module.css'; + +const thStyle: React.CSSProperties = { + padding: '0.75rem 1rem', + textAlign: 'left', + fontSize: '0.75rem', + textTransform: 'uppercase', + color: 'var(--color-text-muted)', +}; + +export function CompaniesPage() { + const navigate = useNavigate(); + const [page, setPage] = useState(1); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [isCreateOpen, setCreateOpen] = useState(false); + const [editingCompany, setEditingCompany] = useState(null); + const [deletingCompany, setDeletingCompany] = useState(null); + + // Debounced search + useEffect(() => { + const t = setTimeout(() => { + setSearch(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + const params: CompaniesQueryParams = { + page, + pageSize: 25, + ...(search ? { search } : {}), + sort: 'createdAt', + order: 'desc', + }; + + const { data, isLoading, error } = useCompanies(params); + const deleteMutation = useDeleteCompany(); + + if (isLoading) return

Laden...

; + if (error) + return ( +

+ Fehler beim Laden der Unternehmen +

+ ); + + const companies = data?.data ?? []; + const pagination = data?.pagination; + + return ( +
+ {/* Header */} +
+

Unternehmen

+
+ + {pagination?.total ?? 0} Unternehmen gesamt + + +
+
+ + {/* Filterleiste */} +
+ setSearchInput(e.target.value)} + className={styles.searchInput} + /> +
+ + {/* Tabelle */} +
+ + + + + + + + + + + + + + + {companies.length === 0 && ( + + + + )} + {companies.map((company) => ( + navigate(`/crm/companies/${company.id}`)} + > + + + + + + + + + + ))} + +
NameBrancheStadtE-MailKontakteVorgängeStatusAktionen
+ Keine Unternehmen gefunden +
+ {company.name} + + {company.industry ?? '—'} + + {company.city ?? '—'} + + {company.email ?? '—'} + + {company._count?.contacts ?? 0} + + {company._count?.deals ?? 0} + + + e.stopPropagation()} + > +
+ + +
+
+ + {/* Paginierung */} + {pagination && pagination.totalPages > 1 && ( +
+ + Seite {pagination.page} von {pagination.totalPages} ( + {pagination.total} Einträge) + +
+ + +
+
+ )} +
+ + {/* Modal: Neues Unternehmen */} + setCreateOpen(false)} + onSuccess={() => setCreateOpen(false)} + /> + + {/* Modal: Unternehmen bearbeiten */} + setEditingCompany(null)} + company={editingCompany} + onSuccess={() => setEditingCompany(null)} + /> + + {/* Modal: Unternehmen löschen */} + setDeletingCompany(null)} + title="Unternehmen löschen" + maxWidth="420px" + > +

+ Soll das Unternehmen {deletingCompany?.name} wirklich + gelöscht werden? +

+

+ Verknüpfte Kontakte und Vorgänge verlieren die Zuordnung. +

+
+ + +
+
+
+ ); +} diff --git a/packages/frontend/src/crm/companies/CompanyDetailPage.module.css b/packages/frontend/src/crm/companies/CompanyDetailPage.module.css new file mode 100644 index 0000000..d0dc448 --- /dev/null +++ b/packages/frontend/src/crm/companies/CompanyDetailPage.module.css @@ -0,0 +1,115 @@ +/* ============================================================ + CompanyDetailPage – Layout & Komponenten + ============================================================ */ + +.backLink { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--color-text-muted); + text-decoration: none; + font-size: 0.875rem; + margin-bottom: 1rem; + transition: color 0.15s; +} + +.backLink:hover { + color: var(--color-primary); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + gap: 1rem; + flex-wrap: wrap; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.name { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + color: var(--color-text); +} + +.layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 960px) { + .layout { + grid-template-columns: 1fr; + } +} + +.card { + background: var(--color-bg-card); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); + padding: 1.5rem; +} + +.cardTitle { + font-size: 1rem; + font-weight: 600; + margin: 0 0 1rem 0; + color: var(--color-text); +} + +.infoGrid { + display: grid; + grid-template-columns: 120px 1fr; + gap: 0.5rem 1rem; + font-size: 0.875rem; +} + +.infoLabel { + color: var(--color-text-muted); + font-weight: 500; +} + +.infoValue { + color: var(--color-text); + word-break: break-word; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.tag { + display: inline-block; + padding: 0.125rem 0.5rem; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 9999px; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.notesSection { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border); +} + +.notesText { + font-size: 0.875rem; + color: var(--color-text-secondary); + white-space: pre-wrap; + line-height: 1.5; + margin: 0; +} diff --git a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx new file mode 100644 index 0000000..1d866b7 --- /dev/null +++ b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx @@ -0,0 +1,542 @@ +import { useState } from 'react'; +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useCompany, useDeleteCompany } from '../hooks'; +import { CompanyFormModal } from './CompanyFormModal'; +import { Modal } from '../../components/Modal'; +import type { DealStatus } from '../types'; +import styles from './CompanyDetailPage.module.css'; + +const STATUS_COLORS: Record = { + OPEN: { bg: '#dbeafe', color: '#1e40af' }, + WON: { bg: '#d1fae5', color: '#065f46' }, + LOST: { bg: '#fee2e2', color: '#991b1b' }, +}; + +const STATUS_LABELS: Record = { + OPEN: 'Offen', + WON: 'Gewonnen', + LOST: 'Verloren', +}; + +const currencyFormatter = new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', +}); + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +export function CompanyDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data, isLoading, error } = useCompany(id!); + const deleteMutation = useDeleteCompany(); + + const [isEditOpen, setEditOpen] = useState(false); + const [isDeleteOpen, setDeleteOpen] = useState(false); + + if (isLoading) return

Laden...

; + if (error || !data) + return ( +

+ Unternehmen konnte nicht geladen werden +

+ ); + + const company = data.data; + const contacts = company.contacts ?? []; + const deals = company.deals ?? []; + + return ( +
+ {/* Zurück */} + + + + + Zurück zu Unternehmen + + + {/* Header */} +
+
+

{company.name}

+ {company.industry && ( + + {company.industry} + + )} + +
+
+ + +
+
+ + {/* 2-Spalten Layout */} +
+ {/* Links: Info */} +
+
+

Unternehmensdaten

+
+ {company.email && ( + <> + E-Mail + + + {company.email} + + + + )} + {company.phone && ( + <> + Telefon + {company.phone} + + )} + {company.website && ( + <> + Website + + + {company.website} + + + + )} + {(company.street || company.zip || company.city) && ( + <> + Adresse + + {company.street && <>{company.street}
} + {company.zip} {company.city} + {company.country && company.country !== 'DE' && ( + <>, {company.country} + )} +
+ + )} + Erstellt am + + {formatDate(company.createdAt)} + +
+ + {/* Tags */} + {company.tags && company.tags.length > 0 && ( +
+ + Tags + +
+ {company.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + + {/* Notizen */} + {company.notes && ( +
+ + Notizen + +

{company.notes}

+
+ )} +
+
+ + {/* Rechts: Kontakte + Vorgänge */} +
+ {/* Kontakte */} +
+

+ Kontakte ({company._count?.contacts ?? contacts.length}) +

+ {contacts.length === 0 ? ( +

+ Keine Kontakte vorhanden +

+ ) : ( + + + + + + + + + + {contacts.map((c) => { + const name = [c.firstName, c.lastName] + .filter(Boolean) + .join(' ') || '—'; + return ( + navigate(`/crm/contacts/${c.id}`)} + > + + + + + ); + })} + +
+ Name + + Position + + E-Mail +
+ {name} + + {c.position ?? '—'} + + {c.email ?? '—'} +
+ )} +
+ + {/* Vorgänge */} +
+

+ Vorgänge ({company._count?.deals ?? deals.length}) +

+ {deals.length === 0 ? ( +

+ Keine Vorgänge vorhanden +

+ ) : ( + + + + + + + + + + {deals.map((deal) => ( + navigate(`/crm/deals/${deal.id}`)} + > + + + + + ))} + +
+ Titel + + Stage + + Wert +
+
+ {deal.title} + + {STATUS_LABELS[deal.status]} + +
+
+ {deal.stage && ( + + + {deal.stage.name} + + )} + + {deal.value + ? currencyFormatter.format(parseFloat(deal.value)) + : '—'} +
+ )} +
+
+
+ + {/* Modals */} + setEditOpen(false)} + company={company} + onSuccess={() => setEditOpen(false)} + /> + + setDeleteOpen(false)} + title="Unternehmen löschen" + maxWidth="420px" + > +

+ Soll das Unternehmen {company.name} wirklich gelöscht + werden? +

+

+ Verknüpfte Kontakte und Vorgänge verlieren die Zuordnung. +

+
+ + +
+
+
+ ); +} diff --git a/packages/frontend/src/crm/companies/CompanyFormModal.tsx b/packages/frontend/src/crm/companies/CompanyFormModal.tsx new file mode 100644 index 0000000..41befd7 --- /dev/null +++ b/packages/frontend/src/crm/companies/CompanyFormModal.tsx @@ -0,0 +1,339 @@ +import { useState, useEffect } from 'react'; +import { Modal } from '../../components/Modal'; +import { useCreateCompany, useUpdateCompany } from '../hooks'; +import type { Company } from '../types'; + +interface CompanyFormModalProps { + isOpen: boolean; + onClose: () => void; + company?: Company | null; + onSuccess: () => void; +} + +const labelStyle: React.CSSProperties = { + fontSize: '0.875rem', + fontWeight: 500, + color: 'var(--color-text)', + marginBottom: '0.25rem', + display: 'block', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.625rem 0.75rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.9375rem', + outline: 'none', + boxSizing: 'border-box', + background: 'var(--color-bg-card)', + color: 'var(--color-text)', +}; + +const rowStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '0.75rem', +}; + +export function CompanyFormModal({ + isOpen, + onClose, + company, + onSuccess, +}: CompanyFormModalProps) { + const isEditMode = !!company; + const createMutation = useCreateCompany(); + const updateMutation = useUpdateCompany(); + const mutation = isEditMode ? updateMutation : createMutation; + + const [error, setError] = useState(''); + const [name, setName] = useState(''); + const [industry, setIndustry] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + const [website, setWebsite] = useState(''); + const [street, setStreet] = useState(''); + const [zip, setZip] = useState(''); + const [city, setCity] = useState(''); + const [country, setCountry] = useState('DE'); + const [notes, setNotes] = useState(''); + const [tagsInput, setTagsInput] = useState(''); + + useEffect(() => { + if (isOpen) { + setError(''); + if (company) { + setName(company.name); + setIndustry(company.industry ?? ''); + setEmail(company.email ?? ''); + setPhone(company.phone ?? ''); + setWebsite(company.website ?? ''); + setStreet(company.street ?? ''); + setZip(company.zip ?? ''); + setCity(company.city ?? ''); + setCountry(company.country ?? 'DE'); + setNotes(company.notes ?? ''); + setTagsInput(company.tags?.join(', ') ?? ''); + } else { + setName(''); + setIndustry(''); + setEmail(''); + setPhone(''); + setWebsite(''); + setStreet(''); + setZip(''); + setCity(''); + setCountry('DE'); + setNotes(''); + setTagsInput(''); + } + } + }, [isOpen, company]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!name.trim()) { + setError('Name ist ein Pflichtfeld'); + return; + } + + const tags = tagsInput + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + + const payload = { + name: name.trim(), + ...(industry ? { industry } : {}), + ...(email ? { email } : {}), + ...(phone ? { phone } : {}), + ...(website ? { website } : {}), + ...(street ? { street } : {}), + ...(zip ? { zip } : {}), + ...(city ? { city } : {}), + country, + ...(notes ? { notes } : {}), + ...(tags.length > 0 ? { tags } : {}), + }; + + if (isEditMode && company) { + updateMutation.mutate( + { id: company.id, data: payload }, + { + onSuccess: () => onSuccess(), + onError: (err: unknown) => { + const msg = + (err as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error?.message ?? 'Fehler beim Speichern'; + setError(msg); + }, + }, + ); + } else { + createMutation.mutate(payload, { + onSuccess: () => onSuccess(), + onError: (err: unknown) => { + const msg = + (err as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error?.message ?? 'Fehler beim Anlegen'; + setError(msg); + }, + }); + } + }; + + return ( + +
+ {error && ( +
+ {error} +
+ )} + + {/* Name + Branche */} +
+
+ + setName(e.target.value)} + placeholder="Firmenname" + required + /> +
+
+ + setIndustry(e.target.value)} + placeholder="z.B. Enterprise Software" + /> +
+
+ + {/* E-Mail + Telefon */} +
+
+ + setEmail(e.target.value)} + placeholder="info@firma.de" + /> +
+
+ + setPhone(e.target.value)} + placeholder="+49 ..." + /> +
+
+ + {/* Website */} +
+ + setWebsite(e.target.value)} + placeholder="https://..." + /> +
+ + {/* Adresse */} +
+ + setStreet(e.target.value)} + /> +
+ +
+
+ + setZip(e.target.value)} + /> +
+
+ + setCity(e.target.value)} + /> +
+
+ +
+ + setCountry(e.target.value)} + maxLength={2} + placeholder="DE" + /> +
+ + {/* Notizen */} +
+ +