mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +02:00
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:
parent
068a7b81d5
commit
72fd57740b
6 changed files with 391 additions and 116 deletions
|
|
@ -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' } },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -412,6 +412,7 @@ export interface ContactsQueryParams {
|
|||
pageSize?: number;
|
||||
search?: string;
|
||||
type?: ContactType;
|
||||
companyId?: string;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue