first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user