diff --git a/Summarize.md b/Summarize.md index fecdf15..88db900 100644 --- a/Summarize.md +++ b/Summarize.md @@ -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) --- diff --git a/docs/INSIGHT-CRM.md b/docs/INSIGHT-CRM.md index 7db19e8..e57d278 100644 --- a/docs/INSIGHT-CRM.md +++ b/docs/INSIGHT-CRM.md @@ -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 diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 5c8eb0b..b53c422 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -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>(`/crm/companies/${companyId}/contracts`, { params }) + .then((r) => r.data), + + create: (companyId: string, data: CreateContractPayload) => + api + .post>(`/crm/companies/${companyId}/contracts`, data) + .then((r) => r.data), + + update: (companyId: string, contractId: string, data: UpdateContractPayload) => + api + .patch>( + `/crm/companies/${companyId}/contracts/${contractId}`, + data, + ) + .then((r) => r.data), + + delete: (companyId: string, contractId: string) => + api + .delete>( + `/crm/companies/${companyId}/contracts/${contractId}`, + ) + .then((r) => r.data), +}; diff --git a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx index 3683005..e0f4990 100644 --- a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx +++ b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx @@ -570,10 +570,7 @@ export function CompanyDetailPage() { {/* ---- Tab 4: Verträge ---- */} {activeTab === 'contracts' && ( - + )} diff --git a/packages/frontend/src/crm/companies/ContractsCard.tsx b/packages/frontend/src/crm/companies/ContractsCard.tsx index 0a1ccd9..fa3ff9a 100644 --- a/packages/frontend/src/crm/companies/ContractsCard.tsx +++ b/packages/frontend/src/crm/companies/ContractsCard.tsx @@ -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 = { + DRAFT: 'Entwurf', + ACTIVE: 'Aktiv', + EXPIRED: 'Abgelaufen', + CANCELLED: 'Storniert', +}; + +const STATUS_COLORS: Record = { + 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(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 ( -
-

- Verträge{contractCount > 0 ? ` (${contractCount})` : ''} -

-

Modul in Entwicklung – bald verfügbar.

+
+
+

+ Verträge ({contracts.length}) +

+ +
+ + {isLoading ? ( +

+ Laden... +

+ ) : contracts.length === 0 ? ( +

+ Keine Verträge vorhanden +

+ ) : ( +
+ {contracts.map((c) => ( +
+
+ + {c.title} + +
+ + {STATUS_LABELS[c.status]} + + {c.value && ( + + {formatValue(c.value, c.currency)} + + )} + + {formatDate(c.startDate)} + {c.endDate ? ` – ${formatDate(c.endDate)}` : ''} + +
+
+
+ + +
+
+ ))} +
+ )} + + {/* Create Modal */} + setAddOpen(false)} + companyId={companyId} + mode="create" + /> + + {/* Edit Modal */} + {editContract && ( + setEditContract(null)} + companyId={companyId} + mode="edit" + contract={editContract} + /> + )}
); } + +// ============================================================ +// 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(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 ( + +
+ {error && ( +
+ {error} +
+ )} + + {/* Titel */} +
+ + setTitle(e.target.value)} + placeholder="z.B. Wartungsvertrag 2026" + required + autoFocus + /> +
+ + {/* Status */} +
+ + +
+ + {/* Laufzeit */} +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + {/* Wert + Währung */} +
+
+ + setValue(e.target.value)} + placeholder="0.00" + /> +
+
+ + +
+
+ + {/* Notizen */} +
+ +