mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +02:00
feat(crm): add Company module + extend Contact/Deal with company relation
- New Company CRUD: CompaniesPage (list/search/pagination), CompanyFormModal, CompanyDetailPage (contacts + deals tables) - Sidebar: "Unternehmen" NavLink between Kontakte and Vorgänge - ContactsPage: Company column, ContactFormModal: company search + position field, ContactDetailPage: company link + position display - DealsPage: Company column, DealFormModal: company search dropdown, DealDetailPage: company link in info card - Types/API/Hooks extended with Company entity, cross-entity query invalidation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a13cca054b
commit
36f571f5c3
16 changed files with 2055 additions and 8 deletions
|
|
@ -22,6 +22,10 @@ import type {
|
||||||
CreateActivityPayload,
|
CreateActivityPayload,
|
||||||
UpdateActivityPayload,
|
UpdateActivityPayload,
|
||||||
ActivitiesQueryParams,
|
ActivitiesQueryParams,
|
||||||
|
Company,
|
||||||
|
CreateCompanyPayload,
|
||||||
|
UpdateCompanyPayload,
|
||||||
|
CompaniesQueryParams,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -166,3 +170,32 @@ export const activitiesApi = {
|
||||||
.delete<SingleResponse<Activity>>(`/crm/activities/${id}`)
|
.delete<SingleResponse<Activity>>(`/crm/activities/${id}`)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Companies ---
|
||||||
|
|
||||||
|
export const companiesApi = {
|
||||||
|
list: (params: CompaniesQueryParams) =>
|
||||||
|
api
|
||||||
|
.get<PaginatedResponse<Company>>('/crm/companies', { params })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api
|
||||||
|
.get<SingleResponse<Company>>(`/crm/companies/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateCompanyPayload) =>
|
||||||
|
api
|
||||||
|
.post<SingleResponse<Company>>('/crm/companies', data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateCompanyPayload) =>
|
||||||
|
api
|
||||||
|
.patch<SingleResponse<Company>>(`/crm/companies/${id}`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
api
|
||||||
|
.delete<SingleResponse<Company>>(`/crm/companies/${id}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
|
||||||
56
packages/frontend/src/crm/companies/CompaniesPage.module.css
Normal file
56
packages/frontend/src/crm/companies/CompaniesPage.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
385
packages/frontend/src/crm/companies/CompaniesPage.tsx
Normal file
385
packages/frontend/src/crm/companies/CompaniesPage.tsx
Normal file
|
|
@ -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<Company | null>(null);
|
||||||
|
const [deletingCompany, setDeletingCompany] = useState<Company | null>(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 <p>Laden...</p>;
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<p style={{ color: 'var(--color-error)' }}>
|
||||||
|
Fehler beim Laden der Unternehmen
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
const companies = data?.data ?? [];
|
||||||
|
const pagination = data?.pagination;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Unternehmen</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pagination?.total ?? 0} Unternehmen gesamt
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Neues Unternehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filterleiste */}
|
||||||
|
<div className={styles.filterBar}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Suche nach Name, Branche, E-Mail, Stadt..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className={styles.searchInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabelle */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<th style={thStyle}>Name</th>
|
||||||
|
<th style={thStyle}>Branche</th>
|
||||||
|
<th style={thStyle}>Stadt</th>
|
||||||
|
<th style={thStyle}>E-Mail</th>
|
||||||
|
<th style={{ ...thStyle, textAlign: 'center' }}>Kontakte</th>
|
||||||
|
<th style={{ ...thStyle, textAlign: 'center' }}>Vorgänge</th>
|
||||||
|
<th style={thStyle}>Status</th>
|
||||||
|
<th style={thStyle}>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{companies.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={8}
|
||||||
|
style={{
|
||||||
|
padding: '2rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine Unternehmen gefunden
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{companies.map((company) => (
|
||||||
|
<tr
|
||||||
|
key={company.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/crm/companies/${company.id}`)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company.name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company.industry ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company.city ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company.email ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company._count?.contacts ?? 0}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company._count?.deals ?? 0}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.75rem 1rem' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: company.isActive
|
||||||
|
? 'var(--color-success)'
|
||||||
|
: 'var(--color-error)',
|
||||||
|
}}
|
||||||
|
title={company.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{ padding: '0.75rem 1rem' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingCompany(company)}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.625rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingCompany(company)}
|
||||||
|
style={{
|
||||||
|
padding: '0.25rem 0.625rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Paginierung */}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<span>
|
||||||
|
Seite {pagination.page} von {pagination.totalPages} (
|
||||||
|
{pagination.total} Einträge)
|
||||||
|
</span>
|
||||||
|
<div className={styles.paginationButtons}>
|
||||||
|
<button
|
||||||
|
className={styles.paginationBtn}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.paginationBtn}
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal: Neues Unternehmen */}
|
||||||
|
<CompanyFormModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSuccess={() => setCreateOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal: Unternehmen bearbeiten */}
|
||||||
|
<CompanyFormModal
|
||||||
|
isOpen={!!editingCompany}
|
||||||
|
onClose={() => setEditingCompany(null)}
|
||||||
|
company={editingCompany}
|
||||||
|
onSuccess={() => setEditingCompany(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal: Unternehmen löschen */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deletingCompany}
|
||||||
|
onClose={() => setDeletingCompany(null)}
|
||||||
|
title="Unternehmen löschen"
|
||||||
|
maxWidth="420px"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Soll das Unternehmen <strong>{deletingCompany?.name}</strong> wirklich
|
||||||
|
gelöscht werden?
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verknüpfte Kontakte und Vorgänge verlieren die Zuordnung.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingCompany(null)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
deletingCompany &&
|
||||||
|
deleteMutation.mutate(deletingCompany.id, {
|
||||||
|
onSuccess: () => setDeletingCompany(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'var(--color-error)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: deleteMutation.isPending ? 'wait' : 'pointer',
|
||||||
|
opacity: deleteMutation.isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Löschen...' : 'Endgültig löschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
packages/frontend/src/crm/companies/CompanyDetailPage.module.css
Normal file
115
packages/frontend/src/crm/companies/CompanyDetailPage.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
542
packages/frontend/src/crm/companies/CompanyDetailPage.tsx
Normal file
542
packages/frontend/src/crm/companies/CompanyDetailPage.tsx
Normal file
|
|
@ -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<DealStatus, { bg: string; color: string }> = {
|
||||||
|
OPEN: { bg: '#dbeafe', color: '#1e40af' },
|
||||||
|
WON: { bg: '#d1fae5', color: '#065f46' },
|
||||||
|
LOST: { bg: '#fee2e2', color: '#991b1b' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<DealStatus, string> = {
|
||||||
|
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 <p>Laden...</p>;
|
||||||
|
if (error || !data)
|
||||||
|
return (
|
||||||
|
<p style={{ color: 'var(--color-error)' }}>
|
||||||
|
Unternehmen konnte nicht geladen werden
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
const company = data.data;
|
||||||
|
const contacts = company.contacts ?? [];
|
||||||
|
const deals = company.deals ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Zurück */}
|
||||||
|
<Link to="/crm/companies" className={styles.backLink}>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
>
|
||||||
|
<path d="M9 2L4 7l5 5" />
|
||||||
|
</svg>
|
||||||
|
Zurück zu Unternehmen
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.headerLeft}>
|
||||||
|
<h1 className={styles.name}>{company.name}</h1>
|
||||||
|
{company.industry && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.125rem 0.5rem',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
background: '#e0e7ff',
|
||||||
|
color: '#3730a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{company.industry}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: company.isActive
|
||||||
|
? 'var(--color-success)'
|
||||||
|
: 'var(--color-error)',
|
||||||
|
}}
|
||||||
|
title={company.isActive ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2-Spalten Layout */}
|
||||||
|
<div className={styles.layout}>
|
||||||
|
{/* Links: Info */}
|
||||||
|
<div>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.cardTitle}>Unternehmensdaten</h2>
|
||||||
|
<div className={styles.infoGrid}>
|
||||||
|
{company.email && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>E-Mail</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
<a
|
||||||
|
href={`mailto:${company.email}`}
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{company.email}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.phone && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Telefon</span>
|
||||||
|
<span className={styles.infoValue}>{company.phone}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{company.website && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Website</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
<a
|
||||||
|
href={company.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{company.website}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(company.street || company.zip || company.city) && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Adresse</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{company.street && <>{company.street}<br /></>}
|
||||||
|
{company.zip} {company.city}
|
||||||
|
{company.country && company.country !== 'DE' && (
|
||||||
|
<>, {company.country}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className={styles.infoLabel}>Erstellt am</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{formatDate(company.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{company.tags && company.tags.length > 0 && (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<span
|
||||||
|
className={styles.infoLabel}
|
||||||
|
style={{ display: 'block', marginBottom: '0.375rem' }}
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</span>
|
||||||
|
<div className={styles.tags}>
|
||||||
|
{company.tags.map((tag) => (
|
||||||
|
<span key={tag} className={styles.tag}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notizen */}
|
||||||
|
{company.notes && (
|
||||||
|
<div className={styles.notesSection}>
|
||||||
|
<span
|
||||||
|
className={styles.infoLabel}
|
||||||
|
style={{ display: 'block', marginBottom: '0.375rem' }}
|
||||||
|
>
|
||||||
|
Notizen
|
||||||
|
</span>
|
||||||
|
<p className={styles.notesText}>{company.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rechts: Kontakte + Vorgänge */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
{/* Kontakte */}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.cardTitle}>
|
||||||
|
Kontakte ({company._count?.contacts ?? contacts.length})
|
||||||
|
</h2>
|
||||||
|
{contacts.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine Kontakte vorhanden
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Position
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
E-Mail
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contacts.map((c) => {
|
||||||
|
const name = [c.firstName, c.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || '—';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={c.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/crm/contacts/${c.id}`)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.position ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.email ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vorgänge */}
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.cardTitle}>
|
||||||
|
Vorgänge ({company._count?.deals ?? deals.length})
|
||||||
|
</h2>
|
||||||
|
{deals.length === 0 ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keine Vorgänge vorhanden
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Titel
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stage
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Wert
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deals.map((deal) => (
|
||||||
|
<tr
|
||||||
|
key={deal.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => navigate(`/crm/deals/${deal.id}`)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
{deal.title}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0 0.375rem',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
background: STATUS_COLORS[deal.status].bg,
|
||||||
|
color: STATUS_COLORS[deal.status].color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[deal.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.5rem 0' }}>
|
||||||
|
{deal.stage && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.375rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: deal.stage.color,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{deal.stage.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0',
|
||||||
|
textAlign: 'right',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deal.value
|
||||||
|
? currencyFormatter.format(parseFloat(deal.value))
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<CompanyFormModal
|
||||||
|
isOpen={isEditOpen}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
company={company}
|
||||||
|
onSuccess={() => setEditOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
title="Unternehmen löschen"
|
||||||
|
maxWidth="420px"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Soll das Unternehmen <strong>{company.name}</strong> wirklich gelöscht
|
||||||
|
werden?
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verknüpfte Kontakte und Vorgänge verlieren die Zuordnung.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
deleteMutation.mutate(company.id, {
|
||||||
|
onSuccess: () => navigate('/crm/companies'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'var(--color-error)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: deleteMutation.isPending ? 'wait' : 'pointer',
|
||||||
|
opacity: deleteMutation.isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Löschen...' : 'Endgültig löschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
339
packages/frontend/src/crm/companies/CompanyFormModal.tsx
Normal file
339
packages/frontend/src/crm/companies/CompanyFormModal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEditMode ? 'Unternehmen bearbeiten' : 'Neues Unternehmen'}
|
||||||
|
maxWidth="600px"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: '#fef2f2',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name + Branche */}
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Name *</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Firmenname"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Branche</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={industry}
|
||||||
|
onChange={(e) => setIndustry(e.target.value)}
|
||||||
|
placeholder="z.B. Enterprise Software"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* E-Mail + Telefon */}
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>E-Mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
style={inputStyle}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="info@firma.de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>Telefon</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+49 ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Website */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Website</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={website}
|
||||||
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adresse */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Straße</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={street}
|
||||||
|
onChange={(e) => setStreet(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
|
<div style={{ maxWidth: 120 }}>
|
||||||
|
<label style={labelStyle}>PLZ</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={zip}
|
||||||
|
onChange={(e) => setZip(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>Stadt</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Land</label>
|
||||||
|
<input
|
||||||
|
style={{ ...inputStyle, maxWidth: 120 }}
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
maxLength={2}
|
||||||
|
placeholder="DE"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notizen */}
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Notizen</label>
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={labelStyle}>Tags (kommasepariert)</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
placeholder="VIP, Bestandskunde, ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: mutation.isPending ? 'wait' : 'pointer',
|
||||||
|
opacity: mutation.isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mutation.isPending
|
||||||
|
? 'Speichern...'
|
||||||
|
: isEditMode
|
||||||
|
? 'Speichern'
|
||||||
|
: 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -222,6 +222,30 @@ export function ContactDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{contact.company && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Unternehmen</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
<Link
|
||||||
|
to={`/crm/companies/${contact.company.id}`}
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{contact.company.name}
|
||||||
|
</Link>
|
||||||
|
{contact.company.industry && (
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', marginLeft: '0.5rem', fontSize: '0.8125rem' }}>
|
||||||
|
({contact.company.industry})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contact.position && (
|
||||||
|
<>
|
||||||
|
<span className={styles.infoLabel}>Position</span>
|
||||||
|
<span className={styles.infoValue}>{contact.position}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{contact.email && (
|
{contact.email && (
|
||||||
<>
|
<>
|
||||||
<span className={styles.infoLabel}>E-Mail</span>
|
<span className={styles.infoLabel}>E-Mail</span>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCreateContact, useUpdateContact } from '../hooks';
|
import { useCreateContact, useUpdateContact } from '../hooks';
|
||||||
import type { Contact, ContactType } from '../types';
|
import { companiesApi } from '../api';
|
||||||
|
import type { Contact, ContactType, Company } from '../types';
|
||||||
|
|
||||||
interface ContactFormModalProps {
|
interface ContactFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -62,6 +63,56 @@ export function ContactFormModal({
|
||||||
const [country, setCountry] = useState('DE');
|
const [country, setCountry] = useState('DE');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [tagsInput, setTagsInput] = useState('');
|
const [tagsInput, setTagsInput] = useState('');
|
||||||
|
const [position, setPosition] = useState('');
|
||||||
|
|
||||||
|
// Unternehmen-Suche
|
||||||
|
const [companySearch, setCompanySearch] = useState('');
|
||||||
|
const [companyResults, setCompanyResults] = useState<Company[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [showCompanyDropdown, setShowCompanyDropdown] = useState(false);
|
||||||
|
const companyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const companySearchTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Click-Outside für Unternehmen-Dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
companyRef.current &&
|
||||||
|
!companyRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Unternehmen suchen (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
|
||||||
|
if (!companySearch || companySearch.length < 2) {
|
||||||
|
setCompanyResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
companySearchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await companiesApi.list({
|
||||||
|
search: companySearch,
|
||||||
|
pageSize: 8,
|
||||||
|
});
|
||||||
|
setCompanyResults(res.data);
|
||||||
|
setShowCompanyDropdown(true);
|
||||||
|
} catch {
|
||||||
|
setCompanyResults([]);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
|
||||||
|
};
|
||||||
|
}, [companySearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -81,6 +132,14 @@ export function ContactFormModal({
|
||||||
setCountry(contact.country ?? 'DE');
|
setCountry(contact.country ?? 'DE');
|
||||||
setNotes(contact.notes ?? '');
|
setNotes(contact.notes ?? '');
|
||||||
setTagsInput((contact.tags ?? []).join(', '));
|
setTagsInput((contact.tags ?? []).join(', '));
|
||||||
|
setPosition(contact.position ?? '');
|
||||||
|
if (contact.company) {
|
||||||
|
setSelectedCompany({ id: contact.company.id, name: contact.company.name });
|
||||||
|
setCompanySearch(contact.company.name);
|
||||||
|
} else {
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setType('PERSON');
|
setType('PERSON');
|
||||||
setFirstName('');
|
setFirstName('');
|
||||||
|
|
@ -96,7 +155,12 @@ export function ContactFormModal({
|
||||||
setCountry('DE');
|
setCountry('DE');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setTagsInput('');
|
setTagsInput('');
|
||||||
|
setPosition('');
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
}
|
}
|
||||||
|
setCompanyResults([]);
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, contact]);
|
}, [isOpen, contact]);
|
||||||
|
|
||||||
|
|
@ -122,6 +186,8 @@ export function ContactFormModal({
|
||||||
country,
|
country,
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
...(tags.length > 0 ? { tags } : {}),
|
...(tags.length > 0 ? { tags } : {}),
|
||||||
|
...(selectedCompany ? { companyId: selectedCompany.id } : {}),
|
||||||
|
...(position ? { position } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditMode && contact) {
|
if (isEditMode && contact) {
|
||||||
|
|
@ -187,6 +253,112 @@ export function ContactFormModal({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Unternehmen-Suche */}
|
||||||
|
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={companyRef}>
|
||||||
|
<label style={labelStyle}>Unternehmen</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={companySearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCompanySearch(e.target.value);
|
||||||
|
if (selectedCompany) setSelectedCompany(null);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (companyResults.length > 0) setShowCompanyDropdown(true);
|
||||||
|
}}
|
||||||
|
placeholder="Unternehmen suchen..."
|
||||||
|
/>
|
||||||
|
{selectedCompany && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: 30,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showCompanyDropdown && companyResults.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
zIndex: 10,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{companyResults.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompany({ id: comp.id, name: comp.name });
|
||||||
|
setCompanySearch(comp.name);
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
((e.target as HTMLDivElement).style.background =
|
||||||
|
'var(--color-bg)')
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
((e.target as HTMLDivElement).style.background =
|
||||||
|
'transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{comp.name}
|
||||||
|
{comp.industry && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comp.industry}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position (nur bei Person) */}
|
||||||
|
{type === 'PERSON' && (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={labelStyle}>Position</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => setPosition(e.target.value)}
|
||||||
|
placeholder="z.B. Geschäftsführer, Einkäufer..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
{type === 'PERSON' ? (
|
{type === 'PERSON' ? (
|
||||||
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ export function ContactsPage() {
|
||||||
>
|
>
|
||||||
<th style={thStyle}>Name / Firma</th>
|
<th style={thStyle}>Name / Firma</th>
|
||||||
<th style={thStyle}>Typ</th>
|
<th style={thStyle}>Typ</th>
|
||||||
|
<th style={thStyle}>Unternehmen</th>
|
||||||
<th style={thStyle}>E-Mail</th>
|
<th style={thStyle}>E-Mail</th>
|
||||||
<th style={thStyle}>Telefon</th>
|
<th style={thStyle}>Telefon</th>
|
||||||
<th style={thStyle}>Stadt</th>
|
<th style={thStyle}>Stadt</th>
|
||||||
|
|
@ -164,7 +165,7 @@ export function ContactsPage() {
|
||||||
{contacts.length === 0 && (
|
{contacts.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={8}
|
||||||
style={{
|
style={{
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
|
@ -210,6 +211,31 @@ export function ContactsPage() {
|
||||||
{TYPE_LABELS[contact.type]}
|
{TYPE_LABELS[contact.type]}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (contact.company) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contact.company ? (
|
||||||
|
<a
|
||||||
|
href={`/crm/companies/${contact.company.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/crm/companies/${contact.company!.id}`);
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{contact.company.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,20 @@ export function DealDetailPage() {
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span className={styles.infoLabel}>Unternehmen</span>
|
||||||
|
<span className={styles.infoValue}>
|
||||||
|
{deal.company ? (
|
||||||
|
<Link
|
||||||
|
to={`/crm/companies/${deal.company.id}`}
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{deal.company.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className={styles.infoLabel}>Erw. Abschluss</span>
|
<span className={styles.infoLabel}>Erw. Abschluss</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
{deal.expectedCloseDate
|
{deal.expectedCloseDate
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
import { useCreateDeal, useUpdateDeal, usePipelines } from '../hooks';
|
||||||
import { contactsApi } from '../api';
|
import { contactsApi, companiesApi } from '../api';
|
||||||
import type { Deal, DealStatus, Contact } from '../types';
|
import type { Deal, DealStatus, Contact, Company } from '../types';
|
||||||
|
|
||||||
interface DealFormModalProps {
|
interface DealFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -78,13 +78,24 @@ export function DealFormModal({
|
||||||
const contactRef = useRef<HTMLDivElement>(null);
|
const contactRef = useRef<HTMLDivElement>(null);
|
||||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Unternehmen-Suche
|
||||||
|
const [companySearch, setCompanySearch] = useState('');
|
||||||
|
const [companyResults, setCompanyResults] = useState<Company[]>([]);
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [showCompanyDropdown, setShowCompanyDropdown] = useState(false);
|
||||||
|
const companyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const companySearchTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Stages der gewaehlten Pipeline
|
// Stages der gewaehlten Pipeline
|
||||||
const selectedPipeline = pipelines.find((p) => p.id === pipelineId);
|
const selectedPipeline = pipelines.find((p) => p.id === pipelineId);
|
||||||
const stages = selectedPipeline?.stages
|
const stages = selectedPipeline?.stages
|
||||||
? [...selectedPipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
|
? [...selectedPipeline.stages].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Click-Outside für Kontakt-Dropdown
|
// Click-Outside für Kontakt- und Unternehmen-Dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (
|
if (
|
||||||
|
|
@ -93,6 +104,12 @@ export function DealFormModal({
|
||||||
) {
|
) {
|
||||||
setShowContactDropdown(false);
|
setShowContactDropdown(false);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
companyRef.current &&
|
||||||
|
!companyRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener('mousedown', handleClick);
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
|
@ -122,6 +139,30 @@ export function DealFormModal({
|
||||||
};
|
};
|
||||||
}, [contactSearch]);
|
}, [contactSearch]);
|
||||||
|
|
||||||
|
// Unternehmen suchen (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
|
||||||
|
if (!companySearch || companySearch.length < 2) {
|
||||||
|
setCompanyResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
companySearchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await companiesApi.list({
|
||||||
|
search: companySearch,
|
||||||
|
pageSize: 8,
|
||||||
|
});
|
||||||
|
setCompanyResults(res.data);
|
||||||
|
setShowCompanyDropdown(true);
|
||||||
|
} catch {
|
||||||
|
setCompanyResults([]);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (companySearchTimeout.current) clearTimeout(companySearchTimeout.current);
|
||||||
|
};
|
||||||
|
}, [companySearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setError('');
|
setError('');
|
||||||
|
|
@ -150,6 +191,13 @@ export function DealFormModal({
|
||||||
setSelectedContact(null);
|
setSelectedContact(null);
|
||||||
setContactSearch('');
|
setContactSearch('');
|
||||||
}
|
}
|
||||||
|
if (deal.company) {
|
||||||
|
setSelectedCompany({ id: deal.company.id, name: deal.company.name });
|
||||||
|
setCompanySearch(deal.company.name);
|
||||||
|
} else {
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setPipelineId(pipelines.find((p) => p.isDefault)?.id ?? pipelines[0]?.id ?? '');
|
setPipelineId(pipelines.find((p) => p.isDefault)?.id ?? pipelines[0]?.id ?? '');
|
||||||
|
|
@ -161,9 +209,13 @@ export function DealFormModal({
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setSelectedContact(null);
|
setSelectedContact(null);
|
||||||
setContactSearch('');
|
setContactSearch('');
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
}
|
}
|
||||||
setContactResults([]);
|
setContactResults([]);
|
||||||
setShowContactDropdown(false);
|
setShowContactDropdown(false);
|
||||||
|
setCompanyResults([]);
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, deal, pipelines]);
|
}, [isOpen, deal, pipelines]);
|
||||||
|
|
||||||
|
|
@ -197,6 +249,7 @@ export function DealFormModal({
|
||||||
stageId,
|
stageId,
|
||||||
status,
|
status,
|
||||||
...(selectedContact ? { contactId: selectedContact.id } : {}),
|
...(selectedContact ? { contactId: selectedContact.id } : {}),
|
||||||
|
...(selectedCompany ? { companyId: selectedCompany.id } : {}),
|
||||||
...(value ? { value: parseFloat(value) } : {}),
|
...(value ? { value: parseFloat(value) } : {}),
|
||||||
currency,
|
currency,
|
||||||
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
|
...(expectedCloseDate ? { expectedCloseDate: new Date(expectedCloseDate).toISOString() } : {}),
|
||||||
|
|
@ -402,6 +455,99 @@ export function DealFormModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Unternehmen-Suche */}
|
||||||
|
<div style={{ marginBottom: '1rem', position: 'relative' }} ref={companyRef}>
|
||||||
|
<label style={labelStyle}>Unternehmen</label>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
value={companySearch}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCompanySearch(e.target.value);
|
||||||
|
if (selectedCompany) setSelectedCompany(null);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (companyResults.length > 0) setShowCompanyDropdown(true);
|
||||||
|
}}
|
||||||
|
placeholder="Unternehmen suchen..."
|
||||||
|
/>
|
||||||
|
{selectedCompany && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompany(null);
|
||||||
|
setCompanySearch('');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: 30,
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showCompanyDropdown && companyResults.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
zIndex: 10,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{companyResults.map((comp) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCompany({ id: comp.id, name: comp.name });
|
||||||
|
setCompanySearch(comp.name);
|
||||||
|
setShowCompanyDropdown(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
((e.target as HTMLDivElement).style.background =
|
||||||
|
'var(--color-bg)')
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
((e.target as HTMLDivElement).style.background =
|
||||||
|
'transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{comp.name}
|
||||||
|
{comp.industry && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comp.industry}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Wert + Währung */}
|
{/* Wert + Währung */}
|
||||||
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
<div style={{ ...rowStyle, marginBottom: '1rem' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ export function DealsPage() {
|
||||||
>
|
>
|
||||||
<th style={thStyle}>Titel</th>
|
<th style={thStyle}>Titel</th>
|
||||||
<th style={thStyle}>Kontakt</th>
|
<th style={thStyle}>Kontakt</th>
|
||||||
|
<th style={thStyle}>Unternehmen</th>
|
||||||
<th style={thStyle}>Pipeline</th>
|
<th style={thStyle}>Pipeline</th>
|
||||||
<th style={thStyle}>Stage</th>
|
<th style={thStyle}>Stage</th>
|
||||||
<th style={{ ...thStyle, textAlign: 'right' }}>Wert</th>
|
<th style={{ ...thStyle, textAlign: 'right' }}>Wert</th>
|
||||||
|
|
@ -222,7 +223,7 @@ export function DealsPage() {
|
||||||
{deals.length === 0 && (
|
{deals.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={8}
|
||||||
style={{
|
style={{
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
|
@ -262,6 +263,31 @@ export function DealsPage() {
|
||||||
>
|
>
|
||||||
{dealContactName(deal)}
|
{dealContactName(deal)}
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (deal.company) e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deal.company ? (
|
||||||
|
<a
|
||||||
|
href={`/crm/companies/${deal.company.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/crm/companies/${deal.company!.id}`);
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
{deal.company.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { contactsApi, dealsApi, pipelinesApi, activitiesApi } from './api';
|
import { contactsApi, dealsApi, pipelinesApi, activitiesApi, companiesApi } from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
ContactsQueryParams,
|
||||||
DealsQueryParams,
|
DealsQueryParams,
|
||||||
|
|
@ -18,6 +18,9 @@ import type {
|
||||||
UpdateStagePayload,
|
UpdateStagePayload,
|
||||||
CreateActivityPayload,
|
CreateActivityPayload,
|
||||||
UpdateActivityPayload,
|
UpdateActivityPayload,
|
||||||
|
CompaniesQueryParams,
|
||||||
|
CreateCompanyPayload,
|
||||||
|
UpdateCompanyPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Query Key Factory ---
|
// --- Query Key Factory ---
|
||||||
|
|
@ -46,6 +49,12 @@ export const crmKeys = {
|
||||||
['crm', 'activities', 'list', params] as const,
|
['crm', 'activities', 'list', params] as const,
|
||||||
detail: (id: string) => ['crm', 'activities', 'detail', id] as const,
|
detail: (id: string) => ['crm', 'activities', 'detail', id] as const,
|
||||||
},
|
},
|
||||||
|
companies: {
|
||||||
|
all: ['crm', 'companies'] as const,
|
||||||
|
list: (params: CompaniesQueryParams) =>
|
||||||
|
['crm', 'companies', 'list', params] as const,
|
||||||
|
detail: (id: string) => ['crm', 'companies', 'detail', id] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -73,6 +82,7 @@ export function useCreateContact() {
|
||||||
mutationFn: (data: CreateContactPayload) => contactsApi.create(data),
|
mutationFn: (data: CreateContactPayload) => contactsApi.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +94,7 @@ export function useUpdateContact() {
|
||||||
contactsApi.update(id, data),
|
contactsApi.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +105,7 @@ export function useDeleteContact() {
|
||||||
mutationFn: (id: string) => contactsApi.delete(id),
|
mutationFn: (id: string) => contactsApi.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +135,7 @@ export function useCreateDeal() {
|
||||||
mutationFn: (data: CreateDealPayload) => dealsApi.create(data),
|
mutationFn: (data: CreateDealPayload) => dealsApi.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +147,7 @@ export function useUpdateDeal() {
|
||||||
dealsApi.update(id, data),
|
dealsApi.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -144,6 +158,7 @@ export function useDeleteDeal() {
|
||||||
mutationFn: (id: string) => dealsApi.delete(id),
|
mutationFn: (id: string) => dealsApi.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -303,3 +318,53 @@ export function useDeleteActivity() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Companies
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useCompanies(params: CompaniesQueryParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.companies.list(params),
|
||||||
|
queryFn: () => companiesApi.list(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompany(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: crmKeys.companies.detail(id),
|
||||||
|
queryFn: () => companiesApi.getById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateCompanyPayload) => companiesApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateCompanyPayload }) =>
|
||||||
|
companiesApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCompany() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => companiesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
type: ContactType;
|
type: ContactType;
|
||||||
|
companyId: string | null;
|
||||||
|
position: string | null;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
companyName: string | null;
|
companyName: string | null;
|
||||||
|
|
@ -34,10 +36,19 @@ export interface Contact {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
activities?: Activity[];
|
activities?: Activity[];
|
||||||
|
company?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
industry: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateContactPayload {
|
export interface CreateContactPayload {
|
||||||
type?: ContactType;
|
type?: ContactType;
|
||||||
|
companyId?: string;
|
||||||
|
position?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
|
@ -148,6 +159,7 @@ export interface Deal {
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
stageId: string;
|
stageId: string;
|
||||||
contactId: string | null;
|
contactId: string | null;
|
||||||
|
companyId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
value: string | null; // Decimal kommt als String vom Backend
|
value: string | null; // Decimal kommt als String vom Backend
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
@ -167,12 +179,14 @@ export interface Deal {
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
companyName: string | null;
|
companyName: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
company?: { id: string; name: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateDealPayload {
|
export interface CreateDealPayload {
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
stageId: string;
|
stageId: string;
|
||||||
contactId?: string;
|
contactId?: string;
|
||||||
|
companyId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
|
@ -183,6 +197,59 @@ export interface CreateDealPayload {
|
||||||
|
|
||||||
export type UpdateDealPayload = Partial<CreateDealPayload>;
|
export type UpdateDealPayload = Partial<CreateDealPayload>;
|
||||||
|
|
||||||
|
// --- Company ---
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
industry: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
website: string | null;
|
||||||
|
street: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
country: string;
|
||||||
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
|
isActive: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_count?: { contacts: number; deals: number };
|
||||||
|
contacts?: {
|
||||||
|
id: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
position: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}[];
|
||||||
|
deals?: Deal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCompanyPayload {
|
||||||
|
name: string;
|
||||||
|
industry?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
street?: string;
|
||||||
|
zip?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCompanyPayload = Partial<CreateCompanyPayload>;
|
||||||
|
|
||||||
// --- API Response Wrapper ---
|
// --- API Response Wrapper ---
|
||||||
|
|
||||||
export interface PaginationMeta {
|
export interface PaginationMeta {
|
||||||
|
|
@ -222,6 +289,7 @@ export interface DealsQueryParams {
|
||||||
pipelineId?: string;
|
pipelineId?: string;
|
||||||
stageId?: string;
|
stageId?: string;
|
||||||
contactId?: string;
|
contactId?: string;
|
||||||
|
companyId?: string;
|
||||||
status?: DealStatus;
|
status?: DealStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
|
|
@ -236,3 +304,12 @@ export interface ActivitiesQueryParams {
|
||||||
sort?: string;
|
sort?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompaniesQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
industry?: string;
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import { ContactDetailPage } from '../crm/contacts/ContactDetailPage';
|
||||||
import { DealsPage } from '../crm/deals/DealsPage';
|
import { DealsPage } from '../crm/deals/DealsPage';
|
||||||
import { DealDetailPage } from '../crm/deals/DealDetailPage';
|
import { DealDetailPage } from '../crm/deals/DealDetailPage';
|
||||||
import { PipelinesPage } from '../crm/pipelines/PipelinesPage';
|
import { PipelinesPage } from '../crm/pipelines/PipelinesPage';
|
||||||
|
import { CompaniesPage } from '../crm/companies/CompaniesPage';
|
||||||
|
import { CompanyDetailPage } from '../crm/companies/CompanyDetailPage';
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
@ -56,6 +58,8 @@ export function App() {
|
||||||
{/* CRM-Bereich */}
|
{/* CRM-Bereich */}
|
||||||
<Route path="crm/contacts" element={<ContactsPage />} />
|
<Route path="crm/contacts" element={<ContactsPage />} />
|
||||||
<Route path="crm/contacts/:id" element={<ContactDetailPage />} />
|
<Route path="crm/contacts/:id" element={<ContactDetailPage />} />
|
||||||
|
<Route path="crm/companies" element={<CompaniesPage />} />
|
||||||
|
<Route path="crm/companies/:id" element={<CompanyDetailPage />} />
|
||||||
<Route path="crm/deals" element={<DealsPage />} />
|
<Route path="crm/deals" element={<DealsPage />} />
|
||||||
<Route path="crm/deals/:id" element={<DealDetailPage />} />
|
<Route path="crm/deals/:id" element={<DealDetailPage />} />
|
||||||
<Route path="crm/pipelines" element={<PipelinesPage />} />
|
<Route path="crm/pipelines" element={<PipelinesPage />} />
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,29 @@ export function AppLayout() {
|
||||||
</svg>
|
</svg>
|
||||||
{!collapsed && 'Kontakte'}
|
{!collapsed && 'Kontakte'}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/crm/companies"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.navLink} ${isActive ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
title="Unternehmen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="2" y="6" width="12" height="9" rx="1" />
|
||||||
|
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
|
||||||
|
<path d="M5 9h2v2H5zM9 9h2v2H9z" />
|
||||||
|
</svg>
|
||||||
|
{!collapsed && 'Unternehmen'}
|
||||||
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/crm/deals"
|
to="/crm/deals"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue