@mytec: iteration3 done for testing

This commit is contained in:
2026-01-30 11:37:38 +02:00
parent d3fb1801a8
commit 8b11163a79
8 changed files with 498 additions and 2 deletions

View 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>
);
}

View 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' : ''} &middot;{' '}
{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"
>
&times;
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}