- DetailModal: thread tz through resolveLabel(...) module helper + default export's 3 inline date/time calls. - IntegrationStatusTabs: thread tz through fmtDate helper + VeeamTab sub-component prop. - SyncScheduler: thread tz into closure-scoped formatDate helper. - audit-log-table, user-table, user-sessions, active-sessions: inline toLocale calls in component body. - analysis-view: useUserTimezone in AnalysisView; thread tz into 4 toLocaleString calls. - resolution-trend, volume-trend (recharts): module-scope fmtDate(iso) → fmtDate(iso, tz); useUserTimezone in named export; thread tz into axis tickFormatter + tooltip labelFormatter. - ticket-detail-modal: thread tz into formatDate arrow inside TicketDetailModal. - TimelineView: useUserTimezone; thread tz into 4 toLocale*String calls (hour/day/month/event-time formatters). - ScoreCard: useUserTimezone in AggregateScoreCard; thread tz into the date-range latest call. - addigy-tab: useUserTimezone in AddigyTab; thread tz into 2 inline calls. - activity-sparkline: module-scope fmtHour(iso) → fmtHour(iso, tz); useUserTimezone in ActivitySparkline; update 3 callsites in title/aria. - compliance-detail-table: thread tz from ComplianceDetailTable into ContractCoverageModal sub-component (2 inline date calls). - company-backup-detail: module-scope formatDate(d) → formatDate(d, tz); useUserTimezone in CompanyBackupDetail; update 3 callsites. Migrates 31 of 81 audit leak callsites.
656 lines
23 KiB
TypeScript
656 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Clock, Play, Pause, Trash2, Plus, Calendar, AlertCircle, CheckCircle2, XCircle, RefreshCw } from 'lucide-react';
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
interface ScheduleConfig {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
cron_expression: string;
|
|
sync_type: 'incremental' | 'full';
|
|
years_back?: number;
|
|
is_enabled: boolean;
|
|
last_run?: string;
|
|
next_run?: string;
|
|
last_status?: 'success' | 'failed';
|
|
last_error?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ScheduleStatus {
|
|
config: ScheduleConfig;
|
|
isRunning: boolean;
|
|
isValid: boolean;
|
|
nextRun?: string;
|
|
}
|
|
|
|
export default function SyncScheduler() {
|
|
const tz = useUserTimezone();
|
|
const [schedules, setSchedules] = useState<ScheduleStatus[]>([]);
|
|
const [reloading, setReloading] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [editingSchedule, setEditingSchedule] = useState<ScheduleConfig | null>(null);
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState({
|
|
id: '',
|
|
name: '',
|
|
description: '',
|
|
cron_expression: '',
|
|
sync_type: 'incremental' as 'incremental' | 'full',
|
|
years_back: 2,
|
|
is_enabled: false,
|
|
});
|
|
|
|
// Common cron presets
|
|
const cronPresets = [
|
|
{ label: 'Every day at 2 AM', value: '0 2 * * *' },
|
|
{ label: 'Every day at 3 AM', value: '0 3 * * *' },
|
|
{ label: 'Every Sunday at 3 AM', value: '0 3 * * 0' },
|
|
{ label: 'Every Monday at 2 AM', value: '0 2 * * 1' },
|
|
{ label: 'Every 6 hours', value: '0 */6 * * *' },
|
|
{ label: 'Every 12 hours', value: '0 */12 * * *' },
|
|
];
|
|
|
|
useEffect(() => {
|
|
fetchSchedules();
|
|
// Refresh every 30 seconds
|
|
const interval = setInterval(fetchSchedules, 30000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const fetchSchedules = async () => {
|
|
try {
|
|
const response = await fetch('/api/sync/schedules');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setSchedules(data.schedules);
|
|
setError(null);
|
|
} else {
|
|
setError(data.error || 'Failed to fetch schedules');
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to fetch schedules');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const reloadSchedules = async () => {
|
|
setReloading(true);
|
|
try {
|
|
const res = await fetch('/api/sync/schedules/reload', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
await fetchSchedules();
|
|
} catch (err) {
|
|
alert(`Reload failed: ${err instanceof Error ? err.message : err}`);
|
|
} finally {
|
|
setReloading(false);
|
|
}
|
|
};
|
|
|
|
const toggleSchedule = async (scheduleId: string, currentState: boolean) => {
|
|
try {
|
|
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ is_enabled: !currentState }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
fetchSchedules();
|
|
} else {
|
|
alert(`Failed to toggle schedule: ${data.error}`);
|
|
}
|
|
} catch (err) {
|
|
alert('Failed to toggle schedule');
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const triggerSchedule = async (scheduleId: string) => {
|
|
if (!confirm('Are you sure you want to trigger this sync now?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/sync/schedules/${scheduleId}/trigger`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
alert('Sync triggered successfully');
|
|
fetchSchedules();
|
|
} else {
|
|
alert(`Failed to trigger sync: ${data.error}`);
|
|
}
|
|
} catch (err) {
|
|
alert('Failed to trigger sync');
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const deleteSchedule = async (scheduleId: string) => {
|
|
if (!confirm('Are you sure you want to delete this schedule?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/sync/schedules/${scheduleId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
fetchSchedules();
|
|
} else {
|
|
alert(`Failed to delete schedule: ${data.error}`);
|
|
}
|
|
} catch (err) {
|
|
alert('Failed to delete schedule');
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const openEditDialog = (schedule: ScheduleConfig) => {
|
|
setEditingSchedule(schedule);
|
|
setFormData({
|
|
id: schedule.id,
|
|
name: schedule.name,
|
|
description: schedule.description,
|
|
cron_expression: schedule.cron_expression,
|
|
sync_type: schedule.sync_type,
|
|
years_back: schedule.years_back || 2,
|
|
is_enabled: schedule.is_enabled,
|
|
});
|
|
setIsEditDialogOpen(true);
|
|
};
|
|
|
|
const openCreateDialog = () => {
|
|
setFormData({
|
|
id: '',
|
|
name: '',
|
|
description: '',
|
|
cron_expression: '0 2 * * *',
|
|
sync_type: 'incremental',
|
|
years_back: 2,
|
|
is_enabled: false,
|
|
});
|
|
setIsCreateDialogOpen(true);
|
|
};
|
|
|
|
const handleSubmit = async (isEdit: boolean) => {
|
|
try {
|
|
const url = isEdit
|
|
? `/api/sync/schedules/${formData.id}`
|
|
: '/api/sync/schedules';
|
|
|
|
const method = isEdit ? 'PATCH' : 'POST';
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
setIsEditDialogOpen(false);
|
|
setIsCreateDialogOpen(false);
|
|
fetchSchedules();
|
|
} else {
|
|
alert(`Failed to ${isEdit ? 'update' : 'create'} schedule: ${data.error}`);
|
|
}
|
|
} catch (err) {
|
|
alert(`Failed to ${isEdit ? 'update' : 'create'} schedule`);
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString?: string) => {
|
|
if (!dateString) return 'Never';
|
|
return new Date(dateString).toLocaleString(undefined, { timeZone: tz });
|
|
};
|
|
|
|
const formatNextRun = (dateString?: string) => {
|
|
if (!dateString) return 'Not scheduled';
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diff = date.getTime() - now.getTime();
|
|
|
|
if (diff < 0) return 'Calculating...';
|
|
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
if (hours > 24) {
|
|
const days = Math.floor(hours / 24);
|
|
return `in ${days} day${days > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
if (hours > 0) {
|
|
return `in ${hours}h ${minutes}m`;
|
|
}
|
|
|
|
return `in ${minutes}m`;
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-center text-muted-foreground">Loading schedules...</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Sync Schedules</CardTitle>
|
|
<CardDescription>
|
|
Manage automatic sync schedules
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" onClick={reloadSchedules} disabled={reloading}>
|
|
<RefreshCw className={`h-4 w-4 mr-2 ${reloading ? 'animate-spin' : ''}`} />
|
|
{reloading ? 'Reloading…' : 'Reload from DB'}
|
|
</Button>
|
|
<Button onClick={openCreateDialog}>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
New Schedule
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{error && (
|
|
<Alert variant="destructive" className="mb-4">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{schedules.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No schedules configured. Create one to enable automatic syncs.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{schedules.map((schedule) => (
|
|
<Card key={schedule.config.id} className="border-2">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="font-semibold text-lg">{schedule.config.name}</h3>
|
|
<Badge variant={schedule.config.is_enabled ? 'default' : 'secondary'}>
|
|
{schedule.config.is_enabled ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
{schedule.isRunning && (
|
|
<Badge variant="outline" className="animate-pulse">
|
|
Running
|
|
</Badge>
|
|
)}
|
|
{!schedule.isValid && (
|
|
<Badge variant="destructive">Invalid Cron</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{schedule.config.description}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<div className="text-muted-foreground">Type</div>
|
|
<div className="font-medium capitalize">{schedule.config.sync_type}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Schedule</div>
|
|
<div className="font-mono text-xs">{schedule.config.cron_expression}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Next Run</div>
|
|
<div className="font-medium">
|
|
{schedule.config.is_enabled
|
|
? formatNextRun(schedule.config.next_run)
|
|
: 'Disabled'
|
|
}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground">Last Run</div>
|
|
<div className="flex items-center gap-1">
|
|
{schedule.config.last_status === 'success' && (
|
|
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
|
)}
|
|
{schedule.config.last_status === 'failed' && (
|
|
<XCircle className="h-3 w-3 text-red-600" />
|
|
)}
|
|
<span className="text-xs">{formatDate(schedule.config.last_run)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{schedule.config.last_error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">
|
|
{schedule.config.last_error}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 ml-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => toggleSchedule(schedule.config.id, schedule.config.is_enabled)}
|
|
disabled={schedule.isRunning}
|
|
>
|
|
{schedule.config.is_enabled ? (
|
|
<Pause className="h-4 w-4" />
|
|
) : (
|
|
<Play className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => triggerSchedule(schedule.config.id)}
|
|
disabled={schedule.isRunning}
|
|
>
|
|
<Clock className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => openEditDialog(schedule.config)}
|
|
>
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => deleteSchedule(schedule.config.id)}
|
|
disabled={schedule.isRunning}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Create Dialog */}
|
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Create New Schedule</DialogTitle>
|
|
<DialogDescription>
|
|
Configure a new automatic sync schedule
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="id">Schedule ID</Label>
|
|
<Input
|
|
id="id"
|
|
value={formData.id}
|
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
|
placeholder="e.g., daily-incremental"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="name">Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="e.g., Daily Incremental Sync"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="description">Description</Label>
|
|
<Input
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="Brief description of this schedule"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="sync_type">Sync Type</Label>
|
|
<Select
|
|
value={formData.sync_type}
|
|
onValueChange={(value: 'incremental' | 'full') =>
|
|
setFormData({ ...formData, sync_type: value })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="incremental">Incremental (last 24 hours)</SelectItem>
|
|
<SelectItem value="full">Full (all data)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{formData.sync_type === 'full' && (
|
|
<div>
|
|
<Label htmlFor="years_back">Years Back</Label>
|
|
<Input
|
|
id="years_back"
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={formData.years_back}
|
|
onChange={(e) => setFormData({ ...formData, years_back: parseInt(e.target.value) })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label htmlFor="cron_preset">Schedule Preset</Label>
|
|
<Select
|
|
value={formData.cron_expression}
|
|
onValueChange={(value) => setFormData({ ...formData, cron_expression: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a preset" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{cronPresets.map((preset) => (
|
|
<SelectItem key={preset.value} value={preset.value}>
|
|
{preset.label} ({preset.value})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="cron_expression">Cron Expression</Label>
|
|
<Input
|
|
id="cron_expression"
|
|
value={formData.cron_expression}
|
|
onChange={(e) => setFormData({ ...formData, cron_expression: e.target.value })}
|
|
placeholder="0 2 * * *"
|
|
className="font-mono"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Format: minute hour day month weekday
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="is_enabled"
|
|
checked={formData.is_enabled}
|
|
onCheckedChange={(checked) => setFormData({ ...formData, is_enabled: checked })}
|
|
/>
|
|
<Label htmlFor="is_enabled">Enable schedule immediately</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => handleSubmit(false)}>
|
|
Create Schedule
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Dialog */}
|
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Schedule</DialogTitle>
|
|
<DialogDescription>
|
|
Update schedule configuration
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="edit_name">Name</Label>
|
|
<Input
|
|
id="edit_name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="edit_description">Description</Label>
|
|
<Input
|
|
id="edit_description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="edit_sync_type">Sync Type</Label>
|
|
<Select
|
|
value={formData.sync_type}
|
|
onValueChange={(value: 'incremental' | 'full') =>
|
|
setFormData({ ...formData, sync_type: value })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="incremental">Incremental</SelectItem>
|
|
<SelectItem value="full">Full</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{formData.sync_type === 'full' && (
|
|
<div>
|
|
<Label htmlFor="edit_years_back">Years Back</Label>
|
|
<Input
|
|
id="edit_years_back"
|
|
type="number"
|
|
min="1"
|
|
max="10"
|
|
value={formData.years_back}
|
|
onChange={(e) => setFormData({ ...formData, years_back: parseInt(e.target.value) })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label htmlFor="edit_cron_preset">Schedule Preset</Label>
|
|
<Select
|
|
value={formData.cron_expression}
|
|
onValueChange={(value) => setFormData({ ...formData, cron_expression: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a preset" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{cronPresets.map((preset) => (
|
|
<SelectItem key={preset.value} value={preset.value}>
|
|
{preset.label} ({preset.value})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="edit_cron_expression">Cron Expression</Label>
|
|
<Input
|
|
id="edit_cron_expression"
|
|
value={formData.cron_expression}
|
|
onChange={(e) => setFormData({ ...formData, cron_expression: e.target.value })}
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="edit_is_enabled"
|
|
checked={formData.is_enabled}
|
|
onCheckedChange={(checked) => setFormData({ ...formData, is_enabled: checked })}
|
|
/>
|
|
<Label htmlFor="edit_is_enabled">Enable schedule</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => handleSubmit(true)}>
|
|
Save Changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|