Compare commits
11 Commits
d4f16d344d
...
ui-fix
| Author | SHA1 | Date | |
|---|---|---|---|
| c13a991778 | |||
| cb815177a1 | |||
| 7335d17900 | |||
| b358f5d7b7 | |||
| 84f63c37ce | |||
| d49bea8f15 | |||
| 3d2065d8fb | |||
| 3a382a28c0 | |||
| 6dfb0224ab | |||
| daea67fddb | |||
| e993d02a1b |
222
docs/LAYER_NOTES.md
Normal file
222
docs/LAYER_NOTES.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Map Layers - Implementation Notes & Documentation
|
||||||
|
|
||||||
|
Personal notes and official documentation references for each map layer implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base Layers
|
||||||
|
|
||||||
|
### OpenStreetMap
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**URL:** `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- OSM - up to zoom 20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🇵🇱 Polish Orthophoto (Standard Resolution)
|
||||||
|
**Status:** ✅ Working (minor issue)
|
||||||
|
**Type:** WMTS
|
||||||
|
**Service:** Polish Geoportal PZGIK/ORTO
|
||||||
|
**URL:** `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish Ortophoto stantard - ok up to zoom 19
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities WMTS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution?Service=WMTS&Request=GetCapabilities`
|
||||||
|
- GetCapabilities WMS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🇵🇱 Polish Orthophoto (High Resolution)
|
||||||
|
**Status:** Not Working
|
||||||
|
**Type:** WMTS
|
||||||
|
**Service:** Polish Geoportal PZGIK/ORTO
|
||||||
|
**URL:** `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish Ortophoto Hirez - doesnt load at all
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities WMTS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution?Service=WMTS&Request=GetCapabilities`
|
||||||
|
- GetCapabilities WMS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/HighResolution?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🌍 Google Satellite
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**URL:** `http://mt1.google.com/vt/lyrs=s&hl=pl&x={x}&y={y}&z={z}`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Google sat - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🌍 Google Hybrid
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**URL:** `http://mt1.google.com/vt/lyrs=y&hl=pl&x={x}&y={y}&z={z}`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Google hyb - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Satellite (Esri)
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**Service:** ArcGIS Online World Imagery
|
||||||
|
**URL:** `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Esri - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Topographic
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**Service:** CARTO Voyager
|
||||||
|
**URL:** `https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Topo - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overlay Layers - Polish Government
|
||||||
|
|
||||||
|
### 📋 Polish Cadastral Data (Działki) - Server 1
|
||||||
|
**Status:** - VERY SLOW
|
||||||
|
**Type:** WMS 1.3.0
|
||||||
|
**Service:** GUGiK - Krajowa Integracja Ewidencji Gruntów
|
||||||
|
**URL:** `https://integracja01.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow`
|
||||||
|
**Opacity:** 0.8
|
||||||
|
|
||||||
|
**Layers:** `powiaty,powiaty_obreby,zsin,obreby,dzialki,geoportal,numery_dzialek,budynki`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish cadastral data server 1 - very slow, works only up to zoom 18
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities: `https://integracja01.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📋 Polish Cadastral Data (Działki) - Server 2
|
||||||
|
**Status:** - VERY SLOW
|
||||||
|
**Type:** WMS 1.3.0
|
||||||
|
**Service:** GUGiK - Krajowa Integracja Ewidencji Gruntów
|
||||||
|
**URL:** `https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow`
|
||||||
|
**Opacity:** 0.8
|
||||||
|
|
||||||
|
**Layers:** `dzialki,obreby,numery_dzialek,budynki,kontury,uzytki`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish cadastral data server 2 - very slow, works only up to zoom 18 (this is the current official one afaik)
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities: `https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🏗️ Polish Spatial Planning
|
||||||
|
**Status:** Not Working
|
||||||
|
**Type:** WMS 1.3.0
|
||||||
|
**Service:** Geoportal - Krajowa Integracja Miejscowych Planów Zagospodarowania Przestrzennego
|
||||||
|
**URL:** `https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego`
|
||||||
|
**Opacity:** 0.7
|
||||||
|
|
||||||
|
**Layers:** `raster,wektor-str,wektor-lzb,wektor-pow,wektor-lin,wektor-pkt,granice`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- doesnt seem to work, or is extremely slow
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities: `https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego?Service=WMS&Request=GetCapabilities`
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overlay Layers - Utility
|
||||||
|
|
||||||
|
### 🌍 Google Roads
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles Overlay
|
||||||
|
**URL:** `http://mt1.google.com/vt/lyrs=h&hl=pl&x={x}&y={y}&z={z}`
|
||||||
|
**Opacity:** 1.0
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Ok
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
### Coordinate Reference Systems
|
||||||
|
- **EPSG:3857** - Web Mercator (current implementation for all layers)
|
||||||
|
- **EPSG:2180** - Polish national projection (PUWG 1992)
|
||||||
|
- Some Polish services support this natively
|
||||||
|
- Would require proj4leaflet for proper support
|
||||||
|
|
||||||
|
### WMS Version Differences
|
||||||
|
- **WMS 1.1.1:** Uses `SRS` parameter for coordinate system
|
||||||
|
- **WMS 1.3.0:** Uses `CRS` parameter for coordinate system
|
||||||
|
- Current implementation auto-detects and handles both
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
-
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- [ ] Dynamic opacity controls
|
||||||
|
- [ ] Layer legends/metadata panels
|
||||||
|
- [ ] EPSG:2180 support via proj4leaflet
|
||||||
|
- [ ] Layer error handling with fallbacks
|
||||||
|
- [ ] Mobile-optimized layer control
|
||||||
|
|
||||||
|
### Ideas
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References & Resources
|
||||||
|
|
||||||
|
### Polish Geoportal
|
||||||
|
- Main portal: https://www.geoportal.gov.pl/
|
||||||
|
- Service catalog: https://www.geoportal.gov.pl/uslugi
|
||||||
|
-
|
||||||
|
|
||||||
|
### GUGiK (Główny Urząd Geodezji i Kartografii)
|
||||||
|
- Main website: https://www.gugik.gov.pl/
|
||||||
|
-
|
||||||
|
|
||||||
|
### LP-Portal
|
||||||
|
- Website: https://lp-portal.pl/
|
||||||
|
-
|
||||||
|
|
||||||
|
### Leaflet Documentation
|
||||||
|
- WMS Layers: https://leafletjs.com/reference.html#tilelayer-wms
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-01-16
|
||||||
|
- Created LAYER_NOTES.md for documentation and personal notes
|
||||||
|
- Initial structure with all current layers documented
|
||||||
788
docs/MAP_SYSTEM_UPDATE_PLAN.md
Normal file
788
docs/MAP_SYSTEM_UPDATE_PLAN.md
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
# Map System - Comprehensive Update & Fix Plan
|
||||||
|
|
||||||
|
Based on layer testing results from LAYER_NOTES.md
|
||||||
|
Date: January 16, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Current Status:**
|
||||||
|
- ✅ **6/7 base layers working** (1 broken: Polish Orthophoto High Resolution)
|
||||||
|
- ⚠️ **2/9 overlay layers working** (2 very slow, 5 not tested, 2 broken)
|
||||||
|
- 🎯 **Priority:** Fix broken layers, optimize slow WMS services, remove LP-Portal layers
|
||||||
|
|
||||||
|
**Key Issues Identified:**
|
||||||
|
1. Polish Orthophoto High Resolution completely broken
|
||||||
|
2. Polish Cadastral Data servers extremely slow (both servers)
|
||||||
|
3. Polish Spatial Planning layer not working
|
||||||
|
4. LP-Portal layers not tested/documented - likely region-specific
|
||||||
|
5. No caching or performance optimization for WMS layers
|
||||||
|
6. Missing zoom level restrictions causing tile request failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Critical Fixes (Week 1)
|
||||||
|
|
||||||
|
### 1.1 Fix Polish Orthophoto High Resolution
|
||||||
|
**Issue:** Doesn't load at all
|
||||||
|
**Root Cause:** Likely incorrect WMTS parameters or service endpoint change
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Test GetCapabilities response:
|
||||||
|
```bash
|
||||||
|
curl "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution?Service=WMTS&Request=GetCapabilities"
|
||||||
|
```
|
||||||
|
2. Compare with Standard Resolution working configuration
|
||||||
|
3. Check for:
|
||||||
|
- Different tile matrix sets
|
||||||
|
- Different available zoom levels
|
||||||
|
- Format differences (jpeg vs png)
|
||||||
|
- Authentication requirements
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Test if service requires different parameters
|
||||||
|
{
|
||||||
|
name: "🇵🇱 Polish Orthophoto (High Resolution)",
|
||||||
|
url: "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg",
|
||||||
|
maxZoom: 19, // May need adjustment based on GetCapabilities
|
||||||
|
minZoom: 15, // High-res often only available at higher zoom
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Layer loads tiles without errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Fix Polish Spatial Planning Layer
|
||||||
|
**Issue:** Doesn't work or extremely slow
|
||||||
|
**Service:** `https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego`
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Verify service is still active via GetCapabilities
|
||||||
|
2. Test with simplified layer list (may be requesting too many layers)
|
||||||
|
3. Check if service moved to new endpoint
|
||||||
|
4. Test with different WMS versions (1.1.1 vs 1.3.0)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Simplified layer request
|
||||||
|
{
|
||||||
|
name: "🏗️ Polish Spatial Planning",
|
||||||
|
type: "wms",
|
||||||
|
url: "https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego",
|
||||||
|
params: {
|
||||||
|
layers: "raster", // Start with just raster
|
||||||
|
format: "image/png",
|
||||||
|
transparent: true,
|
||||||
|
version: "1.3.0",
|
||||||
|
},
|
||||||
|
maxZoom: 18, // Limit to prevent overload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Layer loads or is removed if permanently unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Optimize Polish Cadastral Data Performance
|
||||||
|
**Issue:** Both servers very slow, currently only work up to zoom 18
|
||||||
|
**Impact:** Core functionality for land surveying projects
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Implement tile loading indicators
|
||||||
|
2. Add request debouncing
|
||||||
|
3. Consider caching strategy
|
||||||
|
4. Test alternate GUGiK services
|
||||||
|
5. (Future) Enable zoom 19-20 with proper optimization
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Update both cadastral servers with performance optimizations
|
||||||
|
{
|
||||||
|
name: "📋 Polish Cadastral Data (Działki) - Server 2",
|
||||||
|
type: "wms",
|
||||||
|
url: "https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow",
|
||||||
|
params: {
|
||||||
|
layers: "dzialki,numery_dzialek,budynki", // Simplified - remove slow layers
|
||||||
|
format: "image/png",
|
||||||
|
transparent: true,
|
||||||
|
version: "1.3.0",
|
||||||
|
},
|
||||||
|
maxZoom: 18, // Current working limit (TODO: extend to 20 with optimization)
|
||||||
|
minZoom: 13, // Don't load at far zoom levels
|
||||||
|
opacity: 0.8,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additional Optimizations:**
|
||||||
|
- Add WMS tiled parameter: `tiled: true`
|
||||||
|
- Reduce requested layers to essential only
|
||||||
|
- Implement progressive loading (load parcels first, then details)
|
||||||
|
|
||||||
|
**Success Criteria:** Acceptable load times (<3s) at zoom 15-18, prepare for zoom 20 support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Layer Management (Week 2)
|
||||||
|
|
||||||
|
### 2.1 Remove/Document LP-Portal Layers
|
||||||
|
**Issue:** 4 LP-Portal layers never tested, likely region-specific (Nowy Sącz)
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Test if LP-Portal layers work outside Nowy Sącz region
|
||||||
|
2. If region-specific: Move to separate optional configuration
|
||||||
|
3. Document geographic limitations
|
||||||
|
4. Consider conditional loading based on map center coordinates
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
**Option A - Remove Entirely:**
|
||||||
|
```javascript
|
||||||
|
// Remove from mapLayers.overlays array:
|
||||||
|
// - LP-Portal Roads
|
||||||
|
// - LP-Portal Street Names
|
||||||
|
// - LP-Portal Parcels
|
||||||
|
// - LP-Portal Survey Markers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B - Conditional Loading:**
|
||||||
|
```javascript
|
||||||
|
// Only show LP-Portal layers when in Nowy Sącz region
|
||||||
|
const isInNowySecz = (lat, lng) => {
|
||||||
|
return lat >= 49.5 && lat <= 49.7 && lng >= 20.5 && lng <= 20.8;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter overlays based on location
|
||||||
|
const availableOverlays = mapLayers.overlays.filter(layer => {
|
||||||
|
if (layer.name.includes('LP-Portal')) {
|
||||||
|
return isInNowySecz(mapCenter[0], mapCenter[1]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation:** Option B - Keep but make conditional
|
||||||
|
|
||||||
|
**Success Criteria:** Only relevant layers shown to users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Reorganize Layer Categories
|
||||||
|
**Current:** Mixed organization, no clear hierarchy
|
||||||
|
**Proposed:** Clear categorization with user-friendly names
|
||||||
|
|
||||||
|
**New Structure:**
|
||||||
|
```javascript
|
||||||
|
export const mapLayers = {
|
||||||
|
base: [
|
||||||
|
// International Base Maps
|
||||||
|
{ name: "OpenStreetMap", ... },
|
||||||
|
{ name: "🌍 Google Satellite", ... },
|
||||||
|
{ name: "🌍 Google Hybrid", ... },
|
||||||
|
{ name: "🗺️ Esri Satellite", ... },
|
||||||
|
{ name: "🗺️ Topographic (CARTO)", ... },
|
||||||
|
|
||||||
|
// Polish Aerial Imagery
|
||||||
|
{ name: "🇵🇱 Orthophoto (Standard)", ... },
|
||||||
|
{ name: "🇵🇱 Orthophoto (High-Res)", ... }, // After fix
|
||||||
|
],
|
||||||
|
|
||||||
|
overlays: {
|
||||||
|
government: [
|
||||||
|
{ name: "📋 Cadastral Data (Official)", ... },
|
||||||
|
{ name: "🏗️ Spatial Planning", ... },
|
||||||
|
],
|
||||||
|
utility: [
|
||||||
|
{ name: "🛣️ Google Roads", ... },
|
||||||
|
],
|
||||||
|
regional: [ // Only shown in specific regions
|
||||||
|
{ name: "🏘️ LP-Portal Roads", region: "nowysacz", ... },
|
||||||
|
{ name: "🏘️ LP-Portal Street Names", region: "nowysacz", ... },
|
||||||
|
{ name: "🏘️ LP-Portal Parcels", region: "nowysacz", ... },
|
||||||
|
{ name: "🏘️ LP-Portal Survey Markers", region: "nowysacz", ... },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Clearer user experience, better organization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Performance Optimization (Week 3)
|
||||||
|
|
||||||
|
### 3.1 Implement Tile Caching
|
||||||
|
**Goal:** Reduce redundant WMS requests
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Add to WMSLayer component
|
||||||
|
const WMSLayer = ({ url, params, opacity, attribution }) => {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wmsOptions = {
|
||||||
|
// ... existing options ...
|
||||||
|
// Add caching headers
|
||||||
|
crossOrigin: true,
|
||||||
|
updateWhenIdle: true,
|
||||||
|
updateWhenZooming: false,
|
||||||
|
keepBuffer: 2, // Keep tiles loaded from 2 screens away
|
||||||
|
};
|
||||||
|
|
||||||
|
const wmsLayer = L.tileLayer.wms(url, wmsOptions);
|
||||||
|
wmsLayer.addTo(map);
|
||||||
|
|
||||||
|
return () => map.removeLayer(wmsLayer);
|
||||||
|
}, [map, url, params, opacity, attribution]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** 30% reduction in WMS requests on pan/zoom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Add Loading States
|
||||||
|
**Goal:** User feedback during slow WMS loads
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// New LoadingOverlay component
|
||||||
|
function MapLoadingOverlay({ isLoading }) {
|
||||||
|
if (!isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-16 right-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg px-4 py-2 z-[1000]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent"></div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Loading map layers...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track loading state in LeafletMap
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.on('layeradd', () => setIsLoading(true));
|
||||||
|
map.on('load', () => setIsLoading(false));
|
||||||
|
}, [map]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Visual feedback for all layer loads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Implement Progressive Layer Loading
|
||||||
|
**Goal:** Load essential layers first, details later
|
||||||
|
|
||||||
|
**Strategy:**
|
||||||
|
1. **Zoom 1-12:** Base map only
|
||||||
|
2. **Zoom 13-15:** + Basic cadastral boundaries
|
||||||
|
3. **Zoom 16-18:** + Parcel numbers, buildings
|
||||||
|
4. **Zoom 19-20:** + Survey markers, detailed overlays
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Auto-enable/disable overlays based on zoom
|
||||||
|
function ZoomBasedOverlayManager() {
|
||||||
|
const map = useMap();
|
||||||
|
const [currentZoom, setCurrentZoom] = useState(map.getZoom());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.on('zoomend', () => {
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
setCurrentZoom(zoom);
|
||||||
|
|
||||||
|
// Auto-manage overlay visibility
|
||||||
|
if (zoom < 13) {
|
||||||
|
// Disable all overlays at far zoom
|
||||||
|
disableAllOverlays();
|
||||||
|
} else if (zoom >= 16) {
|
||||||
|
// Enable cadastral at close zoom
|
||||||
|
enableCadastralLayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [map]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Smooth performance at all zoom levels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Enhanced Features (Week 4)
|
||||||
|
|
||||||
|
### 4.1 Dynamic Opacity Controls
|
||||||
|
**Goal:** User-adjustable layer transparency
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// LayerOpacityControl component
|
||||||
|
function LayerOpacityControl({ layerName, currentOpacity, onOpacityChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<label className="text-xs text-gray-600 dark:text-gray-400 w-24 truncate">
|
||||||
|
{layerName}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={currentOpacity * 100}
|
||||||
|
onChange={(e) => onOpacityChange(e.target.value / 100)}
|
||||||
|
className="flex-1 h-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500 w-8 text-right">
|
||||||
|
{Math.round(currentOpacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to layer control
|
||||||
|
<LayersControl position="topright">
|
||||||
|
<Overlay name="Cadastral Data">
|
||||||
|
<WMSLayer {...layer} opacity={cadastralOpacity} />
|
||||||
|
</Overlay>
|
||||||
|
<LayerOpacityControl
|
||||||
|
layerName="Cadastral"
|
||||||
|
currentOpacity={cadastralOpacity}
|
||||||
|
onOpacityChange={setCadastralOpacity}
|
||||||
|
/>
|
||||||
|
</LayersControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** User can adjust opacity for all overlay layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Layer Information Panels
|
||||||
|
**Goal:** Show layer metadata, legends, data source info
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Layer metadata structure
|
||||||
|
const layerMetadata = {
|
||||||
|
"Polish Cadastral Data": {
|
||||||
|
title: "Polish Cadastral Data (Działki)",
|
||||||
|
description: "Official land parcel boundaries and property information from GUGiK",
|
||||||
|
dataSource: "Główny Urząd Geodezji i Kartografii",
|
||||||
|
updateFrequency: "Daily",
|
||||||
|
coverage: "Poland nationwide",
|
||||||
|
legend: "/images/legends/cadastral.png",
|
||||||
|
moreInfo: "https://www.gugik.gov.pl/",
|
||||||
|
usageNotes: "Best viewed at zoom levels 15-18. Performance may vary.",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// InfoButton component next to layer name
|
||||||
|
<LayersControl>
|
||||||
|
<Overlay name={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
📋 Cadastral Data
|
||||||
|
<button onClick={() => showLayerInfo('Polish Cadastral Data')} className="...">
|
||||||
|
ℹ️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
...
|
||||||
|
</Overlay>
|
||||||
|
</LayersControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Users understand what each layer shows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Error Handling & Fallbacks
|
||||||
|
**Goal:** Graceful degradation when layers fail
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// WMSLayer with error handling
|
||||||
|
function WMSLayer({ url, params, opacity, attribution, fallbackLayer }) {
|
||||||
|
const map = useMap();
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wmsLayer = L.tileLayer.wms(url, wmsOptions);
|
||||||
|
|
||||||
|
// Track tile errors
|
||||||
|
wmsLayer.on('tileerror', (error) => {
|
||||||
|
console.error(`WMS tile error for ${params.layers}:`, error);
|
||||||
|
setHasError(true);
|
||||||
|
|
||||||
|
// Show user notification
|
||||||
|
showNotification({
|
||||||
|
type: 'warning',
|
||||||
|
message: `Layer "${params.layers}" is experiencing issues`,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wmsLayer.addTo(map);
|
||||||
|
|
||||||
|
// If too many errors, switch to fallback
|
||||||
|
if (hasError && fallbackLayer) {
|
||||||
|
setTimeout(() => {
|
||||||
|
map.removeLayer(wmsLayer);
|
||||||
|
fallbackLayer.addTo(map);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => map.removeLayer(wmsLayer);
|
||||||
|
}, [map, url, params, hasError]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** No silent failures, users informed of issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Code Quality (Week 5)
|
||||||
|
|
||||||
|
### 5.1 Consolidate Map Components
|
||||||
|
**Current Issue:** Multiple similar map components (ComprehensivePolishMap, ImprovedPolishOrthophotoMap, etc.)
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Audit all map components:
|
||||||
|
- LeafletMap.js (main)
|
||||||
|
- ProjectMap.js (wrapper)
|
||||||
|
- ComprehensivePolishMap.js
|
||||||
|
- ImprovedPolishOrthophotoMap.js
|
||||||
|
- PolishOrthophotoMap.js
|
||||||
|
- AdvancedPolishOrthophotoMap.js
|
||||||
|
- TransparencyDemoMap.js
|
||||||
|
- CustomWMTSMap.js
|
||||||
|
- EnhancedLeafletMap.js
|
||||||
|
|
||||||
|
2. Determine which are:
|
||||||
|
- Production (keep)
|
||||||
|
- Deprecated (remove)
|
||||||
|
- Experimental (move to /docs/examples)
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
```
|
||||||
|
KEEP:
|
||||||
|
- LeafletMap.js (main production component)
|
||||||
|
- ProjectMap.js (SSR wrapper)
|
||||||
|
|
||||||
|
MOVE TO /docs/examples:
|
||||||
|
- TransparencyDemoMap.js (example of opacity controls)
|
||||||
|
- CustomWMTSMap.js (example of custom WMTS)
|
||||||
|
|
||||||
|
DEPRECATE/REMOVE:
|
||||||
|
- ComprehensivePolishMap.js (superseded by LeafletMap)
|
||||||
|
- ImprovedPolishOrthophotoMap.js (experimental)
|
||||||
|
- PolishOrthophotoMap.js (old version)
|
||||||
|
- AdvancedPolishOrthophotoMap.js (experimental)
|
||||||
|
- EnhancedLeafletMap.js (duplicate?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Single source of truth for map rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Improve WMTS Capabilities Parsing
|
||||||
|
**Current Issue:** wmtsCapabilities.js has placeholder code
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
**Option A - Complete XML Parsing:**
|
||||||
|
```javascript
|
||||||
|
export async function parseWMTSCapabilities(url) {
|
||||||
|
const response = await fetch(`${url}?Service=WMTS&Request=GetCapabilities`);
|
||||||
|
const xmlText = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xml = parser.parseFromString(xmlText, 'text/xml');
|
||||||
|
|
||||||
|
const layers = Array.from(xml.querySelectorAll('Layer')).map(layer => ({
|
||||||
|
id: layer.querySelector('Identifier')?.textContent,
|
||||||
|
title: layer.querySelector('Title')?.textContent,
|
||||||
|
formats: Array.from(layer.querySelectorAll('Format')).map(f => f.textContent),
|
||||||
|
tileMatrixSets: Array.from(layer.querySelectorAll('TileMatrixSet')).map(t => t.textContent),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { layers };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B - Remove and Document:**
|
||||||
|
- Remove wmtsCapabilities.js
|
||||||
|
- Document WMTS configuration in MAP_LAYERS.md
|
||||||
|
- Use manual configuration (current working approach)
|
||||||
|
|
||||||
|
**Recommendation:** Option B - Keep it simple, current approach works
|
||||||
|
|
||||||
|
**Success Criteria:** No dead code, clear documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Add TypeScript/JSDoc Types
|
||||||
|
**Goal:** Better IDE support and type safety
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* @typedef {Object} LayerConfig
|
||||||
|
* @property {string} name - Display name for the layer
|
||||||
|
* @property {'tile'|'wms'} type - Layer type
|
||||||
|
* @property {string} url - Service URL
|
||||||
|
* @property {string} attribution - Attribution HTML
|
||||||
|
* @property {number} [maxZoom=20] - Maximum zoom level
|
||||||
|
* @property {number} [minZoom=0] - Minimum zoom level
|
||||||
|
* @property {number} [opacity=1.0] - Layer opacity (0-1)
|
||||||
|
* @property {boolean} [checked=false] - Default enabled state
|
||||||
|
* @property {Object} [params] - WMS parameters (for WMS layers)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} MapLayersConfig
|
||||||
|
* @property {LayerConfig[]} base - Base layer options
|
||||||
|
* @property {LayerConfig[]} overlays - Overlay layer options
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {MapLayersConfig} */
|
||||||
|
export const mapLayers = {
|
||||||
|
base: [...],
|
||||||
|
overlays: [...]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Better autocomplete and error detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Testing & Documentation (Week 6)
|
||||||
|
|
||||||
|
### 6.1 Create Layer Test Suite
|
||||||
|
**Goal:** Automated testing of layer availability
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// __tests__/map-layers.test.js
|
||||||
|
describe('Map Layers', () => {
|
||||||
|
describe('Base Layers', () => {
|
||||||
|
test('OSM tiles are accessible', async () => {
|
||||||
|
const response = await fetch('https://a.tile.openstreetmap.org/15/17560/11326.png');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Polish Orthophoto Standard is accessible', async () => {
|
||||||
|
const url = 'https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution?Service=WMTS&Request=GetCapabilities';
|
||||||
|
const response = await fetch(url);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WMS Overlays', () => {
|
||||||
|
test('Cadastral WMS GetCapabilities works', async () => {
|
||||||
|
const url = 'https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow?Service=WMS&Request=GetCapabilities';
|
||||||
|
const response = await fetch(url);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toContain('xml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** All layers validated before deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Update Documentation
|
||||||
|
**Files to Update:**
|
||||||
|
1. MAP_LAYERS.md - Add troubleshooting section
|
||||||
|
2. LAYER_NOTES.md - Keep updated with testing
|
||||||
|
3. README.md - Add maps usage section
|
||||||
|
|
||||||
|
**New Documentation:**
|
||||||
|
```markdown
|
||||||
|
## Troubleshooting Map Layers
|
||||||
|
|
||||||
|
### Slow Loading Cadastral Data
|
||||||
|
- **Cause:** GUGiK WMS servers are resource-limited
|
||||||
|
- **Solution:** Only enable at zoom 15+, limit to essential layers
|
||||||
|
- **Alternative:** Pre-cache frequently used areas
|
||||||
|
|
||||||
|
### Polish Orthophoto Not Loading
|
||||||
|
- **Check:** Zoom level (works up to 19, not 20)
|
||||||
|
- **Check:** Network connectivity to geoportal.gov.pl
|
||||||
|
- **Alternative:** Use Google Satellite or Esri
|
||||||
|
|
||||||
|
### Layer Control Not Showing
|
||||||
|
- **Cause:** Map container too small
|
||||||
|
- **Solution:** Minimum map height of 400px recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Users can self-service common issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority Matrix
|
||||||
|
|
||||||
|
| Priority | Phase | Task | Impact | Effort | Status |
|
||||||
|
|----------|-------|------|--------|--------|--------|
|
||||||
|
| 🔴 P0 | 1 | Fix Polish Orthophoto High-Res | High | Low | Not Started |
|
||||||
|
| 🔴 P0 | 1 | Add zoom restrictions to Cadastral | High | Low | Not Started |
|
||||||
|
| 🟡 P1 | 1 | Fix/Remove Spatial Planning | Medium | Medium | Not Started |
|
||||||
|
| 🟡 P1 | 2 | Document LP-Portal region limits | Medium | Low | Not Started |
|
||||||
|
| 🟡 P1 | 3 | Add loading indicators | Medium | Low | Not Started |
|
||||||
|
| 🟢 P2 | 2 | Reorganize layer categories | Low | Medium | Not Started |
|
||||||
|
| 🟢 P2 | 4 | Add opacity controls | Low | Medium | Not Started |
|
||||||
|
| 🟢 P2 | 4 | Add layer info panels | Low | High | Not Started |
|
||||||
|
| 🟢 P3 | 5 | Consolidate map components | Low | High | Not Started |
|
||||||
|
| 🟢 P3 | 6 | Add automated tests | Low | Medium | Not Started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Wins (Do First)
|
||||||
|
|
||||||
|
These can be implemented in 1-2 hours with immediate impact:
|
||||||
|
|
||||||
|
1. **Add minZoom to performance-heavy layers**
|
||||||
|
- Prevent loading at far zoom levels (minZoom: 13 for Cadastral)
|
||||||
|
- Reduce unnecessary requests at distant zoom
|
||||||
|
|
||||||
|
2. **Optimize Cadastral layer requests**
|
||||||
|
- Reduce number of requested WMS layers
|
||||||
|
- Add tiled parameter for better performance
|
||||||
|
|
||||||
|
3. **Remove broken Spatial Planning layer**
|
||||||
|
- If GetCapabilities fails, just remove it
|
||||||
|
- Better than showing broken functionality
|
||||||
|
|
||||||
|
4. **Update layer names for clarity**
|
||||||
|
- "Polish Orthophoto Standard" → "🇵🇱 Aerial Imagery (Standard)"
|
||||||
|
- Better user understanding
|
||||||
|
|
||||||
|
5. **Add loading spinner to ProjectMap**
|
||||||
|
- Copy LoadingOverlay component
|
||||||
|
- Better UX during slow loads
|
||||||
|
|
||||||
|
6. **Verify current zoom limits**
|
||||||
|
- Document actual working zoom ranges per layer
|
||||||
|
- Note: Goal is zoom 20 for all layers (future optimization)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] All base layers load in <2 seconds
|
||||||
|
- [ ] Cadastral overlays load in <5 seconds at zoom 15-18
|
||||||
|
- [ ] No console errors for working layers
|
||||||
|
- [ ] 90%+ tile success rate
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Layer control accessible on all screen sizes
|
||||||
|
- [ ] Clear feedback during loading
|
||||||
|
- [ ] No broken/blank layers in production
|
||||||
|
- [ ] Layer purposes clear from names/descriptions
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- [ ] Single production map component
|
||||||
|
- [ ] All map files under 500 lines
|
||||||
|
- [ ] JSDoc types for all exports
|
||||||
|
- [ ] 80%+ test coverage for layer configs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
### Week 1: Emergency Fixes
|
||||||
|
- Fix critical broken layers
|
||||||
|
- Add zoom restrictions
|
||||||
|
- Remove non-working layers
|
||||||
|
|
||||||
|
### Week 2: Optimization
|
||||||
|
- Implement caching
|
||||||
|
- Add loading states
|
||||||
|
- Progressive loading
|
||||||
|
|
||||||
|
### Week 3: Features
|
||||||
|
- Opacity controls
|
||||||
|
- Layer info panels
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### Week 4: Cleanup
|
||||||
|
- Consolidate components
|
||||||
|
- Remove experimental code
|
||||||
|
- Update documentation
|
||||||
|
|
||||||
|
### Week 5: Testing
|
||||||
|
- Automated tests
|
||||||
|
- User acceptance testing
|
||||||
|
- Performance benchmarking
|
||||||
|
|
||||||
|
### Week 6: Release
|
||||||
|
- Deploy to production
|
||||||
|
- Monitor performance
|
||||||
|
- Gather user feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Strategy
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
1. **Keep old mapLayers.js** as `mapLayers.backup.js`
|
||||||
|
2. **Feature flags** for new functionality
|
||||||
|
3. **Incremental rollout** - enable for admin users first
|
||||||
|
4. **Quick disable** - config flag to revert to old layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
### Potential New Features
|
||||||
|
- [ ] **Universal zoom 20 support for all layers**
|
||||||
|
- Optimize WMS services to handle zoom 19-20
|
||||||
|
- Implement tile prefetching and caching
|
||||||
|
- Add progressive detail loading at high zoom
|
||||||
|
- [ ] Save user layer preferences
|
||||||
|
- [ ] Share map view URLs (with layers/zoom)
|
||||||
|
- [ ] Export map as image/PDF
|
||||||
|
- [ ] Offline tile caching
|
||||||
|
- [ ] Custom layer upload (GeoJSON, KML)
|
||||||
|
|
||||||
|
### Alternative Services to Explore
|
||||||
|
- [ ] Planet imagery (if budget allows)
|
||||||
|
- [ ] Bing Maps aerial imagery
|
||||||
|
- [ ] Additional Polish regional services
|
||||||
|
- [ ] CORS proxies for restricted services
|
||||||
|
|
||||||
|
### Advanced Optimizations
|
||||||
|
- [ ] Service worker for tile caching
|
||||||
|
- [ ] WebGL rendering for better performance
|
||||||
|
- [ ] Vector tiles instead of raster
|
||||||
|
- [ ] CDN for frequently accessed tiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- LP-Portal layers appear to be **Nowy Sącz specific** - need regional filtering
|
||||||
|
- Polish government servers are **consistently slow** - can't fix, only mitigate
|
||||||
|
- Google services are **unofficial** - may break without notice
|
||||||
|
- WMTS is more performant than WMS - prefer when available
|
||||||
|
- **Zoom 20 support:** Long-term goal for all layers; currently some layers work only to zoom 18-19
|
||||||
|
- Requires server-side optimization or caching strategy
|
||||||
|
- May need to implement client-side tile scaling/interpolation
|
||||||
|
- Keep LAYER_NOTES.md updated as testing continues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approval & Sign-off
|
||||||
|
|
||||||
|
- [ ] Technical review completed
|
||||||
|
- [ ] Performance benchmarks met
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] Stakeholder approval
|
||||||
|
- [ ] Ready for production deployment
|
||||||
|
|
||||||
|
**Last Updated:** January 16, 2026
|
||||||
|
**Next Review:** After Phase 1 completion
|
||||||
@@ -17,7 +17,9 @@ function exportProjectsToExcel() {
|
|||||||
'Adres': project.address || '',
|
'Adres': project.address || '',
|
||||||
'Działka': project.plot || '',
|
'Działka': project.plot || '',
|
||||||
'WP': project.wp || '',
|
'WP': project.wp || '',
|
||||||
'Data zakończenia': project.finish_date || ''
|
'Data wpływu': project.start_date || '',
|
||||||
|
'Termin zakończenia': project.finish_date || '',
|
||||||
|
'Data odbioru': project.completion_date || ''
|
||||||
});
|
});
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
844
scripts/create-comprehensive-test-data.js
Normal file
844
scripts/create-comprehensive-test-data.js
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Comprehensive Test Data Generator
|
||||||
|
*
|
||||||
|
* Creates realistic test data for the panel application including:
|
||||||
|
* - Users with different roles
|
||||||
|
* - Contracts with realistic data
|
||||||
|
* - Projects scattered across Poland with person/company names
|
||||||
|
* - Task templates and sets
|
||||||
|
* - Project tasks with various statuses
|
||||||
|
* - Contacts
|
||||||
|
* - Notes and file attachments
|
||||||
|
* - Notifications and audit logs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import db from '../src/lib/db.js';
|
||||||
|
import initializeDatabase from '../src/lib/init-db.js';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
clearExistingData: true,
|
||||||
|
preserveAdmin: true, // Keep existing admin user
|
||||||
|
seed: 42, // For reproducible random data
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seeded random number generator
|
||||||
|
class SeededRandom {
|
||||||
|
constructor(seed) {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.seed = (this.seed * 9301 + 49297) % 233280;
|
||||||
|
return this.seed / 233280;
|
||||||
|
}
|
||||||
|
|
||||||
|
choice(array) {
|
||||||
|
return array[Math.floor(this.next() * array.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
integer(min, max) {
|
||||||
|
return Math.floor(this.next() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean(probability = 0.5) {
|
||||||
|
return this.next() < probability;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const random = new SeededRandom(CONFIG.seed);
|
||||||
|
|
||||||
|
// Polish cities with coordinates
|
||||||
|
const POLISH_CITIES = [
|
||||||
|
{ name: 'Warszawa', coordinates: '52.2297,21.0122' },
|
||||||
|
{ name: 'Kraków', coordinates: '50.0647,19.9450' },
|
||||||
|
{ name: 'Wrocław', coordinates: '51.1079,17.0385' },
|
||||||
|
{ name: 'Poznań', coordinates: '52.4064,16.9252' },
|
||||||
|
{ name: 'Gdańsk', coordinates: '54.3520,18.6466' },
|
||||||
|
{ name: 'Szczecin', coordinates: '53.4289,14.5530' },
|
||||||
|
{ name: 'Lublin', coordinates: '51.2465,22.5684' },
|
||||||
|
{ name: 'Katowice', coordinates: '50.2649,19.0238' },
|
||||||
|
{ name: 'Łódź', coordinates: '51.7592,19.4600' },
|
||||||
|
{ name: 'Bydgoszcz', coordinates: '53.1235,18.0084' },
|
||||||
|
{ name: 'Białystok', coordinates: '53.1325,23.1688' },
|
||||||
|
{ name: 'Rzeszów', coordinates: '50.0412,21.9991' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Street names
|
||||||
|
const STREET_TYPES = ['ul.', 'al.', 'pl.'];
|
||||||
|
const STREET_NAMES = [
|
||||||
|
'Główna', 'Kwiatowa', 'Słoneczna', 'Przemysłowa', 'Leśna',
|
||||||
|
'Parkowa', 'Centralna', 'Sportowa', 'Polna', 'Krótka',
|
||||||
|
'Długa', 'Nowa', 'Stara', 'Morska', 'Górska', 'Wolności',
|
||||||
|
'Mickiewicza', 'Kościuszki', 'Piłsudskiego', 'Kolejowa'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Project names - people
|
||||||
|
const PERSON_NAMES = [
|
||||||
|
'Jan Kowalski', 'Anna Nowak', 'Piotr Wiśniewski', 'Maria Lewandowska',
|
||||||
|
'Tomasz Kamiński', 'Małgorzata Zielińska', 'Krzysztof Szymański',
|
||||||
|
'Agnieszka Woźniak', 'Andrzej Dąbrowski', 'Barbara Kozłowska',
|
||||||
|
'Józef Jankowski', 'Ewa Wojciechowska', 'Stanisław Kwiatkowski',
|
||||||
|
'Krystyna Kaczmarek', 'Tadeusz Piotrowski'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Project names - companies
|
||||||
|
const COMPANY_NAMES = [
|
||||||
|
'PolBud Sp. z o.o.', 'Constructo Group', 'BuildMaster SA',
|
||||||
|
'EuroDevelopment', 'Invest Property', 'Metropolitan Construction',
|
||||||
|
'Green Building Solutions', 'Nova Inwestycje', 'Prime Estate',
|
||||||
|
'TechBuild Industries', 'Horizon Development', 'Skyline Properties',
|
||||||
|
'Urban Solutions', 'Future Living', 'Capital Investments'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Task templates
|
||||||
|
const DESIGN_TASKS = [
|
||||||
|
{ name: 'Wstępne uzgodnienia z klientem', max_wait_days: 7 },
|
||||||
|
{ name: 'Wizja lokalna i pomiary', max_wait_days: 5 },
|
||||||
|
{ name: 'Projekt koncepcyjny', max_wait_days: 14 },
|
||||||
|
{ name: 'Uzgodnienia projektu koncepcyjnego', max_wait_days: 7 },
|
||||||
|
{ name: 'Projekt budowlany', max_wait_days: 21 },
|
||||||
|
{ name: 'Projekt wykonawczy', max_wait_days: 21 },
|
||||||
|
{ name: 'Specyfikacja techniczna', max_wait_days: 10 },
|
||||||
|
{ name: 'Kosztorys inwestorski', max_wait_days: 7 },
|
||||||
|
{ name: 'Wniosek o pozwolenie na budowę', max_wait_days: 14 },
|
||||||
|
{ name: 'Uzyskanie pozwolenia na budowę', max_wait_days: 60 },
|
||||||
|
{ name: 'Projekt wykonawczy - instalacje', max_wait_days: 21 },
|
||||||
|
{ name: 'Projekt zagospodarowania terenu', max_wait_days: 14 },
|
||||||
|
{ name: 'Dokumentacja powykonawcza', max_wait_days: 14 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSTRUCTION_TASKS = [
|
||||||
|
{ name: 'Przygotowanie placu budowy', max_wait_days: 7 },
|
||||||
|
{ name: 'Wykopy i fundamenty', max_wait_days: 14 },
|
||||||
|
{ name: 'Stan zero', max_wait_days: 21 },
|
||||||
|
{ name: 'Stan surowy otwarty', max_wait_days: 30 },
|
||||||
|
{ name: 'Stan surowy zamknięty', max_wait_days: 30 },
|
||||||
|
{ name: 'Instalacje wewnętrzne', max_wait_days: 21 },
|
||||||
|
{ name: 'Tynki i wylewki', max_wait_days: 14 },
|
||||||
|
{ name: 'Stolarka okienna i drzwiowa', max_wait_days: 10 },
|
||||||
|
{ name: 'Wykończenie - malowanie', max_wait_days: 14 },
|
||||||
|
{ name: 'Wykończenie - podłogi', max_wait_days: 10 },
|
||||||
|
{ name: 'Instalacje sanitarne', max_wait_days: 14 },
|
||||||
|
{ name: 'Instalacje elektryczne', max_wait_days: 14 },
|
||||||
|
{ name: 'Odbiór techniczny', max_wait_days: 7 },
|
||||||
|
{ name: 'Odbiór końcowy', max_wait_days: 7 },
|
||||||
|
{ name: 'Przekazanie dokumentacji', max_wait_days: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Contact types and data
|
||||||
|
const CONTACT_FIRST_NAMES = ['Jan', 'Piotr', 'Anna', 'Maria', 'Tomasz', 'Krzysztof', 'Agnieszka', 'Magdalena', 'Andrzej', 'Ewa'];
|
||||||
|
const CONTACT_LAST_NAMES = ['Kowalski', 'Nowak', 'Wiśniewski', 'Lewandowski', 'Kamiński', 'Zieliński', 'Szymański', 'Woźniak', 'Dąbrowski', 'Kozłowski'];
|
||||||
|
const POSITIONS = ['Kierownik projektu', 'Inżynier', 'Architekt', 'Inspektor nadzoru', 'Przedstawiciel inwestora', 'Dyrektor', 'Koordynator'];
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function generateId() {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateWP() {
|
||||||
|
const part1 = String(random.integer(100000, 999999));
|
||||||
|
const part2 = String(random.integer(1000, 9999));
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
const part3 = Array(6).fill(0).map(() => chars[random.integer(0, chars.length - 1)]).join('');
|
||||||
|
return `${part1}/${part2}/${part3}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInvestmentNumber() {
|
||||||
|
const letter = String.fromCharCode(65 + random.integer(0, 25)); // A-Z
|
||||||
|
const letters = String.fromCharCode(65 + random.integer(0, 25)) + String.fromCharCode(65 + random.integer(0, 25));
|
||||||
|
const number = String(random.integer(1000000, 9999999));
|
||||||
|
return `${letter}-${letters}-${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDate(startDate, endDate) {
|
||||||
|
const start = new Date(startDate).getTime();
|
||||||
|
const end = new Date(endDate).getTime();
|
||||||
|
const timestamp = start + random.next() * (end - start);
|
||||||
|
return new Date(timestamp).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateStr, days) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePhoneNumber() {
|
||||||
|
return `${random.integer(500, 799)}-${random.integer(100, 999)}-${random.integer(100, 999)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
function clearData() {
|
||||||
|
console.log('\n🗑️ Clearing existing data...\n');
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
'field_change_history',
|
||||||
|
'notifications',
|
||||||
|
'audit_logs',
|
||||||
|
'file_attachments',
|
||||||
|
'notes',
|
||||||
|
'project_tasks',
|
||||||
|
'task_set_templates',
|
||||||
|
'task_sets',
|
||||||
|
'tasks',
|
||||||
|
'project_contacts',
|
||||||
|
'contacts',
|
||||||
|
'projects',
|
||||||
|
'contracts',
|
||||||
|
'password_reset_tokens',
|
||||||
|
'sessions',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!CONFIG.preserveAdmin) {
|
||||||
|
tables.push('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
tables.forEach(table => {
|
||||||
|
try {
|
||||||
|
db.prepare(`DELETE FROM ${table}`).run();
|
||||||
|
console.log(` ✓ Cleared ${table}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠ Warning clearing ${table}:`, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset sequences
|
||||||
|
db.prepare('DELETE FROM sqlite_sequence').run();
|
||||||
|
|
||||||
|
console.log('\n✅ Data cleared successfully\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Create Users
|
||||||
|
function createUsers() {
|
||||||
|
console.log('\n👥 Creating users...\n');
|
||||||
|
|
||||||
|
const users = [];
|
||||||
|
const defaultPassword = bcrypt.hashSync('password123', 10);
|
||||||
|
|
||||||
|
// Keep existing admin if preserveAdmin is true
|
||||||
|
if (CONFIG.preserveAdmin) {
|
||||||
|
const existingAdmin = db.prepare('SELECT * FROM users WHERE role = ?').get('admin');
|
||||||
|
if (existingAdmin) {
|
||||||
|
users.push(existingAdmin);
|
||||||
|
console.log(` ✓ Preserved existing admin: ${existingAdmin.username}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUsers = [
|
||||||
|
{ name: 'Maria Kowalska', username: 'maria.kowalska', role: 'team_lead' },
|
||||||
|
{ name: 'Piotr Nowak', username: 'piotr.nowak', role: 'team_lead' },
|
||||||
|
{ name: 'Anna Wiśniewska', username: 'anna.wisniewska', role: 'project_manager' },
|
||||||
|
{ name: 'Tomasz Kamiński', username: 'tomasz.kaminski', role: 'project_manager' },
|
||||||
|
{ name: 'Krzysztof Lewandowski', username: 'krzysztof.lewandowski', role: 'project_manager' },
|
||||||
|
{ name: 'Agnieszka Zielińska', username: 'agnieszka.zielinska', role: 'user' },
|
||||||
|
{ name: 'Marek Szymański', username: 'marek.szymanski', role: 'user' },
|
||||||
|
{ name: 'Ewa Dąbrowska', username: 'ewa.dabrowska', role: 'user' },
|
||||||
|
{ name: 'Janusz Kozłowski', username: 'janusz.kozlowski', role: 'user' },
|
||||||
|
{ name: 'Barbara Wojciechowska', username: 'barbara.wojciechowska', role: 'user' },
|
||||||
|
{ name: 'Viewing Account', username: 'viewer', role: 'read_only' },
|
||||||
|
];
|
||||||
|
|
||||||
|
newUsers.forEach(userData => {
|
||||||
|
const userId = generateId();
|
||||||
|
|
||||||
|
// Generate initials from name
|
||||||
|
const nameParts = userData.name.trim().split(/\s+/);
|
||||||
|
const initial = nameParts.map(part => part.charAt(0).toUpperCase()).join('');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (id, name, username, password_hash, role, initial, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(userId, userData.name, userData.username, defaultPassword, userData.role, initial);
|
||||||
|
|
||||||
|
users.push({ id: userId, ...userData });
|
||||||
|
console.log(` ✓ Created ${userData.role}: ${userData.name} (${userData.username})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${newUsers.length} new users (Total: ${users.length})\n`);
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Create Contracts
|
||||||
|
function createContracts() {
|
||||||
|
console.log('\n📄 Creating contracts...\n');
|
||||||
|
|
||||||
|
const contracts = [
|
||||||
|
{
|
||||||
|
number: '2025/FW-001',
|
||||||
|
name: 'Umowa ramowa - projekty mieszkaniowe 2025',
|
||||||
|
customer: 'Deweloper Mieszkaniowy Sp. z o.o.',
|
||||||
|
investor: 'Invest Property Fund',
|
||||||
|
customerContractNumber: 'DMH/2025/001',
|
||||||
|
dateSigned: '2025-01-10',
|
||||||
|
finishDate: '2026-12-31',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '2025/INF-002',
|
||||||
|
name: 'Projekty infrastrukturalne miasta',
|
||||||
|
customer: 'Zarząd Dróg Miejskich',
|
||||||
|
investor: 'Gmina Miasto',
|
||||||
|
customerContractNumber: 'ZDM-2025-02-INF',
|
||||||
|
dateSigned: '2025-02-01',
|
||||||
|
finishDate: '2026-06-30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '2025/COM-003',
|
||||||
|
name: 'Obiekty komercyjne - centra handlowe',
|
||||||
|
customer: 'Retail Development Group',
|
||||||
|
investor: 'Metropolitan Investments',
|
||||||
|
customerContractNumber: 'RDG/25/COM/03',
|
||||||
|
dateSigned: '2025-01-15',
|
||||||
|
finishDate: '2026-09-30',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const contractIds = [];
|
||||||
|
|
||||||
|
contracts.forEach((contract, index) => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO contracts (
|
||||||
|
contract_number, contract_name, customer_contract_number,
|
||||||
|
customer, investor, date_signed, finish_date
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
contract.number,
|
||||||
|
contract.name,
|
||||||
|
contract.customerContractNumber,
|
||||||
|
contract.customer,
|
||||||
|
contract.investor,
|
||||||
|
contract.dateSigned,
|
||||||
|
contract.finishDate
|
||||||
|
);
|
||||||
|
|
||||||
|
contractIds.push(result.lastInsertRowid);
|
||||||
|
console.log(` ✓ Created contract: ${contract.number} - ${contract.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${contracts.length} contracts\n`);
|
||||||
|
return contractIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Create Projects
|
||||||
|
function createProjects(contractIds, users) {
|
||||||
|
console.log('\n🏗️ Creating projects...\n');
|
||||||
|
|
||||||
|
const projectCount = random.integer(12, 15);
|
||||||
|
const projects = [];
|
||||||
|
const projectStatuses = ['registered', 'in_progress_design', 'in_progress_construction', 'fulfilled', 'cancelled'];
|
||||||
|
const projectTypes = ['design', 'construction', 'design+construction'];
|
||||||
|
|
||||||
|
const usedCities = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < projectCount; i++) {
|
||||||
|
// Select contract
|
||||||
|
const contractId = random.choice(contractIds);
|
||||||
|
const contractInfo = db.prepare('SELECT contract_number FROM contracts WHERE contract_id = ?').get(contractId);
|
||||||
|
|
||||||
|
// Get sequential number for this contract
|
||||||
|
const existingCount = db.prepare('SELECT COUNT(*) as count FROM projects WHERE contract_id = ?').get(contractId);
|
||||||
|
const sequenceNumber = existingCount.count + 1;
|
||||||
|
const projectNumber = `${sequenceNumber}/${contractInfo.contract_number}`;
|
||||||
|
|
||||||
|
// Select city (try to use different cities)
|
||||||
|
let city;
|
||||||
|
if (usedCities.length < POLISH_CITIES.length) {
|
||||||
|
const availableCities = POLISH_CITIES.filter(c => !usedCities.includes(c.name));
|
||||||
|
city = random.choice(availableCities);
|
||||||
|
usedCities.push(city.name);
|
||||||
|
} else {
|
||||||
|
city = random.choice(POLISH_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate address
|
||||||
|
const streetType = random.choice(STREET_TYPES);
|
||||||
|
const streetName = random.choice(STREET_NAMES);
|
||||||
|
const buildingNumber = random.integer(1, 200);
|
||||||
|
const address = `${streetType} ${streetName} ${buildingNumber}`;
|
||||||
|
|
||||||
|
// Project name (person or company)
|
||||||
|
const projectName = random.boolean(0.6) ? random.choice(PERSON_NAMES) : random.choice(COMPANY_NAMES);
|
||||||
|
|
||||||
|
// Project type and status
|
||||||
|
const projectType = random.choice(projectTypes);
|
||||||
|
const projectStatus = random.choice(projectStatuses);
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
const startDate = generateDate('2025-01-01', '2025-12-31');
|
||||||
|
const finishDate = addDays(startDate, random.integer(90, 365));
|
||||||
|
const completionDate = (projectStatus === 'fulfilled') ? addDays(finishDate, random.integer(-30, 10)) : null;
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
const wp = generateWP();
|
||||||
|
const investmentNumber = generateInvestmentNumber();
|
||||||
|
const plot = `${random.integer(1, 500)}/${random.integer(1, 50)}`;
|
||||||
|
const district = random.choice(['Centrum', 'Północ', 'Południe', 'Wschód', 'Zachód', 'Śródmieście']);
|
||||||
|
const unit = random.choice(['A', 'B', 'C', 'D', 'E', '1', '2', '3']);
|
||||||
|
|
||||||
|
// Assign to project manager
|
||||||
|
const projectManagers = users.filter(u => u.role === 'project_manager');
|
||||||
|
const assignedTo = random.choice(projectManagers).id;
|
||||||
|
const createdBy = random.choice(users.filter(u => u.role === 'admin' || u.role === 'team_lead')).id;
|
||||||
|
|
||||||
|
const wartoscZlecenia = random.integer(100000, 5000000);
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO projects (
|
||||||
|
contract_id, project_name, project_number, address, plot, district, unit, city,
|
||||||
|
investment_number, start_date, finish_date, completion_date, wp,
|
||||||
|
coordinates, project_type, project_status, wartosc_zlecenia,
|
||||||
|
created_by, assigned_to, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(
|
||||||
|
contractId, projectName, projectNumber, address, plot, district, unit, city.name,
|
||||||
|
investmentNumber, startDate, finishDate, completionDate, wp,
|
||||||
|
city.coordinates, projectType, projectStatus, wartoscZlecenia,
|
||||||
|
createdBy, assignedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
projects.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
name: projectName,
|
||||||
|
number: projectNumber,
|
||||||
|
type: projectType,
|
||||||
|
status: projectStatus,
|
||||||
|
city: city.name,
|
||||||
|
assignedTo: assignedTo,
|
||||||
|
createdBy: createdBy,
|
||||||
|
startDate: startDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${projectNumber}: ${projectName} (${city.name}) - ${projectStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${projects.length} projects\n`);
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Create Task Templates
|
||||||
|
function createTaskTemplates() {
|
||||||
|
console.log('\n✅ Creating task templates...\n');
|
||||||
|
|
||||||
|
const taskIds = { design: [], construction: [] };
|
||||||
|
|
||||||
|
console.log(' Design tasks:');
|
||||||
|
DESIGN_TASKS.forEach(task => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO tasks (name, max_wait_days, is_standard, task_category)
|
||||||
|
VALUES (?, ?, 1, 'design')
|
||||||
|
`).run(task.name, task.max_wait_days);
|
||||||
|
taskIds.design.push(result.lastInsertRowid);
|
||||||
|
console.log(` ✓ ${task.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n Construction tasks:');
|
||||||
|
CONSTRUCTION_TASKS.forEach(task => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO tasks (name, max_wait_days, is_standard, task_category)
|
||||||
|
VALUES (?, ?, 1, 'construction')
|
||||||
|
`).run(task.name, task.max_wait_days);
|
||||||
|
taskIds.construction.push(result.lastInsertRowid);
|
||||||
|
console.log(` ✓ ${task.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${DESIGN_TASKS.length + CONSTRUCTION_TASKS.length} task templates\n`);
|
||||||
|
return taskIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: Create Task Sets
|
||||||
|
function createTaskSets(taskIds) {
|
||||||
|
console.log('\n📋 Creating task sets...\n');
|
||||||
|
|
||||||
|
const sets = [
|
||||||
|
{
|
||||||
|
name: 'Standard - Projektowanie',
|
||||||
|
category: 'design',
|
||||||
|
tasks: taskIds.design.slice(0, 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pełny zakres - Projektowanie',
|
||||||
|
category: 'design',
|
||||||
|
tasks: taskIds.design,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Standard - Budowa',
|
||||||
|
category: 'construction',
|
||||||
|
tasks: taskIds.construction.slice(0, 10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pełny zakres - Budowa',
|
||||||
|
category: 'construction',
|
||||||
|
tasks: taskIds.construction,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const setIds = [];
|
||||||
|
|
||||||
|
sets.forEach(set => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO task_sets (name, task_category, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(set.name, set.category);
|
||||||
|
|
||||||
|
const setId = result.lastInsertRowid;
|
||||||
|
setIds.push(setId);
|
||||||
|
|
||||||
|
// Add tasks to set
|
||||||
|
set.tasks.forEach((taskId, index) => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO task_set_templates (set_id, task_template_id, sort_order)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(setId, taskId, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${set.name} (${set.tasks.length} tasks)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${sets.length} task sets\n`);
|
||||||
|
return setIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6: Create Project Tasks
|
||||||
|
function createProjectTasks(projects, taskIds, users) {
|
||||||
|
console.log('\n📝 Creating project tasks...\n');
|
||||||
|
|
||||||
|
const taskStatuses = ['not_started', 'in_progress', 'completed', 'cancelled'];
|
||||||
|
const priorities = ['normal', 'low', 'high'];
|
||||||
|
let totalTasks = 0;
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
// Select appropriate tasks based on project type
|
||||||
|
let availableTasks = [];
|
||||||
|
if (project.type === 'design') {
|
||||||
|
availableTasks = taskIds.design;
|
||||||
|
} else if (project.type === 'construction') {
|
||||||
|
availableTasks = taskIds.construction;
|
||||||
|
} else {
|
||||||
|
availableTasks = [...taskIds.design, ...taskIds.construction];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 3-7 tasks per project
|
||||||
|
const taskCount = random.integer(3, 7);
|
||||||
|
const selectedTasks = [];
|
||||||
|
|
||||||
|
// Select random tasks
|
||||||
|
for (let i = 0; i < taskCount && selectedTasks.length < availableTasks.length; i++) {
|
||||||
|
let taskId;
|
||||||
|
do {
|
||||||
|
taskId = random.choice(availableTasks);
|
||||||
|
} while (selectedTasks.includes(taskId));
|
||||||
|
selectedTasks.push(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTasks.forEach(taskTemplateId => {
|
||||||
|
// Determine status based on project status
|
||||||
|
let status;
|
||||||
|
if (project.status === 'registered') {
|
||||||
|
status = 'not_started';
|
||||||
|
} else if (project.status === 'fulfilled') {
|
||||||
|
status = 'completed';
|
||||||
|
} else if (project.status === 'cancelled') {
|
||||||
|
status = random.choice(['not_started', 'cancelled']);
|
||||||
|
} else {
|
||||||
|
status = random.choice(taskStatuses.slice(0, 3)); // not_started, in_progress, completed
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = random.choice(priorities);
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
let dateAdded = project.startDate;
|
||||||
|
let dateStarted = null;
|
||||||
|
let dateCompleted = null;
|
||||||
|
|
||||||
|
if (status === 'in_progress' || status === 'completed') {
|
||||||
|
dateStarted = addDays(dateAdded, random.integer(1, 30));
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
dateCompleted = addDays(dateStarted, random.integer(5, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment
|
||||||
|
const regularUsers = users.filter(u => u.role === 'user' || u.role === 'project_manager');
|
||||||
|
const assignedTo = random.boolean(0.7) ? random.choice(regularUsers).id : null;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO project_tasks (
|
||||||
|
project_id, task_template_id, status, priority,
|
||||||
|
date_added, date_started, date_completed,
|
||||||
|
created_by, assigned_to, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(
|
||||||
|
project.id, taskTemplateId, status, priority,
|
||||||
|
dateAdded, dateStarted, dateCompleted,
|
||||||
|
project.createdBy, assignedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
totalTasks++;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${project.number}: Created ${taskCount} tasks`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${totalTasks} project tasks\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 7: Create Contacts
|
||||||
|
function createContacts(users) {
|
||||||
|
console.log('\n👤 Creating contacts...\n');
|
||||||
|
|
||||||
|
const contactTypes = ['project', 'contractor', 'office', 'supplier', 'other'];
|
||||||
|
const contacts = [];
|
||||||
|
const contactCount = random.integer(25, 35);
|
||||||
|
|
||||||
|
for (let i = 0; i < contactCount; i++) {
|
||||||
|
const firstName = random.choice(CONTACT_FIRST_NAMES);
|
||||||
|
const lastName = random.choice(CONTACT_LAST_NAMES);
|
||||||
|
const name = `${firstName} ${lastName}`;
|
||||||
|
const phone = generatePhoneNumber();
|
||||||
|
const email = random.boolean(0.6) ? `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com` : null;
|
||||||
|
const company = random.boolean(0.5) ? random.choice(COMPANY_NAMES) : null;
|
||||||
|
const position = random.boolean(0.7) ? random.choice(POSITIONS) : null;
|
||||||
|
const contactType = random.choice(contactTypes);
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO contacts (
|
||||||
|
name, phone, email, company, position, contact_type, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(name, phone, email, company, position, contactType);
|
||||||
|
|
||||||
|
contacts.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
name: name,
|
||||||
|
type: contactType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${contacts.length} contacts\n`);
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 8: Link Projects to Contacts
|
||||||
|
function linkProjectContacts(projects, contacts, users) {
|
||||||
|
console.log('\n🔗 Linking projects to contacts...\n');
|
||||||
|
|
||||||
|
let linkCount = 0;
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
// Link 1-4 contacts per project
|
||||||
|
const contactsToLink = random.integer(1, 4);
|
||||||
|
const linkedContacts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < contactsToLink; i++) {
|
||||||
|
let contact;
|
||||||
|
do {
|
||||||
|
contact = random.choice(contacts);
|
||||||
|
} while (linkedContacts.includes(contact.id));
|
||||||
|
|
||||||
|
linkedContacts.push(contact.id);
|
||||||
|
|
||||||
|
const isPrimary = i === 0 ? 1 : 0;
|
||||||
|
const relationshipType = random.choice(['general', 'technical', 'commercial', 'administrative']);
|
||||||
|
const addedBy = random.choice(users).id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO project_contacts (
|
||||||
|
project_id, contact_id, relationship_type, is_primary, added_by, added_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`).run(project.id, contact.id, relationshipType, isPrimary, addedBy);
|
||||||
|
|
||||||
|
linkCount++;
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore duplicate key errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${linkCount} project-contact links\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 9: Create Notes
|
||||||
|
function createNotes(projects, users) {
|
||||||
|
console.log('\n📝 Creating notes...\n');
|
||||||
|
|
||||||
|
const noteTemplates = [
|
||||||
|
'Spotkanie z klientem - uzgodniono zakres prac',
|
||||||
|
'Wykonano wizję lokalną',
|
||||||
|
'Przesłano dokumentację do uzgodnień',
|
||||||
|
'Otrzymano uwagi do projektu',
|
||||||
|
'Zaktualizowano dokumentację zgodnie z uwagami',
|
||||||
|
'Projekt zatwierdzony przez inwestora',
|
||||||
|
'Rozpoczęto prace na budowie',
|
||||||
|
'Wykonano odbiór częściowy',
|
||||||
|
'Zgłoszono problemy techniczne',
|
||||||
|
'Problem rozwiązany',
|
||||||
|
'Zamówiono materiały',
|
||||||
|
'Dostawa materiałów opóźniona',
|
||||||
|
'Materiały dostarczone na plac budowy',
|
||||||
|
];
|
||||||
|
|
||||||
|
let noteCount = 0;
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
// Create 2-6 notes per project
|
||||||
|
const notesPerProject = random.integer(2, 6);
|
||||||
|
|
||||||
|
for (let i = 0; i < notesPerProject; i++) {
|
||||||
|
const note = random.choice(noteTemplates);
|
||||||
|
const createdBy = random.choice(users).id;
|
||||||
|
const isSystem = random.boolean(0.1) ? 1 : 0;
|
||||||
|
|
||||||
|
// Generate date between project start and now
|
||||||
|
const noteDate = generateDate(project.startDate, '2026-01-26');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO notes (
|
||||||
|
project_id, note, note_date, is_system, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(project.id, note, noteDate, isSystem, createdBy);
|
||||||
|
|
||||||
|
noteCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${noteCount} notes\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 10: Create Audit Logs
|
||||||
|
function createAuditLogs(users, projects) {
|
||||||
|
console.log('\n📊 Creating audit logs...\n');
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
'user.login',
|
||||||
|
'project.create',
|
||||||
|
'project.update',
|
||||||
|
'project.view',
|
||||||
|
'task.create',
|
||||||
|
'task.update',
|
||||||
|
'task.complete',
|
||||||
|
'file.upload',
|
||||||
|
'contract.create',
|
||||||
|
'contact.create',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ipAddresses = [
|
||||||
|
'192.168.1.100',
|
||||||
|
'192.168.1.101',
|
||||||
|
'10.0.0.50',
|
||||||
|
'172.16.0.10',
|
||||||
|
'83.24.156.78',
|
||||||
|
];
|
||||||
|
|
||||||
|
let logCount = 0;
|
||||||
|
|
||||||
|
// Create 100-200 audit logs
|
||||||
|
const totalLogs = random.integer(100, 200);
|
||||||
|
|
||||||
|
for (let i = 0; i < totalLogs; i++) {
|
||||||
|
const user = random.choice(users);
|
||||||
|
const action = random.choice(actions);
|
||||||
|
const timestamp = generateDate('2025-01-01', '2026-01-26');
|
||||||
|
const ip = random.choice(ipAddresses);
|
||||||
|
|
||||||
|
let resourceType = null;
|
||||||
|
let resourceId = null;
|
||||||
|
|
||||||
|
if (action.includes('project')) {
|
||||||
|
resourceType = 'project';
|
||||||
|
resourceId = String(random.choice(projects).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id, action, resource_type, resource_id, ip_address,
|
||||||
|
user_agent, timestamp
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
user.id,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ip,
|
||||||
|
'Mozilla/5.0 (compatible)',
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
logCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${logCount} audit logs\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
async function main() {
|
||||||
|
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ Comprehensive Test Data Generator ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize database
|
||||||
|
console.log('\n🔧 Initializing database schema...');
|
||||||
|
initializeDatabase();
|
||||||
|
console.log('✅ Database schema ready\n');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
if (CONFIG.clearExistingData) {
|
||||||
|
clearData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate data in phases
|
||||||
|
const users = createUsers();
|
||||||
|
const contractIds = createContracts();
|
||||||
|
const projects = createProjects(contractIds, users);
|
||||||
|
const taskIds = createTaskTemplates();
|
||||||
|
const taskSetIds = createTaskSets(taskIds);
|
||||||
|
createProjectTasks(projects, taskIds, users);
|
||||||
|
const contacts = createContacts(users);
|
||||||
|
linkProjectContacts(projects, contacts, users);
|
||||||
|
createNotes(projects, users);
|
||||||
|
createAuditLogs(users, projects);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ SUMMARY ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝\n');
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
users: db.prepare('SELECT COUNT(*) as count FROM users').get().count,
|
||||||
|
contracts: db.prepare('SELECT COUNT(*) as count FROM contracts').get().count,
|
||||||
|
projects: db.prepare('SELECT COUNT(*) as count FROM projects').get().count,
|
||||||
|
tasks: db.prepare('SELECT COUNT(*) as count FROM tasks').get().count,
|
||||||
|
taskSets: db.prepare('SELECT COUNT(*) as count FROM task_sets').get().count,
|
||||||
|
projectTasks: db.prepare('SELECT COUNT(*) as count FROM project_tasks').get().count,
|
||||||
|
contacts: db.prepare('SELECT COUNT(*) as count FROM contacts').get().count,
|
||||||
|
projectContacts: db.prepare('SELECT COUNT(*) as count FROM project_contacts').get().count,
|
||||||
|
notes: db.prepare('SELECT COUNT(*) as count FROM notes').get().count,
|
||||||
|
auditLogs: db.prepare('SELECT COUNT(*) as count FROM audit_logs').get().count,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(` 👥 Users: ${stats.users}`);
|
||||||
|
console.log(` 📄 Contracts: ${stats.contracts}`);
|
||||||
|
console.log(` 🏗️ Projects: ${stats.projects}`);
|
||||||
|
console.log(` ✅ Task Templates: ${stats.tasks}`);
|
||||||
|
console.log(` 📋 Task Sets: ${stats.taskSets}`);
|
||||||
|
console.log(` 📝 Project Tasks: ${stats.projectTasks}`);
|
||||||
|
console.log(` 👤 Contacts: ${stats.contacts}`);
|
||||||
|
console.log(` 🔗 Project-Contacts: ${stats.projectContacts}`);
|
||||||
|
console.log(` 📝 Notes: ${stats.notes}`);
|
||||||
|
console.log(` 📊 Audit Logs: ${stats.auditLogs}`);
|
||||||
|
|
||||||
|
console.log('\n✨ Test data generation completed successfully!\n');
|
||||||
|
console.log('💡 Default password for all users: password123\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Error:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -15,7 +15,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit A',
|
unit: 'Unit A',
|
||||||
city: 'Warszawa',
|
city: 'Warszawa',
|
||||||
investment_number: 'INV-2025-001',
|
investment_number: 'INV-2025-001',
|
||||||
|
start_date: '2025-01-15',
|
||||||
finish_date: '2025-06-30',
|
finish_date: '2025-06-30',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-001',
|
wp: 'WP-001',
|
||||||
contact: 'Jan Kowalski, tel. 123-456-789',
|
contact: 'Jan Kowalski, tel. 123-456-789',
|
||||||
notes: 'Modern residential building with 50 apartments',
|
notes: 'Modern residential building with 50 apartments',
|
||||||
@@ -32,7 +34,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit B',
|
unit: 'Unit B',
|
||||||
city: 'Warszawa',
|
city: 'Warszawa',
|
||||||
investment_number: 'INV-2025-002',
|
investment_number: 'INV-2025-002',
|
||||||
|
start_date: '2025-02-01',
|
||||||
finish_date: '2025-09-15',
|
finish_date: '2025-09-15',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-002',
|
wp: 'WP-002',
|
||||||
contact: 'Anna Nowak, tel. 987-654-321',
|
contact: 'Anna Nowak, tel. 987-654-321',
|
||||||
notes: 'Commercial office space, 10 floors',
|
notes: 'Commercial office space, 10 floors',
|
||||||
@@ -49,7 +53,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit C',
|
unit: 'Unit C',
|
||||||
city: 'Kraków',
|
city: 'Kraków',
|
||||||
investment_number: 'INV-2025-003',
|
investment_number: 'INV-2025-003',
|
||||||
|
start_date: '2025-01-10',
|
||||||
finish_date: '2025-12-20',
|
finish_date: '2025-12-20',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-003',
|
wp: 'WP-003',
|
||||||
contact: 'Piotr Wiśniewski, tel. 555-123-456',
|
contact: 'Piotr Wiśniewski, tel. 555-123-456',
|
||||||
notes: 'Large shopping center with parking',
|
notes: 'Large shopping center with parking',
|
||||||
@@ -66,7 +72,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit D',
|
unit: 'Unit D',
|
||||||
city: 'Łódź',
|
city: 'Łódź',
|
||||||
investment_number: 'INV-2025-004',
|
investment_number: 'INV-2025-004',
|
||||||
|
start_date: '2024-11-01',
|
||||||
finish_date: '2025-08-10',
|
finish_date: '2025-08-10',
|
||||||
|
completion_date: '2025-08-05',
|
||||||
wp: 'WP-004',
|
wp: 'WP-004',
|
||||||
contact: 'Maria Lewandowska, tel. 444-789-012',
|
contact: 'Maria Lewandowska, tel. 444-789-012',
|
||||||
notes: 'Logistics warehouse facility',
|
notes: 'Logistics warehouse facility',
|
||||||
@@ -83,7 +91,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit E',
|
unit: 'Unit E',
|
||||||
city: 'Gdańsk',
|
city: 'Gdańsk',
|
||||||
investment_number: 'INV-2025-005',
|
investment_number: 'INV-2025-005',
|
||||||
|
start_date: '2025-01-20',
|
||||||
finish_date: '2025-11-05',
|
finish_date: '2025-11-05',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-005',
|
wp: 'WP-005',
|
||||||
contact: 'Tomasz Malinowski, tel. 333-456-789',
|
contact: 'Tomasz Malinowski, tel. 333-456-789',
|
||||||
notes: 'Luxury hotel with conference facilities',
|
notes: 'Luxury hotel with conference facilities',
|
||||||
@@ -100,7 +110,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit F',
|
unit: 'Unit F',
|
||||||
city: 'Poznań',
|
city: 'Poznań',
|
||||||
investment_number: 'INV-2025-006',
|
investment_number: 'INV-2025-006',
|
||||||
|
start_date: '2025-02-10',
|
||||||
finish_date: '2025-07-20',
|
finish_date: '2025-07-20',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-006',
|
wp: 'WP-006',
|
||||||
contact: 'Ewa Dombrowska, tel. 222-333-444',
|
contact: 'Ewa Dombrowska, tel. 222-333-444',
|
||||||
notes: 'Modern educational facility with sports complex',
|
notes: 'Modern educational facility with sports complex',
|
||||||
@@ -117,7 +129,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit G',
|
unit: 'Unit G',
|
||||||
city: 'Wrocław',
|
city: 'Wrocław',
|
||||||
investment_number: 'INV-2025-007',
|
investment_number: 'INV-2025-007',
|
||||||
|
start_date: '2024-12-15',
|
||||||
finish_date: '2025-10-30',
|
finish_date: '2025-10-30',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-007',
|
wp: 'WP-007',
|
||||||
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
|
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
|
||||||
notes: 'Specialized medical center with emergency department',
|
notes: 'Specialized medical center with emergency department',
|
||||||
@@ -134,7 +148,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit H',
|
unit: 'Unit H',
|
||||||
city: 'Szczecin',
|
city: 'Szczecin',
|
||||||
investment_number: 'INV-2025-008',
|
investment_number: 'INV-2025-008',
|
||||||
|
start_date: '2024-09-01',
|
||||||
finish_date: '2025-05-15',
|
finish_date: '2025-05-15',
|
||||||
|
completion_date: '2025-05-12',
|
||||||
wp: 'WP-008',
|
wp: 'WP-008',
|
||||||
contact: 'Katarzyna Wojcik, tel. 999-888-777',
|
contact: 'Katarzyna Wojcik, tel. 999-888-777',
|
||||||
notes: 'Multi-purpose sports stadium with seating for 20,000',
|
notes: 'Multi-purpose sports stadium with seating for 20,000',
|
||||||
@@ -151,7 +167,9 @@ const sampleProjects = [
|
|||||||
unit: 'Unit I',
|
unit: 'Unit I',
|
||||||
city: 'Lublin',
|
city: 'Lublin',
|
||||||
investment_number: 'INV-2025-009',
|
investment_number: 'INV-2025-009',
|
||||||
|
start_date: '2025-01-05',
|
||||||
finish_date: '2025-08-25',
|
finish_date: '2025-08-25',
|
||||||
|
completion_date: null,
|
||||||
wp: 'WP-009',
|
wp: 'WP-009',
|
||||||
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
|
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
|
||||||
notes: 'Modern library with digital archives and community spaces',
|
notes: 'Modern library with digital archives and community spaces',
|
||||||
@@ -174,9 +192,9 @@ sampleProjects.forEach((projectData, index) => {
|
|||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO projects (
|
INSERT INTO projects (
|
||||||
contract_id, project_name, project_number, address, plot, district, unit, city,
|
contract_id, project_name, project_number, address, plot, district, unit, city,
|
||||||
investment_number, finish_date, wp, contact, notes, coordinates,
|
investment_number, start_date, finish_date, completion_date, wp, contact, notes, coordinates,
|
||||||
project_type, project_status, created_at, updated_at
|
project_type, project_status, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`).run(
|
`).run(
|
||||||
projectData.contract_id,
|
projectData.contract_id,
|
||||||
projectData.project_name,
|
projectData.project_name,
|
||||||
@@ -187,7 +205,9 @@ sampleProjects.forEach((projectData, index) => {
|
|||||||
projectData.unit,
|
projectData.unit,
|
||||||
projectData.city,
|
projectData.city,
|
||||||
projectData.investment_number,
|
projectData.investment_number,
|
||||||
|
projectData.start_date,
|
||||||
projectData.finish_date,
|
projectData.finish_date,
|
||||||
|
projectData.completion_date,
|
||||||
projectData.wp,
|
projectData.wp,
|
||||||
projectData.contact,
|
projectData.contact,
|
||||||
projectData.notes,
|
projectData.notes,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
import { withReadAuth, withTeamLeadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
async function getContractHandler(req, { params }) {
|
async function getContractHandler(req, { params }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -21,6 +21,79 @@ async function getContractHandler(req, { params }) {
|
|||||||
return NextResponse.json(contract);
|
return NextResponse.json(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateContractHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
contract_number,
|
||||||
|
contract_name,
|
||||||
|
customer_contract_number,
|
||||||
|
customer,
|
||||||
|
investor,
|
||||||
|
date_signed,
|
||||||
|
finish_date,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Check if contract exists
|
||||||
|
const existingContract = db
|
||||||
|
.prepare("SELECT * FROM contracts WHERE contract_id = ?")
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!existingContract) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Contract not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the contract
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE contracts
|
||||||
|
SET contract_number = ?,
|
||||||
|
contract_name = ?,
|
||||||
|
customer_contract_number = ?,
|
||||||
|
customer = ?,
|
||||||
|
investor = ?,
|
||||||
|
date_signed = ?,
|
||||||
|
finish_date = ?
|
||||||
|
WHERE contract_id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
contract_number,
|
||||||
|
contract_name || null,
|
||||||
|
customer_contract_number || null,
|
||||||
|
customer || null,
|
||||||
|
investor || null,
|
||||||
|
date_signed || null,
|
||||||
|
finish_date || null,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update contract" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and return the updated contract
|
||||||
|
const updatedContract = db
|
||||||
|
.prepare("SELECT * FROM contracts WHERE contract_id = ?")
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
return NextResponse.json(updatedContract);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating contract:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContractHandler(req, { params }) {
|
async function deleteContractHandler(req, { params }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
@@ -61,4 +134,5 @@ async function deleteContractHandler(req, { params }) {
|
|||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
export const GET = withReadAuth(getContractHandler);
|
export const GET = withReadAuth(getContractHandler);
|
||||||
export const DELETE = withUserAuth(deleteContractHandler);
|
export const PUT = withUserAuth(updateContractHandler);
|
||||||
|
export const DELETE = withTeamLeadAuth(deleteContractHandler);
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export async function GET(request) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate values by contract
|
||||||
|
const contractSummary = {};
|
||||||
|
|
||||||
projects.forEach(project => {
|
projects.forEach(project => {
|
||||||
const value = parseFloat(project.wartosc_zlecenia) || 0;
|
const value = parseFloat(project.wartosc_zlecenia) || 0;
|
||||||
const type = project.project_type;
|
const type = project.project_type;
|
||||||
@@ -46,6 +49,26 @@ export async function GET(request) {
|
|||||||
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
|
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
|
||||||
typeSummary[type].unrealisedValue += value;
|
typeSummary[type].unrealisedValue += value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group by contract
|
||||||
|
if (project.contract_number && project.wartosc_zlecenia && project.project_status !== 'cancelled') {
|
||||||
|
const contractKey = project.contract_number;
|
||||||
|
if (!contractSummary[contractKey]) {
|
||||||
|
contractSummary[contractKey] = {
|
||||||
|
contract_name: project.contract_name || project.contract_number,
|
||||||
|
realisedValue: 0,
|
||||||
|
unrealisedValue: 0,
|
||||||
|
totalValue: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.project_status === 'fulfilled' && project.completion_date) {
|
||||||
|
contractSummary[contractKey].realisedValue += value;
|
||||||
|
} else {
|
||||||
|
contractSummary[contractKey].unrealisedValue += value;
|
||||||
|
}
|
||||||
|
contractSummary[contractKey].totalValue += value;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate overall totals
|
// Calculate overall totals
|
||||||
@@ -132,6 +155,26 @@ export async function GET(request) {
|
|||||||
realisedValue: 158000,
|
realisedValue: 158000,
|
||||||
unrealisedValue: 242000
|
unrealisedValue: 242000
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
byContract: {
|
||||||
|
'UMK/001/2024': {
|
||||||
|
contract_name: 'Modernizacja budynku głównego',
|
||||||
|
realisedValue: 320000,
|
||||||
|
unrealisedValue: 180000,
|
||||||
|
totalValue: 500000
|
||||||
|
},
|
||||||
|
'UMK/002/2024': {
|
||||||
|
contract_name: 'Budowa parkingu wielopoziomowego',
|
||||||
|
realisedValue: 480000,
|
||||||
|
unrealisedValue: 320000,
|
||||||
|
totalValue: 800000
|
||||||
|
},
|
||||||
|
'UMK/003/2024': {
|
||||||
|
contract_name: 'Remont elewacji',
|
||||||
|
realisedValue: 158000,
|
||||||
|
unrealisedValue: 242000,
|
||||||
|
totalValue: 400000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -251,6 +294,17 @@ export async function GET(request) {
|
|||||||
unrealisedValue: Math.round(data.unrealisedValue)
|
unrealisedValue: Math.round(data.unrealisedValue)
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
),
|
||||||
|
byContract: Object.fromEntries(
|
||||||
|
Object.entries(contractSummary).map(([contractNumber, data]) => [
|
||||||
|
contractNumber,
|
||||||
|
{
|
||||||
|
contract_name: data.contract_name,
|
||||||
|
realisedValue: Math.round(data.realisedValue),
|
||||||
|
unrealisedValue: Math.round(data.unrealisedValue),
|
||||||
|
totalValue: Math.round(data.totalValue)
|
||||||
|
}
|
||||||
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { logFieldChange } from "@/lib/queries/fieldHistory";
|
|||||||
import { addNoteToProject } from "@/lib/queries/notes";
|
import { addNoteToProject } from "@/lib/queries/notes";
|
||||||
import initializeDatabase from "@/lib/init-db";
|
import initializeDatabase from "@/lib/init-db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
import { withReadAuth, withUserAuth, withTeamLeadAuth } from "@/lib/middleware/auth";
|
||||||
import {
|
import {
|
||||||
logApiActionSafe,
|
logApiActionSafe,
|
||||||
AUDIT_ACTIONS,
|
AUDIT_ACTIONS,
|
||||||
@@ -155,4 +155,4 @@ async function deleteProjectHandler(req, { params }) {
|
|||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
export const GET = withReadAuth(getProjectHandler);
|
export const GET = withReadAuth(getProjectHandler);
|
||||||
export const PUT = withUserAuth(updateProjectHandler);
|
export const PUT = withUserAuth(updateProjectHandler);
|
||||||
export const DELETE = withUserAuth(deleteProjectHandler);
|
export const DELETE = withTeamLeadAuth(deleteProjectHandler);
|
||||||
|
|||||||
@@ -207,7 +207,10 @@ export default function ContactsPage() {
|
|||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||||
<Card>
|
<Card
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'all' ? 'ring-2 ring-gray-900' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('all')}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
{stats.total_contacts}
|
{stats.total_contacts}
|
||||||
@@ -215,7 +218,10 @@ export default function ContactsPage() {
|
|||||||
<div className="text-sm text-gray-600">Wszystkie</div>
|
<div className="text-sm text-gray-600">Wszystkie</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'project' ? 'ring-2 ring-blue-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('project')}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
{stats.project_contacts}
|
{stats.project_contacts}
|
||||||
@@ -223,7 +229,10 @@ export default function ContactsPage() {
|
|||||||
<div className="text-sm text-gray-600">Projekty</div>
|
<div className="text-sm text-gray-600">Projekty</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'contractor' ? 'ring-2 ring-orange-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('contractor')}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold text-orange-600">
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
{stats.contractor_contacts}
|
{stats.contractor_contacts}
|
||||||
@@ -231,7 +240,10 @@ export default function ContactsPage() {
|
|||||||
<div className="text-sm text-gray-600">Wykonawcy</div>
|
<div className="text-sm text-gray-600">Wykonawcy</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'office' ? 'ring-2 ring-purple-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('office')}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
{stats.office_contacts}
|
{stats.office_contacts}
|
||||||
@@ -239,7 +251,10 @@ export default function ContactsPage() {
|
|||||||
<div className="text-sm text-gray-600">Urzędy</div>
|
<div className="text-sm text-gray-600">Urzędy</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'supplier' ? 'ring-2 ring-green-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('supplier')}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<div className="text-2xl font-bold text-green-600">
|
||||||
{stats.supplier_contacts}
|
{stats.supplier_contacts}
|
||||||
@@ -247,7 +262,10 @@ export default function ContactsPage() {
|
|||||||
<div className="text-sm text-gray-600">Dostawcy</div>
|
<div className="text-sm text-gray-600">Dostawcy</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'other' ? 'ring-2 ring-gray-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('other')}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="text-2xl font-bold text-gray-600">
|
<div className="text-2xl font-bold text-gray-600">
|
||||||
{stats.other_contacts}
|
{stats.other_contacts}
|
||||||
|
|||||||
139
src/app/contracts/[id]/edit/page.js
Normal file
139
src/app/contracts/[id]/edit/page.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import ContractForm from "@/components/ContractForm";
|
||||||
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LoadingState } from "@/components/ui/States";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function EditContractPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id;
|
||||||
|
const [contract, setContract] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchContract() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/contracts/${id}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch contract");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setContract(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contract:", error);
|
||||||
|
setError("Nie udało się pobrać danych umowy.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
fetchContract();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={t('contracts.editContract')}
|
||||||
|
description={t('contracts.editContractDescription')}
|
||||||
|
/>
|
||||||
|
<LoadingState message={t('navigation.loading')} />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !contract) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={t('contracts.editContract')}
|
||||||
|
description={t('contracts.editContractDescription')}
|
||||||
|
/>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start mb-6">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-red-600 mr-3 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800">
|
||||||
|
{error || "Nie znaleziono umowy."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/contracts">
|
||||||
|
<Button variant="outline">
|
||||||
|
<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="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('contracts.backToContracts')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={t('contracts.editContract')}
|
||||||
|
description={`${t('contracts.editing')} ${contract.contract_number}`}
|
||||||
|
action={
|
||||||
|
<Link href={`/contracts/${id}`}>
|
||||||
|
<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="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('contracts.backToContract')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ContractForm initialData={contract} />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -113,6 +113,24 @@ export default function ContractDetailsPage() {
|
|||||||
{t('contracts.backToContracts')}
|
{t('contracts.backToContracts')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={`/contracts/${contractId}/edit`}>
|
||||||
|
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('contracts.editContract')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||||
<Button variant="primary" size="sm">
|
<Button variant="primary" size="sm">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
@@ -15,6 +16,7 @@ import { useTranslation } from "@/lib/i18n";
|
|||||||
|
|
||||||
export default function ContractsMainPage() {
|
export default function ContractsMainPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = useSession();
|
||||||
const [contracts, setContracts] = useState([]);
|
const [contracts, setContracts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@@ -22,17 +24,27 @@ export default function ContractsMainPage() {
|
|||||||
const [sortBy, setSortBy] = useState("date_signed");
|
const [sortBy, setSortBy] = useState("date_signed");
|
||||||
const [sortOrder, setSortOrder] = useState("desc");
|
const [sortOrder, setSortOrder] = useState("desc");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [contractToDelete, setContractToDelete] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [success, setSuccess] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchContracts() {
|
async function fetchContracts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/contracts");
|
const res = await fetch("/api/contracts");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch contracts");
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setContracts(data);
|
setContracts(data);
|
||||||
setFilteredContracts(data);
|
setFilteredContracts(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching contracts:", error);
|
console.error("Error fetching contracts:", error);
|
||||||
|
setError("Nie udało się pobrać listy umów. Spróbuj ponownie później.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -93,27 +105,6 @@ export default function ContractsMainPage() {
|
|||||||
setFilteredContracts(filtered);
|
setFilteredContracts(filtered);
|
||||||
}, [searchTerm, contracts, sortBy, sortOrder, statusFilter]);
|
}, [searchTerm, contracts, sortBy, sortOrder, statusFilter]);
|
||||||
|
|
||||||
async function handleDelete(id) {
|
|
||||||
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/contracts/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setContracts(contracts.filter((c) => c.contract_id !== id));
|
|
||||||
} else {
|
|
||||||
alert("Błąd podczas usuwania umowy.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting contract:", error);
|
|
||||||
alert("Błąd podczas usuwania umowy.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get contract statistics
|
|
||||||
const getContractStats = () => {
|
const getContractStats = () => {
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const total = contracts.length;
|
const total = contracts.length;
|
||||||
@@ -148,25 +139,50 @@ export default function ContractsMainPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleDelete(id) {
|
const initiateDelete = (contract) => {
|
||||||
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
|
setContractToDelete(contract);
|
||||||
if (!confirmed) return;
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!contractToDelete) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/contracts/${id}`, {
|
const res = await fetch(`/api/contracts/${contractToDelete.contract_id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setContracts(contracts.filter((c) => c.contract_id !== id));
|
setContracts(contracts.filter((c) => c.contract_id !== contractToDelete.contract_id));
|
||||||
|
setSuccess(`Umowa "${contractToDelete.contract_number}" została usunięta.`);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setContractToDelete(null);
|
||||||
|
|
||||||
|
// Auto-hide success message after 5 seconds
|
||||||
|
setTimeout(() => setSuccess(null), 5000);
|
||||||
} else {
|
} else {
|
||||||
alert("Błąd podczas usuwania umowy.");
|
setError(data.error || "Nie udało się usunąć umowy.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting contract:", error);
|
console.error("Error deleting contract:", error);
|
||||||
alert("Błąd podczas usuwania umowy.");
|
setError("Wystąpił błąd podczas usuwania umowy. Spróbuj ponownie.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDelete = () => {
|
||||||
|
if (!deleting) {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setContractToDelete(null);
|
||||||
|
setError(null);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchChange = (e) => {
|
const handleSearchChange = (e) => {
|
||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
@@ -264,6 +280,67 @@ export default function ContractsMainPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-green-600 mr-3 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-800">{success}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuccess(null)}
|
||||||
|
className="text-green-600 hover:text-green-800 ml-3"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-red-600 mr-3 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-600 hover:text-red-800 ml-3"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -573,10 +650,11 @@ export default function ContractsMainPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Szczegóły
|
Szczegóły
|
||||||
</Link>
|
</Link>
|
||||||
|
{session?.user?.role === 'team_lead' && (
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(contract.contract_id)}
|
onClick={() => initiateDelete(contract)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-1"
|
className="w-4 h-4 mr-1"
|
||||||
@@ -593,6 +671,7 @@ export default function ContractsMainPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Usuń
|
Usuń
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -620,6 +699,124 @@ export default function ContractsMainPage() {
|
|||||||
</p>{" "}
|
</p>{" "}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && contractToDelete && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !deleting && cancelDelete()}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="ml-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Potwierdź usunięcie
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{!deleting && (
|
||||||
|
<button
|
||||||
|
onClick={cancelDelete}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Czy na pewno chcesz usunąć umowę <strong className="font-semibold">"{contractToDelete.contract_number}"</strong>
|
||||||
|
{contractToDelete.contract_name && (
|
||||||
|
<> — <strong className="font-semibold">{contractToDelete.contract_name}</strong></>
|
||||||
|
)}?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
Ta operacja jest nieodwracalna.
|
||||||
|
</p>
|
||||||
|
{contractToDelete.customer && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Zleceniodawca: <strong>{contractToDelete.customer}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={cancelDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline-block"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Usuwanie...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2 inline-block"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Usuń umowę
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,87 @@ export default function TeamLeadsDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* By Contract Section */}
|
||||||
|
{summaryData?.byContract && Object.keys(summaryData.byContract).length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
{t('teamDashboard.byContract')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="h-96">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={Object.entries(summaryData.byContract).map(([contractNumber, data]) => ({
|
||||||
|
name: contractNumber,
|
||||||
|
fullName: data.contract_name,
|
||||||
|
realised: data.realisedValue,
|
||||||
|
unrealised: data.unrealisedValue,
|
||||||
|
total: data.totalValue
|
||||||
|
}))}
|
||||||
|
margin={{
|
||||||
|
top: 20,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={100}
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
fontSize={11}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
fontSize={12}
|
||||||
|
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white mb-2">{payload[0].payload.fullName}</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{payload[0].payload.name}</p>
|
||||||
|
<p className="text-green-600 dark:text-green-400 text-sm">
|
||||||
|
{`${t('teamDashboard.realised')}: ${formatCurrency(payload[0].payload.realised)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-purple-600 dark:text-purple-400 text-sm">
|
||||||
|
{`${t('teamDashboard.unrealised')}: ${formatCurrency(payload[0].payload.unrealised)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-600 dark:text-blue-400 text-sm font-semibold mt-1">
|
||||||
|
{`${t('teamDashboard.total')}: ${formatCurrency(payload[0].payload.total)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="realised"
|
||||||
|
stackId="a"
|
||||||
|
fill="#10b981"
|
||||||
|
name={t('teamDashboard.realised')}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="unrealised"
|
||||||
|
stackId="a"
|
||||||
|
fill="#8b5cf6"
|
||||||
|
name={t('teamDashboard.unrealised')}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import ProjectForm from "@/components/ProjectForm";
|
import ProjectForm from "@/components/ProjectForm";
|
||||||
import PageContainer from "@/components/ui/PageContainer";
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
@@ -9,16 +9,44 @@ import Button from "@/components/ui/Button";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LoadingState } from "@/components/ui/States";
|
import { LoadingState } from "@/components/ui/States";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
export default function EditProjectPage() {
|
export default function EditProjectPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
const [project, setProject] = useState(null);
|
const [project, setProject] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = useSession();
|
||||||
const formRef = useRef();
|
const formRef = useRef();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/projects');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || 'Błąd podczas usuwania projektu');
|
||||||
|
setDeleting(false);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', error);
|
||||||
|
alert('Błąd podczas usuwania projektu');
|
||||||
|
setDeleting(false);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProject = async () => {
|
const fetchProject = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -130,7 +158,159 @@ export default function EditProjectPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<ProjectForm ref={formRef} initialData={project} />
|
<ProjectForm ref={formRef} initialData={project} />
|
||||||
|
|
||||||
|
{/* Delete Button - Only for team_lead */}
|
||||||
|
{session?.user?.role === 'team_lead' && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">
|
||||||
|
Usuwanie projektu
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Operacja nieodwracalna. Wszystkie powiązane dane zostaną trwale usunięte.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteModal(true)}
|
||||||
|
>
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Usuń projekt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !deleting && setShowDeleteModal(false)}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="ml-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Potwierdź usunięcie
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{!deleting && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Czy na pewno chcesz usunąć projekt <strong className="font-semibold">"{project?.project_name}"</strong>?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
Ta operacja jest nieodwracalna. Zostaną usunięte wszystkie powiązane dane, w tym:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
|
||||||
|
<li>Notatki projektu</li>
|
||||||
|
<li>Załączone pliki</li>
|
||||||
|
<li>Zadania projektu</li>
|
||||||
|
<li>Historia zmian</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteModal(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Usuwanie...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Tak, usuń projekt
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ export default function ProjectViewPage() {
|
|||||||
const [editText, setEditText] = useState('');
|
const [editText, setEditText] = useState('');
|
||||||
const [projectContacts, setProjectContacts] = useState([]);
|
const [projectContacts, setProjectContacts] = useState([]);
|
||||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Helper function to copy WP/Investment number to clipboard
|
||||||
|
const handleCopyReference = async () => {
|
||||||
|
const wp = project.wp || '';
|
||||||
|
const investmentNumber = project.investment_number ? project.investment_number.split('-').pop() : "";
|
||||||
|
const reference = `${wp}/${investmentNumber}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reference);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
alert('Nie udało się skopiować');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to parse note text with links
|
// Helper function to parse note text with links
|
||||||
const parseNoteText = (text) => {
|
const parseNoteText = (text) => {
|
||||||
@@ -446,7 +463,17 @@ export default function ProjectViewPage() {
|
|||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{project.unit || "N/A"}
|
{project.unit || "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>{" "}
|
</div>
|
||||||
|
{project.start_date && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
|
Data wpływu
|
||||||
|
</span>
|
||||||
|
<p className="text-gray-900 font-medium">
|
||||||
|
{formatDate(project.start_date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<FieldWithHistory
|
<FieldWithHistory
|
||||||
tableName="projects"
|
tableName="projects"
|
||||||
recordId={project.project_id}
|
recordId={project.project_id}
|
||||||
@@ -457,7 +484,7 @@ export default function ProjectViewPage() {
|
|||||||
{project.completion_date && (
|
{project.completion_date && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Data zakończenia projektu
|
Data odbioru
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{formatDate(project.completion_date)}
|
{formatDate(project.completion_date)}
|
||||||
@@ -736,72 +763,6 @@ export default function ProjectViewPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<Link href={`/projects/${params.id}/edit`} 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Edytuj projekt
|
|
||||||
</Button>
|
|
||||||
</Link>{" "}
|
|
||||||
<Link href="/projects" 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="M15 19l-7-7 7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Powrót do projektów
|
|
||||||
</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>
|
|
||||||
Zobacz wszystkie na mapie
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -823,6 +784,48 @@ export default function ProjectViewPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Generuj dokument
|
Generuj dokument
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleCopyReference}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-green-600">Skopiowano!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Kopiuj {project.wp || 'N/A'}/{project.investment_number ? project.investment_number.split('-').pop() : ""}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export default function ProjectListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [customers, setCustomers] = useState([]);
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [sortConfig, setSortConfig] = useState({
|
||||||
|
key: 'finish_date',
|
||||||
|
direction: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
|
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,8 +129,44 @@ export default function ProjectListPage() {
|
|||||||
setSearchMatchType(null);
|
setSearchMatchType(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered = [...filtered].sort((a, b) => {
|
||||||
|
let aVal = a[sortConfig.key];
|
||||||
|
let bVal = b[sortConfig.key];
|
||||||
|
|
||||||
|
// Handle null/undefined
|
||||||
|
if (!aVal && !bVal) return 0;
|
||||||
|
if (!aVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
if (!bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
|
||||||
|
// Handle dates
|
||||||
|
if (sortConfig.key === 'finish_date') {
|
||||||
|
aVal = new Date(aVal);
|
||||||
|
bVal = new Date(bVal);
|
||||||
|
}
|
||||||
|
// Handle numbers (project_number)
|
||||||
|
else if (sortConfig.key === 'project_number') {
|
||||||
|
// Extract numeric part if it's a string like "P-123" or "123"
|
||||||
|
const aNum = parseInt(String(aVal).replace(/\D/g, '')) || 0;
|
||||||
|
const bNum = parseInt(String(bVal).replace(/\D/g, '')) || 0;
|
||||||
|
aVal = aNum;
|
||||||
|
bVal = bNum;
|
||||||
|
}
|
||||||
|
// Handle strings
|
||||||
|
else if (typeof aVal === 'string') {
|
||||||
|
aVal = aVal.toLowerCase();
|
||||||
|
bVal = String(bVal).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setFilteredProjects(filtered);
|
setFilteredProjects(filtered);
|
||||||
}, [searchTerm, projects, filters, session]);
|
}, [searchTerm, projects, filters, session, sortConfig]);
|
||||||
|
|
||||||
async function handleDelete(id) {
|
async function handleDelete(id) {
|
||||||
const confirmed = confirm(t('projects.deleteConfirm'));
|
const confirmed = confirm(t('projects.deleteConfirm'));
|
||||||
@@ -171,6 +211,13 @@ export default function ProjectListPage() {
|
|||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
setSortConfig(prev => ({
|
||||||
|
key,
|
||||||
|
direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleExportExcel = async () => {
|
const handleExportExcel = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/projects/export');
|
const response = await fetch('/api/projects/export');
|
||||||
@@ -228,6 +275,42 @@ export default function ProjectListPage() {
|
|||||||
default: return "-";
|
default: return "-";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sortable header component
|
||||||
|
const SortableHeader = ({ columnKey, label, className = "" }) => {
|
||||||
|
const isSorted = sortConfig.key === columnKey;
|
||||||
|
const direction = isSorted ? sortConfig.direction : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors select-none ${className}`}
|
||||||
|
onClick={() => handleSort(columnKey)}
|
||||||
|
title={`Sort by ${label}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-gray-400 flex-shrink-0">
|
||||||
|
{!isSorted && (
|
||||||
|
<svg className="w-3 h-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isSorted && direction === 'asc' && (
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 14l5-5 5 5H7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isSorted && direction === 'desc' && (
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 10l5 5 5-5H7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
|
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
|
||||||
@@ -606,9 +689,30 @@ export default function ProjectListPage() {
|
|||||||
|
|
||||||
{/* Results and clear button row */}
|
{/* Results and clear button row */}
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
|
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
|
||||||
</div>
|
</div>
|
||||||
|
{sortConfig.key && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<span>•</span>
|
||||||
|
<span>Sortowanie:</span>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{sortConfig.key === 'project_number' && 'Nr.'}
|
||||||
|
{sortConfig.key === 'project_name' && t('projects.projectName')}
|
||||||
|
{sortConfig.key === 'address' && t('projects.address')}
|
||||||
|
{sortConfig.key === 'wp' && 'WP'}
|
||||||
|
{sortConfig.key === 'city' && t('projects.city')}
|
||||||
|
{sortConfig.key === 'plot' && t('projects.plot')}
|
||||||
|
{sortConfig.key === 'finish_date' && t('projects.finishDate')}
|
||||||
|
{sortConfig.key === 'project_type' && t('common.type')}
|
||||||
|
{sortConfig.key === 'project_status' && t('common.status')}
|
||||||
|
{sortConfig.key === 'assigned_to' && t('projects.assigned')}
|
||||||
|
</span>
|
||||||
|
<span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
|
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
|
||||||
<Button
|
<Button
|
||||||
@@ -699,36 +803,56 @@ export default function ProjectListPage() {
|
|||||||
<table className="w-full min-w-[600px] table-fixed">
|
<table className="w-full min-w-[600px] table-fixed">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-100 dark:bg-gray-700 border-b dark:border-gray-600">
|
<tr className="bg-gray-100 dark:bg-gray-700 border-b dark:border-gray-600">
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24">
|
<SortableHeader
|
||||||
Nr.
|
columnKey="project_number"
|
||||||
</th>
|
label="Nr."
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-[200px] md:w-[250px]">
|
className="w-20 md:w-24"
|
||||||
{t('projects.projectName')}
|
/>
|
||||||
</th>
|
<SortableHeader
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24 hidden lg:table-cell">
|
columnKey="project_name"
|
||||||
{t('projects.address')}
|
label={t('projects.projectName')}
|
||||||
</th>
|
className="w-[200px] md:w-[250px]"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-16 md:w-20 hidden sm:table-cell">
|
/>
|
||||||
WP
|
<SortableHeader
|
||||||
</th>
|
columnKey="address"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden md:table-cell">
|
label={t('projects.address')}
|
||||||
{t('projects.city')}
|
className="w-20 md:w-24 hidden lg:table-cell"
|
||||||
</th>
|
/>
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden sm:table-cell">
|
<SortableHeader
|
||||||
{t('projects.plot')}
|
columnKey="wp"
|
||||||
</th>
|
label="WP"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-18 md:w-20 hidden md:table-cell">
|
className="w-16 md:w-20 hidden sm:table-cell"
|
||||||
{t('projects.finishDate')}
|
/>
|
||||||
</th>
|
<SortableHeader
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
|
columnKey="city"
|
||||||
{t('common.type') || 'Typ'}
|
label={t('projects.city')}
|
||||||
</th>
|
className="w-14 md:w-16 hidden md:table-cell"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
|
/>
|
||||||
{t('common.status') || 'Status'}
|
<SortableHeader
|
||||||
</th>
|
columnKey="plot"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16">
|
label={t('projects.plot')}
|
||||||
{t('projects.assigned') || 'Przypisany'}
|
className="w-14 md:w-16 hidden sm:table-cell"
|
||||||
</th>
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="finish_date"
|
||||||
|
label={t('projects.finishDate')}
|
||||||
|
className="w-18 md:w-20 hidden md:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="project_type"
|
||||||
|
label={t('common.type') || 'Typ'}
|
||||||
|
className="w-10"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="project_status"
|
||||||
|
label={t('common.status') || 'Status'}
|
||||||
|
className="w-10"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="assigned_to"
|
||||||
|
label={t('projects.assigned') || 'Przypisany'}
|
||||||
|
className="w-14 md:w-16"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
@@ -8,8 +8,9 @@ import { Input } from "@/components/ui/Input";
|
|||||||
import { formatDateForInput } from "@/lib/utils";
|
import { formatDateForInput } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function ContractForm() {
|
export default function ContractForm({ initialData = null }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isEdit = !!initialData;
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
contract_number: "",
|
contract_number: "",
|
||||||
contract_name: "",
|
contract_name: "",
|
||||||
@@ -23,6 +24,21 @@ export default function ContractForm() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Update form when initialData changes (for edit mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
contract_number: initialData.contract_number || "",
|
||||||
|
contract_name: initialData.contract_name || "",
|
||||||
|
customer_contract_number: initialData.customer_contract_number || "",
|
||||||
|
customer: initialData.customer || "",
|
||||||
|
investor: initialData.investor || "",
|
||||||
|
date_signed: initialData.date_signed || "",
|
||||||
|
finish_date: initialData.finish_date || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
function handleChange(e) {
|
function handleChange(e) {
|
||||||
setForm({ ...form, [e.target.name]: e.target.value });
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
}
|
}
|
||||||
@@ -34,21 +50,32 @@ export default function ContractForm() {
|
|||||||
try {
|
try {
|
||||||
console.log("Submitting form:", form);
|
console.log("Submitting form:", form);
|
||||||
|
|
||||||
const res = await fetch("/api/contracts", {
|
const url = isEdit
|
||||||
method: "POST",
|
? `/api/contracts/${initialData.contract_id}`
|
||||||
|
: "/api/contracts";
|
||||||
|
const method = isEdit ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(form),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const contract = await res.json();
|
const contract = await res.json();
|
||||||
router.push(`/contracts/${contract.contract_id}`);
|
router.push(`/contracts/${contract.contract_id || initialData.contract_id}`);
|
||||||
} else {
|
} else {
|
||||||
alert(t('contracts.failedToCreateContract'));
|
const errorMessage = isEdit
|
||||||
|
? t('contracts.failedToUpdateContract')
|
||||||
|
: t('contracts.failedToCreateContract');
|
||||||
|
alert(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating contract:", error);
|
console.error(`Error ${isEdit ? 'updating' : 'creating'} contract:`, error);
|
||||||
alert(t('contracts.failedToCreateContract'));
|
const errorMessage = isEdit
|
||||||
|
? t('contracts.failedToUpdateContract')
|
||||||
|
: t('contracts.failedToCreateContract');
|
||||||
|
alert(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -189,7 +216,7 @@ export default function ContractForm() {
|
|||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('common.creating')}
|
{isEdit ? t('common.updating') : t('common.creating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -203,10 +230,10 @@ export default function ContractForm() {
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M12 4v16m8-8H4"
|
d={isEdit ? "M5 13l4 4L19 7" : "M12 4v16m8-8H4"}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('contracts.createContract')}
|
{isEdit ? t('contracts.updateContract') : t('contracts.createContract')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
|||||||
unit: "",
|
unit: "",
|
||||||
city: "",
|
city: "",
|
||||||
investment_number: "",
|
investment_number: "",
|
||||||
|
start_date: "",
|
||||||
finish_date: "",
|
finish_date: "",
|
||||||
completion_date: "",
|
completion_date: "",
|
||||||
wp: "",
|
wp: "",
|
||||||
@@ -63,6 +64,7 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
|||||||
unit: "",
|
unit: "",
|
||||||
city: "",
|
city: "",
|
||||||
investment_number: "",
|
investment_number: "",
|
||||||
|
start_date: "",
|
||||||
finish_date: "",
|
finish_date: "",
|
||||||
completion_date: "",
|
completion_date: "",
|
||||||
wp: "",
|
wp: "",
|
||||||
@@ -78,6 +80,9 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
|||||||
assigned_to: initialData.assigned_to || "",
|
assigned_to: initialData.assigned_to || "",
|
||||||
wartosc_zlecenia: initialData.wartosc_zlecenia || "",
|
wartosc_zlecenia: initialData.wartosc_zlecenia || "",
|
||||||
// Format dates for input if they exist
|
// Format dates for input if they exist
|
||||||
|
start_date: initialData.start_date
|
||||||
|
? formatDateForInput(initialData.start_date)
|
||||||
|
: "",
|
||||||
finish_date: initialData.finish_date
|
finish_date: initialData.finish_date
|
||||||
? formatDateForInput(initialData.finish_date)
|
? formatDateForInput(initialData.finish_date)
|
||||||
: "",
|
: "",
|
||||||
@@ -292,7 +297,19 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
{t('projects.finishDate')}
|
Data wpływu
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
name="start_date"
|
||||||
|
value={formatDateForInput(form.start_date)}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Termin zakończenia
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -304,7 +321,7 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Data zakończenia projektu
|
Data odbioru
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ const translations = {
|
|||||||
realisedValue: "Wartość zrealizowana",
|
realisedValue: "Wartość zrealizowana",
|
||||||
unrealisedValue: "Wartość niezrealizowana",
|
unrealisedValue: "Wartość niezrealizowana",
|
||||||
byProjectType: "Według typu projektu",
|
byProjectType: "Według typu projektu",
|
||||||
|
byContract: "Według umowy",
|
||||||
|
total: "Razem",
|
||||||
monthLabel: "Miesiąc:",
|
monthLabel: "Miesiąc:",
|
||||||
monthlyValue: "Wartość miesięczna:",
|
monthlyValue: "Wartość miesięczna:",
|
||||||
cumulative: "Skumulowana:",
|
cumulative: "Skumulowana:",
|
||||||
@@ -184,7 +186,7 @@ const translations = {
|
|||||||
plot: "Działka",
|
plot: "Działka",
|
||||||
district: "Jednostka ewidencyjna",
|
district: "Jednostka ewidencyjna",
|
||||||
unit: "Obręb",
|
unit: "Obręb",
|
||||||
finishDate: "Data zakończenia",
|
finishDate: "Termin zakończenia",
|
||||||
type: "Typ",
|
type: "Typ",
|
||||||
contact: "Kontakt",
|
contact: "Kontakt",
|
||||||
coordinates: "Współrzędne",
|
coordinates: "Współrzędne",
|
||||||
@@ -285,6 +287,11 @@ const translations = {
|
|||||||
contract: "Umowa",
|
contract: "Umowa",
|
||||||
newContract: "Nowa umowa",
|
newContract: "Nowa umowa",
|
||||||
editContract: "Edytuj umowę",
|
editContract: "Edytuj umowę",
|
||||||
|
editContractDescription: "Edycja szczegółów umowy",
|
||||||
|
editing: "Edycja umowy",
|
||||||
|
backToContract: "Powrót do umowy",
|
||||||
|
updateContract: "Zaktualizuj umowę",
|
||||||
|
failedToUpdateContract: "Nie udało się zaktualizować umowy. Sprawdź dane i spróbuj ponownie.",
|
||||||
deleteContract: "Usuń umowę",
|
deleteContract: "Usuń umowę",
|
||||||
contractNumber: "Numer umowy",
|
contractNumber: "Numer umowy",
|
||||||
contractName: "Nazwa umowy",
|
contractName: "Nazwa umowy",
|
||||||
@@ -292,7 +299,7 @@ const translations = {
|
|||||||
customer: "Klient",
|
customer: "Klient",
|
||||||
investor: "Inwestor",
|
investor: "Inwestor",
|
||||||
dateSigned: "Data zawarcia",
|
dateSigned: "Data zawarcia",
|
||||||
finishDate: "Data zakończenia",
|
finishDate: "Termin zakończenia",
|
||||||
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
|
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
|
||||||
noContracts: "Brak umów",
|
noContracts: "Brak umów",
|
||||||
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
|
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
|
||||||
@@ -323,7 +330,7 @@ const translations = {
|
|||||||
customer: "Klient",
|
customer: "Klient",
|
||||||
investor: "Inwestor",
|
investor: "Inwestor",
|
||||||
dateSigned: "Data zawarcia",
|
dateSigned: "Data zawarcia",
|
||||||
finishDate: "Data zakończenia",
|
finishDate: "Termin zakończenia",
|
||||||
summary: "Podsumowanie",
|
summary: "Podsumowanie",
|
||||||
projectsCount: "Liczba projektów",
|
projectsCount: "Liczba projektów",
|
||||||
projects: "projektów",
|
projects: "projektów",
|
||||||
@@ -532,7 +539,7 @@ const translations = {
|
|||||||
dateCreated: "Data utworzenia",
|
dateCreated: "Data utworzenia",
|
||||||
dateModified: "Data modyfikacji",
|
dateModified: "Data modyfikacji",
|
||||||
startDate: "Data rozpoczęcia",
|
startDate: "Data rozpoczęcia",
|
||||||
finishDate: "Data zakończenia"
|
finishDate: "Termin zakończenia"
|
||||||
},
|
},
|
||||||
|
|
||||||
// Date formats
|
// Date formats
|
||||||
@@ -769,6 +776,8 @@ const translations = {
|
|||||||
realisedValue: "Realised Value",
|
realisedValue: "Realised Value",
|
||||||
unrealisedValue: "Unrealised Value",
|
unrealisedValue: "Unrealised Value",
|
||||||
byProjectType: "By Project Type",
|
byProjectType: "By Project Type",
|
||||||
|
byContract: "By Contract",
|
||||||
|
total: "Total",
|
||||||
monthLabel: "Month:",
|
monthLabel: "Month:",
|
||||||
monthlyValue: "Monthly Value:",
|
monthlyValue: "Monthly Value:",
|
||||||
cumulative: "Cumulative:",
|
cumulative: "Cumulative:",
|
||||||
@@ -936,8 +945,14 @@ const translations = {
|
|||||||
contracts: {
|
contracts: {
|
||||||
title: "Contracts",
|
title: "Contracts",
|
||||||
subtitle: "Manage your contracts and agreements",
|
subtitle: "Manage your contracts and agreements",
|
||||||
|
contract: "Contract",
|
||||||
newContract: "New Contract",
|
newContract: "New Contract",
|
||||||
editContract: "Edit Contract",
|
editContract: "Edit Contract",
|
||||||
|
editContractDescription: "Edit contract details",
|
||||||
|
editing: "Editing contract",
|
||||||
|
backToContract: "Back to Contract",
|
||||||
|
updateContract: "Update Contract",
|
||||||
|
failedToUpdateContract: "Failed to update contract. Please check your data and try again.",
|
||||||
deleteContract: "Delete Contract",
|
deleteContract: "Delete Contract",
|
||||||
contractNumber: "Contract Number",
|
contractNumber: "Contract Number",
|
||||||
contractName: "Contract Name",
|
contractName: "Contract Name",
|
||||||
@@ -964,7 +979,40 @@ const translations = {
|
|||||||
signedOn: "Signed:",
|
signedOn: "Signed:",
|
||||||
finishOn: "Finish:",
|
finishOn: "Finish:",
|
||||||
customerLabel: "Customer:",
|
customerLabel: "Customer:",
|
||||||
investorLabel: "Investor:"
|
investorLabel: "Investor:",
|
||||||
|
loadingContractDetails: "Loading contract details...",
|
||||||
|
contractNotFound: "Contract not found.",
|
||||||
|
backToContracts: "Back to Contracts",
|
||||||
|
addProject: "Add Project",
|
||||||
|
contractInformation: "Contract Information",
|
||||||
|
summary: "Summary",
|
||||||
|
projectsCount: "Projects Count",
|
||||||
|
projects: "projects",
|
||||||
|
contractStatus: "Contract Status",
|
||||||
|
contractDocuments: "Contract Documents",
|
||||||
|
uploadDocument: "Upload Document",
|
||||||
|
associatedProjects: "Associated Projects",
|
||||||
|
noProjectsYet: "No projects yet",
|
||||||
|
getStartedMessage: "Get started by creating the first project for this contract",
|
||||||
|
createFirstProject: "Create First Project",
|
||||||
|
viewDetails: "View Details",
|
||||||
|
createNewContract: "Create New Contract",
|
||||||
|
addNewContractDescription: "Add a new contract to your portfolio",
|
||||||
|
contractDetails: "Contract Details",
|
||||||
|
failedToCreateContract: "Failed to create contract. Please check your data and try again.",
|
||||||
|
uploadDocumentTitle: "Upload Document",
|
||||||
|
descriptionOptional: "Description (optional)",
|
||||||
|
descriptionPlaceholder: "Short description of the document...",
|
||||||
|
uploading: "Uploading...",
|
||||||
|
dropFilesHere: "Drop files here or click to browse",
|
||||||
|
supportedFiles: "PDF, DOC, XLS, Images up to 10MB",
|
||||||
|
chooseFile: "Choose File",
|
||||||
|
failedToUploadFile: "Failed to upload file",
|
||||||
|
loadingFiles: "Loading files...",
|
||||||
|
noDocumentsUploaded: "No documents uploaded",
|
||||||
|
download: "Download",
|
||||||
|
confirmDeleteFile: "Are you sure you want to delete this file?",
|
||||||
|
failedToDeleteFile: "Failed to delete file"
|
||||||
},
|
},
|
||||||
|
|
||||||
tasks: {
|
tasks: {
|
||||||
|
|||||||
@@ -289,6 +289,14 @@ export default function initializeDatabase() {
|
|||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE projects ADD COLUMN start_date TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: Update task status system - add 'not_started' status
|
// Migration: Update task status system - add 'not_started' status
|
||||||
// DISABLED: This migration was running on every init and converting legitimate
|
// DISABLED: This migration was running on every init and converting legitimate
|
||||||
// 'pending' tasks back to 'not_started'. The initial migration has been completed.
|
// 'pending' tasks back to 'not_started'. The initial migration has been completed.
|
||||||
@@ -389,6 +397,34 @@ export default function initializeDatabase() {
|
|||||||
console.warn("Migration warning:", e.message);
|
console.warn("Migration warning:", e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Add initial column to users table
|
||||||
|
try {
|
||||||
|
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
const hasInitial = columns.some(col => col.name === 'initial');
|
||||||
|
|
||||||
|
if (!hasInitial) {
|
||||||
|
// Add initial column
|
||||||
|
db.exec(`ALTER TABLE users ADD COLUMN initial TEXT;`);
|
||||||
|
|
||||||
|
// Generate initials from existing names
|
||||||
|
const users = db.prepare('SELECT id, name FROM users WHERE initial IS NULL').all();
|
||||||
|
const updateStmt = db.prepare('UPDATE users SET initial = ? WHERE id = ?');
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
if (user.name) {
|
||||||
|
// Generate initials from name (e.g., "John Doe" -> "JD")
|
||||||
|
const nameParts = user.name.trim().split(/\s+/);
|
||||||
|
const initial = nameParts.map(part => part.charAt(0).toUpperCase()).join('');
|
||||||
|
updateStmt.run(initial, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ Added initial column to users table and generated initials");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Migration warning:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: Rename project_type to task_category in task_sets
|
// Migration: Rename project_type to task_category in task_sets
|
||||||
try {
|
try {
|
||||||
// Check if the old column exists and rename it
|
// Check if the old column exists and rename it
|
||||||
|
|||||||
@@ -75,3 +75,8 @@ export function withAdminAuth(handler) {
|
|||||||
export function withManagerAuth(handler) {
|
export function withManagerAuth(handler) {
|
||||||
return withAuth(handler, { requiredRole: 'project_manager' })
|
return withAuth(handler, { requiredRole: 'project_manager' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper for team lead operations
|
||||||
|
export function withTeamLeadAuth(handler) {
|
||||||
|
return withAuth(handler, { requiredRole: 'team_lead' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ export function createProject(data, userId = null) {
|
|||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO projects (
|
INSERT INTO projects (
|
||||||
contract_id, project_name, project_number, address, plot, district, unit, city, investment_number, finish_date, completion_date,
|
contract_id, project_name, project_number, address, plot, district, unit, city, investment_number, start_date, finish_date, completion_date,
|
||||||
wp, contact, notes, wartosc_zlecenia, project_type, project_status, coordinates, created_by, assigned_to, created_at, updated_at
|
wp, contact, notes, wartosc_zlecenia, project_type, project_status, coordinates, created_by, assigned_to, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
@@ -90,6 +90,7 @@ export function createProject(data, userId = null) {
|
|||||||
data.unit,
|
data.unit,
|
||||||
data.city,
|
data.city,
|
||||||
data.investment_number,
|
data.investment_number,
|
||||||
|
data.start_date || null,
|
||||||
data.finish_date,
|
data.finish_date,
|
||||||
data.completion_date,
|
data.completion_date,
|
||||||
data.wp,
|
data.wp,
|
||||||
@@ -110,7 +111,7 @@ export function updateProject(id, data, userId = null) {
|
|||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
UPDATE projects SET
|
UPDATE projects SET
|
||||||
contract_id = ?, project_name = ?, project_number = ?, address = ?, plot = ?, district = ?, unit = ?, city = ?,
|
contract_id = ?, project_name = ?, project_number = ?, address = ?, plot = ?, district = ?, unit = ?, city = ?,
|
||||||
investment_number = ?, finish_date = ?, completion_date = ?, wp = ?, contact = ?, notes = ?, wartosc_zlecenia = ?, project_type = ?, project_status = ?,
|
investment_number = ?, start_date = ?, finish_date = ?, completion_date = ?, wp = ?, contact = ?, notes = ?, wartosc_zlecenia = ?, project_type = ?, project_status = ?,
|
||||||
coordinates = ?, assigned_to = ?, updated_at = CURRENT_TIMESTAMP
|
coordinates = ?, assigned_to = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE project_id = ?
|
WHERE project_id = ?
|
||||||
`);
|
`);
|
||||||
@@ -124,6 +125,7 @@ export function updateProject(id, data, userId = null) {
|
|||||||
data.unit,
|
data.unit,
|
||||||
data.city,
|
data.city,
|
||||||
data.investment_number,
|
data.investment_number,
|
||||||
|
data.start_date || null,
|
||||||
data.finish_date,
|
data.finish_date,
|
||||||
data.completion_date,
|
data.completion_date,
|
||||||
data.wp,
|
data.wp,
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ export async function createUser({ name, username, password, role = 'user', is_a
|
|||||||
const passwordHash = await bcrypt.hash(password, 12)
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
const userId = randomBytes(16).toString('hex')
|
const userId = randomBytes(16).toString('hex')
|
||||||
|
|
||||||
|
// Generate initials from name (e.g., "John Doe" -> "JD")
|
||||||
|
const nameParts = name.trim().split(/\s+/)
|
||||||
|
const initial = nameParts.map(part => part.charAt(0).toUpperCase()).join('')
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO users (id, name, username, password_hash, role, is_active, can_be_assigned)
|
INSERT INTO users (id, name, username, password_hash, role, initial, is_active, can_be_assigned)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(userId, name, username, passwordHash, role, is_active ? 1 : 0, can_be_assigned ? 1 : 0)
|
`).run(userId, name, username, passwordHash, role, initial, is_active ? 1 : 0, can_be_assigned ? 1 : 0)
|
||||||
|
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT id, name, username, role, created_at, updated_at, last_login,
|
SELECT id, name, username, role, created_at, updated_at, last_login,
|
||||||
|
|||||||
Reference in New Issue
Block a user