feat(crm): Vertraege-Modul Frontend — ContractsCard vollstaendig implementiert

- ContractsCard.tsx: CRUD-Card ersetzt Platzhalter (Liste, Create, Edit, Delete)
- types.ts: CreateContractPayload, UpdateContractPayload, ContractsQueryParams ergaenzt
- api.ts: contractsApi mit 4 Methoden (nested /crm/companies/:id/contracts)
- hooks.ts: crmKeys.contracts + useContracts, useCreate/Update/DeleteContract
- CompanyDetailPage.tsx: contractCount-Prop entfernt (Card laedt via API)
- docs/INSIGHT-CRM.md + Summarize.md aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 21:32:51 +01:00
parent e3d254c0f9
commit 0e6565e210
7 changed files with 695 additions and 16 deletions

View file

@ -2,7 +2,24 @@
## Stand: 2026-03-12
### Aktueller Sprint: CRM Phase 2 (Feature-Branch: feature/crm-service)
### Aktueller Sprint: CRM Phase 2 / Vertraege-Modul (Feature-Branch: feature/crm-service)
---
### Aenderungen 2026-03-12: Vertraege-Modul Frontend (ContractsCard)
#### Neue/geaenderte Dateien
- `crm/companies/ContractsCard.tsx` — Vollstaendige Vertraege-Card fuer CompanyDetailPage
- Tabelle mit Titel, Status-Badge, Laufzeit, Vertragswert
- "+ Neu" Button oeffnet Create-Modal
- Stift-Icon (Bearbeiten) + X-Icon (Loeschen) pro Zeile
- Status-Farben: DRAFT=grau, ACTIVE=gruen, EXPIRED=orange, CANCELLED=rot
- Wert-Formatierung (Intl.NumberFormat de-DE), Datumsformatierung
- `crm/types.ts``CreateContractPayload`, `UpdateContractPayload`, `ContractsQueryParams` ergaenzt
- `crm/api.ts``contractsApi` (list, create, update, delete) mit `/crm/companies/:id/contracts`
- `crm/hooks.ts``crmKeys.contracts` + `useContracts`, `useCreateContract`, `useUpdateContract`, `useDeleteContract`
- `crm/companies/CompanyDetailPage.tsx``contractCount` Prop entfernt (Card laedt selbst)
---

View file

@ -3183,7 +3183,77 @@ Die Tabs "E-Mail" und "Aufgaben" auf der Company-Detail-Seite sind aktuell disab
### TODO Backend (naechste Session)
- [ ] Vertraege-API: `GET/POST/PATCH/DELETE /crm/companies/:id/contracts`
- [ ] Prisma-Migration: Contract-Tabelle falls noch nicht vorhanden (pruefen)
- [ ] Optional: Activity-Filter `?type=TASK` fuer Aufgaben-Tab
- [ ] Nach Implementierung: Diesen Eintrag mit "DONE" markieren und Endpoints dokumentieren
- [x] Vertraege-API: `GET/POST/PATCH/DELETE /crm/companies/:id/contracts` — **DONE**
- [x] Prisma-Migration: Contract-Tabelle bereits im Schema vorhanden, keine Migration noetig
- [x] Activity-Filter `?type=TASK` — Bereits implementiert in `QueryActivitiesDto` + `ActivitiesService.findAll()`
- [x] Dokumentation aktualisiert
---
### 2026-03-12 | Backend: Contract CRUD — Fertiggestellt
**Neue Dateien:**
```
packages/crm-service/src/contracts/
contracts.module.ts — Feature Module
contracts.service.ts — CRUD Service (scoped by tenantId + companyId)
contracts.controller.ts — REST Controller (nested: /companies/:companyId/contracts)
dto/
create-contract.dto.ts — CreateContractDto + ContractStatus Enum
update-contract.dto.ts — UpdateContractDto (PartialType)
query-contracts.dto.ts — QueryContractsDto (Pagination + Status-Filter + Search)
```
**Geaenderte Dateien:**
- `src/app.module.ts` — ContractsModule registriert
- `Summarize.md` — Contracts-Modul dokumentiert
**Endpoints:**
| Methode | Pfad | Beschreibung |
|---------|------|-------------|
| GET | /api/v1/crm/companies/:id/contracts | Vertraege auflisten (paginiert, filterbar nach status, search) |
| POST | /api/v1/crm/companies/:id/contracts | Vertrag anlegen |
| GET | /api/v1/crm/companies/:id/contracts/:cid | Vertrag-Details |
| PATCH | /api/v1/crm/companies/:id/contracts/:cid | Vertrag aktualisieren |
| DELETE | /api/v1/crm/companies/:id/contracts/:cid | Vertrag loeschen |
**Query-Parameter (GET Liste):** `?status=ACTIVE&search=Wartung&sort=startDate&order=asc&page=1&pageSize=25`
**Response-Format:** Standard `{ success, data, meta }` / `{ success, data, pagination, meta }`
**Activity-Filter:** Bereits implementiert — `GET /api/v1/crm/activities?type=TASK&companyId=xxx` funktioniert.
**TODO Frontend:** ~~Hooks + API-Calls in `ContractsCard.tsx` implementieren.~~**DONE** (2026-03-12)
---
## 2026-03-12 | Frontend: Vertraege-Modul implementiert
### Was wurde umgesetzt
Die `ContractsCard.tsx` ist vollstaendig implementiert. Das "Modul in Entwicklung"-Platzhalter wurde ersetzt durch eine vollwertige CRUD-Komponente.
### Geaenderte / neue Dateien
| Datei | Aenderung |
|-------|-----------|
| `crm/companies/ContractsCard.tsx` | Komplette Neuentwicklung (Platzhalter → vollwertige CRUD-Card) |
| `crm/types.ts` | `CreateContractPayload`, `UpdateContractPayload`, `ContractsQueryParams` ergaenzt |
| `crm/api.ts` | `contractsApi` mit list, create, update, delete (nested unter /companies/:id/contracts) |
| `crm/hooks.ts` | `crmKeys.contracts` + 4 Hooks: useContracts, useCreateContract, useUpdateContract, useDeleteContract |
| `crm/companies/CompanyDetailPage.tsx` | contractCount-Prop entfernt (Card laedt nun selbst via API) |
### Funktionsumfang
- **Liste**: Alle Vertraege einer Company mit Status-Badge (Entwurf/Aktiv/Abgelaufen/Storniert), Laufzeit, formatiertem Vertragswert
- **Erstellen**: "+ Neu" Button oeffnet Modal mit Titel*, Status, Beginn/Ende, Wert/Waehrung, Notizen
- **Bearbeiten**: Stift-Icon pro Zeile oeffnet vorbefuelltes Edit-Modal
- **Loeschen**: X-Icon mit Bestaetigung per window.confirm
- **Waehrungen**: EUR, USD, CHF, GBP auswaehlbar
- **Fehlerbehandlung**: API-Fehler werden im Modal angezeigt
### TypeScript-Check
`npx tsc --noEmit` — 0 Fehler

