feat(crm): expandable contact sub-rows in companies table

Companies with linked contacts now show an expand arrow. Clicking it
lazy-loads and displays contacts as indented sub-rows with name,
position, email, phone, type badge and status. Backend gains companyId
filter on GET /contacts for efficient per-company querying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-11 20:03:26 +01:00
parent 068a7b81d5
commit 72fd57740b
6 changed files with 391 additions and 116 deletions

View file

@ -52,6 +52,10 @@ export class ContactsService {
where.type = query.type;
}
if (query.companyId) {
where.companyId = query.companyId;
}
if (query.search) {
where.OR = [
{ firstName: { contains: query.search, mode: 'insensitive' } },

View file

@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsEnum, IsUUID } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { PaginationDto } from '../../common/dto/pagination.dto';
import { ContactType } from './create-contact.dto';
@ -9,6 +9,11 @@ export class QueryContactsDto extends PaginationDto {
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Filter nach Unternehmen (UUID)' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({ enum: ContactType })
@IsOptional()
@IsEnum(ContactType)

View file

@ -54,3 +54,105 @@
opacity: 0.4;
cursor: not-allowed;
}
/* ============================================================
Expandable Rows Kontakte als Sub-Rows
============================================================ */
/* Expand/Collapse Button */
.expandBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
color: var(--color-text-muted);
transition: background 0.15s, color 0.15s;
}
.expandBtn:hover {
background: var(--color-bg);
color: var(--color-text);
}
.expandIcon {
display: inline-block;
font-size: 0.625rem;
transition: transform 0.2s ease;
line-height: 1;
}
/* Company row when expanded */
.expandedRow {
background: var(--color-bg);
}
/* Contact sub-rows */
.subRow {
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.subRow:hover {
background: var(--color-bg-card);
}
.subRowIndent {
padding: 0.5rem 0.5rem 0.5rem 1rem;
width: 40px;
position: relative;
}
.subRowConnector {
display: block;
width: 12px;
height: 1px;
background: var(--color-border);
margin-left: auto;
}
.subRowCell {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.subRowLoading,
.subRowError,
.subRowEmpty {
padding: 0.75rem 1rem 0.75rem 3.5rem;
font-size: 0.8125rem;
color: var(--color-text-muted);
font-style: italic;
text-align: left;
}
.subRowError {
color: var(--color-error);
}
/* Typ-Badge in Sub-Rows */
.typeBadge {
display: inline-block;
padding: 0.0625rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
white-space: nowrap;
}
.typeBadge[data-type='PERSON'] {
background: #dbeafe;
color: #1e40af;
}
.typeBadge[data-type='ORGANIZATION'] {
background: #d1fae5;
color: #065f46;
}

View file

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { Modal } from '../../components/Modal';
import { useCompanies, useDeleteCompany } from '../hooks';
import { useCompanies, useDeleteCompany, useCompanyContacts } from '../hooks';
import { CompanyFormModal } from './CompanyFormModal';
import type { Company, CompaniesQueryParams } from '../types';
import type { Company, Contact, CompaniesQueryParams, ContactType } from '../types';
import styles from './CompaniesPage.module.css';
const thStyle: React.CSSProperties = {
@ -14,6 +14,124 @@ const thStyle: React.CSSProperties = {
color: 'var(--color-text-muted)',
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
};
// --------------------------------------------------------
// Kontakt-Anzeigename
// --------------------------------------------------------
function contactDisplayName(c: Contact): string {
if (c.type === 'ORGANIZATION') return c.companyName ?? '—';
return [c.firstName, c.lastName].filter(Boolean).join(' ') || '—';
}
const TYPE_LABELS: Record<ContactType, string> = {
PERSON: 'Person',
ORGANIZATION: 'Organisation',
};
// --------------------------------------------------------
// ContactSubRows — lazy-loaded Kontakte eines Unternehmens
// --------------------------------------------------------
function ContactSubRows({ companyId }: { companyId: string }) {
const navigate = useNavigate();
const { data, isLoading, error } = useCompanyContacts(companyId, true);
if (isLoading) {
return (
<tr className={styles.subRow}>
<td colSpan={9} className={styles.subRowLoading}>
Kontakte laden...
</td>
</tr>
);
}
if (error) {
return (
<tr className={styles.subRow}>
<td colSpan={9} className={styles.subRowError}>
Fehler beim Laden der Kontakte
</td>
</tr>
);
}
const contacts = data?.data ?? [];
if (contacts.length === 0) {
return (
<tr className={styles.subRow}>
<td colSpan={9} className={styles.subRowEmpty}>
Keine Kontakte vorhanden
</td>
</tr>
);
}
return (
<>
{contacts.map((contact) => (
<tr
key={contact.id}
className={styles.subRow}
onClick={() => navigate(`/crm/contacts/${contact.id}`)}
>
{/* Indent / Connector */}
<td className={styles.subRowIndent}>
<span className={styles.subRowConnector} />
</td>
{/* Name */}
<td className={styles.subRowCell} style={{ fontWeight: 500 }}>
{contactDisplayName(contact)}
</td>
{/* Position (anstelle Branche) */}
<td className={styles.subRowCell}>
{contact.position ?? '—'}
</td>
{/* E-Mail */}
<td className={styles.subRowCell}>
{contact.email ?? '—'}
</td>
{/* Telefon (anstelle Kontakte-Zahl) */}
<td className={styles.subRowCell}>
{contact.phone ?? contact.mobile ?? '—'}
</td>
{/* Typ-Badge (anstelle Vorgaenge) */}
<td className={styles.subRowCell} style={{ textAlign: 'center' }}>
<span className={styles.typeBadge} data-type={contact.type}>
{TYPE_LABELS[contact.type] ?? '—'}
</span>
</td>
{/* Status */}
<td className={styles.subRowCell}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: contact.isActive
? 'var(--color-success)'
: 'var(--color-error)',
}}
title={contact.isActive ? 'Aktiv' : 'Inaktiv'}
/>
</td>
{/* Leere Aktionen-Spalte */}
<td className={styles.subRowCell} />
</tr>
))}
</>
);
}
// --------------------------------------------------------
// CompaniesPage — Hauptkomponente
// --------------------------------------------------------
export function CompaniesPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
@ -22,6 +140,7 @@ export function CompaniesPage() {
const [isCreateOpen, setCreateOpen] = useState(false);
const [editingCompany, setEditingCompany] = useState<Company | null>(null);
const [deletingCompany, setDeletingCompany] = useState<Company | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
// Debounced search
useEffect(() => {
@ -32,6 +151,11 @@ export function CompaniesPage() {
return () => clearTimeout(t);
}, [searchInput]);
// Reset expanded rows on page change
useEffect(() => {
setExpandedIds(new Set());
}, [page]);
const params: CompaniesQueryParams = {
page,
pageSize: 25,
@ -43,6 +167,19 @@ export function CompaniesPage() {
const { data, isLoading, error } = useCompanies(params);
const deleteMutation = useDeleteCompany();
const toggleExpand = (companyId: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(companyId)) {
next.delete(companyId);
} else {
next.add(companyId);
}
return next;
});
};
if (isLoading) return <p>Laden...</p>;
if (error)
return (
@ -122,9 +259,9 @@ export function CompaniesPage() {
background: 'var(--color-bg)',
}}
>
<th style={{ ...thStyle, width: '40px', padding: '0.75rem 0.5rem' }} />
<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>
@ -136,7 +273,7 @@ export function CompaniesPage() {
{companies.length === 0 && (
<tr>
<td
colSpan={8}
colSpan={9}
style={{
padding: '2rem',
textAlign: 'center',
@ -147,122 +284,127 @@ export function CompaniesPage() {
</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
{companies.map((company) => {
const contactCount = company._count?.contacts ?? 0;
const isExpanded = expandedIds.has(company.id);
return (
<Fragment key={company.id}>
{/* Company Row */}
<tr
className={isExpanded ? styles.expandedRow : undefined}
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: company.isActive
? 'var(--color-success)'
: 'var(--color-error)',
borderBottom: '1px solid var(--color-border)',
cursor: 'pointer',
}}
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)}
onClick={() => navigate(`/crm/companies/${company.id}`)}
>
{/* Expand Button */}
<td
style={{ padding: '0.75rem 0.5rem', textAlign: 'center', width: '40px' }}
onClick={(e) => contactCount > 0 && toggleExpand(company.id, e)}
>
{contactCount > 0 && (
<button
className={styles.expandBtn}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Kontakte einklappen' : 'Kontakte aufklappen'}
>
<span
className={styles.expandIcon}
style={{
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
}}
>
&#9654;
</span>
</button>
)}
</td>
{/* Name */}
<td
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)',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
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)',
}}
{company.name}
</td>
{/* Branche */}
<td style={tdStyle}>
{company.industry ?? '—'}
</td>
{/* E-Mail */}
<td style={tdStyle}>
{company.email ?? '—'}
</td>
{/* Kontakte */}
<td style={{ ...tdStyle, textAlign: 'center' }}>
{contactCount}
</td>
{/* Vorgaenge */}
<td style={{ ...tdStyle, textAlign: 'center' }}>
{company._count?.deals ?? 0}
</td>
{/* Status */}
<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>
{/* Aktionen */}
<td
style={{ padding: '0.75rem 1rem' }}
onClick={(e) => e.stopPropagation()}
>
Löschen
</button>
</div>
</td>
</tr>
))}
<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>
{/* Contact Sub-Rows (lazy-loaded) */}
{isExpanded && <ContactSubRows companyId={company.id} />}
</Fragment>
);
})}
</tbody>
</table>

View file

@ -53,6 +53,8 @@ export const crmKeys = {
list: (params: ContactsQueryParams) =>
['crm', 'contacts', 'list', params] as const,
detail: (id: string) => ['crm', 'contacts', 'detail', id] as const,
byCompany: (companyId: string) =>
['crm', 'contacts', 'byCompany', companyId] as const,
},
deals: {
all: ['crm', 'deals'] as const,
@ -166,6 +168,25 @@ export function useDeleteContact() {
});
}
/**
* Lazy-load contacts for a specific company (expandable rows).
* Only fetches when enabled is true (i.e., the row is expanded).
*/
export function useCompanyContacts(companyId: string, enabled: boolean) {
return useQuery({
queryKey: crmKeys.contacts.byCompany(companyId),
queryFn: () =>
contactsApi.list({
companyId,
pageSize: 50,
sort: 'lastName',
order: 'asc',
}),
enabled: enabled && !!companyId,
staleTime: 2 * 60 * 1000,
});
}
// ============================================================
// Deals
// ============================================================

View file

@ -412,6 +412,7 @@ export interface ContactsQueryParams {
pageSize?: number;
search?: string;
type?: ContactType;
companyId?: string;
sort?: string;
order?: 'asc' | 'desc';
}