wulf-pulse/app/rmm-mappings/page.tsx

558 lines
18 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import {
Server,
Building2,
CheckCircle,
XCircle,
AlertCircle,
Save,
Trash2,
Search,
RefreshCw,
MapPin,
Globe
} from 'lucide-react';
import { Company } from '@/lib/types/autotask';
// Simple toast implementation
const useToast = () => {
return {
toast: ({ title, description, variant }: { title: string; description: string; variant?: string }) => {
if (variant === 'destructive') {
console.error(`${title}: ${description}`);
alert(`Error: ${description}`);
} else {
console.log(`${title}: ${description}`);
}
}
};
};
interface SiteRow {
id?: number | null;
company_id?: number | null;
company_name?: string | null;
rmm_site_uid: string;
rmm_site_name: string;
is_primary: boolean;
device_count: number;
notes?: string | null;
last_sync_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
created_by?: string | null;
isMapped: boolean;
}
export default function RMMSiteMappingsPage() {
const [sites, setSites] = useState<SiteRow[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
const [filterCompany, setFilterCompany] = useState<string>('all');
const { toast } = useToast();
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
try {
// Fetch site mappings (including unmapped)
const mappingsRes = await fetch('/api/rmm/site-mappings?includeUnmapped=true');
const mappingsData = await mappingsRes.json();
// Fetch all companies
const companiesRes = await fetch('/api/companies');
const companiesData = await companiesRes.json();
const siteRows: SiteRow[] = mappingsData.mappings.map((m: any) => ({
...m,
isMapped: m.company_id !== null && m.company_id > 0,
}));
setSites(siteRows);
setCompanies(companiesData.companies || []);
} catch (error) {
console.error('Error fetching data:', error);
toast({
title: 'Error',
description: 'Failed to load RMM site mappings',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleSyncCompanies = async () => {
setSyncing(true);
try {
const res = await fetch('/api/sync/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entities: ['companies'], triggeredBy: 'manual' }),
});
if (!res.ok) throw new Error('Sync failed');
// Poll briefly then refresh — companies sync is fast
await new Promise(r => setTimeout(r, 4000));
await fetchData();
toast({ title: 'Done', description: 'Companies synced from Autotask' });
} catch (error) {
toast({ title: 'Error', description: 'Failed to sync companies', variant: 'destructive' });
} finally {
setSyncing(false);
}
};
const handleSaveMapping = async (
siteUid: string,
siteName: string,
companyId: number,
isPrimary: boolean = false
) => {
setSaving(siteUid);
try {
const company = companies.find((c) => c.id === companyId);
const response = await fetch('/api/rmm/site-mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rmmSiteUid: siteUid,
rmmSiteName: siteName,
companyId: companyId,
companyName: company?.companyName ?? null,
isPrimary: isPrimary,
}),
});
if (!response.ok) {
throw new Error('Failed to save mapping');
}
toast({
title: 'Success',
description: `Mapped ${siteName} to ${company?.companyName ?? `Company #${companyId}`}`,
});
await fetchData();
} catch (error) {
console.error('Error saving mapping:', error);
toast({
title: 'Error',
description: 'Failed to save mapping',
variant: 'destructive',
});
} finally {
setSaving(null);
}
};
const handleDeleteMapping = async (mappingId: number) => {
try {
const response = await fetch(`/api/rmm/site-mappings?id=${mappingId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete mapping');
}
toast({
title: 'Success',
description: 'Mapping deleted successfully',
});
await fetchData();
} catch (error) {
console.error('Error deleting mapping:', error);
toast({
title: 'Error',
description: 'Failed to delete mapping',
variant: 'destructive',
});
}
};
const filteredSites = sites.filter((site) => {
const matchesSearch =
site.rmm_site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
site.company_name?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter =
filterStatus === 'all' ||
(filterStatus === 'mapped' && site.isMapped) ||
(filterStatus === 'unmapped' && !site.isMapped);
const matchesCompany =
filterCompany === 'all' ||
(filterCompany === 'unmapped' && !site.isMapped) ||
site.company_id?.toString() === filterCompany;
return matchesSearch && matchesFilter && matchesCompany;
});
const stats = {
total: sites.length,
mapped: sites.filter((s) => s.isMapped).length,
unmapped: sites.filter((s) => !s.isMapped).length,
companies: new Set(sites.filter(s => s.company_id).map(s => s.company_id)).size,
};
// Get unique companies with mappings for filter dropdown
const mappedCompanies = companies.filter(c =>
sites.some(s => s.company_id === c.id)
);
return (
<div className="container mx-auto py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-3">
<Server className="w-8 h-8 text-purple-600" />
RMM Site Mappings
</h1>
<p className="text-muted-foreground mt-2">
Map RMM (Datto) sites to Autotask companies for complete device coverage
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSyncCompanies} variant="outline" size="sm" disabled={syncing}>
{syncing ? (
<><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</>
) : (
<><Building2 className="w-4 h-4 mr-2" />Sync Companies</>
)}
</Button>
<Button onClick={fetchData} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Sites
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
Mapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.mapped}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
Unmapped
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.unmapped}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Building2 className="w-4 h-4 text-blue-600" />
Companies
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{stats.companies}</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle>Site Mappings</CardTitle>
<CardDescription>
Map RMM sites to Autotask companies. Companies can have multiple sites for different locations.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search sites or companies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select
value={filterStatus}
onValueChange={(value: any) => setFilterStatus(value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sites</SelectItem>
<SelectItem value="mapped">Mapped Only</SelectItem>
<SelectItem value="unmapped">Unmapped Only</SelectItem>
</SelectContent>
</Select>
<Select
value={filterCompany}
onValueChange={setFilterCompany}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="Filter by company..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Companies</SelectItem>
<SelectItem value="unmapped">Unmapped Sites</SelectItem>
{mappedCompanies.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[300px]">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
RMM Site
</div>
</TableHead>
<TableHead>
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
Autotask Company
</div>
</TableHead>
<TableHead className="w-[100px]">Primary</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSites.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No sites found
</TableCell>
</TableRow>
) : (
filteredSites.map((site) => (
<SiteMappingRow
key={site.rmm_site_uid}
site={site}
companies={companies}
saving={saving === site.rmm_site_uid}
onSave={handleSaveMapping}
onDelete={handleDeleteMapping}
/>
))
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
interface SiteMappingRowProps {
site: SiteRow;
companies: Company[];
saving: boolean;
onSave: (siteUid: string, siteName: string, companyId: number, isPrimary: boolean) => void;
onDelete: (mappingId: number) => void;
}
function SiteMappingRow({
site,
companies,
saving,
onSave,
onDelete,
}: SiteMappingRowProps) {
const [selectedCompanyId, setSelectedCompanyId] = useState<number>(
site.company_id || 0
);
const [isPrimary, setIsPrimary] = useState(site.is_primary);
const [hasChanges, setHasChanges] = useState(false);
const handleCompanyChange = (value: string) => {
const companyId = parseInt(value);
setSelectedCompanyId(companyId);
setHasChanges(companyId !== site.company_id || isPrimary !== site.is_primary);
};
const handlePrimaryChange = (checked: boolean) => {
setIsPrimary(checked);
setHasChanges(selectedCompanyId !== site.company_id || checked !== site.is_primary);
};
const handleSave = () => {
if (selectedCompanyId > 0) {
onSave(site.rmm_site_uid, site.rmm_site_name, selectedCompanyId, isPrimary);
setHasChanges(false);
}
};
return (
<TableRow>
<TableCell>
<div className="flex items-center justify-between">
<div>
<div className="font-medium flex items-center gap-2">
<MapPin className="w-4 h-4 text-muted-foreground" />
{site.rmm_site_name}
</div>
<div className="text-xs text-muted-foreground font-mono">
{site.rmm_site_uid}
</div>
</div>
{site.device_count !== undefined && site.device_count > 0 && (
<Badge variant="secondary" className="ml-2">
<Server className="w-3 h-3 mr-1" />
{site.device_count} {site.device_count === 1 ? 'device' : 'devices'}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Select
value={selectedCompanyId.toString()}
onValueChange={handleCompanyChange}
disabled={saving}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a company...">
{selectedCompanyId == 0
? "No mapping"
: companies.find(c => c.id == selectedCompanyId)?.companyName || "Select a company..."}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No mapping</SelectItem>
{companies
.sort((a, b) => a.companyName.localeCompare(b.companyName))
.map((company) => (
<SelectItem key={company.id} value={company.id.toString()}>
{company.companyName}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Checkbox
checked={isPrimary}
onCheckedChange={handlePrimaryChange}
disabled={saving || selectedCompanyId === 0}
/>
</TableCell>
<TableCell>
{site.isMapped ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">
<CheckCircle className="w-3 h-3 mr-1" />
Mapped
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Unmapped
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{hasChanges && (
<Button
size="sm"
onClick={handleSave}
disabled={saving || selectedCompanyId === 0}
>
{saving ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<>
<Save className="w-3 h-3 mr-1" />
Save
</>
)}
</Button>
)}
{site.isMapped && site.id && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(site.id!)}
disabled={saving}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
}