558 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|