View file

@ -62,6 +62,10 @@ import type {
ImportExecuteResponse,
EnrichmentResponse,
EnrichmentConfig,
Contract,
CreateContractPayload,
UpdateContractPayload,
ContractsQueryParams,
PaginatedResponse,
SingleResponse,
} from './types';
@ -674,3 +678,32 @@ export const enrichmentApi = {
)
.then((r) => r.data),
};
// --- Contracts ---
export const contractsApi = {
list: (companyId: string, params?: ContractsQueryParams) =>
api
.get<PaginatedResponse<Contract>>(`/crm/companies/${companyId}/contracts`, { params })
.then((r) => r.data),
create: (companyId: string, data: CreateContractPayload) =>
api
.post<SingleResponse<Contract>>(`/crm/companies/${companyId}/contracts`, data)
.then((r) => r.data),
update: (companyId: string, contractId: string, data: UpdateContractPayload) =>
api
.patch<SingleResponse<Contract>>(
`/crm/companies/${companyId}/contracts/${contractId}`,
data,
)
.then((r) => r.data),
delete: (companyId: string, contractId: string) =>
api
.delete<SingleResponse<Contract>>(
`/crm/companies/${companyId}/contracts/${contractId}`,
)
.then((r) => r.data),
};

View file

@ -570,10 +570,7 @@ export function CompanyDetailPage() {
{/* ---- Tab 4: Verträge ---- */}
{activeTab === 'contracts' && (
<ContractsCard
companyId={company.id}
contractCount={company._count?.contracts ?? 0}
/>
<ContractsCard companyId={company.id} />
)}
</div>

View file

@ -1,17 +1,504 @@
import { useState } from 'react';
import { Modal } from '../../components/Modal';
import {
useContracts,
useCreateContract,
useUpdateContract,
useDeleteContract,
} from '../hooks';
import type { Contract, ContractStatus, CreateContractPayload } from '../types';
import styles from './CompanyDetailPage.module.css';
// ============================================================
// Constants
// ============================================================
const STATUS_LABELS: Record<ContractStatus, string> = {
DRAFT: 'Entwurf',
ACTIVE: 'Aktiv',
EXPIRED: 'Abgelaufen',
CANCELLED: 'Storniert',
};
const STATUS_COLORS: Record<ContractStatus, { bg: string; color: string }> = {
DRAFT: { bg: '#f3f4f6', color: '#6b7280' },
ACTIVE: { bg: '#dcfce7', color: '#16a34a' },
EXPIRED: { bg: '#fef3c7', color: '#d97706' },
CANCELLED: { bg: '#fee2e2', color: '#dc2626' },
};
// ============================================================
// Helpers
// ============================================================
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatValue(val: string | null, currency = 'EUR'): string {
if (!val) return '—';
const num = parseFloat(val);
if (isNaN(num)) return val;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(num);
}
// ============================================================
// Props
// ============================================================
interface ContractsCardProps {
companyId: string;
contractCount?: number;
}
export function ContractsCard({ contractCount = 0 }: ContractsCardProps) {
// ============================================================
// Component
// ============================================================
export function ContractsCard({ companyId }: ContractsCardProps) {
const { data, isLoading } = useContracts(companyId);
const deleteMut = useDeleteContract(companyId);
const [isAddOpen, setAddOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
const contracts: Contract[] = data?.data ?? [];
const handleDelete = (contract: Contract) => {
if (
window.confirm(
`Vertrag "${contract.title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`,
)
) {
deleteMut.mutate(contract.id);
}
};
return (
<div className={`${styles.card} ${styles.placeholderCard}`}>
<h2 className={styles.cardTitle}>
Verträge{contractCount > 0 ? ` (${contractCount})` : ''}
<div className={styles.card}>
<div className={styles.cardTitleRow}>
<h2 className={styles.cardTitle} style={{ marginBottom: 0 }}>
Verträge ({contracts.length})
</h2>
<p>Modul in Entwicklung bald verfügbar.</p>
<button className={styles.smallAddBtn} onClick={() => setAddOpen(true)}>
+ Neu
</button>
</div>
{isLoading ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Laden...
</p>
) : contracts.length === 0 ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Keine Verträge vorhanden
</p>
) : (
<div>
{contracts.map((c) => (
<div key={c.id} className={styles.relationItem}>
<div className={styles.relationInfo}>
<span
className={styles.relationName}
style={{ cursor: 'default' }}
title={c.title}
>
{c.title}
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '0.125rem 0.375rem',
borderRadius: '999px',
background: STATUS_COLORS[c.status].bg,
color: STATUS_COLORS[c.status].color,
}}
>
{STATUS_LABELS[c.status]}
</span>
{c.value && (
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text)',
fontWeight: 500,
}}
>
{formatValue(c.value, c.currency)}
</span>
)}
<span
style={{
fontSize: '0.6875rem',
color: 'var(--color-text-muted)',
}}
>
{formatDate(c.startDate)}
{c.endDate ? ` ${formatDate(c.endDate)}` : ''}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button
className={styles.relationDeleteBtn}
onClick={() => setEditContract(c)}
title="Bearbeiten"
style={{ color: 'var(--color-text-muted)' }}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8.5 1.5l2 2L4 10H2v-2L8.5 1.5z" />
</svg>
</button>
<button
className={styles.relationDeleteBtn}
onClick={() => handleDelete(c)}
title="Löschen"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
<ContractFormModal
isOpen={isAddOpen}
onClose={() => setAddOpen(false)}
companyId={companyId}
mode="create"
/>
{/* Edit Modal */}
{editContract && (
<ContractFormModal
isOpen={true}
onClose={() => setEditContract(null)}
companyId={companyId}
mode="edit"
contract={editContract}
/>
)}
</div>
);
}
// ============================================================
// Contract Form Modal (Create + Edit)
// ============================================================
interface ContractFormModalProps {
isOpen: boolean;
onClose: () => void;
companyId: string;
mode: 'create' | 'edit';
contract?: Contract;
}
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)',
};
function ContractFormModal({
isOpen,
onClose,
companyId,
mode,
contract,
}: ContractFormModalProps) {
const createMut = useCreateContract(companyId);
const updateMut = useUpdateContract(companyId);
const [title, setTitle] = useState(contract?.title ?? '');
const [status, setStatus] = useState<ContractStatus>(contract?.status ?? 'DRAFT');
const [startDate, setStartDate] = useState(
contract?.startDate ? contract.startDate.substring(0, 10) : '',
);
const [endDate, setEndDate] = useState(
contract?.endDate ? contract.endDate.substring(0, 10) : '',
);
const [value, setValue] = useState(contract?.value ?? '');
const [currency, setCurrency] = useState(contract?.currency ?? 'EUR');
const [notes, setNotes] = useState(contract?.notes ?? '');
const [error, setError] = useState('');
const isPending = createMut.isPending || updateMut.isPending;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!title.trim()) {
setError('Titel ist ein Pflichtfeld.');
return;
}
const payload: CreateContractPayload = {
title: title.trim(),
status,
startDate: startDate || null,
endDate: endDate || null,
value: value.trim() || null,
currency: currency || 'EUR',
notes: notes.trim() || null,
};
if (mode === 'create') {
createMut.mutate(payload, {
onSuccess: () => onClose(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Erstellen';
setError(msg);
},
});
} else if (contract) {
updateMut.mutate(
{ contractId: contract.id, data: payload },
{
onSuccess: () => onClose(),
onError: (err: unknown) => {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Fehler beim Speichern';
setError(msg);
},
},
);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={mode === 'create' ? 'Vertrag anlegen' : 'Vertrag bearbeiten'}
maxWidth="520px"
>
<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>
)}
{/* Titel */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Titel *</label>
<input
style={inputStyle}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z.B. Wartungsvertrag 2026"
required
autoFocus
/>
</div>
{/* Status */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Status</label>
<select
style={inputStyle}
value={status}
onChange={(e) => setStatus(e.target.value as ContractStatus)}
>
{(Object.keys(STATUS_LABELS) as ContractStatus[]).map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</div>
{/* Laufzeit */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '0.75rem',
marginBottom: '1rem',
}}
>
<div>
<label style={labelStyle}>Beginn</label>
<input
style={inputStyle}
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>Ende</label>
<input
style={inputStyle}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{/* Wert + Währung */}
<div
style={{
display: 'grid',
gridTemplateColumns: '2fr 1fr',
gap: '0.75rem',
marginBottom: '1rem',
}}
>
<div>
<label style={labelStyle}>Vertragswert</label>
<input
style={inputStyle}
type="number"
min="0"
step="0.01"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.00"
/>
</div>
<div>
<label style={labelStyle}>Währung</label>
<select
style={inputStyle}
value={currency}
onChange={(e) => setCurrency(e.target.value)}
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="CHF">CHF</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
{/* Notizen */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={labelStyle}>Notizen</label>
<textarea
style={{
...inputStyle,
cursor: 'text',
minHeight: 72,
resize: 'vertical',
}}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Notizen..."
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button
type="button"
onClick={onClose}
disabled={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={isPending}
style={{
padding: '0.5rem 1rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: isPending ? 'wait' : 'pointer',
opacity: isPending ? 0.7 : 1,
}}
>
{isPending
? mode === 'create'
? 'Erstellen...'
: 'Speichern...'
: mode === 'create'
? 'Erstellen'
: 'Speichern'}
</button>
</div>
</form>
</Modal>
);
}

View file

@ -22,6 +22,7 @@ import {
customFieldsApi,
importApi,
enrichmentApi,
contractsApi,
} from './api';
import type {
ContactsQueryParams,
@ -59,6 +60,9 @@ import type {
ForecastPeriod,
ImportEntityType,
ImportExecuteRequest,
ContractsQueryParams,
CreateContractPayload,
UpdateContractPayload,
} from './types';
// --- Query Key Factory ---
@ -147,6 +151,11 @@ export const crmKeys = {
enrichment: {
config: () => ['crm', 'enrichment', 'config'] as const,
},
contracts: {
all: ['crm', 'contracts'] as const,
list: (companyId: string, params?: ContractsQueryParams) =>
['crm', 'contracts', 'list', companyId, params] as const,
},
};
// ============================================================
@ -1172,3 +1181,48 @@ export function useSetEnrichmentConfig() {
},
});
}
// ============================================================
// Contracts
// ============================================================
export function useContracts(companyId: string, params?: ContractsQueryParams) {
return useQuery({
queryKey: crmKeys.contracts.list(companyId, params),
queryFn: () => contractsApi.list(companyId, params),
enabled: !!companyId,
});
}
export function useCreateContract(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateContractPayload) => contractsApi.create(companyId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contracts.all });
qc.invalidateQueries({ queryKey: crmKeys.companies.detail(companyId) });
},
});
}
export function useUpdateContract(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ contractId, data }: { contractId: string; data: UpdateContractPayload }) =>
contractsApi.update(companyId, contractId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contracts.all });
},
});
}
export function useDeleteContract(companyId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (contractId: string) => contractsApi.delete(companyId, contractId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contracts.all });
qc.invalidateQueries({ queryKey: crmKeys.companies.detail(companyId) });
},
});
}

View file

@ -185,6 +185,27 @@ export interface Contract {
updatedAt: string;
}
export interface CreateContractPayload {
title: string;
status?: ContractStatus;
startDate?: string | null;
endDate?: string | null;
value?: string | null;
currency?: string;
notes?: string | null;
}
export type UpdateContractPayload = Partial<CreateContractPayload>;
export interface ContractsQueryParams {
status?: ContractStatus;
search?: string;
sort?: string;
order?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
export interface TenantUser {
id: string;
firstName: string | null;