diff --git a/packages/crm-service/src/contacts/contacts.service.ts b/packages/crm-service/src/contacts/contacts.service.ts index 95712bd..a947c7d 100644 --- a/packages/crm-service/src/contacts/contacts.service.ts +++ b/packages/crm-service/src/contacts/contacts.service.ts @@ -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' } }, diff --git a/packages/crm-service/src/contacts/dto/query-contacts.dto.ts b/packages/crm-service/src/contacts/dto/query-contacts.dto.ts index 9c045b4..b5c494c 100644 --- a/packages/crm-service/src/contacts/dto/query-contacts.dto.ts +++ b/packages/crm-service/src/contacts/dto/query-contacts.dto.ts @@ -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) diff --git a/packages/frontend/src/crm/companies/CompaniesPage.module.css b/packages/frontend/src/crm/companies/CompaniesPage.module.css index e18f0be..2a3a70e 100644 --- a/packages/frontend/src/crm/companies/CompaniesPage.module.css +++ b/packages/frontend/src/crm/companies/CompaniesPage.module.css @@ -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; +} diff --git a/packages/frontend/src/crm/companies/CompaniesPage.tsx b/packages/frontend/src/crm/companies/CompaniesPage.tsx index 47dbcea..912ba68 100644 --- a/packages/frontend/src/crm/companies/CompaniesPage.tsx +++ b/packages/frontend/src/crm/companies/CompaniesPage.tsx @@ -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 = { + 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 ( + + + Kontakte laden... + + + ); + } + + if (error) { + return ( + + + Fehler beim Laden der Kontakte + + + ); + } + + const contacts = data?.data ?? []; + + if (contacts.length === 0) { + return ( + + + Keine Kontakte vorhanden + + + ); + } + + return ( + <> + {contacts.map((contact) => ( + navigate(`/crm/contacts/${contact.id}`)} + > + {/* Indent / Connector */} + + + + {/* Name */} + + {contactDisplayName(contact)} + + {/* Position (anstelle Branche) */} + + {contact.position ?? '—'} + + {/* E-Mail */} + + {contact.email ?? '—'} + + {/* Telefon (anstelle Kontakte-Zahl) */} + + {contact.phone ?? contact.mobile ?? '—'} + + {/* Typ-Badge (anstelle Vorgaenge) */} + + + {TYPE_LABELS[contact.type] ?? '—'} + + + {/* Status */} + + + + {/* Leere Aktionen-Spalte */} + + + ))} + + ); +} + +// -------------------------------------------------------- +// 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(null); const [deletingCompany, setDeletingCompany] = useState(null); + const [expandedIds, setExpandedIds] = useState>(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

Laden...

; if (error) return ( @@ -122,9 +259,9 @@ export function CompaniesPage() { background: 'var(--color-bg)', }} > + Name Branche - Stadt E-Mail Kontakte Vorgänge @@ -136,7 +273,7 @@ export function CompaniesPage() { {companies.length === 0 && ( )} - {companies.map((company) => ( - navigate(`/crm/companies/${company.id}`)} - > - - {company.name} - - - {company.industry ?? '—'} - - - {company.city ?? '—'} - - - {company.email ?? '—'} - - - {company._count?.contacts ?? 0} - - - {company._count?.deals ?? 0} - - - { + const contactCount = company._count?.contacts ?? 0; + const isExpanded = expandedIds.has(company.id); + + return ( + + {/* Company Row */} + - - e.stopPropagation()} - > -
- + )} + + {/* Name */} + - Bearbeiten - - -
- - - ))} +
+ + +
+ + + + {/* Contact Sub-Rows (lazy-loaded) */} + {isExpanded && } +
+ ); + })} diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index a9abd53..c4fccbe 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -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 // ============================================================ diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index 4ed4ac0..cb05ea3 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -412,6 +412,7 @@ export interface ContactsQueryParams { pageSize?: number; search?: string; type?: ContactType; + companyId?: string; sort?: string; order?: 'asc' | 'desc'; }