304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
||
import { MapPin, Upload, X, Eye, EyeOff } from './Icons';
|
||
import { Zone } from '../types/zone';
|
||
|
||
interface ZoneMarker {
|
||
id: number;
|
||
x: number; // Percentage from left
|
||
y: number; // Percentage from top
|
||
}
|
||
|
||
interface SitePlanProps {
|
||
zones: Zone[];
|
||
onZoneSelect: (zone: Zone) => void;
|
||
selectedZoneId?: number;
|
||
}
|
||
|
||
const SitePlan: React.FC<SitePlanProps> = ({ zones, onZoneSelect, selectedZoneId }) => {
|
||
const [sitePlanImage, setSitePlanImage] = useState<string | null>(
|
||
localStorage.getItem('sitePlanImage')
|
||
);
|
||
const [zoneMarkers, setZoneMarkers] = useState<ZoneMarker[]>(() => {
|
||
const saved = localStorage.getItem('zoneMarkers');
|
||
return saved ? JSON.parse(saved) : [];
|
||
});
|
||
const [isEditMode, setIsEditMode] = useState(false);
|
||
const [showMarkers, setShowMarkers] = useState(true);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const imageRef = useRef<HTMLImageElement>(null);
|
||
|
||
// Save markers to localStorage whenever they change
|
||
useEffect(() => {
|
||
localStorage.setItem('zoneMarkers', JSON.stringify(zoneMarkers));
|
||
}, [zoneMarkers]);
|
||
|
||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const imageUrl = e.target?.result as string;
|
||
setSitePlanImage(imageUrl);
|
||
localStorage.setItem('sitePlanImage', imageUrl);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
const handleImageClick = (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (!isEditMode || !imageRef.current) return;
|
||
|
||
const rect = imageRef.current.getBoundingClientRect();
|
||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||
|
||
// Find a zone that doesn't have a marker yet
|
||
const unmarkedZone = zones.find(zone =>
|
||
!zoneMarkers.some(marker => marker.id === zone.id)
|
||
);
|
||
|
||
if (unmarkedZone) {
|
||
const newMarker: ZoneMarker = {
|
||
id: unmarkedZone.id,
|
||
x,
|
||
y
|
||
};
|
||
setZoneMarkers(prev => [...prev, newMarker]);
|
||
}
|
||
};
|
||
|
||
const removeMarker = (zoneId: number) => {
|
||
setZoneMarkers(prev => prev.filter(marker => marker.id !== zoneId));
|
||
};
|
||
|
||
const clearSitePlan = () => {
|
||
if (window.confirm('Are you sure you want to remove the site plan and all markers?')) {
|
||
setSitePlanImage(null);
|
||
setZoneMarkers([]);
|
||
localStorage.removeItem('sitePlanImage');
|
||
localStorage.removeItem('zoneMarkers');
|
||
setIsEditMode(false);
|
||
}
|
||
};
|
||
|
||
const getZoneById = (id: number) => zones.find(zone => zone.id === id);
|
||
|
||
const getMarkerColor = (zone: Zone) => {
|
||
switch (zone.status) {
|
||
case 'overdue':
|
||
return 'bg-red-500 border-red-600 shadow-red-200';
|
||
case 'due':
|
||
return 'bg-orange-500 border-orange-600 shadow-orange-200';
|
||
default:
|
||
return 'bg-green-500 border-green-600 shadow-green-200';
|
||
}
|
||
};
|
||
|
||
if (!sitePlanImage) {
|
||
return (
|
||
<div className="bg-white rounded-lg shadow-md p-8">
|
||
<div className="text-center">
|
||
<div className="mb-6">
|
||
<MapPin className="mx-auto h-16 w-16 text-gray-400" />
|
||
<h3 className="mt-4 text-lg font-semibold text-gray-900">План участка</h3>
|
||
<p className="mt-2 text-gray-600">
|
||
Загрузите изображение участка, чтобы визуализировать расположение зон
|
||
</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 mx-auto transition-colors duration-200"
|
||
>
|
||
<Upload className="h-5 w-5" />
|
||
Загрузка плана участка
|
||
</button>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageUpload}
|
||
className="hidden"
|
||
/>
|
||
|
||
<p className="mt-4 text-sm text-gray-500">
|
||
Поддерживаемые форматы: JPG, PNG, GIF (максимально 10MB)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||
{/* Header */}
|
||
<div className="p-4 border-b bg-gray-50">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<MapPin className="h-6 w-6 text-blue-600" />
|
||
<h3 className="text-lg font-semibold text-gray-900">План участка</h3>
|
||
<span className="text-sm text-gray-500">
|
||
({zoneMarkers.length} of {zones.length} zones marked)
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setShowMarkers(!showMarkers)}
|
||
className={`p-2 rounded-md transition-colors duration-200 ${
|
||
showMarkers
|
||
? 'bg-blue-100 text-blue-600'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
title={showMarkers ? 'Hide markers' : 'Show markers'}
|
||
>
|
||
{showMarkers ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setIsEditMode(!isEditMode)}
|
||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200 ${
|
||
isEditMode
|
||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
{isEditMode ? 'Done Editing' : 'Edit Markers'}
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="px-3 py-2 bg-blue-100 text-blue-700 rounded-md text-sm font-medium hover:bg-blue-200 transition-colors duration-200"
|
||
>
|
||
Change Image
|
||
</button>
|
||
|
||
<button
|
||
onClick={clearSitePlan}
|
||
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors duration-200"
|
||
title="Remove site plan"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isEditMode && (
|
||
<div className="mt-3 p-3 bg-blue-50 rounded-md">
|
||
<p className="text-sm text-blue-800">
|
||
<strong>Edit Mode:</strong> Click on the image to place markers for zones.
|
||
Zones without markers: {zones.filter(zone => !zoneMarkers.some(marker => marker.id === zone.id)).map(zone => zone.name).join(', ') || 'None'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Site Plan Image */}
|
||
<div className="relative">
|
||
<img
|
||
ref={imageRef}
|
||
src={sitePlanImage}
|
||
alt="План участка"
|
||
className={`w-full h-auto max-h-[600px] object-contain ${
|
||
isEditMode ? 'cursor-crosshair' : 'cursor-default'
|
||
}`}
|
||
onClick={handleImageClick}
|
||
/>
|
||
|
||
{/* Zone Markers */}
|
||
{showMarkers && zoneMarkers.map(marker => {
|
||
const zone = getZoneById(marker.id);
|
||
if (!zone) return null;
|
||
|
||
return (
|
||
<div
|
||
key={marker.id}
|
||
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
|
||
style={{
|
||
left: `${marker.x}%`,
|
||
top: `${marker.y}%`
|
||
}}
|
||
>
|
||
{/* Marker */}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (!isEditMode) {
|
||
onZoneSelect(zone);
|
||
}
|
||
}}
|
||
className={`w-6 h-6 rounded-full border-2 shadow-lg transition-all duration-200 hover:scale-110 ${
|
||
getMarkerColor(zone)
|
||
} ${
|
||
selectedZoneId === zone.id
|
||
? 'ring-4 ring-blue-300 scale-110'
|
||
: ''
|
||
} ${
|
||
isEditMode ? 'cursor-pointer' : 'cursor-pointer hover:shadow-xl'
|
||
}`}
|
||
>
|
||
<span className="sr-only">{zone.name}</span>
|
||
</button>
|
||
|
||
{/* Tooltip */}
|
||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
|
||
<div className="bg-gray-900 text-white text-xs rounded-md px-2 py-1 whitespace-nowrap">
|
||
<div className="font-medium">{zone.name}</div>
|
||
<div className="text-gray-300">
|
||
{zone.isOverdue ? 'Срок прошел' : zone.isDueToday ? 'Срок - сегодня' : `${zone.daysUntilNext} дней`}
|
||
</div>
|
||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Remove button in edit mode */}
|
||
{isEditMode && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
removeMarker(zone.id);
|
||
}}
|
||
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors duration-200 flex items-center justify-center"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
{showMarkers && zoneMarkers.length > 0 && (
|
||
<div className="p-4 border-t bg-gray-50">
|
||
<h4 className="text-sm font-medium text-gray-900 mb-3">Zone Status Legend</h4>
|
||
<div className="flex flex-wrap gap-4 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full bg-green-500 border border-green-600"></div>
|
||
<span className="text-gray-700">Up to date</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full bg-orange-500 border border-orange-600"></div>
|
||
<span className="text-gray-700">Срок - сегодня</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full bg-red-500 border border-red-600"></div>
|
||
<span className="text-gray-700">Overdue</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageUpload}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SitePlan; |