Compare commits

...

11 Commits

23 changed files with 3090 additions and 187 deletions

222
docs/LAYER_NOTES.md Normal file
View 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

View 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

View File

@@ -17,7 +17,9 @@ function exportProjectsToExcel() {
'Adres': project.address || '',
'Działka': project.plot || '',
'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;
}, {});

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

View File

@@ -15,7 +15,9 @@ const sampleProjects = [
unit: 'Unit A',
city: 'Warszawa',
investment_number: 'INV-2025-001',
start_date: '2025-01-15',
finish_date: '2025-06-30',
completion_date: null,
wp: 'WP-001',
contact: 'Jan Kowalski, tel. 123-456-789',
notes: 'Modern residential building with 50 apartments',
@@ -32,7 +34,9 @@ const sampleProjects = [
unit: 'Unit B',
city: 'Warszawa',
investment_number: 'INV-2025-002',
start_date: '2025-02-01',
finish_date: '2025-09-15',
completion_date: null,
wp: 'WP-002',
contact: 'Anna Nowak, tel. 987-654-321',
notes: 'Commercial office space, 10 floors',
@@ -49,7 +53,9 @@ const sampleProjects = [
unit: 'Unit C',
city: 'Kraków',
investment_number: 'INV-2025-003',
start_date: '2025-01-10',
finish_date: '2025-12-20',
completion_date: null,
wp: 'WP-003',
contact: 'Piotr Wiśniewski, tel. 555-123-456',
notes: 'Large shopping center with parking',
@@ -66,7 +72,9 @@ const sampleProjects = [
unit: 'Unit D',
city: 'Łódź',
investment_number: 'INV-2025-004',
start_date: '2024-11-01',
finish_date: '2025-08-10',
completion_date: '2025-08-05',
wp: 'WP-004',
contact: 'Maria Lewandowska, tel. 444-789-012',
notes: 'Logistics warehouse facility',
@@ -83,7 +91,9 @@ const sampleProjects = [
unit: 'Unit E',
city: 'Gdańsk',
investment_number: 'INV-2025-005',
start_date: '2025-01-20',
finish_date: '2025-11-05',
completion_date: null,
wp: 'WP-005',
contact: 'Tomasz Malinowski, tel. 333-456-789',
notes: 'Luxury hotel with conference facilities',
@@ -100,7 +110,9 @@ const sampleProjects = [
unit: 'Unit F',
city: 'Poznań',
investment_number: 'INV-2025-006',
start_date: '2025-02-10',
finish_date: '2025-07-20',
completion_date: null,
wp: 'WP-006',
contact: 'Ewa Dombrowska, tel. 222-333-444',
notes: 'Modern educational facility with sports complex',
@@ -117,7 +129,9 @@ const sampleProjects = [
unit: 'Unit G',
city: 'Wrocław',
investment_number: 'INV-2025-007',
start_date: '2024-12-15',
finish_date: '2025-10-30',
completion_date: null,
wp: 'WP-007',
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
notes: 'Specialized medical center with emergency department',
@@ -134,7 +148,9 @@ const sampleProjects = [
unit: 'Unit H',
city: 'Szczecin',
investment_number: 'INV-2025-008',
start_date: '2024-09-01',
finish_date: '2025-05-15',
completion_date: '2025-05-12',
wp: 'WP-008',
contact: 'Katarzyna Wojcik, tel. 999-888-777',
notes: 'Multi-purpose sports stadium with seating for 20,000',
@@ -151,7 +167,9 @@ const sampleProjects = [
unit: 'Unit I',
city: 'Lublin',
investment_number: 'INV-2025-009',
start_date: '2025-01-05',
finish_date: '2025-08-25',
completion_date: null,
wp: 'WP-009',
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
notes: 'Modern library with digital archives and community spaces',
@@ -174,9 +192,9 @@ sampleProjects.forEach((projectData, index) => {
const result = db.prepare(`
INSERT INTO projects (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`).run(
projectData.contract_id,
projectData.project_name,
@@ -187,7 +205,9 @@ sampleProjects.forEach((projectData, index) => {
projectData.unit,
projectData.city,
projectData.investment_number,
projectData.start_date,
projectData.finish_date,
projectData.completion_date,
projectData.wp,
projectData.contact,
projectData.notes,

View File

@@ -1,6 +1,6 @@
import db from "@/lib/db";
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 }) {
const { id } = await params;
@@ -21,6 +21,79 @@ async function getContractHandler(req, { params }) {
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 }) {
const { id } = params;
@@ -61,4 +134,5 @@ async function deleteContractHandler(req, { params }) {
// Protected routes - require authentication
export const GET = withReadAuth(getContractHandler);
export const DELETE = withUserAuth(deleteContractHandler);
export const PUT = withUserAuth(updateContractHandler);
export const DELETE = withTeamLeadAuth(deleteContractHandler);

View File

@@ -35,6 +35,9 @@ export async function GET(request) {
};
});
// Calculate values by contract
const contractSummary = {};
projects.forEach(project => {
const value = parseFloat(project.wartosc_zlecenia) || 0;
const type = project.project_type;
@@ -46,6 +49,26 @@ export async function GET(request) {
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
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
@@ -132,6 +155,26 @@ export async function GET(request) {
realisedValue: 158000,
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 {
@@ -251,6 +294,17 @@ export async function GET(request) {
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)
}
])
)
}
});

View File

@@ -11,7 +11,7 @@ import { logFieldChange } from "@/lib/queries/fieldHistory";
import { addNoteToProject } from "@/lib/queries/notes";
import initializeDatabase from "@/lib/init-db";
import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
import { withReadAuth, withUserAuth, withTeamLeadAuth } from "@/lib/middleware/auth";
import {
logApiActionSafe,
AUDIT_ACTIONS,
@@ -155,4 +155,4 @@ async function deleteProjectHandler(req, { params }) {
// Protected routes - require authentication
export const GET = withReadAuth(getProjectHandler);
export const PUT = withUserAuth(updateProjectHandler);
export const DELETE = withUserAuth(deleteProjectHandler);
export const DELETE = withTeamLeadAuth(deleteProjectHandler);

View File

@@ -207,7 +207,10 @@ export default function ContactsPage() {
{/* Stats */}
{stats && (
<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">
<div className="text-2xl font-bold text-gray-900">
{stats.total_contacts}
@@ -215,7 +218,10 @@ export default function ContactsPage() {
<div className="text-sm text-gray-600">Wszystkie</div>
</CardContent>
</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">
<div className="text-2xl font-bold text-blue-600">
{stats.project_contacts}
@@ -223,7 +229,10 @@ export default function ContactsPage() {
<div className="text-sm text-gray-600">Projekty</div>
</CardContent>
</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">
<div className="text-2xl font-bold text-orange-600">
{stats.contractor_contacts}
@@ -231,7 +240,10 @@ export default function ContactsPage() {
<div className="text-sm text-gray-600">Wykonawcy</div>
</CardContent>
</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">
<div className="text-2xl font-bold text-purple-600">
{stats.office_contacts}
@@ -239,7 +251,10 @@ export default function ContactsPage() {
<div className="text-sm text-gray-600">Urzędy</div>
</CardContent>
</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">
<div className="text-2xl font-bold text-green-600">
{stats.supplier_contacts}
@@ -247,7 +262,10 @@ export default function ContactsPage() {
<div className="text-sm text-gray-600">Dostawcy</div>
</CardContent>
</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">
<div className="text-2xl font-bold text-gray-600">
{stats.other_contacts}

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

View File

@@ -113,6 +113,24 @@ export default function ContractDetailsPage() {
{t('contracts.backToContracts')}
</Button>
</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}`}>
<Button variant="primary" size="sm">
<svg

View File

@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
@@ -15,6 +16,7 @@ import { useTranslation } from "@/lib/i18n";
export default function ContractsMainPage() {
const { t } = useTranslation();
const { data: session } = useSession();
const [contracts, setContracts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
@@ -22,17 +24,27 @@ export default function ContractsMainPage() {
const [sortBy, setSortBy] = useState("date_signed");
const [sortOrder, setSortOrder] = useState("desc");
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(() => {
async function fetchContracts() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/contracts");
if (!res.ok) {
throw new Error("Failed to fetch contracts");
}
const data = await res.json();
setContracts(data);
setFilteredContracts(data);
} catch (error) {
console.error("Error fetching contracts:", error);
setError("Nie udało się pobrać listy umów. Spróbuj ponownie później.");
} finally {
setLoading(false);
}
@@ -93,27 +105,6 @@ export default function ContractsMainPage() {
setFilteredContracts(filtered);
}, [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 currentDate = new Date();
const total = contracts.length;
@@ -148,25 +139,50 @@ export default function ContractsMainPage() {
}
};
async function handleDelete(id) {
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
if (!confirmed) return;
const initiateDelete = (contract) => {
setContractToDelete(contract);
setShowDeleteModal(true);
};
const handleDelete = async () => {
if (!contractToDelete) return;
setDeleting(true);
setError(null);
try {
const res = await fetch(`/api/contracts/${id}`, {
const res = await fetch(`/api/contracts/${contractToDelete.contract_id}`, {
method: "DELETE",
});
const data = await res.json();
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 {
alert("Błąd podczas usuwania umowy.");
setError(data.error || "Nie udało się usunąć umowy.");
}
} catch (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) => {
setSearchTerm(e.target.value);
@@ -264,6 +280,67 @@ export default function ContractsMainPage() {
</Button>
</Link>{" "}
</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 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<Card>
@@ -573,26 +650,28 @@ export default function ContractsMainPage() {
</svg>
Szczegóły
</Link>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(contract.contract_id)}
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
{session?.user?.role === 'team_lead' && (
<Button
variant="danger"
size="sm"
onClick={() => initiateDelete(contract)}
>
<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ń
</Button>
<svg
className="w-4 h-4 mr-1"
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ń
</Button>
)}
</div>
</div>
</CardContent>
@@ -620,6 +699,124 @@ export default function ContractsMainPage() {
</p>{" "}
</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>
);
}

View File

@@ -284,6 +284,87 @@ export default function TeamLeadsDashboard() {
</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>
</PageContainer>
);

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import ProjectForm from "@/components/ProjectForm";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
@@ -9,16 +9,44 @@ import Button from "@/components/ui/Button";
import Link from "next/link";
import { LoadingState } from "@/components/ui/States";
import { useTranslation } from "@/lib/i18n";
import { useSession } from "next-auth/react";
export default function EditProjectPage() {
const params = useParams();
const router = useRouter();
const id = params.id;
const [project, setProject] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const { t } = useTranslation();
const { data: session } = useSession();
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(() => {
const fetchProject = async () => {
try {
@@ -130,7 +158,159 @@ export default function EditProjectPage() {
/>
<div className="max-w-2xl">
<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>
<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>
);
}

View File

@@ -33,6 +33,23 @@ export default function ProjectViewPage() {
const [editText, setEditText] = useState('');
const [projectContacts, setProjectContacts] = useState([]);
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
const parseNoteText = (text) => {
@@ -446,7 +463,17 @@ export default function ProjectViewPage() {
<p className="text-gray-900 font-medium">
{project.unit || "N/A"}
</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
tableName="projects"
recordId={project.project_id}
@@ -457,7 +484,7 @@ export default function ProjectViewPage() {
{project.completion_date && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Data zakończenia projektu
Data odbioru
</span>
<p className="text-gray-900 font-medium">
{formatDate(project.completion_date)}
@@ -736,72 +763,6 @@ export default function ProjectViewPage() {
</h2>
</CardHeader>
<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
variant="outline"
size="sm"
@@ -823,6 +784,48 @@ export default function ProjectViewPage() {
</svg>
Generuj dokument
</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>
</Card>

View File

@@ -29,6 +29,10 @@ export default function ProjectListPage() {
});
const [customers, setCustomers] = useState([]);
const [sortConfig, setSortConfig] = useState({
key: 'finish_date',
direction: 'desc'
});
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
useEffect(() => {
@@ -125,8 +129,44 @@ export default function ProjectListPage() {
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);
}, [searchTerm, projects, filters, session]);
}, [searchTerm, projects, filters, session, sortConfig]);
async function handleDelete(id) {
const confirmed = confirm(t('projects.deleteConfirm'));
@@ -171,6 +211,13 @@ export default function ProjectListPage() {
setSearchTerm('');
};
const handleSort = (key) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
}));
};
const handleExportExcel = async () => {
try {
const response = await fetch('/api/projects/export');
@@ -228,6 +275,42 @@ export default function ProjectListPage() {
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 (
<PageContainer>
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
@@ -606,8 +689,29 @@ export default function ProjectListPage() {
{/* Results and clear button row */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<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`}
<div className="flex items-center gap-4">
<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`}
</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) && (
@@ -699,36 +803,56 @@ export default function ProjectListPage() {
<table className="w-full min-w-[600px] table-fixed">
<thead>
<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">
Nr.
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-[200px] md:w-[250px]">
{t('projects.projectName')}
</th>
<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">
{t('projects.address')}
</th>
<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
</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 md:table-cell">
{t('projects.city')}
</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">
{t('projects.plot')}
</th>
<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">
{t('projects.finishDate')}
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
{t('common.type') || 'Typ'}
</th>
<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'}
</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">
{t('projects.assigned') || 'Przypisany'}
</th>
<SortableHeader
columnKey="project_number"
label="Nr."
className="w-20 md:w-24"
/>
<SortableHeader
columnKey="project_name"
label={t('projects.projectName')}
className="w-[200px] md:w-[250px]"
/>
<SortableHeader
columnKey="address"
label={t('projects.address')}
className="w-20 md:w-24 hidden lg:table-cell"
/>
<SortableHeader
columnKey="wp"
label="WP"
className="w-16 md:w-20 hidden sm:table-cell"
/>
<SortableHeader
columnKey="city"
label={t('projects.city')}
className="w-14 md:w-16 hidden md:table-cell"
/>
<SortableHeader
columnKey="plot"
label={t('projects.plot')}
className="w-14 md:w-16 hidden sm:table-cell"
/>
<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>
</thead>
<tbody>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
@@ -8,8 +8,9 @@ import { Input } from "@/components/ui/Input";
import { formatDateForInput } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ContractForm() {
export default function ContractForm({ initialData = null }) {
const { t } = useTranslation();
const isEdit = !!initialData;
const [form, setForm] = useState({
contract_number: "",
contract_name: "",
@@ -23,6 +24,21 @@ export default function ContractForm() {
const [loading, setLoading] = useState(false);
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) {
setForm({ ...form, [e.target.name]: e.target.value });
}
@@ -34,21 +50,32 @@ export default function ContractForm() {
try {
console.log("Submitting form:", form);
const res = await fetch("/api/contracts", {
method: "POST",
const url = isEdit
? `/api/contracts/${initialData.contract_id}`
: "/api/contracts";
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) {
const contract = await res.json();
router.push(`/contracts/${contract.contract_id}`);
router.push(`/contracts/${contract.contract_id || initialData.contract_id}`);
} else {
alert(t('contracts.failedToCreateContract'));
const errorMessage = isEdit
? t('contracts.failedToUpdateContract')
: t('contracts.failedToCreateContract');
alert(errorMessage);
}
} catch (error) {
console.error("Error creating contract:", error);
alert(t('contracts.failedToCreateContract'));
console.error(`Error ${isEdit ? 'updating' : 'creating'} contract:`, error);
const errorMessage = isEdit
? t('contracts.failedToUpdateContract')
: t('contracts.failedToCreateContract');
alert(errorMessage);
} finally {
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"
></path>
</svg>
{t('common.creating')}
{isEdit ? t('common.updating') : t('common.creating')}
</>
) : (
<>
@@ -203,10 +230,10 @@ export default function ContractForm() {
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
d={isEdit ? "M5 13l4 4L19 7" : "M12 4v16m8-8H4"}
/>
</svg>
{t('contracts.createContract')}
{isEdit ? t('contracts.updateContract') : t('contracts.createContract')}
</>
)}
</Button>

View File

@@ -22,6 +22,7 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
unit: "",
city: "",
investment_number: "",
start_date: "",
finish_date: "",
completion_date: "",
wp: "",
@@ -63,6 +64,7 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
unit: "",
city: "",
investment_number: "",
start_date: "",
finish_date: "",
completion_date: "",
wp: "",
@@ -78,6 +80,9 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
assigned_to: initialData.assigned_to || "",
wartosc_zlecenia: initialData.wartosc_zlecenia || "",
// Format dates for input if they exist
start_date: initialData.start_date
? formatDateForInput(initialData.start_date)
: "",
finish_date: initialData.finish_date
? formatDateForInput(initialData.finish_date)
: "",
@@ -292,7 +297,19 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
<div>
<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>
<Input
type="date"
@@ -304,7 +321,7 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data zakończenia projektu
Data odbioru
</label>
<Input
type="date"

View File

@@ -136,6 +136,8 @@ const translations = {
realisedValue: "Wartość zrealizowana",
unrealisedValue: "Wartość niezrealizowana",
byProjectType: "Według typu projektu",
byContract: "Według umowy",
total: "Razem",
monthLabel: "Miesiąc:",
monthlyValue: "Wartość miesięczna:",
cumulative: "Skumulowana:",
@@ -184,7 +186,7 @@ const translations = {
plot: "Działka",
district: "Jednostka ewidencyjna",
unit: "Obręb",
finishDate: "Data zakończenia",
finishDate: "Termin zakończenia",
type: "Typ",
contact: "Kontakt",
coordinates: "Współrzędne",
@@ -285,6 +287,11 @@ const translations = {
contract: "Umowa",
newContract: "Nowa umowa",
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ę",
contractNumber: "Numer umowy",
contractName: "Nazwa umowy",
@@ -292,7 +299,7 @@ const translations = {
customer: "Klient",
investor: "Inwestor",
dateSigned: "Data zawarcia",
finishDate: "Data zakończenia",
finishDate: "Termin zakończenia",
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
noContracts: "Brak umów",
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
@@ -323,7 +330,7 @@ const translations = {
customer: "Klient",
investor: "Inwestor",
dateSigned: "Data zawarcia",
finishDate: "Data zakończenia",
finishDate: "Termin zakończenia",
summary: "Podsumowanie",
projectsCount: "Liczba projektów",
projects: "projektów",
@@ -532,7 +539,7 @@ const translations = {
dateCreated: "Data utworzenia",
dateModified: "Data modyfikacji",
startDate: "Data rozpoczęcia",
finishDate: "Data zakończenia"
finishDate: "Termin zakończenia"
},
// Date formats
@@ -769,6 +776,8 @@ const translations = {
realisedValue: "Realised Value",
unrealisedValue: "Unrealised Value",
byProjectType: "By Project Type",
byContract: "By Contract",
total: "Total",
monthLabel: "Month:",
monthlyValue: "Monthly Value:",
cumulative: "Cumulative:",
@@ -936,8 +945,14 @@ const translations = {
contracts: {
title: "Contracts",
subtitle: "Manage your contracts and agreements",
contract: "Contract",
newContract: "New 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",
contractNumber: "Contract Number",
contractName: "Contract Name",
@@ -964,7 +979,40 @@ const translations = {
signedOn: "Signed:",
finishOn: "Finish:",
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: {

View File

@@ -289,6 +289,14 @@ export default function initializeDatabase() {
// 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
// DISABLED: This migration was running on every init and converting legitimate
// '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);
}
// 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
try {
// Check if the old column exists and rename it

View File

@@ -75,3 +75,8 @@ export function withAdminAuth(handler) {
export function withManagerAuth(handler) {
return withAuth(handler, { requiredRole: 'project_manager' })
}
// Helper for team lead operations
export function withTeamLeadAuth(handler) {
return withAuth(handler, { requiredRole: 'team_lead' })
}

View File

@@ -75,9 +75,9 @@ export function createProject(data, userId = null) {
const stmt = db.prepare(`
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
`);
const result = stmt.run(
@@ -90,6 +90,7 @@ export function createProject(data, userId = null) {
data.unit,
data.city,
data.investment_number,
data.start_date || null,
data.finish_date,
data.completion_date,
data.wp,
@@ -110,7 +111,7 @@ export function updateProject(id, data, userId = null) {
const stmt = db.prepare(`
UPDATE projects SET
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
WHERE project_id = ?
`);
@@ -124,6 +125,7 @@ export function updateProject(id, data, userId = null) {
data.unit,
data.city,
data.investment_number,
data.start_date || null,
data.finish_date,
data.completion_date,
data.wp,

View File

@@ -11,11 +11,15 @@ export async function createUser({ name, username, password, role = 'user', is_a
const passwordHash = await bcrypt.hash(password, 12)
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(`
INSERT INTO users (id, name, username, password_hash, role, is_active, can_be_assigned)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, name, username, passwordHash, role, is_active ? 1 : 0, can_be_assigned ? 1 : 0)
INSERT INTO users (id, name, username, password_hash, role, initial, is_active, can_be_assigned)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, name, username, passwordHash, role, initial, is_active ? 1 : 0, can_be_assigned ? 1 : 0)
return db.prepare(`
SELECT id, name, username, role, created_at, updated_at, last_login,