first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit e209e9bbca
12105 changed files with 2480672 additions and 0 deletions
@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cancellationPeriodApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { CancellationPeriod } from '../../types';
export default function CancellationPeriodList() {
const [showModal, setShowModal] = useState(false);
const [editingPeriod, setEditingPeriod] = useState<CancellationPeriod | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['cancellation-periods', showInactive],
queryFn: () => cancellationPeriodApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: cancellationPeriodApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
},
});
const handleEdit = (period: CancellationPeriod) => {
setEditingPeriod(period);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingPeriod(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neue Frist
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm">
<strong>Code-Format:</strong> Zahl + Buchstabe (T=Tage, M=Monate, J=Jahre)
<br />
<strong>Beispiele:</strong> 14T = 14 Tage, 3M = 3 Monate, 1J = 1 Jahr
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Code</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beschreibung</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((period) => (
<tr key={period.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono font-medium">{period.code}</td>
<td className="py-3 px-4">{period.description}</td>
<td className="py-3 px-4">
<Badge variant={period.isActive ? 'success' : 'danger'}>
{period.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Kündigungsfrist wirklich löschen?')) {
deleteMutation.mutate(period.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Kündigungsfristen vorhanden.</div>
)}
</Card>
<CancellationPeriodModal
isOpen={showModal}
onClose={handleClose}
period={editingPeriod}
/>
</div>
);
}
function CancellationPeriodModal({
isOpen,
onClose,
period,
}: {
isOpen: boolean;
onClose: () => void;
period: CancellationPeriod | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
code: '',
description: '',
isActive: true,
});
// Reset form when modal opens or period changes
useEffect(() => {
if (isOpen) {
if (period) {
setFormData({
code: period.code,
description: period.description,
isActive: period.isActive,
});
} else {
setFormData({ code: '', description: '', isActive: true });
}
}
}, [isOpen, period]);
const createMutation = useMutation({
mutationFn: cancellationPeriodApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
onClose();
setFormData({ code: '', description: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<CancellationPeriod>) =>
cancellationPeriodApi.update(period!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cancellation-periods'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (period) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={period ? 'Kündigungsfrist bearbeiten' : 'Neue Kündigungsfrist'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Code *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
required
placeholder="z.B. 14T, 3M, 1J"
/>
<Input
label="Beschreibung *"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
placeholder="z.B. 14 Tage, 3 Monate, 1 Jahr"
/>
{period && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,351 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractCategoryApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft, GripVertical, Zap, Flame, Wifi, Cable, Smartphone, Tv, Car, FileText } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { ContractCategory } from '../../types';
// Icon-Mapping für die Anzeige
const iconMap: Record<string, React.ReactNode> = {
Zap: <Zap className="w-5 h-5" />,
Flame: <Flame className="w-5 h-5" />,
Wifi: <Wifi className="w-5 h-5" />,
Cable: <Cable className="w-5 h-5" />,
Smartphone: <Smartphone className="w-5 h-5" />,
Tv: <Tv className="w-5 h-5" />,
Car: <Car className="w-5 h-5" />,
FileText: <FileText className="w-5 h-5" />,
};
const availableIcons = [
{ value: 'Zap', label: 'Blitz (Strom)' },
{ value: 'Flame', label: 'Flamme (Gas)' },
{ value: 'Wifi', label: 'WLAN (DSL)' },
{ value: 'Cable', label: 'Kabel (Glasfaser)' },
{ value: 'Smartphone', label: 'Smartphone (Mobilfunk)' },
{ value: 'Tv', label: 'TV' },
{ value: 'Car', label: 'Auto (KFZ)' },
{ value: 'FileText', label: 'Dokument (Sonstige)' },
];
const availableColors = [
{ value: '#FFC107', label: 'Gelb' },
{ value: '#FF5722', label: 'Orange' },
{ value: '#2196F3', label: 'Blau' },
{ value: '#9C27B0', label: 'Lila' },
{ value: '#4CAF50', label: 'Grün' },
{ value: '#E91E63', label: 'Pink' },
{ value: '#607D8B', label: 'Grau' },
{ value: '#795548', label: 'Braun' },
{ value: '#00BCD4', label: 'Cyan' },
{ value: '#F44336', label: 'Rot' },
];
export default function ContractCategoryList() {
const [showModal, setShowModal] = useState(false);
const [editingCategory, setEditingCategory] = useState<ContractCategory | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['contract-categories', showInactive],
queryFn: () => contractCategoryApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: contractCategoryApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleEdit = (category: ContractCategory) => {
setEditingCategory(category);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingCategory(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Vertragstyp
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="space-y-2">
{data.data.map((category) => (
<div
key={category.id}
className="flex items-center p-4 border rounded-lg hover:bg-gray-50"
>
<div className="mr-3 text-gray-400">
<GripVertical className="w-5 h-5" />
</div>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center mr-4"
style={{ backgroundColor: category.color || '#E5E7EB', color: '#fff' }}
>
{category.icon && iconMap[category.icon] ? iconMap[category.icon] : <FileText className="w-5 h-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{category.name}</span>
<Badge variant={category.isActive ? 'success' : 'danger'}>
{category.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
<span className="text-sm text-gray-500">
({category._count?.contracts || 0} Verträge)
</span>
</div>
<div className="text-sm text-gray-500">
Code: <span className="font-mono">{category.code}</span>
</div>
</div>
<div className="flex gap-2 ml-4">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Vertragstyp wirklich löschen?')) {
deleteMutation.mutate(category.id);
}
}}
title="Löschen"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Vertragstypen vorhanden.</div>
)}
</Card>
<ContractCategoryModal
isOpen={showModal}
onClose={handleClose}
category={editingCategory}
/>
</div>
);
}
function ContractCategoryModal({
isOpen,
onClose,
category,
}: {
isOpen: boolean;
onClose: () => void;
category: ContractCategory | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
code: '',
name: '',
icon: 'FileText',
color: '#607D8B',
sortOrder: 0,
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (category) {
setFormData({
code: category.code,
name: category.name,
icon: category.icon || 'FileText',
color: category.color || '#607D8B',
sortOrder: category.sortOrder,
isActive: category.isActive,
});
} else {
setFormData({
code: '',
name: '',
icon: 'FileText',
color: '#607D8B',
sortOrder: 0,
isActive: true,
});
}
}
}, [isOpen, category]);
const createMutation = useMutation({
mutationFn: contractCategoryApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<ContractCategory>) =>
contractCategoryApi.update(category!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-categories'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (category) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={category ? 'Vertragstyp bearbeiten' : 'Neuer Vertragstyp'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Code (technisch) *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '') })}
required
placeholder="z.B. ELECTRICITY, MOBILE_BUSINESS"
disabled={!!category} // Code nicht änderbar bei Bearbeitung
/>
<Input
label="Anzeigename *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Strom, Mobilfunk Business"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Icon</label>
<div className="grid grid-cols-4 gap-2">
{availableIcons.map((icon) => (
<button
key={icon.value}
type="button"
onClick={() => setFormData({ ...formData, icon: icon.value })}
className={`p-3 border rounded-lg flex flex-col items-center gap-1 text-xs ${
formData.icon === icon.value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:bg-gray-50'
}`}
>
{iconMap[icon.value]}
<span className="truncate w-full text-center">{icon.label.split(' ')[0]}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Farbe</label>
<div className="flex flex-wrap gap-2">
{availableColors.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setFormData({ ...formData, color: color.value })}
className={`w-8 h-8 rounded-full border-2 ${
formData.color === color.value ? 'border-gray-800 ring-2 ring-offset-2 ring-gray-400' : 'border-transparent'
}`}
style={{ backgroundColor: color.value }}
title={color.label}
/>
))}
</div>
</div>
<Input
label="Sortierung"
type="number"
value={formData.sortOrder}
onChange={(e) => setFormData({ ...formData, sortOrder: parseInt(e.target.value) || 0 })}
placeholder="0"
/>
{category && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,249 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contractDurationApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { ContractDuration } from '../../types';
export default function ContractDurationList() {
const [showModal, setShowModal] = useState(false);
const [editingDuration, setEditingDuration] = useState<ContractDuration | null>(null);
const [showInactive, setShowInactive] = useState(false);
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['contract-durations', showInactive],
queryFn: () => contractDurationApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: contractDurationApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
},
});
const handleEdit = (duration: ContractDuration) => {
setEditingDuration(duration);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingDuration(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
{hasPermission('platforms:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neue Laufzeit
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm">
<strong>Code-Format:</strong> Zahl + Buchstabe (T=Tage, M=Monate, J=Jahre)
<br />
<strong>Beispiele:</strong> 12M = 12 Monate, 24M = 24 Monate, 2J = 2 Jahre
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-600">Code</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Beschreibung</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Status</th>
<th className="text-right py-3 px-4 font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody>
{data.data.map((duration) => (
<tr key={duration.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-mono font-medium">{duration.code}</td>
<td className="py-3 px-4">{duration.description}</td>
<td className="py-3 px-4">
<Badge variant={duration.isActive ? 'success' : 'danger'}>
{duration.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
</td>
<td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2">
{hasPermission('platforms:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('platforms:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Laufzeit wirklich löschen?')) {
deleteMutation.mutate(duration.id);
}
}}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Laufzeiten vorhanden.</div>
)}
</Card>
<ContractDurationModal
isOpen={showModal}
onClose={handleClose}
duration={editingDuration}
/>
</div>
);
}
function ContractDurationModal({
isOpen,
onClose,
duration,
}: {
isOpen: boolean;
onClose: () => void;
duration: ContractDuration | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
code: '',
description: '',
isActive: true,
});
// Reset form when modal opens or duration changes
useEffect(() => {
if (isOpen) {
if (duration) {
setFormData({
code: duration.code,
description: duration.description,
isActive: duration.isActive,
});
} else {
setFormData({ code: '', description: '', isActive: true });
}
}
}, [isOpen, duration]);
const createMutation = useMutation({
mutationFn: contractDurationApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
onClose();
setFormData({ code: '', description: '', isActive: true });
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<ContractDuration>) =>
contractDurationApi.update(duration!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contract-durations'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (duration) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={duration ? 'Laufzeit bearbeiten' : 'Neue Laufzeit'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Code *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })}
required
placeholder="z.B. 12M, 24M, 2J"
/>
<Input
label="Beschreibung *"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
required
placeholder="z.B. 12 Monate, 24 Monate, 2 Jahre"
/>
{duration && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,181 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { appSettingsApi } from '../../services/api';
import Card from '../../components/ui/Card';
import Input from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import { ArrowLeft, Clock, AlertTriangle, AlertCircle, CheckCircle } from 'lucide-react';
export default function DeadlineSettings() {
const queryClient = useQueryClient();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => appSettingsApi.getAll(),
});
const [criticalDays, setCriticalDays] = useState('14');
const [warningDays, setWarningDays] = useState('42');
const [okDays, setOkDays] = useState('90');
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
if (settingsData?.data) {
setCriticalDays(settingsData.data.deadlineCriticalDays || '14');
setWarningDays(settingsData.data.deadlineWarningDays || '42');
setOkDays(settingsData.data.deadlineOkDays || '90');
setHasChanges(false);
}
}, [settingsData]);
const updateMutation = useMutation({
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
queryClient.invalidateQueries({ queryKey: ['contract-cockpit'] });
setHasChanges(false);
},
});
const handleSave = () => {
// Validierung
const critical = parseInt(criticalDays);
const warning = parseInt(warningDays);
const ok = parseInt(okDays);
if (isNaN(critical) || isNaN(warning) || isNaN(ok)) {
alert('Bitte gültige Zahlen eingeben');
return;
}
if (critical >= warning || warning >= ok) {
alert('Die Werte müssen aufsteigend sein: Kritisch < Warnung < OK');
return;
}
updateMutation.mutate({
deadlineCriticalDays: criticalDays,
deadlineWarningDays: warningDays,
deadlineOkDays: okDays,
});
};
const handleChange = (setter: (value: string) => void, value: string) => {
setter(value);
setHasChanges(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Clock className="w-6 h-6" />
<h1 className="text-2xl font-bold">Fristenschwellen</h1>
</div>
</div>
<Card title="Farbkodierung für Fristen">
<p className="text-gray-600 mb-6">
Definiere, ab wann Vertragsfristen als kritisch (rot), Warnung (gelb) oder OK (grün)
angezeigt werden sollen. Die Werte geben die Anzahl der Tage bis zur Frist an.
</p>
<div className="space-y-6">
{/* Kritisch (Rot) */}
<div className="flex items-center gap-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-8 h-8 text-red-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-red-800 mb-1">
Kritisch (Rot)
</label>
<p className="text-sm text-red-600 mb-2">
Fristen mit weniger als X Tagen werden rot markiert
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={criticalDays}
onChange={(e) => handleChange(setCriticalDays, e.target.value)}
className="w-24"
/>
<span className="text-red-700">Tage</span>
</div>
</div>
</div>
{/* Warnung (Gelb) */}
<div className="flex items-center gap-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<AlertTriangle className="w-8 h-8 text-yellow-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-yellow-800 mb-1">
Warnung (Gelb)
</label>
<p className="text-sm text-yellow-600 mb-2">
Fristen mit weniger als X Tagen werden gelb markiert
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={warningDays}
onChange={(e) => handleChange(setWarningDays, e.target.value)}
className="w-24"
/>
<span className="text-yellow-700">Tage</span>
</div>
</div>
</div>
{/* OK (Grün) */}
<div className="flex items-center gap-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<CheckCircle className="w-8 h-8 text-green-500 flex-shrink-0" />
<div className="flex-1">
<label className="block font-medium text-green-800 mb-1">
OK (Grün)
</label>
<p className="text-sm text-green-600 mb-2">
Fristen mit weniger als X Tagen werden grün markiert (darüber nicht angezeigt)
</p>
<div className="flex items-center gap-2">
<Input
type="number"
min="1"
value={okDays}
onChange={(e) => handleChange(setOkDays, e.target.value)}
className="w-24"
/>
<span className="text-green-700">Tage</span>
</div>
</div>
</div>
</div>
<div className="mt-6 pt-4 border-t flex justify-between items-center">
<p className="text-sm text-gray-500">
Beispiel: Bei 14/42/90 Tagen wird eine Frist die in 10 Tagen abläuft rot,
eine in 30 Tagen gelb, und eine in 60 Tagen grün markiert.
</p>
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
>
{updateMutation.isPending ? 'Speichere...' : 'Speichern'}
</Button>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,539 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { emailProviderApi, EmailProviderConfig } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select';
import { ArrowLeft, Plus, Edit, Trash2, Check, X, Wifi, WifiOff, Eye, EyeOff } from 'lucide-react';
const PROVIDER_TYPES = [
{ value: 'PLESK', label: 'Plesk' },
{ value: 'CPANEL', label: 'cPanel' },
{ value: 'DIRECTADMIN', label: 'DirectAdmin' },
];
interface ProviderFormData {
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey: string;
username: string;
password: string;
domain: string;
defaultForwardEmail: string;
isActive: boolean;
isDefault: boolean;
}
const emptyForm: ProviderFormData = {
name: '',
type: 'PLESK',
apiUrl: '',
apiKey: '',
username: '',
password: '',
domain: 'stressfrei-wechseln.de',
defaultForwardEmail: '',
isActive: true,
isDefault: false,
};
interface TestResult {
success: boolean;
message?: string;
error?: string;
}
export default function EmailProviders() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<ProviderFormData>(emptyForm);
const [showPassword, setShowPassword] = useState(false);
const [modalTestResult, setModalTestResult] = useState<TestResult | null>(null);
const [isTestingInModal, setIsTestingInModal] = useState(false);
// Test-Status pro Provider in der Liste
const [providerTestResults, setProviderTestResults] = useState<Record<number, TestResult | null>>({});
const [testingProviderId, setTestingProviderId] = useState<number | null>(null);
const { data: configsData, isLoading } = useQuery({
queryKey: ['email-provider-configs'],
queryFn: () => emailProviderApi.getConfigs(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<EmailProviderConfig> & { password?: string }) =>
emailProviderApi.createConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
closeModal();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<EmailProviderConfig> & { password?: string } }) =>
emailProviderApi.updateConfig(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
closeModal();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => emailProviderApi.deleteConfig(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-provider-configs'] });
},
});
const configs = configsData?.data || [];
const openCreateModal = () => {
setFormData(emptyForm);
setEditingId(null);
setShowPassword(false);
setModalTestResult(null);
setIsModalOpen(true);
};
const openEditModal = (config: EmailProviderConfig) => {
setFormData({
name: config.name,
type: config.type,
apiUrl: config.apiUrl,
apiKey: config.apiKey || '',
username: config.username || '',
password: '', // Passwort wird nicht geladen
domain: config.domain,
defaultForwardEmail: config.defaultForwardEmail || '',
isActive: config.isActive,
isDefault: config.isDefault,
});
setEditingId(config.id);
setShowPassword(false);
setModalTestResult(null);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setEditingId(null);
setFormData(emptyForm);
setShowPassword(false);
setModalTestResult(null);
};
// Test für einen gespeicherten Provider in der Liste
const handleTestProvider = async (config: EmailProviderConfig) => {
setTestingProviderId(config.id);
setProviderTestResults((prev) => ({ ...prev, [config.id]: null }));
try {
// Provider per ID testen (Backend holt das gespeicherte Passwort)
const result = await emailProviderApi.testConnection({ id: config.id });
const testResult: TestResult = {
success: result.data?.success || false,
message: result.data?.message,
error: result.data?.error,
};
setProviderTestResults((prev) => ({ ...prev, [config.id]: testResult }));
} catch (error) {
setProviderTestResults((prev) => ({
...prev,
[config.id]: {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler beim Testen',
},
}));
} finally {
setTestingProviderId(null);
}
};
// Test im Modal mit aktuellen Formulardaten
const handleTestInModal = async () => {
// Nur URL und Domain sind Pflicht - Auth-Fehler kommen vom Backend
if (!formData.apiUrl || !formData.domain) {
setModalTestResult({ success: false, error: 'Bitte geben Sie API-URL und Domain ein.' });
return;
}
setIsTestingInModal(true);
setModalTestResult(null);
try {
const result = await emailProviderApi.testConnection({
testData: {
type: formData.type,
apiUrl: formData.apiUrl,
apiKey: formData.apiKey || undefined,
username: formData.username || undefined,
password: formData.password || undefined,
domain: formData.domain,
}
});
setModalTestResult({
success: result.data?.success || false,
message: result.data?.message,
error: result.data?.error,
});
} catch (error) {
setModalTestResult({
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler beim Verbindungstest'
});
} finally {
setIsTestingInModal(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: Partial<EmailProviderConfig> & { password?: string } = {
name: formData.name,
type: formData.type,
apiUrl: formData.apiUrl,
apiKey: formData.apiKey, // Leerer String wird im Backend zu null
username: formData.username,
domain: formData.domain,
defaultForwardEmail: formData.defaultForwardEmail,
isActive: formData.isActive,
isDefault: formData.isDefault,
};
// Passwort nur senden wenn eingegeben
if (formData.password) {
data.password = formData.password;
}
if (editingId) {
updateMutation.mutate({ id: editingId, data });
} else {
createMutation.mutate(data);
}
};
const handleDelete = (id: number, name: string) => {
if (confirm(`Möchten Sie den Provider "${name}" wirklich löschen?`)) {
deleteMutation.mutate(id);
}
};
// Fehlermeldung benutzerfreundlich formatieren
const formatErrorMessage = (result: TestResult): string => {
if (result.error) return result.error;
if (result.message) return result.message;
return 'Verbindung fehlgeschlagen';
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/settings')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<h1 className="text-2xl font-bold">Email-Provisionierung</h1>
</div>
<Card className="mb-6">
<p className="text-gray-600 mb-4">
Hier konfigurieren Sie die automatische Erstellung von Stressfrei-Wechseln E-Mail-Adressen.
Wenn beim Anlegen einer Stressfrei-Adresse die Option "Bei Provider anlegen" aktiviert ist,
wird die E-Mail-Weiterleitung automatisch erstellt.
</p>
<Button onClick={openCreateModal}>
<Plus className="w-4 h-4 mr-2" />
Provider hinzufügen
</Button>
</Card>
{isLoading ? (
<div className="text-center py-8">Laden...</div>
) : configs.length === 0 ? (
<Card>
<div className="text-center py-8 text-gray-500">
Noch keine Email-Provider konfiguriert.
</div>
</Card>
) : (
<div className="space-y-4">
{configs.map((config) => {
const testResult = providerTestResults[config.id];
const isTesting = testingProviderId === config.id;
return (
<Card key={config.id}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg">{config.name}</h3>
<span className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800">
{config.type}
</span>
{config.isDefault && (
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-800">
Standard
</span>
)}
{!config.isActive && (
<span className="px-2 py-1 text-xs rounded bg-gray-100 text-gray-600">
Inaktiv
</span>
)}
</div>
<dl className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<dt className="text-gray-500">API-URL</dt>
<dd className="font-mono text-xs truncate">{config.apiUrl}</dd>
</div>
<div>
<dt className="text-gray-500">Domain</dt>
<dd>{config.domain}</dd>
</div>
<div>
<dt className="text-gray-500">Benutzer</dt>
<dd>{config.username || '-'}</dd>
</div>
<div>
<dt className="text-gray-500">Standard-Weiterleitung</dt>
<dd className="truncate">{config.defaultForwardEmail || '-'}</dd>
</div>
</dl>
{/* Test-Ergebnis für diesen Provider */}
{testResult && (
<div className={`mt-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{testResult.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>Verbindung erfolgreich!</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{formatErrorMessage(testResult)}</span>
</div>
)}
</div>
)}
</div>
<div className="flex gap-2 ml-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestProvider(config)}
disabled={isTesting}
title="Verbindung testen"
>
{isTesting ? (
<span className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Wifi className="w-4 h-4 text-blue-500" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => openEditModal(config)}>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(config.id, config.name)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</div>
</Card>
);
})}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">
{editingId ? 'Provider bearbeiten' : 'Neuer Provider'}
</h2>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
{/* Mutation Fehler anzeigen */}
{(createMutation.error || updateMutation.error) && (
<div className="mb-4 p-3 rounded-lg bg-red-50 text-red-800 text-sm">
<div className="flex items-start gap-2">
<X className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>
{createMutation.error instanceof Error
? createMutation.error.message
: updateMutation.error instanceof Error
? updateMutation.error.message
: 'Fehler beim Speichern'}
</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Name *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Plesk Hauptserver"
required
/>
<Select
label="Provider-Typ *"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'PLESK' | 'CPANEL' | 'DIRECTADMIN' })}
options={PROVIDER_TYPES}
/>
<Input
label="API-URL *"
value={formData.apiUrl}
onChange={(e) => setFormData({ ...formData, apiUrl: e.target.value })}
placeholder="https://server.de:8443"
required
/>
<Input
label="API-Key"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Optional - alternativ zu Benutzername/Passwort"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Benutzername"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="admin"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{editingId ? 'Neues Passwort (leer = beibehalten)' : 'Passwort'}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<Input
label="Domain *"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
placeholder="stressfrei-wechseln.de"
required
/>
<Input
label="Standard-Weiterleitungsadresse"
value={formData.defaultForwardEmail}
onChange={(e) => setFormData({ ...formData, defaultForwardEmail: e.target.value })}
placeholder="info@meinefirma.de"
type="email"
/>
<p className="text-xs text-gray-500 -mt-2">
Diese E-Mail-Adresse wird zusätzlich zur Kunden-E-Mail als Weiterleitungsziel hinzugefügt.
</p>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm">Aktiv</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.isDefault}
onChange={(e) => setFormData({ ...formData, isDefault: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm">Als Standard verwenden</span>
</label>
</div>
{/* Verbindungstest im Modal */}
<div className="pt-4 border-t">
<Button
type="button"
variant="secondary"
onClick={handleTestInModal}
disabled={isTestingInModal}
className="w-full"
>
{isTestingInModal ? (
'Teste Verbindung...'
) : (
<>
<Wifi className="w-4 h-4 mr-2" />
Verbindung testen
</>
)}
</Button>
{modalTestResult && (
<div className={`mt-2 p-3 rounded-lg text-sm ${modalTestResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'}`}>
{modalTestResult.success ? (
<div className="flex items-center gap-2">
<Check className="w-4 h-4 flex-shrink-0" />
<span>Verbindung erfolgreich!</span>
</div>
) : (
<div className="flex items-start gap-2">
<WifiOff className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{formatErrorMessage(modalTestResult)}</span>
</div>
)}
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button type="button" variant="secondary" onClick={closeModal}>
Abbrechen
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
>
{(createMutation.isPending || updateMutation.isPending) ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { appSettingsApi } from '../../services/api';
import Card from '../../components/ui/Card';
import { ArrowLeft, Globe, MessageSquare } from 'lucide-react';
export default function PortalSettings() {
const queryClient = useQueryClient();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['app-settings'],
queryFn: () => appSettingsApi.getAll(),
});
const [customerSupportEnabled, setCustomerSupportEnabled] = useState(false);
useEffect(() => {
if (settingsData?.data) {
setCustomerSupportEnabled(settingsData.data.customerSupportTicketsEnabled === 'true');
}
}, [settingsData]);
const updateMutation = useMutation({
mutationFn: (settings: Record<string, string>) => appSettingsApi.update(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
queryClient.invalidateQueries({ queryKey: ['app-settings-public'] });
},
});
const handleSupportToggle = (enabled: boolean) => {
setCustomerSupportEnabled(enabled);
updateMutation.mutate({ customerSupportTicketsEnabled: enabled ? 'true' : 'false' });
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Laden...</div>
</div>
);
}
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings" className="text-gray-500 hover:text-gray-700">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Globe className="w-6 h-6" />
<h1 className="text-2xl font-bold">Kundenportal</h1>
</div>
</div>
<Card title="Support-Anfragen">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Kunden können Support-Anfragen erstellen</p>
<p className="text-sm text-gray-500">
Wenn aktiviert, können Kunden im Portal Support-Anfragen zu ihren Verträgen erstellen.
Diese erscheinen als Aufgaben in der Vertragsdetailansicht.
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={customerSupportEnabled}
onChange={(e) => handleSupportToggle(e.target.checked)}
disabled={updateMutation.isPending}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{customerSupportEnabled && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Hinweis:</strong> Kunden sehen diese Anfragen als "Support-Anfragen" in ihrem Portal.
Sie können die Anfrage mit einem Titel und einer Beschreibung erstellen.
Ihre Mitarbeiter können dann mit Antworten (Unteraufgaben) reagieren.
</p>
</div>
)}
</Card>
</div>
);
}
@@ -0,0 +1,521 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { providerApi, tariffApi } from '../../services/api';
import { useAuth } from '../../context/AuthContext';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Modal from '../../components/ui/Modal';
import Badge from '../../components/ui/Badge';
import { Plus, Edit, Trash2, ArrowLeft, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
import { Link } from 'react-router-dom';
import type { Provider, Tariff } from '../../types';
export default function ProviderList() {
const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [showInactive, setShowInactive] = useState(false);
const [expandedProviders, setExpandedProviders] = useState<Set<number>>(new Set());
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['providers', showInactive],
queryFn: () => providerApi.getAll(showInactive),
});
const deleteMutation = useMutation({
mutationFn: providerApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const toggleExpanded = (providerId: number) => {
setExpandedProviders(prev => {
const next = new Set(prev);
if (next.has(providerId)) {
next.delete(providerId);
} else {
next.add(providerId);
}
return next;
});
};
const handleEdit = (provider: Provider) => {
setEditingProvider(provider);
setShowModal(true);
};
const handleClose = () => {
setShowModal(false);
setEditingProvider(null);
};
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link to="/settings">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-2xl font-bold flex-1">Anbieter & Tarife</h1>
{hasPermission('providers:create') && (
<Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Neuer Anbieter
</Button>
)}
</div>
<Card>
<div className="mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded"
/>
Inaktive anzeigen
</label>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : data?.data && data.data.length > 0 ? (
<div className="space-y-2">
{data.data.map((provider) => (
<ProviderRow
key={provider.id}
provider={provider}
isExpanded={expandedProviders.has(provider.id)}
onToggle={() => toggleExpanded(provider.id)}
onEdit={() => handleEdit(provider)}
onDelete={() => {
if (confirm('Anbieter wirklich löschen?')) {
deleteMutation.mutate(provider.id);
}
}}
hasPermission={hasPermission}
showInactive={showInactive}
/>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">Keine Anbieter vorhanden.</div>
)}
</Card>
<ProviderModal
isOpen={showModal}
onClose={handleClose}
provider={editingProvider}
/>
</div>
);
}
function ProviderRow({
provider,
isExpanded,
onToggle,
onEdit,
onDelete,
hasPermission,
showInactive,
}: {
provider: Provider;
isExpanded: boolean;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
hasPermission: (permission: string) => boolean;
showInactive: boolean;
}) {
const [showTariffModal, setShowTariffModal] = useState(false);
const [editingTariff, setEditingTariff] = useState<Tariff | null>(null);
const queryClient = useQueryClient();
const deleteTariffMutation = useMutation({
mutationFn: tariffApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
},
onError: (error: Error) => {
alert(error.message);
},
});
const tariffs = provider.tariffs?.filter(t => showInactive || t.isActive) || [];
return (
<div className="border rounded-lg">
<div className="flex items-center p-4 hover:bg-gray-50">
<button onClick={onToggle} className="mr-3 p-1 hover:bg-gray-200 rounded">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{provider.name}</span>
<Badge variant={provider.isActive ? 'success' : 'danger'}>
{provider.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
<span className="text-sm text-gray-500">
({tariffs.length} Tarife, {provider._count?.contracts || 0} Verträge)
</span>
</div>
{provider.portalUrl && (
<a
href={provider.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1"
>
<ExternalLink className="w-3 h-3" />
{provider.portalUrl}
</a>
)}
</div>
<div className="flex gap-2 ml-4">
{hasPermission('providers:update') && (
<Button variant="ghost" size="sm" onClick={onEdit} title="Bearbeiten">
<Edit className="w-4 h-4" />
</Button>
)}
{hasPermission('providers:delete') && (
<Button variant="ghost" size="sm" onClick={onDelete} title="Löschen">
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</div>
{isExpanded && (
<div className="border-t bg-gray-50 p-4">
<div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-gray-700">Tarife</h4>
{hasPermission('providers:create') && (
<Button size="sm" onClick={() => setShowTariffModal(true)}>
<Plus className="w-4 h-4 mr-1" />
Tarif hinzufügen
</Button>
)}
</div>
{tariffs.length > 0 ? (
<div className="space-y-2">
{tariffs.map((tariff) => (
<div key={tariff.id} className="flex items-center justify-between bg-white p-3 rounded border">
<div className="flex items-center gap-2">
<span>{tariff.name}</span>
<Badge variant={tariff.isActive ? 'success' : 'danger'} className="text-xs">
{tariff.isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
{tariff._count?.contracts !== undefined && (
<span className="text-xs text-gray-500">
({tariff._count.contracts} Verträge)
</span>
)}
</div>
<div className="flex gap-1">
{hasPermission('providers:update') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingTariff(tariff);
setShowTariffModal(true);
}}
title="Bearbeiten"
>
<Edit className="w-3 h-3" />
</Button>
)}
{hasPermission('providers:delete') && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Tarif wirklich löschen?')) {
deleteTariffMutation.mutate(tariff.id);
}
}}
title="Löschen"
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">Keine Tarife vorhanden.</p>
)}
</div>
)}
<TariffModal
isOpen={showTariffModal}
onClose={() => {
setShowTariffModal(false);
setEditingTariff(null);
}}
providerId={provider.id}
tariff={editingTariff}
/>
</div>
);
}
function ProviderModal({
isOpen,
onClose,
provider,
}: {
isOpen: boolean;
onClose: () => void;
provider: Provider | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (provider) {
setFormData({
name: provider.name,
portalUrl: provider.portalUrl || '',
usernameFieldName: provider.usernameFieldName || '',
passwordFieldName: provider.passwordFieldName || '',
isActive: provider.isActive,
});
} else {
setFormData({
name: '',
portalUrl: '',
usernameFieldName: '',
passwordFieldName: '',
isActive: true,
});
}
}
}, [isOpen, provider]);
const createMutation = useMutation({
mutationFn: providerApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Provider>) =>
providerApi.update(provider!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (provider) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={provider ? 'Anbieter bearbeiten' : 'Neuer Anbieter'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Anbietername *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Vodafone, E.ON, Allianz"
/>
<Input
label="Portal-URL (Login-Seite)"
value={formData.portalUrl}
onChange={(e) => setFormData({ ...formData, portalUrl: e.target.value })}
placeholder="https://kundenportal.anbieter.de/login"
/>
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Auto-Login Felder</strong> (optional)<br />
Feldnamen für URL-Parameter beim Auto-Login:
</p>
<Input
label="Benutzername-Feldname"
value={formData.usernameFieldName}
onChange={(e) => setFormData({ ...formData, usernameFieldName: e.target.value })}
placeholder="z.B. username, email, login"
/>
<Input
label="Passwort-Feldname"
value={formData.passwordFieldName}
onChange={(e) => setFormData({ ...formData, passwordFieldName: e.target.value })}
placeholder="z.B. password, pwd, kennwort"
/>
</div>
{provider && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
function TariffModal({
isOpen,
onClose,
providerId,
tariff,
}: {
isOpen: boolean;
onClose: () => void;
providerId: number;
tariff: Tariff | null;
}) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
isActive: true,
});
useEffect(() => {
if (isOpen) {
if (tariff) {
setFormData({
name: tariff.name,
isActive: tariff.isActive,
});
} else {
setFormData({ name: '', isActive: true });
}
}
}, [isOpen, tariff]);
const createMutation = useMutation({
mutationFn: (data: { name: string }) => providerApi.createTariff(providerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Tariff>) => tariffApi.update(tariff!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['providers'] });
onClose();
},
onError: (error: Error) => {
alert(error.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (tariff) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={tariff ? 'Tarif bearbeiten' : 'Neuer Tarif'}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Tarifname *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder="z.B. Comfort Plus, Basic 100"
/>
{tariff && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="rounded"
/>
Aktiv
</label>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Speichern...' : 'Speichern'}
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,59 @@
import { Link } from 'react-router-dom';
import { useAppSettings } from '../../context/AppSettingsContext';
import Card from '../../components/ui/Card';
import Select from '../../components/ui/Select';
import { ArrowLeft, Eye } from 'lucide-react';
const scrollThresholdOptions = [
{ value: '0.1', label: '10%' },
{ value: '0.2', label: '20%' },
{ value: '0.3', label: '30%' },
{ value: '0.4', label: '40%' },
{ value: '0.5', label: '50%' },
{ value: '0.6', label: '60%' },
{ value: '0.7', label: '70% (Standard)' },
{ value: '0.8', label: '80%' },
{ value: '0.9', label: '90%' },
{ value: '999', label: 'Deaktiviert' },
];
export default function ViewSettings() {
const { settings, updateSettings } = useAppSettings();
return (
<div>
<div className="flex items-center gap-4 mb-6">
<Link
to="/settings"
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<Eye className="w-6 h-6" />
<h1 className="text-2xl font-bold">Ansicht</h1>
</div>
</div>
<Card title="Scroll-Verhalten">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Nach-oben-Button</p>
<p className="text-sm text-gray-500">
Ab welcher Scroll-Position der Button unten rechts erscheinen soll
</p>
</div>
<div className="w-48">
<Select
options={scrollThresholdOptions}
value={settings.scrollToTopThreshold.toString()}
onChange={(e) => updateSettings({ scrollToTopThreshold: parseFloat(e.target.value) })}
/>
</div>
</div>
</div>
</Card>
</div>
);
}