feat: Add comprehensive roadmap for app development and prioritize features

- Created ROADMAP.md outlining current features, critical missing features, implementation phases, and technology recommendations.
- Added immediate next steps for development focus.

fix: Enhance test data creation scripts for diverse project scenarios

- Implemented create-diverse-test-data.js to generate varied test projects with different statuses and locations.
- Updated create-additional-test-data.js for better project creation consistency.

refactor: Improve project view page with additional navigation and map features

- Added link to view all projects on the map from the project view page.
- Enhanced project location display with status indicators.

feat: Implement project map page with status filtering

- Added status filters for project markers on the map.
- Integrated color-coded markers based on project status.

fix: Update LeafletMap and ProjectMap components for better marker handling

- Created colored marker icons for different project statuses.
- Enhanced ProjectMap to display project status and coordinates more effectively.

test: Add mobile view test HTML for responsive map testing

- Created test-mobile.html to test the map's responsive behavior on mobile devices.
This commit is contained in:
Chop
2025-06-19 20:57:50 +02:00
parent a8f52f6d28
commit aaa08a3504
8 changed files with 969 additions and 174 deletions

View File

@@ -365,7 +365,7 @@ export default async function ProjectViewPage({ params }) {
</svg>
Edit Project
</Button>
</Link>
</Link>{" "}
<Link href="/projects" className="block">
<Button
variant="outline"
@@ -388,6 +388,28 @@ export default async function ProjectViewPage({ params }) {
Back to Projects
</Button>
</Link>
<Link href="/projects/map" className="block">
<Button
variant="outline"
size="sm"
className="w-full justify-start"
>
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
View All on Map
</Button>
</Link>
</CardContent>
</Card>
</div>
@@ -395,16 +417,38 @@ export default async function ProjectViewPage({ params }) {
{/* Project Location Map */}
{project.coordinates && (
<div className="mb-8">
{" "}
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Project Location
</h2>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
Project Location
</h2>
<Link href="/projects/map">
<Button variant="outline" size="sm">
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
View on Full Map
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<ProjectMap
coordinates={project.coordinates}
projectName={project.project_name}
projectStatus={project.project_status}
showLayerControl={true}
mapHeight="h-80"
defaultLayer="Polish Geoportal Orthophoto"

View File

@@ -19,6 +19,59 @@ export default function ProjectsMapPage() {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [mapCenter, setMapCenter] = useState([50.0614, 19.9366]); // Default to Krakow, Poland
const [statusFilters, setStatusFilters] = useState({
registered: true,
in_progress_design: true,
in_progress_construction: true,
fulfilled: true,
});
// Status configuration with colors and labels
const statusConfig = {
registered: {
color: "#6B7280",
label: "Registered",
shortLabel: "Zarejestr.",
},
in_progress_design: {
color: "#3B82F6",
label: "In Progress (Design)",
shortLabel: "W real. (P)",
},
in_progress_construction: {
color: "#F59E0B",
label: "In Progress (Construction)",
shortLabel: "W real. (R)",
},
fulfilled: {
color: "#10B981",
label: "Completed",
shortLabel: "Zakończony",
},
};
// Toggle all status filters
const toggleAllFilters = () => {
const allActive = Object.values(statusFilters).every((value) => value);
const newState = allActive
? Object.keys(statusFilters).reduce(
(acc, key) => ({ ...acc, [key]: false }),
{}
)
: Object.keys(statusFilters).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
setStatusFilters(newState);
};
// Toggle status filter
const toggleStatusFilter = (status) => {
setStatusFilters((prev) => ({
...prev,
[status]: !prev[status],
}));
};
// Hide navigation and ensure full-screen layout
useEffect(() => {
// Hide navigation bar for full-screen experience
@@ -76,20 +129,24 @@ export default function ProjectsMapPage() {
setLoading(false);
});
}, []);
// Convert projects to map markers
// Convert projects to map markers with filtering
const markers = projects
.filter((project) => project.coordinates)
.filter((project) => statusFilters[project.project_status] !== false)
.map((project) => {
const [lat, lng] = project.coordinates
.split(",")
.map((coord) => parseFloat(coord.trim()));
if (isNaN(lat) || isNaN(lng)) {
return null;
}
const statusInfo =
statusConfig[project.project_status] || statusConfig.registered;
return {
position: [lat, lng],
color: statusInfo.color,
popup: (
<div className="min-w-72 max-w-80">
<div className="mb-3 pb-2 border-b border-gray-200">
@@ -135,7 +192,6 @@ export default function ProjectsMapPage() {
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
{project.wp && (
<div>
@@ -149,29 +205,15 @@ export default function ProjectsMapPage() {
{project.plot}
</div>
)}
</div>
</div>{" "}
{project.project_status && (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-700">Status:</span>
<span
className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${
project.project_status === "fulfilled"
? "bg-green-100 text-green-800"
: project.project_status === "registered"
? "bg-gray-100 text-gray-800"
: "bg-blue-100 text-blue-800"
}`}
className="inline-block px-2 py-1 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: statusInfo.color }}
>
{project.project_status === "registered"
? "Zarejestr."
: project.project_status === "in_progress_design"
? "W real. (P)"
: project.project_status === "in_progress_construction"
? "W real. (R)"
: project.project_status === "fulfilled"
? "Zakończony"
: project.project_status}
{statusInfo.shortLabel}
</span>
</div>
)}
@@ -223,15 +265,94 @@ export default function ProjectsMapPage() {
}
return (
<div className="fixed inset-0 bg-gray-50 overflow-hidden">
{" "}
{/* Floating Header with Controls */}
<div className="absolute top-4 left-4 right-4 z-[1000] flex items-center justify-between">
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-gray-900">
Projects Map
</h1>
<div className="text-sm text-gray-600">
{markers.length} of {projects.length} projects with coordinates
<div className="flex gap-3">
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold text-gray-900">
Projects Map
</h1>
<div className="text-sm text-gray-600">
{markers.length} of {projects.length} projects with coordinates
</div>
</div>
</div>
{/* Status Filter Panel */}
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 mr-2">
Filters:
</span>
{/* Toggle All Button */}
<button
onClick={toggleAllFilters}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
title="Toggle all filters"
>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<span className="text-gray-600">
{Object.values(statusFilters).every((v) => v)
? "Hide All"
: "Show All"}
</span>
</button>
{/* Individual Status Filters */}
{Object.entries(statusConfig).map(([status, config]) => {
const isActive = statusFilters[status];
const projectCount = projects.filter(
(p) => p.project_status === status && p.coordinates
).length;
return (
<button
key={status}
onClick={() => toggleStatusFilter(status)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
}`}
title={`Toggle ${config.label} (${projectCount} projects)`}
>
<div
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
isActive ? "border-white shadow-sm" : "border-gray-300"
}`}
style={{
backgroundColor: isActive ? config.color : "#e5e7eb",
}}
></div>
<span
className={`transition-colors duration-200 ${
isActive ? "text-gray-700" : "text-gray-400"
}`}
>
{config.shortLabel}
</span>
<span
className={`ml-1 text-xs transition-colors duration-200 ${
isActive ? "text-gray-500" : "text-gray-300"
}`}
>
({projectCount})
</span>
</button>
);
})}
</div>
</div>
</div>
@@ -278,37 +399,79 @@ export default function ProjectsMapPage() {
</Button>
</Link>
</div>
</div>
</div>{" "}
{/* Stats Panel - Bottom Left */}
{markers.length > 0 && (
<div className="absolute bottom-4 left-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200 max-w-xs">
<div className="text-sm text-gray-600">
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium">
{markers.length} projects shown
{markers.length} of{" "}
{projects.filter((p) => p.coordinates).length} projects shown
</span>
</div>
{projects.length > markers.length && (
<div className="flex items-center gap-2">
{/* Status breakdown */}
<div className="space-y-1 mb-2">
{Object.entries(statusConfig).map(([status, config]) => {
const totalCount = projects.filter(
(p) => p.project_status === status && p.coordinates
).length;
const visibleCount = markers.filter((m) => {
const project = projects.find(
(p) =>
p.coordinates &&
p.coordinates
.split(",")
.map((c) => parseFloat(c.trim()))
.toString() === m.position.toString()
);
return project && project.project_status === status;
}).length;
if (totalCount === 0) return null;
return (
<div key={status} className="flex items-center gap-2 text-xs">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: config.color }}
></div>
<span
className={
statusFilters[status]
? "text-gray-600"
: "text-gray-400"
}
>
{config.shortLabel}:{" "}
{statusFilters[status] ? visibleCount : 0}/{totalCount}
</span>
</div>
);
})}
</div>
{projects.length > projects.filter((p) => p.coordinates).length && (
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
<div className="w-2 h-2 bg-amber-500 rounded-full"></div>
<span className="text-amber-600">
{projects.length - markers.length} missing coordinates
<span className="text-amber-600 text-xs">
{projects.length -
projects.filter((p) => p.coordinates).length}{" "}
missing coordinates
</span>
</div>
)}
</div>
</div>
)}
{/* Help Panel - Bottom Right */}
<div className="absolute bottom-4 right-4 z-[1000] bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-3 py-2 border border-gray-200">
<div className="text-xs text-gray-500">
Click markers for details Use 📚 to switch layers
</div>
</div>
{/* Full Screen Map */}
{markers.length === 0 ? (
<div className="h-full w-full flex items-center justify-center bg-gray-100">

View File

@@ -1,74 +1,107 @@
"use client";
import { MapContainer, TileLayer, Marker, Popup, LayersControl } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { useEffect } from 'react';
import { mapLayers } from './mapLayers';
import {
MapContainer,
TileLayer,
Marker,
Popup,
LayersControl,
} from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { useEffect } from "react";
import { mapLayers } from "./mapLayers";
// Fix for default markers in react-leaflet
const fixLeafletIcons = () => {
if (typeof window !== 'undefined') {
const L = require('leaflet');
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/leaflet/marker-icon-2x.png',
iconUrl: '/leaflet/marker-icon.png',
shadowUrl: '/leaflet/marker-shadow.png',
});
}
if (typeof window !== "undefined") {
const L = require("leaflet");
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
iconUrl: "/leaflet/marker-icon.png",
shadowUrl: "/leaflet/marker-shadow.png",
});
}
};
export default function EnhancedLeafletMap({
center,
zoom = 13,
markers = [],
showLayerControl = true,
defaultLayer = 'OpenStreetMap'
// Create colored marker icons
const createColoredMarkerIcon = (color) => {
if (typeof window !== "undefined") {
const L = require("leaflet");
return new L.Icon({
iconUrl: `data:image/svg+xml;base64,${btoa(`
<svg width="25" height="41" viewBox="0 0 25 41" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 0C5.596 0 0 5.596 0 12.5c0 12.5 12.5 28.5 12.5 28.5S25 25 25 12.5C25 5.596 19.404 0 12.5 0z" fill="${color}" stroke="#ffffff" stroke-width="2"/>
<circle cx="12.5" cy="12.5" r="6" fill="#ffffff"/>
</svg>
`)}`,
shadowUrl: "/leaflet/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
}
return null;
};
export default function EnhancedLeafletMap({
center,
zoom = 13,
markers = [],
showLayerControl = true,
defaultLayer = "OpenStreetMap",
}) {
useEffect(() => {
fixLeafletIcons();
}, []);
useEffect(() => {
fixLeafletIcons();
}, []);
const { BaseLayer } = LayersControl;
const { BaseLayer } = LayersControl;
return (
<MapContainer
center={center}
zoom={zoom}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
{showLayerControl ? (
<LayersControl position="topright">
{mapLayers.base.map((layer, index) => (
<BaseLayer
key={index}
checked={layer.checked || layer.name === defaultLayer}
name={layer.name}
>
<TileLayer
attribution={layer.attribution}
url={layer.url}
maxZoom={layer.maxZoom}
tileSize={layer.tileSize || 256}
/>
</BaseLayer>
))}
</LayersControl>
) : (
// Default layer when no layer control
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
)}
{markers.map((marker, index) => (
<Marker key={index} position={marker.position}>
{marker.popup && <Popup>{marker.popup}</Popup>}
</Marker>
))}
</MapContainer>
);
return (
<MapContainer
center={center}
zoom={zoom}
style={{ height: "100%", width: "100%" }}
scrollWheelZoom={true}
>
{showLayerControl ? (
<LayersControl position="topright">
{mapLayers.base.map((layer, index) => (
<BaseLayer
key={index}
checked={layer.checked || layer.name === defaultLayer}
name={layer.name}
>
<TileLayer
attribution={layer.attribution}
url={layer.url}
maxZoom={layer.maxZoom}
tileSize={layer.tileSize || 256}
/>
</BaseLayer>
))}
</LayersControl>
) : (
// Default layer when no layer control
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
)}{" "}
{markers.map((marker, index) => (
<Marker
key={index}
position={marker.position}
icon={
marker.color ? createColoredMarkerIcon(marker.color) : undefined
}
>
{marker.popup && <Popup>{marker.popup}</Popup>}
</Marker>
))}
</MapContainer>
);
}

View File

@@ -1,65 +1,173 @@
"use client";
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// Dynamically import the map component to avoid SSR issues
const DynamicMap = dynamic(() => import('./LeafletMap'), {
ssr: false,
loading: () => <div className="w-full h-64 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
<span className="text-gray-500">Loading map...</span>
</div>
const DynamicMap = dynamic(() => import("./LeafletMap"), {
ssr: false,
loading: () => (
<div className="w-full h-64 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
<span className="text-gray-500">Loading map...</span>
</div>
),
});
export default function ProjectMap({
coordinates,
projectName,
showLayerControl = true,
mapHeight = 'h-64',
defaultLayer = 'OpenStreetMap'
export default function ProjectMap({
coordinates,
projectName,
projectStatus = "registered",
showLayerControl = true,
mapHeight = "h-64",
defaultLayer = "OpenStreetMap",
}) {
const [coords, setCoords] = useState(null);
const [coords, setCoords] = useState(null);
useEffect(() => {
if (coordinates) {
// Parse coordinates string (e.g., "49.622958,20.629562")
const [lat, lng] = coordinates.split(',').map(coord => parseFloat(coord.trim()));
if (!isNaN(lat) && !isNaN(lng)) {
setCoords({ lat, lng });
}
}
}, [coordinates]);
// Status configuration matching the main map
const statusConfig = {
registered: { color: "#6B7280", label: "Registered" },
in_progress_design: { color: "#3B82F6", label: "In Progress (Design)" },
in_progress_construction: {
color: "#F59E0B",
label: "In Progress (Construction)",
},
fulfilled: { color: "#10B981", label: "Completed" },
};
if (!coords) {
return null;
}
useEffect(() => {
if (coordinates) {
// Parse coordinates string (e.g., "49.622958,20.629562")
const [lat, lng] = coordinates
.split(",")
.map((coord) => parseFloat(coord.trim()));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">Project Location</h3>
{showLayerControl && (
<div className="text-xs text-gray-500">
Use the layer control (📚) to switch map views
</div>
)}
</div>
<div className={`w-full ${mapHeight} rounded-lg overflow-hidden border border-gray-200`}>
<DynamicMap
center={[coords.lat, coords.lng]}
zoom={15}
markers={[{
position: [coords.lat, coords.lng],
popup: projectName || 'Project Location'
}]}
showLayerControl={showLayerControl}
defaultLayer={defaultLayer}
/>
</div>
<p className="text-xs text-gray-500">
Coordinates: {coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}
</p>
</div>
);
if (!isNaN(lat) && !isNaN(lng)) {
setCoords({ lat, lng });
}
}
}, [coordinates]);
if (!coords) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">
Project Location
</h3>
<div className="text-xs text-gray-500">No coordinates available</div>
</div>
<div
className={`w-full ${mapHeight} rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center`}
>
<div className="text-center text-gray-500">
<svg
className="w-8 h-8 mx-auto mb-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
clipRule="evenodd"
/>
</svg>
<p className="text-sm">Map unavailable</p>
<p className="text-xs">No coordinates provided</p>
</div>
</div>
</div>
);
}
const statusInfo = statusConfig[projectStatus] || statusConfig.registered;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-gray-700">
Project Location
</h3>
<div
className="w-3 h-3 rounded-full border border-white shadow-sm"
style={{ backgroundColor: statusInfo.color }}
title={`Status: ${statusInfo.label}`}
></div>
</div>
{showLayerControl && (
<div className="text-xs text-gray-500">
Use the layer control (📚) to switch map views
</div>
)}
</div>
<div
className={`w-full ${mapHeight} rounded-lg overflow-hidden border border-gray-200`}
>
<DynamicMap
center={[coords.lat, coords.lng]}
zoom={15}
markers={[
{
position: [coords.lat, coords.lng],
color: statusInfo.color,
popup: (
<div className="min-w-48">
<div className="mb-2 pb-2 border-b border-gray-200">
<h4 className="font-semibold text-gray-900 mb-1">
{projectName || "Project Location"}
</h4>
<span
className="inline-block px-2 py-1 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: statusInfo.color }}
>
{statusInfo.label}
</span>
</div>
<div className="text-sm text-gray-600">
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="font-mono text-xs">
{coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}
</span>
</div>
</div>
</div>
),
},
]}
showLayerControl={showLayerControl}
defaultLayer={defaultLayer}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
Coordinates: {coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}
</p>
<div className="flex items-center gap-1 text-xs text-gray-500">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusInfo.color }}
></div>
<span>{statusInfo.label}</span>
</div>
</div>
</div>
);
}