@mytec: iteration3 done for testing
This commit is contained in:
92
frontend/src/components/panels/ExportPanel.tsx
Normal file
92
frontend/src/components/panels/ExportPanel.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
|
||||
export default function ExportPanel() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const points = result?.points ?? [];
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportCSV = () => {
|
||||
if (points.length === 0) {
|
||||
addToast('No coverage data to export. Run a calculation first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const csv = [
|
||||
'lat,lon,rsrp,site_id',
|
||||
...points.map((p) => `${p.lat},${p.lon},${p.rsrp},${p.siteId}`),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
downloadBlob(blob, `rfcp-coverage-${Date.now()}.csv`);
|
||||
addToast(`Exported ${points.length.toLocaleString()} points as CSV`, 'success');
|
||||
};
|
||||
|
||||
const exportGeoJSON = () => {
|
||||
if (points.length === 0) {
|
||||
addToast('No coverage data to export. Run a calculation first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const geojson = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: points.map((p) => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [p.lon, p.lat],
|
||||
},
|
||||
properties: {
|
||||
rsrp: p.rsrp,
|
||||
siteId: p.siteId,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(geojson, null, 2)], {
|
||||
type: 'application/geo+json;charset=utf-8',
|
||||
});
|
||||
downloadBlob(blob, `rfcp-coverage-${Date.now()}.geojson`);
|
||||
addToast(`Exported ${points.length.toLocaleString()} points as GeoJSON`, 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
Export Coverage
|
||||
</h3>
|
||||
{points.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{points.length.toLocaleString()} coverage points available
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={exportCSV} size="sm" variant="secondary">
|
||||
CSV
|
||||
</Button>
|
||||
<Button onClick={exportGeoJSON} size="sm" variant="secondary">
|
||||
GeoJSON
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 dark:text-dark-muted">
|
||||
No coverage data. Calculate coverage first to enable export.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/components/panels/ProjectPanel.tsx
Normal file
165
frontend/src/components/panels/ProjectPanel.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProjectsStore } from '@/store/projects.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
|
||||
export default function ProjectPanel() {
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const currentProjectId = useProjectsStore((s) => s.currentProjectId);
|
||||
const loadProjects = useProjectsStore((s) => s.loadProjects);
|
||||
const saveProject = useProjectsStore((s) => s.saveProject);
|
||||
const loadProject = useProjectsStore((s) => s.loadProject);
|
||||
const deleteProject = useProjectsStore((s) => s.deleteProject);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = projectName.trim();
|
||||
if (!name) {
|
||||
addToast('Please enter a project name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveProject(name);
|
||||
addToast(`Project "${name}" saved`, 'success');
|
||||
setProjectName('');
|
||||
setShowSaveForm(false);
|
||||
} catch (err) {
|
||||
console.error('Save project error:', err);
|
||||
addToast('Failed to save project', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoad = async (id: string, name: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const project = await loadProject(id);
|
||||
if (project) {
|
||||
addToast(`Loaded project "${name}" (${project.sites.length} sites)`, 'success');
|
||||
} else {
|
||||
addToast('Project not found', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load project error:', err);
|
||||
addToast('Failed to load project', 'error');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
addToast(`Project "${name}" deleted`, 'info');
|
||||
} catch (err) {
|
||||
console.error('Delete project error:', err);
|
||||
addToast('Failed to delete project', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
Projects
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showSaveForm ? 'secondary' : 'primary'}
|
||||
onClick={() => setShowSaveForm(!showSaveForm)}
|
||||
>
|
||||
{showSaveForm ? 'Cancel' : 'Save Current'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save form */}
|
||||
{showSaveForm && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
placeholder="Project name"
|
||||
className="w-full px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={handleSave} disabled={!projectName.trim()}>
|
||||
Save Project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects list */}
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 dark:text-dark-muted">
|
||||
No saved projects yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={`flex items-center justify-between gap-2 px-2.5 py-2 rounded-md transition-colors
|
||||
${
|
||||
currentProjectId === project.id
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-50 dark:bg-dark-border/30 hover:bg-gray-100 dark:hover:bg-dark-border/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
|
||||
{project.name}
|
||||
{currentProjectId === project.id && (
|
||||
<span className="ml-1.5 text-xs text-blue-500 dark:text-blue-400">
|
||||
(active)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{project.sites.length} site{project.sites.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDate(project.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleLoad(project.id, project.name)}
|
||||
disabled={isLoading}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded
|
||||
min-w-[40px] min-h-[32px] flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(project.id, project.name)}
|
||||
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded
|
||||
min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user