feat: database management - auto/USB backup toggles, folder browser, cron jobs

This commit is contained in:
ff
2026-04-11 00:32:39 -04:00
parent b9a7ddb6d7
commit 4025ca45e0
218 changed files with 1995 additions and 1381 deletions

View File

@@ -3,6 +3,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
@@ -16,11 +17,13 @@ import {
import { FolderOpen, Trash2 } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { FolderBrowserModal } from "./folder-browser-modal";
export function BackupDestinationManager() {
const { toast } = useToast();
const [path, setPath] = useState("");
const [deleteId, setDeleteId] = useState<number | null>(null);
const [browserOpen, setBrowserOpen] = useState(false);
// ==============================
// Queries
@@ -36,6 +39,39 @@ export function BackupDestinationManager() {
},
});
const { data: usbSettingData } = useQuery({
queryKey: ["/db/usb-backup-setting"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/usb-backup-setting");
return res.json();
},
});
const usbBackupEnabled = usbSettingData?.usbBackupEnabled ?? false;
const usbToggleMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiRequest("PUT", "/api/database-management/usb-backup-setting", {
usbBackupEnabled: enabled,
});
return res.json();
},
onSuccess: (data) => {
queryClient.setQueryData(["/db/usb-backup-setting"], data);
toast({
title: "Setting Saved",
description: `USB backup ${data.usbBackupEnabled ? "enabled" : "disabled"}.`,
});
},
onError: () => {
toast({
title: "Error",
description: "Failed to update USB backup setting.",
variant: "destructive",
});
},
});
// ==============================
// Mutations
// ==============================
@@ -67,30 +103,10 @@ export function BackupDestinationManager() {
});
// ==============================
// Folder picker (browser limitation)
// Folder browser
// ==============================
const openFolderPicker = async () => {
// @ts-ignore
if (!window.showDirectoryPicker) {
toast({
title: "Not supported",
description: "Your browser does not support folder picking",
variant: "destructive",
});
return;
}
try {
// @ts-ignore
const dirHandle = await window.showDirectoryPicker();
toast({
title: "Folder selected",
description: `Selected folder: ${dirHandle.name}. Please enter the full path manually.`,
});
} catch {
// user cancelled
}
const handleFolderSelect = (selectedPath: string) => {
setPath(selectedPath);
};
// ==============================
@@ -102,17 +118,46 @@ export function BackupDestinationManager() {
<CardTitle>External Backup Destination</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-3">
<Switch
id="usb-backup-toggle"
checked={usbBackupEnabled}
onCheckedChange={(checked) => usbToggleMutation.mutate(checked)}
disabled={usbToggleMutation.isPending}
/>
<label
htmlFor="usb-backup-toggle"
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
>
USB Backup
</label>
<span className="text-xs text-gray-400">
(daily at 9 PM saves to the &quot;USB Backup&quot; folder on your drive)
</span>
</div>
<p className="text-sm text-gray-500">
Enter the root path of your USB drive below. The app will automatically back up to the{" "}
<span className="font-medium text-gray-700">USB Backup</span> folder inside it every night at 9 PM when the toggle is on.
</p>
<div className="flex gap-2">
<Input
placeholder="/media/usb-drive or D:\\Backups"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<Button variant="outline" onClick={openFolderPicker}>
<Button variant="outline" onClick={() => setBrowserOpen(true)} title="Browse folders">
<FolderOpen className="h-4 w-4" />
</Button>
</div>
<FolderBrowserModal
open={browserOpen}
onClose={() => setBrowserOpen(false)}
onSelect={handleFolderSelect}
/>
<Button
onClick={() => saveMutation.mutate()}
disabled={!path || saveMutation.isPending}

View File

@@ -0,0 +1,130 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import { Folder, FolderOpen, ChevronLeft, Loader2 } from "lucide-react";
interface BrowseResult {
current: string;
parent: string | null;
dirs: { name: string; path: string }[];
}
interface FolderBrowserModalProps {
open: boolean;
onClose: () => void;
onSelect: (path: string) => void;
}
export function FolderBrowserModal({ open, onClose, onSelect }: FolderBrowserModalProps) {
const [browsePath, setBrowsePath] = useState("/");
const [selected, setSelected] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery<BrowseResult>({
queryKey: ["/db/browse", browsePath],
queryFn: async () => {
const res = await apiRequest(
"GET",
`/api/database-management/browse?path=${encodeURIComponent(browsePath)}`
);
if (!res.ok) throw new Error((await res.json()).error);
return res.json();
},
enabled: open,
});
const handleNavigate = (path: string) => {
setSelected(null);
setBrowsePath(path);
};
const handleConfirm = () => {
if (selected) {
onSelect(selected);
onClose();
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Select Folder</DialogTitle>
</DialogHeader>
{/* Current path breadcrumb */}
<div className="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 font-mono break-all">
{data?.current ?? browsePath}
</div>
{/* Back button */}
{data?.parent && (
<Button
variant="ghost"
size="sm"
className="justify-start text-gray-600"
onClick={() => handleNavigate(data.parent!)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
{/* Directory list */}
<div className="border rounded-md overflow-y-auto max-h-64">
{isLoading && (
<div className="flex items-center justify-center py-8 text-gray-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading...
</div>
)}
{isError && (
<p className="text-sm text-red-500 p-4">Cannot read this directory.</p>
)}
{!isLoading && !isError && data?.dirs.length === 0 && (
<p className="text-sm text-gray-400 p-4">No sub-folders here.</p>
)}
{!isLoading &&
!isError &&
data?.dirs.map((dir) => (
<button
key={dir.path}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-gray-50 transition-colors ${
selected === dir.path ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-700"
}`}
onClick={() => setSelected(dir.path)}
onDoubleClick={() => handleNavigate(dir.path)}
>
{selected === dir.path ? (
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
) : (
<Folder className="h-4 w-4 shrink-0 text-yellow-500" />
)}
{dir.name}
</button>
))}
</div>
<p className="text-xs text-gray-400">
Single-click to select · Double-click to open
</p>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selected}>
Select Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -77,7 +77,7 @@ export function Sidebar() {
icon: <Cloud className="h-5 w-5" />,
},
{
name: "Backup Database",
name: "Database Management",
path: "/database-management",
icon: <Database className="h-5 w-5" />,
},

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import {
Database,
@@ -75,6 +76,40 @@ export default function DatabaseManagementPage() {
}
}
// ----- Auto backup setting query -----
const { data: autoBackupData } = useQuery({
queryKey: ["/db/auto-backup-setting"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/database-management/auto-backup-setting");
return res.json();
},
});
const autoBackupEnabled = autoBackupData?.autoBackupEnabled ?? true;
const autoBackupMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await apiRequest("PUT", "/api/database-management/auto-backup-setting", {
autoBackupEnabled: enabled,
});
return res.json();
},
onSuccess: (data) => {
queryClient.setQueryData(["/db/auto-backup-setting"], data);
toast({
title: "Setting Saved",
description: `Automatic backup ${data.autoBackupEnabled ? "enabled" : "disabled"}.`,
});
},
onError: () => {
toast({
title: "Error",
description: "Failed to update automatic backup setting.",
variant: "destructive",
});
},
});
// ----- Backup mutation -----
const backupMutation = useMutation({
mutationFn: async () => {
@@ -178,6 +213,22 @@ export default function DatabaseManagementPage() {
including patients, appointments, claims, and all related data.
</p>
<div className="flex items-center space-x-3">
<Switch
id="auto-backup-toggle"
checked={autoBackupEnabled}
onCheckedChange={(checked) => autoBackupMutation.mutate(checked)}
disabled={autoBackupMutation.isPending}
/>
<label
htmlFor="auto-backup-toggle"
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
>
Automatic Backup
</label>
<span className="text-xs text-gray-400">(daily at 8 PM to server backup folder)</span>
</div>
<div className="flex items-center space-x-4">
<Button
onClick={() => backupMutation.mutate()}