added contract history
This commit is contained in:
-1
File diff suppressed because one or more lines are too long
+725
File diff suppressed because one or more lines are too long
-715
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenCRM</title>
|
||||
<script type="module" crossorigin src="/assets/index-XQdvYOWp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B06MVODt.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cdzd21Iz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-b8RXSgxB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, ChevronDown, ChevronUp, History, Clock, Bot, User } from 'lucide-react';
|
||||
import Modal from '../ui/Modal';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Badge from '../ui/Badge';
|
||||
import { contractHistoryApi } from '../../services/api';
|
||||
import type { ContractHistoryEntry } from '../../types';
|
||||
|
||||
interface ContractHistorySectionProps {
|
||||
contractId: number;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export default function ContractHistorySection({
|
||||
contractId,
|
||||
canEdit,
|
||||
}: ContractHistorySectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingEntry, setEditingEntry] = useState<ContractHistoryEntry | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract-history', contractId],
|
||||
queryFn: () => contractHistoryApi.getByContract(contractId),
|
||||
});
|
||||
|
||||
const deleteEntryMutation = useMutation({
|
||||
mutationFn: (entryId: number) => contractHistoryApi.delete(contractId, entryId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-history', contractId] });
|
||||
},
|
||||
});
|
||||
|
||||
const entries = data?.data || [];
|
||||
|
||||
// Sort entries by date (newest first)
|
||||
const sortedEntries = [...entries].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
<h4 className="text-sm font-medium text-gray-700">Vertragshistorie</h4>
|
||||
<Badge variant="default">{entries.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{entries.length > 0 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-500">Laden...</p>
|
||||
)}
|
||||
|
||||
{/* Collapsed view - show latest entry */}
|
||||
{!isExpanded && !isLoading && sortedEntries.length > 0 && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">
|
||||
{new Date(sortedEntries[0].createdAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{' - '}
|
||||
{sortedEntries[0].title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded view */}
|
||||
{isExpanded && !isLoading && sortedEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start justify-between p-3 bg-gray-50 rounded-lg group"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{entry.title}
|
||||
</span>
|
||||
{entry.isAutomatic ? (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-blue-100 text-blue-700" title="Automatisch erstellt">
|
||||
<Bot className="w-3 h-3" />
|
||||
Auto
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600" title="Manuell erstellt">
|
||||
<User className="w-3 h-3" />
|
||||
Manuell
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.description && (
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap mb-1">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(entry.createdAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span>von {entry.createdBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && !entry.isAutomatic && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 ml-3">
|
||||
<button
|
||||
onClick={() => setEditingEntry(entry)}
|
||||
className="text-gray-500 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Eintrag wirklich löschen?')) {
|
||||
deleteEntryMutation.mutate(entry.id);
|
||||
}
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && !isLoading && sortedEntries.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic">Keine Historie vorhanden.</p>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{(showAddModal || editingEntry) && (
|
||||
<HistoryEntryModal
|
||||
isOpen={true}
|
||||
onClose={() => {
|
||||
setShowAddModal(false);
|
||||
setEditingEntry(null);
|
||||
}}
|
||||
contractId={contractId}
|
||||
entry={editingEntry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// History Entry Modal Component
|
||||
function HistoryEntryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
contractId,
|
||||
entry,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contractId: number;
|
||||
entry?: ContractHistoryEntry | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!entry;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: entry?.title || '',
|
||||
description: entry?.description || '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractHistoryApi.create(contractId, {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-history', contractId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractHistoryApi.update(contractId, entry!.id, {
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-history', contractId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
setError('Titel ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate();
|
||||
} else {
|
||||
createMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Eintrag bearbeiten' : 'Historie-Eintrag hinzufügen'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Titel *"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="z.B. kWh auf 18000 erhöht"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Weitere Details..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Wird gespeichert...' : isEditing ? 'Speichern' : 'Hinzufügen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ContractDetailModal } from './ContractDetailModal';
|
||||
export { default as ContractHistorySection } from './ContractHistorySection';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { contractApi, uploadApi, meterApi, contractTaskApi, appSettingsApi } from '../../services/api';
|
||||
import { ContractEmailsSection } from '../../components/email';
|
||||
import { ContractDetailModal } from '../../components/contracts';
|
||||
import { ContractDetailModal, ContractHistorySection } from '../../components/contracts';
|
||||
import InvoicesSection from '../../components/contracts/InvoicesSection';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import Card from '../../components/ui/Card';
|
||||
@@ -2683,6 +2683,14 @@ export default function ContractDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vertragshistorie (nur für Mitarbeiter) */}
|
||||
{!isCustomerPortal && hasPermission('contracts:read') && (
|
||||
<ContractHistorySection
|
||||
contractId={contractId}
|
||||
canEdit={hasPermission('contracts:update')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Vorgängervertrag Modal */}
|
||||
{showPredecessorModal && c.previousContract && (
|
||||
<ContractDetailModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary } from '../types';
|
||||
import type { ApiResponse, Customer, Contract, ContractTask, ContractTaskSubtask, ContractTaskStatus, SalesPlatform, CancellationPeriod, ContractDuration, ContractCategory, Provider, Tariff, User, Address, BankCard, IdentityDocument, Meter, MeterReading, Invoice, Role, PortalSettings, CustomerRepresentative, CustomerSummary, ContractHistoryEntry } from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -648,6 +648,26 @@ export const contractApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Contract History (Vertragshistorie - nur intern)
|
||||
export const contractHistoryApi = {
|
||||
getByContract: async (contractId: number) => {
|
||||
const res = await api.get<ApiResponse<ContractHistoryEntry[]>>(`/contracts/${contractId}/history`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (contractId: number, data: { title: string; description?: string }) => {
|
||||
const res = await api.post<ApiResponse<ContractHistoryEntry>>(`/contracts/${contractId}/history`, data);
|
||||
return res.data;
|
||||
},
|
||||
update: async (contractId: number, entryId: number, data: { title?: string; description?: string }) => {
|
||||
const res = await api.put<ApiResponse<ContractHistoryEntry>>(`/contracts/${contractId}/history/${entryId}`, data);
|
||||
return res.data;
|
||||
},
|
||||
delete: async (contractId: number, entryId: number) => {
|
||||
const res = await api.delete<ApiResponse<void>>(`/contracts/${contractId}/history/${entryId}`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Contract Tasks (Aufgaben)
|
||||
export const contractTaskApi = {
|
||||
// Alle Tasks über alle Verträge (für Task-Liste & Dashboard)
|
||||
|
||||
@@ -216,6 +216,16 @@ export interface ContractTask {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContractHistoryEntry {
|
||||
id: number;
|
||||
contractId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
isAutomatic: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SalesPlatform {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user