Compare commits

..

106 Commits

Author SHA1 Message Date
6ac5ac9dda feat: add documentation for files to delete from codebase, including debug, test, and one-off scripts 2025-11-20 21:37:21 +01:00
fae7615818 feat: add edited_at column to notes and implement note update functionality with audit logging 2025-11-18 09:43:39 +01:00
acb7117c7d feat: add internationalization support for TeamLeadsDashboard with translations for various labels and messages 2025-11-14 15:22:36 +01:00
1d8ee8b0ab feat: add year selection filter to TeamLeadsDashboard and update API request accordingly 2025-11-14 15:17:29 +01:00
d3fa4df621 feat: enhance dashboard API to generate continuous sample data and calculate cumulative values for project timelines 2025-11-14 15:06:36 +01:00
a1f1b33e44 feat: enhance TeamLeadsDashboard with detailed realised vs unrealised values by project type and improved chart visualization 2025-11-14 12:24:41 +01:00
7f63dc1df6 feat: add realised vs unrealised value summary to TeamLeadsDashboard with pie chart visualization 2025-11-14 12:08:46 +01:00
ac77a9d259 feat: enhance TeamLeadsDashboard with ComposedChart and improved tooltip for monthly and cumulative values 2025-11-14 11:17:29 +01:00
38b9401b04 feat: implement team lead dashboard with data fetching and chart display 2025-11-14 11:05:36 +01:00
9b1f42c4ec feat: add completion_date field to projects and update related functionalities in forms and queries 2025-11-14 10:01:49 +01:00
6b205f36bb refactor: simplify role checks for wartosc_zlecenia display in ProjectViewPage and ProjectForm 2025-11-14 09:41:06 +01:00
be1bab103f feat: update team_lead role checks for wartosc_zlecenia display in ProjectViewPage and ProjectForm 2025-11-14 09:33:27 +01:00
c2dbc9d777 feat: track wartosc_zlecenia in project updates and display in ProjectViewPage for team leads 2025-11-14 09:09:23 +01:00
3f87ea16f2 feat: add wartosc_zlecenia field to projects table and update related functionalities 2025-11-14 09:04:46 +01:00
056198ff16 feat: add team_lead role to valid roles in user creation and update handlers 2025-11-14 08:22:11 +01:00
5b1a284fc3 feat: add team_lead option to user role selection in EditUserPage 2025-11-14 08:16:05 +01:00
23b3c0e9e8 feat: add team_lead role to user management and update related functionalities 2025-11-14 08:08:01 +01:00
eec0c0a281 feat: add Geoportal link for project coordinates with EPSG:2180 projection 2025-11-05 11:21:00 +01:00
cc242d4e10 feat: load phoneOnly filter from localStorage after component mount 2025-10-27 10:52:03 +01:00
b6ceac6e38 feat: add Google Maps link for project coordinates display 2025-10-24 14:00:04 +02:00
42668862fd feat: add download functionality for upcoming projects report in Excel format 2025-10-22 11:38:19 +02:00
af28be8112 feat: preserve phone toggle state in filter reset and update active filter logic 2025-10-18 13:04:54 +02:00
27247477c9 feat: add phone number filter and related translations in project list 2025-10-17 16:00:44 +02:00
bd0345df1a feat: add locale support for date formatting in task components 2025-10-14 13:13:16 +02:00
a1b9c05673 feat: add 'Przypisany do' field to exported projects and update filename format 2025-10-14 13:07:33 +02:00
d9e559982a feat: hide appearance and language settings cards in the SettingsPage 2025-10-14 12:57:13 +02:00
0e237a9549 feat: add filter for user's assigned tasks and update translation for 'mine' 2025-10-14 12:55:28 +02:00
f1e7c2d7aa feat: implement password reset functionality with token verification and change password feature 2025-10-10 09:15:29 +02:00
7ec4bdf620 feat: update project titles in metadata and translations for consistency 2025-10-10 08:59:22 +02:00
ec5b60d478 feat: update ProjectListPage layout and button styles for improved UI/UX 2025-10-09 20:56:55 +02:00
ac5fedb61a feat: implement project export to Excel with API endpoint and UI button 2025-10-09 20:43:53 +02:00
ce3c53b4a8 feat: add 'can_be_assigned' column to users with default settings for admin users 2025-10-09 15:01:01 +02:00
cdfc37c273 feat: add 'can_be_assigned' field to users with updates to user creation, retrieval, and assignment queries 2025-10-09 14:55:11 +02:00
1288fe1cf8 feat: add 'not_started' task status with updates to task creation, status handling, and translations 2025-10-09 12:39:43 +02:00
33c5466d77 feat: implement 'not_started' task status with validation and UI updates 2025-10-07 23:07:15 +02:00
a6ef325813 feat: add task_category field to tasks with validation and update related forms 2025-10-07 22:22:10 +02:00
952caf10d1 feat: add task sets functionality with CRUD operations and UI integration
- Implemented NewTaskSetPage for creating task sets with templates.
- Created TaskSetsPage for listing and filtering task sets.
- Enhanced TaskTemplatesPage with navigation to task sets.
- Updated ProjectTaskForm to support task set selection.
- Modified PageHeader to support multiple action buttons.
- Initialized database with task_sets and task_set_templates tables.
- Added queries for task sets including creation, retrieval, and deletion.
- Implemented applyTaskSetToProject function for bulk task creation.
- Added test script for verifying task sets functionality.
2025-10-07 21:58:08 +02:00
e19172d2bb feat: Restrict notifications feature to admin users only in navigation 2025-10-06 16:07:17 +02:00
80a53d5d15 feat: Add notifications dropdown to navigation and update translations for notifications 2025-10-06 16:01:29 +02:00
5011f80fc4 feat: Add timezone configuration for Docker to ensure correct time handling in production and development environments 2025-10-04 20:52:39 +02:00
9357c2e0b9 Merge branch 'ui-fix' of https://git.wastpol.pl/admin/Panel into ui-fix 2025-10-04 19:44:39 +02:00
119b03a7ba feat: Update date handling to use local time formatting across various components and queries 2025-10-04 19:44:35 +02:00
f4b30c0faf feat: Update README.md with authentication, user management, and audit logging details 2025-10-02 19:35:31 +02:00
79238dd643 feat: Add link parsing functionality to notes in ProjectViewPage and TaskCommentsModal 2025-10-02 11:36:36 +02:00
31736ccc78 feat: Enhance FieldWithHistory component with improved tooltip display and time formatting 2025-10-02 10:01:54 +02:00
50760ab099 feat: Integrate translation support in FieldWithHistory component and update i18n strings 2025-10-02 09:57:51 +02:00
a59dc83678 feat: Add 'mine' filter to ProjectListPage and update translations 2025-09-29 22:07:18 +02:00
769fc73898 feat: Add ProjectAssigneeDropdown component and integrate it into ProjectViewPage 2025-09-29 20:09:11 +02:00
6ab87c7396 feat: Add assigned user initial to project details and update translations 2025-09-29 19:50:22 +02:00
a4e607bfe1 feat: Add migration script to add 'initial' column to users table 2025-09-29 19:44:36 +02:00
e589d6667f feat: Add initial identifier field to user management and update related functions 2025-09-29 19:41:49 +02:00
fc5f0fd39a feat: Enhance EditProjectPage with translation support and refactor ProjectForm to use forwardRef 2025-09-29 19:32:08 +02:00
e68b185aeb feat: Update task assignment logic to exclude admin users and enhance project page layout 2025-09-29 19:25:15 +02:00
5aac63dfde feat: Implement route planning feature with project selection and optimization
- Added route planning functionality to the map page, allowing users to select projects for routing.
- Implemented state management for route projects, start/end points, and search functionality.
- Integrated OpenRouteService API for route calculation and optimization.
- Enhanced UI with a route planning panel, including search and drag-and-drop reordering of projects.
- Added visual indicators for route start and end points on the map.
- Included translations for route planning features in both Polish and English.
- Created utility functions for route calculations, optimizations, and formatting of route data.
2025-09-26 00:18:10 +02:00
8a0baa02c3 fix: Refine theme initialization logic to respect saved preferences and system color scheme 2025-09-25 09:03:13 +02:00
fd87b66b06 feat: Implement dark mode support across components and UI elements
- Added dark mode styles to TaskStatusDropdown, TaskStatusDropdownDebug, and TaskStatusDropdownSimple components.
- Introduced ThemeProvider and useTheme hook for managing theme state.
- Updated Button, Card, Input, Loading, Navigation, PageContainer, PageHeader, ProjectCalendarWidget, ProjectMap, SearchBar, States, Tooltip, and other UI components to support dark mode.
- Created ThemeToggle component for switching between light and dark modes.
- Enhanced i18n translations for settings related to theme and language preferences.
- Configured Tailwind CSS to support dark mode with class-based toggling.
2025-09-25 08:58:03 +02:00
96333ecced feat: Implement file upload and management features in ProjectViewPage 2025-09-24 21:55:44 +02:00
0f451555d3 feat: Add script to create sample projects with detailed information 2025-09-24 21:20:05 +02:00
5193442e10 feat: Include project number in project details for enhanced information display 2025-09-19 13:52:20 +02:00
94b46be15b fix: Improve layout of project status indicators for better alignment and visibility 2025-09-19 07:40:33 +02:00
c39746f4f6 fix: Update shortLabel for in-progress construction status and improve project count display in ProjectsMapPage 2025-09-18 16:02:30 +02:00
671a4490d7 feat: Update SVG icons in ProjectViewPage and ProjectTasksSection for improved styling and consistency 2025-09-18 12:44:42 +02:00
e091e29a80 feat: Reorganize overdue projects section for improved visibility in project calendar 2025-09-18 12:37:47 +02:00
142b6490cc feat: Implement task template management with translation support and improved UI components 2025-09-18 12:36:03 +02:00
abfd174f85 fix: Update upcoming projects calculation to include the next 5 weeks 2025-09-18 12:01:52 +02:00
8964a9b29b feat: Enhance note creation and project cancellation with user information and translations 2025-09-18 11:11:22 +02:00
1a49919000 refactor: Optimize map component rendering and memoize functions to improve performance 2025-09-17 08:29:51 +02:00
0bb0b07429 feat: Add measurement tool with distance calculation and visual markers on the map 2025-09-16 16:41:08 +02:00
e4a4261a0e BUGFIX: Refactor map loading to use a dedicated loading component and improve dynamic import handling 2025-09-16 16:35:25 +02:00
029b091b10 feat: Add translation support for map-related components and improve loading messages 2025-09-16 16:32:04 +02:00
cf8ff874da feat: Update project and contract terminology for consistency in UI and translations 2025-09-16 14:44:01 +02:00
c75982818c feat: Enhance map controls and overlay visibility in project map components 2025-09-16 12:11:52 +02:00
e5e72b597a BUGFIX: Remove redundant GET function for file retrieval 2025-09-16 11:29:19 +02:00
06599c844a feat: Implement file upload structure and API for file retrieval 2025-09-16 11:27:25 +02:00
e5955a31fd feat: Integrate translation support for contract-related components and improve user feedback messages 2025-09-16 11:20:14 +02:00
43622f8e65 feat: Add comprehensive documentation for Route Planning feature with optimization details 2025-09-16 10:58:28 +02:00
7a2611f031 feat: Update page titles dynamically based on project name and standardize layout metadata 2025-09-16 10:57:25 +02:00
249b1e21c3 feat: Enhance project display with hoverable overflow for additional projects 2025-09-15 09:45:49 +02:00
551a0ea71a feat: Implement mobile-friendly filter toggle and enhance project list filters 2025-09-12 13:36:28 +02:00
adc348b61b refactor: Update user role display in navigation component to improve readability 2025-09-12 12:12:33 +02:00
49f97a9939 feat: Enhance navigation component with mobile menu support and improved styling 2025-09-12 12:03:07 +02:00
99f3d657ab feat: Update audit log queries to improve clarity and accuracy in statistics 2025-09-12 11:41:51 +02:00
cc6d217476 feat: Enhance project status handling with additional statuses and translations 2025-09-12 11:17:36 +02:00
47d730f192 feat: Add AUTH_TRUST_HOST environment variable to production Docker configuration 2025-09-12 11:08:06 +02:00
c1d49689da feat: Enhance deployment scripts with environment variable validation and loading 2025-09-12 09:21:53 +02:00
95ef139843 feat: Add support for project cancellation status across the application 2025-09-11 16:19:46 +02:00
2735d46552 feat: Add finish date update tracking for projects 2025-09-11 15:50:48 +02:00
0dd988730f feat: Implement internationalization for task management components
- Added translation support for task-related strings in ProjectTaskForm and ProjectTasksSection components.
- Integrated translation for navigation items in the Navigation component.
- Created ProjectCalendarWidget component with Polish translations for project statuses and deadlines.
- Developed Tooltip component for enhanced user experience with tooltips.
- Established a field change history logging system in the database with associated queries.
- Enhanced task update logging to include translated status and priority changes.
- Introduced server-side translations for system messages to improve localization.
2025-09-11 15:49:07 +02:00
50adc50a24 feat: Update .gitignore to include /kosz and /public/uploads directories 2025-07-30 12:46:05 +02:00
639a7b7eab feat: Implement file upload and management system with database integration 2025-07-30 11:37:25 +02:00
07b4af5f24 feat: Refactor user management to replace email with username across the application 2025-07-28 22:25:23 +02:00
6fc2e6703b feat: Implement redirect to projects page on home component load 2025-07-28 22:08:28 +02:00
764f6d1100 feat: Update Docker entrypoint scripts to create admin account on container startup 2025-07-28 22:00:47 +02:00
225d16c1c9 feat: Update Docker deployment scripts and configurations for default admin account creation 2025-07-28 21:55:11 +02:00
aada481c0a feat: Automatically create default admin account during Docker build process 2025-07-28 21:48:28 +02:00
c767e65819 fix: Update port mapping to avoid conflict in production and development configurations 2025-07-28 21:44:02 +02:00
8e35821344 Refactor project tasks page and navigation components
- Updated the description in ProjectTasksPage to a placeholder.
- Commented out the display of assigned email in ProjectTasksList.
- Removed the dashboard link from the navigation items.
- Changed the main link in the navigation to point to projects instead of the dashboard.
- Commented out the LanguageSwitcher and user role display in the navigation.
- Translated "Project Location" to "Lokalizacja projektu" in ProjectMap.
- Commented out the instruction for using the layer control in ProjectMap.
- Removed the label "Coordinates:" from the coordinates display in ProjectMap.
- Updated project and contract subtitles in translations to placeholders.
- Added a new empty validation schema file.
2025-07-28 20:56:04 +02:00
Chop
747a68832e feat(i18n): Add Polish translations for task management components and update search placeholder 2025-07-27 23:54:56 +02:00
Chop
e828aa660b feat(i18n): Implement multilingual support with Polish and English translations
- Added translation context and provider for managing language state.
- Integrated translation functionality into existing components (TaskStatusDropdown, Navigation).
- Created LanguageSwitcher component for language selection.
- Updated task statuses and navigation labels to use translations.
- Added Polish translations for various UI elements, including navigation, tasks, projects, and contracts.
- Refactored utility functions to return localized strings for deadlines and date formatting.
2025-07-27 22:01:15 +02:00
Chop
9b6307eabe feat: Add TaskCommentsModal for viewing and managing task comments 2025-07-17 22:49:12 +02:00
Chop
490994d323 feat: Add optional max wait display to task rows and tables 2025-07-17 22:39:54 +02:00
Chop
b5120657a9 refactor: Update layout of task template actions for improved alignment and usability 2025-07-10 23:45:50 +02:00
Chop
5228ed3fc0 feat: Enhance ProjectTasksSection with improved button styles and layout adjustments 2025-07-10 23:41:03 +02:00
Chop
51d37fc65a feat: Implement task editing functionality with validation and user assignment 2025-07-10 23:35:17 +02:00
Chop
92f458e59b fix: Update note rendering logic to simplify system note display 2025-07-10 23:02:45 +02:00
Chop
33ea8de17e feat: Add coordinate formatting utility and update project views 2025-07-10 22:56:03 +02:00
146 changed files with 17955 additions and 2647 deletions

3
.gitignore vendored
View File

@@ -45,3 +45,6 @@ next-env.d.ts
# kosz
/kosz
# uploads
/public/uploads

129
DEPLOYMENT_TIMEZONE_FIX.md Normal file
View File

@@ -0,0 +1,129 @@
# Quick Deployment Guide - Timezone Fix
## For Production Server
1. **SSH into your server** where Docker is running
2. **Navigate to project directory**
```bash
cd /path/to/panel
```
3. **Pull latest code** (includes timezone fixes)
```bash
git pull origin main
```
4. **Stop running containers**
```bash
docker-compose -f docker-compose.prod.yml down
```
5. **Rebuild Docker images** (this is critical - it bakes in the timezone configuration)
```bash
docker-compose -f docker-compose.prod.yml build --no-cache
```
6. **Start containers**
```bash
docker-compose -f docker-compose.prod.yml up -d
```
7. **Verify timezone is correct**
```bash
# Check container timezone
docker-compose -f docker-compose.prod.yml exec app date
# Should show Polish time with CEST/CET timezone
# Example output:
# Sat Oct 4 19:45:00 CEST 2025
```
8. **Test the fix**
- Post a new note at a known time (e.g., 19:45)
- Verify it displays the same time (19:45)
- Test both project notes and task notes
## What Changed
### Code Changes
- ✅ Fixed `datetime('now', 'localtime')` in all database queries
- ✅ Updated display formatters to use Europe/Warsaw timezone
- ✅ Fixed note display in components
### Docker Changes (Critical!)
- ✅ Set `ENV TZ=Europe/Warsaw` in Dockerfile
- ✅ Configured system timezone in containers
- ✅ Added TZ environment variable to docker-compose files
## Why Rebuild is Necessary
The timezone configuration is **baked into the Docker image** during build time:
- `ENV TZ=Europe/Warsaw` - Set during image build
- `RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime` - Executed during image build
Just restarting containers (`docker-compose restart`) will **NOT** apply these changes!
## Troubleshooting
### If times are still wrong after deployment:
1. **Verify you rebuilt the images**
```bash
docker images | grep panel
# Check the "CREATED" timestamp - should be recent
```
2. **Check if container has correct timezone**
```bash
docker-compose -f docker-compose.prod.yml exec app date
```
Should show Polish time, not UTC!
3. **Check SQLite is using correct time**
```bash
docker-compose -f docker-compose.prod.yml exec app node -e "const db = require('better-sqlite3')('./data/database.sqlite'); console.log(db.prepare(\"SELECT datetime('now', 'localtime') as time\").get());"
```
Should show current Polish time
4. **Force rebuild if needed**
```bash
docker-compose -f docker-compose.prod.yml down
docker system prune -f
docker-compose -f docker-compose.prod.yml build --no-cache
docker-compose -f docker-compose.prod.yml up -d
```
## Expected Behavior After Fix
### Before Fix (Docker in UTC):
```
User posts note at 10:30 Poland time
→ Docker sees 08:30 UTC as "local time"
→ SQLite stores: 08:30
→ Display shows: 08:30 ❌ (2 hours off!)
```
### After Fix (Docker in Europe/Warsaw):
```
User posts note at 10:30 Poland time
→ Docker sees 10:30 Poland time as "local time"
→ SQLite stores: 10:30
→ Display shows: 10:30 ✅ (correct!)
```
## Important Notes
1. **Old notes**: Notes created before this fix may still show incorrect times (they were stored in UTC)
2. **New notes**: All new notes after deployment will show correct times
3. **Audit logs**: Continue to work correctly (they always used ISO format)
4. **Zero downtime**: Can't achieve - need to stop/rebuild/start containers
## Quick Check Command
After deployment, run this one-liner to verify everything:
```bash
docker-compose -f docker-compose.prod.yml exec app sh -c 'date && node -e "console.log(new Date().toLocaleString(\"pl-PL\"))"'
```
Both outputs should show the same Polish time!

204
DOCKER_GIT_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,204 @@
# Docker Git Deployment Guide
This project now supports deploying directly from a Git repository using Docker. This is useful for automated deployments and CI/CD pipelines.
## File Structure
- `Dockerfile` - Production dockerfile that supports Git deployment
- `Dockerfile.dev` - Development dockerfile
- `docker-compose.yml` - Development environment
- `docker-compose.prod.yml` - Production environment with Git support
- `deploy.sh` / `deploy.bat` - Deployment scripts
## Deployment Options
### 1. Deploy from Local Files (Default)
```bash
# Development
docker-compose up
# Production
docker-compose -f docker-compose.prod.yml up --build
```
**Note**: Both development and production Docker builds automatically:
- Create the default admin account
- Run any pending database migrations
- Initialize/update the database schema
### 2. Deploy from Git Repository
#### Using Environment Variables
Create a `.env` file with:
```env
GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git
GIT_BRANCH=ui-fix
GIT_COMMIT=abc123 # Optional: specific commit hash
```
Then run:
```bash
docker-compose -f docker-compose.prod.yml up --build
```
#### Using Build Arguments
```bash
docker build \
--build-arg GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git \
--build-arg GIT_BRANCH=ui-fix \
--build-arg GIT_COMMIT=abc123 \
-t your-app .
```
#### Using Deployment Scripts
```bash
# Linux/Mac
./deploy.sh https://git.wastpol.pl/Admin/panel.git ui-fix abc123
# Windows
deploy.bat https://git.wastpol.pl/Admin/panel.git ui-fix abc123
```
## Private Repositories
For private repositories, you have several options:
### 1. SSH Keys (Recommended for development)
```bash
# Build with SSH URL
docker build --build-arg GIT_REPO_URL=git@git.wastpol.pl:Admin/panel.git .
```
### 2. Personal Access Token
```bash
# Build with token in URL
docker build --build-arg GIT_REPO_URL=https://username:token@git.wastpol.pl/Admin/panel.git .
```
### 3. Docker Secrets (Recommended for production)
```yaml
# In docker-compose.prod.yml
services:
app:
build:
context: .
args:
- GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git
secrets:
- git_token
secrets:
git_token:
file: ./git_token.txt
```
## CI/CD Integration
### GitHub Actions Example
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to server
run: |
docker build \
--build-arg GIT_REPO_URL=${{ github.repository }} \
--build-arg GIT_COMMIT=${{ github.sha }} \
-t my-app .
docker run -d -p 3000:3000 my-app
```
### Docker Compose in CI/CD
```bash
# Set environment variables in your CI/CD system
export GIT_REPO_URL="https://git.wastpol.pl/Admin/panel.git"
export GIT_BRANCH="ui-fix"
export GIT_COMMIT="$CI_COMMIT_SHA"
# Deploy
docker-compose -f docker-compose.prod.yml up --build -d
```
## Build Process
When `GIT_REPO_URL` is provided:
1. Git repository is cloned into the container
2. If `GIT_COMMIT` is specified, checkout that specific commit
3. Install dependencies from the repository's package.json
4. Build the application
5. **Admin account is created when container starts (not during build)**
6. Start the production server
When `GIT_REPO_URL` is not provided:
1. Copy local files into the container
2. Install dependencies
3. Build the application
4. **Admin account is created when container starts (not during build)**
5. Start the production server
## Default Admin Account
Both development and production Docker containers automatically create a default admin account **when the container starts** (not during build). This ensures the database is properly persisted in mounted volumes.
- **Email**: `admin@localhost.com`
- **Password**: `admin123456`
- **Role**: `admin`
⚠️ **Important Security Note**: Please change the default password immediately after your first login!
### Manual Admin Account Creation
If you need to create the admin account manually (for development or testing):
```bash
# Using npm script
npm run create-admin
# Or directly
node scripts/create-admin.js
```
The script will skip creation if an admin account already exists.
## Environment Variables
- `GIT_REPO_URL` - Git repository URL (HTTPS or SSH)
- `GIT_BRANCH` - Git branch to checkout (default: main)
- `GIT_COMMIT` - Specific commit hash to checkout (optional)
- `NODE_ENV` - Node.js environment (development/production)
## Troubleshooting
### Git Authentication Issues
- Ensure your Git credentials are properly configured
- For HTTPS, use personal access tokens instead of passwords
- For SSH, ensure SSH keys are properly mounted or available
### Build Failures
- Check if the repository URL is accessible
- Verify the branch name exists
- Ensure the commit hash is valid
- Check Docker build logs for specific errors
### Permission Issues
- Ensure the Docker daemon has network access
- For private repositories, verify authentication tokens/keys
### Admin Account Issues
- If admin creation fails during startup, check database initialization
- Ensure the `./data` directory is writable on the host
- Database files are persisted in the mounted `./data` volume
- Admin account is created on container startup, not during build
- If admin already exists, the script will skip creation (this is normal)
- To recreate admin account, delete the database file in `./data/` and restart the container

156
DOCKER_TIMEZONE_FIX.md Normal file
View File

@@ -0,0 +1,156 @@
# Docker Timezone Configuration Fix
## Problem
Even after fixing the SQLite `datetime('now', 'localtime')` calls, notes posted at 10:00 still showed as 08:00 when running in Docker.
## Root Cause
**Docker containers run in UTC timezone by default!**
When using `datetime('now', 'localtime')` in SQLite:
- On local Windows machine: Uses Windows timezone (Europe/Warsaw) → ✅ Correct
- In Docker container: Uses container timezone (UTC) → ❌ Wrong by 2 hours
Example:
```
User posts at 10:00 Poland time (UTC+2)
Docker container thinks local time is 08:00 UTC
SQLite datetime('now', 'localtime') stores: 08:00
Display shows: 08:00 (wrong!)
```
## Solution
Set the Docker container timezone to Europe/Warsaw
### 1. Updated Dockerfile (Production)
```dockerfile
# Use Node.js 22.11.0 as the base image
FROM node:22.11.0
# Set timezone to Europe/Warsaw (Polish timezone)
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# ... rest of Dockerfile
```
### 2. Updated Dockerfile.dev (Development)
```dockerfile
# Use Node.js 22.11.0 as the base image
FROM node:22.11.0
# Set timezone to Europe/Warsaw (Polish timezone)
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# ... rest of Dockerfile
```
### 3. Updated docker-compose.yml (Development)
```yaml
environment:
- NODE_ENV=development
- TZ=Europe/Warsaw
```
### 4. Updated docker-compose.prod.yml (Production)
```yaml
environment:
- NODE_ENV=production
- TZ=Europe/Warsaw
- NEXTAUTH_SECRET=...
- NEXTAUTH_URL=...
```
## How to Apply
### Option 1: Rebuild Docker Images
```bash
# Stop containers
docker-compose down
# Rebuild images
docker-compose build --no-cache
# Start containers
docker-compose up -d
```
### Option 2: For Production Deployment
```bash
# Pull latest code with fixes
git pull
# Rebuild production image
docker-compose -f docker-compose.prod.yml build --no-cache
# Restart
docker-compose -f docker-compose.prod.yml up -d
```
## Verification
After rebuilding and restarting, verify timezone inside container:
```bash
# Check timezone
docker exec -it <container_name> date
# Should show: Sat Oct 4 19:00:00 CEST 2025
# Check Node.js sees correct timezone
docker exec -it <container_name> node -e "console.log(new Date().toLocaleString('pl-PL', {timeZone: 'Europe/Warsaw'}))"
# Should show current Polish time
# Check SQLite sees correct timezone
docker exec -it <container_name> node -e "const db = require('better-sqlite3')('./data/database.sqlite'); console.log(db.prepare(\"SELECT datetime('now', 'localtime')\").get());"
# Should show current Polish time
```
## Why This Works
1. **TZ Environment Variable**: Tells all processes (including Node.js and SQLite) what timezone to use
2. **Symlink /etc/localtime**: Updates system timezone for the entire container
3. **echo TZ > /etc/timezone**: Ensures the timezone persists
Now when SQLite uses `datetime('now', 'localtime')`:
- Container local time is 10:00 Poland time
- SQLite stores: 10:00
- Display shows: 10:00 ✅
## Important Notes
1. **Must rebuild images**: Just restarting containers is not enough - the timezone configuration is baked into the image
2. **All existing data**: Old notes will still show incorrect times (they were stored in UTC)
3. **New notes**: Will now display correctly
4. **DST handling**: Europe/Warsaw automatically handles Daylight Saving Time transitions
## Alternative Approach (Not Recommended)
Instead of changing container timezone, you could:
1. Store everything in UTC (like audit logs do with ISO format)
2. Always convert on display
But this requires more code changes and the current approach is simpler and more maintainable.
## Files Modified
1. `Dockerfile` - Added TZ configuration
2. `Dockerfile.dev` - Added TZ configuration
3. `docker-compose.yml` - Added TZ environment variable
4. `docker-compose.prod.yml` - Added TZ environment variable
## Testing Checklist
After deployment:
- [ ] Container shows correct date/time with `docker exec <container> date`
- [ ] Post a new note at known time (e.g., 10:30)
- [ ] Verify note displays the same time (10:30)
- [ ] Check both project notes and task notes
- [ ] Verify audit logs still work correctly
- [ ] Check task timestamps (date_started, date_completed)

View File

@@ -1,20 +1,47 @@
# Use Node.js 22.11.0 as the base image
FROM node:22.11.0
# Set timezone to Europe/Warsaw (Polish timezone)
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Install git
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json (if any)
# If building from a git repository, clone it
# This will be used when the build context doesn't include source files
ARG GIT_REPO_URL
ARG GIT_BRANCH=main
ARG GIT_COMMIT
# If GIT_REPO_URL is provided, clone the repo; otherwise copy local files
RUN if [ -n "$GIT_REPO_URL" ]; then \
git clone --branch ${GIT_BRANCH} ${GIT_REPO_URL} . && \
if [ -n "$GIT_COMMIT" ]; then git checkout ${GIT_COMMIT}; fi; \
fi
# Copy package.json and package-lock.json (if not cloned from git)
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the app
# Copy the rest of the app (if not cloned from git)
RUN if [ -z "$GIT_REPO_URL" ]; then echo "Copying local files..."; fi
COPY . .
# Build the application for production
RUN npm run build
# Copy the entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose the default Next.js port
EXPOSE 3000
# Start the dev server
CMD ["npm", "run", "dev"]
# Use the entrypoint script
ENTRYPOINT ["/docker-entrypoint.sh"]

31
Dockerfile.dev Normal file
View File

@@ -0,0 +1,31 @@
# Use Node.js 22.11.0 as the base image
FROM node:22.11.0
# Set timezone to Europe/Warsaw (Polish timezone)
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Install git for development
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json (if any)
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the app
COPY . .
# Copy the development entrypoint script
COPY docker-entrypoint-dev.sh /docker-entrypoint-dev.sh
RUN chmod +x /docker-entrypoint-dev.sh
# Expose the default Next.js port
EXPOSE 3000
# Use the development entrypoint script
ENTRYPOINT ["/docker-entrypoint-dev.sh"]

View File

@@ -32,13 +32,43 @@ A comprehensive project management system built with Next.js for managing constr
- Task completion tracking
- Quick access to pending items
### 🔐 Authentication & Authorization
- Complete user authentication system with NextAuth.js
- Role-based access control (Admin, User, Guest roles)
- Secure session management
- Password-based authentication
- User registration and management
### 👥 User Management
- Admin interface for user administration
- Create, edit, and delete user accounts
- Role assignment and permission management
- User activity monitoring
### 📋 Audit Logging
- Comprehensive logging of all user actions
- Security event tracking
- System activity monitoring
- Audit trail for compliance and debugging
### 📊 Data Export
- Export projects to Excel format grouped by status
- Includes project name, address, plot, WP, and finish date
- Separate sheets for each project status (registered, in progress, fulfilled, etc.)
## Tech Stack
- **Framework**: Next.js 15.1.8
- **Database**: SQLite with better-sqlite3
- **Authentication**: NextAuth.js
- **Styling**: Tailwind CSS
- **Date Handling**: date-fns
- **Frontend**: React 19
- **Mapping**: Leaflet with React-Leaflet
- **Container**: Docker & Docker Compose
## Getting Started
@@ -95,6 +125,16 @@ docker-compose up
The application uses SQLite database which will be automatically initialized on first run. The database file is located at `data/database.sqlite`.
### Admin Setup
To create an initial admin user:
```bash
npm run create-admin
```
This will create an admin user account. Access the admin panel at `/admin/users` to manage users and roles.
## Project Structure
```
@@ -144,6 +184,7 @@ src/
- `npm run build` - Build for production
- `npm run start` - Start production server
- `npm run lint` - Run ESLint
- `npm run export-projects` - Export all projects to Excel file grouped by status
## Docker Commands
@@ -219,12 +260,25 @@ The application uses the following main tables:
## Advanced Map Features
This project includes a powerful map system for project locations, supporting multiple dynamic base layers:
This project includes a powerful map system for project locations, supporting multiple dynamic base layers and transparent overlays:
### Base Layers (8 total)
- **OpenStreetMap** (default street map)
- **Polish Geoportal Orthophoto** (aerial imagery via WMTS)
- **Polish Land Records** (WMS cadastral data)
- **Satellite (Esri)** and **Topographic** layers
- **🇵🇱 Polish Orthophoto (Standard Resolution)** - WMTS aerial imagery
- **🇵🇱 Polish Orthophoto (High Resolution)** - WMTS high-res aerial imagery
- **🌍 Google Satellite** - Global satellite imagery
- **🌍 Google Hybrid** - Satellite imagery with road overlays
- **🌍 Google Roads** - Google Maps road view
- **Satellite (Esri)** - Alternative satellite imagery
- **Topographic** - CartoDB topographic maps
### Overlay Layers (6 total with transparency)
- **📋 Polish Cadastral Data** (WMS, property boundaries - 80% opacity)
- **🏗️ Polish Spatial Planning** (WMS, zoning data - 70% opacity)
- **🛣️ LP-Portal Roads** (WMS, road network - 90% opacity)
- **🏷️ LP-Portal Street Names** (WMS, street labels - 100% opacity)
- **📐 LP-Portal Parcels** (WMS, property parcels - 60% opacity)
- **📍 LP-Portal Survey Markers** (WMS, survey points - 80% opacity)
Users can switch layers using the map control (📚 icon). WMTS/WMS layers are configured dynamically using OGC GetCapabilities, making it easy to add new sources. See [`docs/MAP_LAYERS.md`](docs/MAP_LAYERS.md) for details on adding and configuring map layers.

View File

@@ -16,59 +16,43 @@ This is a solid Next.js-based project management system for construction/enginee
- **API Structure**: RESTful API endpoints for all entities
- **Docker Support**: Containerized development and deployment
- **Testing Setup**: Jest, Playwright, Testing Library configured
- **Authentication & Authorization**: NextAuth.js with role-based access control, user management UI, session management
- **Security Features**: Input validation with Zod, password hashing with bcryptjs, audit logging
- **Reporting Libraries**: Recharts for charts, jsPDF/jspdf-autotable for PDF, exceljs/xlsx for Excel export
- **Search & Filtering**: Basic search functionality implemented
---
## Critical Missing Features for App
### 🔐 **1. Authentication & Authorization (HIGH PRIORITY)**
### <EFBFBD> **1. Security & Data Protection (HIGH PRIORITY)**
**Current State**: No authentication system
**Current State**: Partial security measures implemented (Zod validation, bcrypt hashing, audit logging)
**Required**:
- User login/logout system
- Role-based access control (Admin, Project Manager, User, Read-only)
- Session management
- Password reset functionality
- User management interface
- API route protection
**Implementation Options**:
- NextAuth.js with database sessions
- Auth0 integration
- Custom JWT implementation
### 🔒 **2. Security & Data Protection (HIGH PRIORITY)**
**Current State**: No security measures
**Required**:
- Input validation and sanitization
- SQL injection protection (prepared statements are good start)
- XSS protection
- CSRF protection
- Rate limiting
- Environment variable security
- Data encryption for sensitive fields
- Audit logging
- XSS protection (additional measures)
- Security headers middleware
- Comprehensive error handling
### 📊 **3. Advanced Reporting & Analytics (MEDIUM PRIORITY)**
### 📊 **2. Advanced Reporting & Analytics (MEDIUM PRIORITY)**
**Current State**: Basic dashboard statistics
**Current State**: Libraries installed (Recharts, jsPDF, exceljs), basic dashboard statistics, API endpoints for reports
**Required**:
- Project timeline reports
- Full UI for project timeline reports
- Budget tracking and financial reports
- Task completion analytics
- Project performance metrics
- Export to PDF/Excel
- Custom report builder
- Charts and graphs (Chart.js, D3.js)
- Charts and graphs integration in UI
### 💾 **4. Backup & Data Management (HIGH PRIORITY)**
### 💾 **3. Backup & Data Management (HIGH PRIORITY)**
**Current State**: Single SQLite file
**Current State**: Single SQLite file, manual export scripts
**Required**:
- Automated database backups
@@ -77,6 +61,122 @@ This is a solid Next.js-based project management system for construction/enginee
- Data archiving for old projects
- Recovery procedures
### 📱 **4. Mobile Responsiveness & PWA (MEDIUM PRIORITY)**
**Current State**: Basic responsive design
**Required**:
- Progressive Web App capabilities
- Offline functionality
- Mobile-optimized interface
- Push notifications
- App manifest and service workers
### 🔗 **5. API & Integration (MEDIUM PRIORITY)**
**Current State**: Internal REST API only
**Required**:
- External API integrations (accounting software, CRM)
- Webhook support
- API documentation (Swagger/OpenAPI)
- API versioning
- Third-party service integrations
### <20> **6. Communication & Notifications (MEDIUM PRIORITY)**
**Current State**: No notification system
**Required**:
- Email notifications for deadlines, status changes
- In-app notifications
- SMS notifications (optional)
- Email templates
- Notification preferences per user
### 📋 **7. Enhanced Project Management (MEDIUM PRIORITY)**
**Current State**: Basic project tracking
**Required**:
- Gantt charts for project timelines
- Resource allocation and management
- Budget tracking per project
- Document attachment system
- Project templates
- Milestone tracking
- Dependencies between tasks
### 🔍 **8. Search & Filtering (LOW PRIORITY)**
**Current State**: Basic search implemented
**Required**:
- Advanced search with filters
- Full-text search
- Saved search queries
- Search autocomplete
- Global search across all entities
### ⚡ **9. Performance & Scalability (MEDIUM PRIORITY)**
**Current State**: Good for small-medium datasets
**Required**:
- Database optimization and indexing
- Caching layer (Redis)
- Image optimization
- Lazy loading
- Pagination for large datasets
- Background job processing
### 📝 **10. Documentation & Help System (LOW PRIORITY)**
**Current State**: README.md only
**Required**:
- User manual/documentation
- In-app help system
- API documentation
- Video tutorials
- FAQ section
### 🧪 **11. Testing & Quality Assurance (MEDIUM PRIORITY)**
**Current State**: Testing frameworks set up but minimal actual tests
**Required**:
- Unit tests for all components
- Integration tests for API endpoints
- E2E tests for critical user flows
- Performance testing
- Accessibility testing
- Code coverage reports
### <20> **12. DevOps & Deployment (MEDIUM PRIORITY)**
**Current State**: Docker setup exists
**Required**:
- CI/CD pipeline
- Production deployment strategy
- Environment management (dev, staging, prod)
- Monitoring and logging
- Error tracking (Sentry)
- Health checks
### 🎨 **13. UI/UX Improvements (LOW PRIORITY)**
**Current State**: Clean, functional interface
**Required**:
- Dark mode support
- Customizable themes
- Accessibility improvements (WCAG compliance)
- Keyboard navigation
- Better loading states
- Drag and drop functionality
### 📱 **5. Mobile Responsiveness & PWA (MEDIUM PRIORITY)**
**Current State**: Basic responsive design
@@ -197,18 +297,18 @@ This is a solid Next.js-based project management system for construction/enginee
## Implementation Priority Levels
### Phase 1: Security & Stability (Weeks 1-4)
### Phase 1: Security Completion & Backup (Weeks 1-4)
1. Authentication system
2. Authorization and role management
3. Input validation and security
4. Backup system
1. Complete security measures (CSRF protection, rate limiting, security headers)
2. Backup system implementation
3. Password reset functionality
4. Enhanced error handling
5. Basic testing coverage
### Phase 2: Core Features (Weeks 5-8)
1. Advanced reporting
2. Mobile optimization
1. Advanced reporting UI
2. Mobile optimization & PWA
3. Notification system
4. Enhanced project management features
@@ -230,34 +330,36 @@ This is a solid Next.js-based project management system for construction/enginee
## Immediate Next Steps (Recommended Order)
1. **Set up Authentication**
1. **Complete Security Measures**
- Install NextAuth.js or implement custom auth
- Create user management system
- Add login/logout functionality
- Implement CSRF protection
- Add rate limiting
- Set up security headers middleware
- Enhance error handling
2. **Implement Input Validation**
- Add Zod or Joi for schema validation
- Protect all API endpoints
- Add error handling
3. **Create Backup System**
2. **Create Backup System**
- Implement database backup scripts
- Set up automated backups
- Create recovery procedures
3. **Implement Password Reset**
- Add password reset functionality
- Email templates and sending
- Secure token generation
4. **Add Basic Tests**
- Write unit tests for critical functions
- Add integration tests for API routes
- Set up test automation
5. **Implement Reporting**
- Add Chart.js for visualizations
- Create project timeline reports
- Add export functionality
5. **Build Advanced Reporting UI**
- Create project timeline reports page
- Integrate charts with Recharts
- Add PDF/Excel export UI
---
@@ -265,25 +367,25 @@ This is a solid Next.js-based project management system for construction/enginee
### Authentication
- **NextAuth.js** - For easy authentication setup
- **NextAuth.js** - ✅ Implemented with role-based access and user management
- **Prisma** - For better database management (optional upgrade from better-sqlite3)
### Security
- **Zod** - Runtime type checking and validation
- **bcryptjs** - Password hashing
- **rate-limiter-flexible** - Rate limiting
- **Zod** - ✅ Implemented for validation
- **bcryptjs** - ✅ Implemented for password hashing
- **rate-limiter-flexible** - Rate limiting (to implement)
### Reporting
- **Chart.js** or **Recharts** - Data visualization
- **jsPDF** - PDF generation
- **xlsx** - Excel export
- **Recharts** - ✅ Installed for data visualization
- **jsPDF/jspdf-autotable** - ✅ Installed for PDF generation
- **exceljs/xlsx** - ✅ Installed for Excel export
### Notifications
- **Nodemailer** - Email sending
- **Socket.io** - Real-time notifications
- **Nodemailer** - Email sending (to implement)
- **Socket.io** - Real-time notifications (to implement)
### Testing
@@ -302,13 +404,16 @@ This is a solid Next.js-based project management system for construction/enginee
5. **Docker support** for easy deployment
6. **Map integration** with multiple layers
7. **Modular components** that are reusable
8. **Authentication & Authorization** fully implemented with NextAuth.js
9. **Security foundations** (validation, hashing, audit logging)
10. **Reporting capabilities** with installed libraries for charts and exports
---
## Estimated Development Time
- **Minimum Viable Professional App**: 8-12 weeks
- **Full-featured Professional App**: 16-20 weeks
- **Enterprise-grade Application**: 24-30 weeks
- **Minimum Viable Professional App**: 6-10 weeks
- **Full-featured Professional App**: 14-18 weeks
- **Enterprise-grade Application**: 22-28 weeks
This assessment is based on a single developer working full-time. Team development could reduce these timelines significantly.

18
add-assignable-column.mjs Normal file
View File

@@ -0,0 +1,18 @@
import db from "./src/lib/db.js";
console.log("Adding can_be_assigned column to users table...");
// Add the new column
db.prepare(`
ALTER TABLE users
ADD COLUMN can_be_assigned INTEGER DEFAULT 1
`).run();
// Set admin users to not be assignable by default
db.prepare(`
UPDATE users
SET can_be_assigned = 0
WHERE role = 'admin'
`).run();
console.log("Migration completed. Admin users are now not assignable by default.");

57
deploy.bat Normal file
View File

@@ -0,0 +1,57 @@
@echo off
REM Production deployment script for Windows
REM Usage: deploy.bat [git_repo_url] [branch] [commit_hash]
set GIT_REPO_URL=%1
set GIT_BRANCH=%2
if "%GIT_BRANCH%"=="" set GIT_BRANCH=ui-fix
set GIT_COMMIT=%3
REM Check if .env.production exists
if exist .env.production (
echo Loading production environment variables...
for /f "delims=" %%x in (.env.production) do (
set "%%x"
)
) else (
echo Warning: .env.production not found. Make sure environment variables are set!
)
REM Validate critical environment variables
if "%NEXTAUTH_SECRET%"=="" (
echo ERROR: NEXTAUTH_SECRET must be set to a secure random string!
echo Generate one with: openssl rand -base64 32
exit /b 1
)
@REM if "%NEXTAUTH_SECRET%"=="YOUR_SUPER_SECURE_SECRET_KEY_HERE_AT_LEAST_32_CHARACTERS_LONG" (
@REM echo ERROR: NEXTAUTH_SECRET must be changed from the default value!
@REM echo Generate one with: openssl rand -base64 32
@REM exit /b 1
@REM )
if "%NEXTAUTH_URL%"=="" (
echo ERROR: NEXTAUTH_URL must be set to your production URL!
exit /b 1
)
if "%GIT_REPO_URL%"=="" (
echo Building from local files...
docker-compose -f docker-compose.prod.yml build
) else (
echo Building from git repository: %GIT_REPO_URL%
echo Branch: %GIT_BRANCH%
if not "%GIT_COMMIT%"=="" echo Commit: %GIT_COMMIT%
set GIT_REPO_URL=%GIT_REPO_URL%
set GIT_BRANCH=%GIT_BRANCH%
set GIT_COMMIT=%GIT_COMMIT%
docker-compose -f docker-compose.prod.yml build
)
echo Starting production deployment...
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
echo Deployment completed successfully!
echo Application is running at http://localhost:3001

52
deploy.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Production deployment script
# Usage: ./deploy.sh [git_repo_url] [branch] [commit_hash]
set -e
# Default values
GIT_REPO_URL=${1:-""}
GIT_BRANCH=${2:-"ui-fix"}
GIT_COMMIT=${3:-""}
# Check if .env.production exists and source it
if [ -f .env.production ]; then
echo "Loading production environment variables..."
export $(grep -v '^#' .env.production | xargs)
else
echo "Warning: .env.production not found. Make sure environment variables are set!"
fi
# Validate critical environment variables
# if [ -z "$NEXTAUTH_SECRET" ] || [ "$NEXTAUTH_SECRET" = "YOUR_SUPER_SECURE_SECRET_KEY_HERE_AT_LEAST_32_CHARACTERS_LONG" ]; then
# echo "ERROR: NEXTAUTH_SECRET must be set to a secure random string!"
# echo "Generate one with: openssl rand -base64 32"
# exit 1
# fi
if [ -z "$NEXTAUTH_URL" ]; then
echo "ERROR: NEXTAUTH_URL must be set to your production URL!"
exit 1
fi
if [ -z "$GIT_REPO_URL" ]; then
echo "Building from local files..."
docker-compose -f docker-compose.prod.yml build
else
echo "Building from git repository: $GIT_REPO_URL"
echo "Branch: $GIT_BRANCH"
if [ -n "$GIT_COMMIT" ]; then
echo "Commit: $GIT_COMMIT"
fi
GIT_REPO_URL=$GIT_REPO_URL GIT_BRANCH=$GIT_BRANCH GIT_COMMIT=$GIT_COMMIT \
docker-compose -f docker-compose.prod.yml build
fi
echo "Starting production deployment..."
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
echo "Deployment completed successfully!"
echo "Application is running at http://localhost:3001"

23
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "3.9"
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
- GIT_REPO_URL=${GIT_REPO_URL}
- GIT_BRANCH=${GIT_BRANCH:-main}
- GIT_COMMIT=${GIT_COMMIT}
ports:
- "3001:3000"
volumes:
- ./data:/app/data
- ./uploads:/app/public/uploads
environment:
- NODE_ENV=production
- TZ=Europe/Warsaw
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-your-secret-key-generate-a-strong-random-string-at-least-32-characters}
- NEXTAUTH_URL=${NEXTAUTH_URL:-https://panel2.wastpol.pl}
- AUTH_TRUST_HOST=true
restart: unless-stopped

View File

@@ -2,12 +2,15 @@ version: "3.9"
services:
app:
build: .
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
- "3001:3000"
volumes:
- .:/app
- /app/node_modules
- ./data:/app/data
environment:
- NODE_ENV=development
- TZ=Europe/Warsaw

25
docker-entrypoint-dev.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Development container startup script
# This runs when the development container starts
echo "🚀 Starting development environment..."
# Ensure data directory exists
mkdir -p /app/data
# Ensure uploads directory structure exists
mkdir -p /app/public/uploads/contracts
mkdir -p /app/public/uploads/projects
mkdir -p /app/public/uploads/tasks
# Set proper permissions for uploads directory
chmod -R 755 /app/public/uploads
# Create admin account if it doesn't exist
echo "🔧 Setting up admin account..."
node scripts/create-admin.js
# Start the development server
echo "✅ Starting development server..."
exec npm run dev

29
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Container startup script
# This runs when the container starts, not during build
echo "🚀 Starting application..."
# Ensure data directory exists
mkdir -p /app/data
# Ensure uploads directory structure exists
mkdir -p /app/public/uploads/contracts
mkdir -p /app/public/uploads/projects
mkdir -p /app/public/uploads/tasks
# Set proper permissions for uploads directory
chmod -R 755 /app/public/uploads
# Create admin account if it doesn't exist
echo "🔧 Setting up admin account..."
node scripts/create-admin.js
# Run any pending database migrations
echo "🔄 Running database migrations..."
./run-migrations.sh
# Start the application
echo "✅ Starting production server..."
exec npm start

View File

@@ -0,0 +1,58 @@
import * as XLSX from 'xlsx';
import { getAllProjects } from './src/lib/queries/projects.js';
function exportProjectsToExcel() {
try {
// Get all projects
const projects = getAllProjects();
// Group projects by status
const groupedProjects = projects.reduce((acc, project) => {
const status = project.project_status || 'unknown';
if (!acc[status]) {
acc[status] = [];
}
acc[status].push({
'Nazwa projektu': project.project_name,
'Adres': project.address || '',
'Działka': project.plot || '',
'WP': project.wp || '',
'Data zakończenia': project.finish_date || ''
});
return acc;
}, {});
// Polish status translations for sheet names
const statusTranslations = {
'registered': 'Zarejestrowany',
'in_progress_design': 'W realizacji (projektowanie)',
'in_progress_construction': 'W realizacji (budowa)',
'fulfilled': 'Zakończony',
'cancelled': 'Wycofany',
'unknown': 'Nieznany'
};
// Create workbook
const workbook = XLSX.utils.book_new();
// Create a sheet for each status
Object.keys(groupedProjects).forEach(status => {
const sheetName = statusTranslations[status] || status;
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
// Write to file
const filename = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, filename);
console.log(`Excel file created: ${filename}`);
console.log(`Sheets created for statuses: ${Object.keys(groupedProjects).join(', ')}`);
} catch (error) {
console.error('Error exporting projects to Excel:', error);
}
}
// Run the export
exportProjectsToExcel();

92
files-to-delete.md Normal file
View File

@@ -0,0 +1,92 @@
# Files to Delete from Codebase
Based on analysis of the workspace, the following files and folders appear to be temporary, debug-related, test-specific, or one-off scripts that should not remain in the production codebase. Review and delete as appropriate.
## Debug/Test Folders (entirely removable)
- `debug-disabled/` (and all subfolders: comprehensive-polish-map/, debug-polish-orthophoto/, test-improved-wmts/, test-polish-map/, test-polish-orthophoto/)
- `data/` (contains database.sqlite, likely a development database)
- `uploads/` (user-uploaded files that should be gitignored or stored elsewhere)
- `scripts/` (test data creation scripts: create-additional-test-data.js, create-admin.js, create-diverse-test-data.js, create-sample-projects.js, create-test-data.js)
## Test Files (one-off test scripts)
- test-audit-fix-direct.mjs
- test-audit-logging.mjs
- test-auth-api.mjs
- test-auth-detailed.mjs
- test-auth-pages.mjs
- test-auth-session.mjs
- test-auth.mjs
- test-complete-auth.mjs
- test-create-function.mjs
- test-current-audit-logs.mjs
- test-date-formatting.js
- test-dropdown-comprehensive.html
- test-dropdown.html
- test-edge-compatibility.mjs
- test-logged-in-flow.mjs
- test-logging.mjs
- test-mobile.html
- test-nextauth.mjs
- test-project-api.mjs
- test-project-creation.mjs
- test-safe-audit-logging.mjs
- test-task-api.mjs
- test-task-sets.mjs
- test-user-tracking.mjs
## Debug Files
- debug-dropdown.js
- debug-task-insert.mjs
## Check/Verification Scripts (one-off)
- check-audit-db.mjs
- check-columns.mjs
- check-projects-table.mjs
- check-projects.mjs
- check-schema.mjs
- check-task-schema.mjs
## Migration Scripts (likely already executed)
- migrate-add-completion-date.mjs
- migrate-add-edited-at-to-notes.mjs
- migrate-add-initial-column.mjs
- migrate-add-team-lead-role.mjs
- migrate-add-wartosc-zlecenia.mjs
- migrate-to-username.js
- run-migrations.sh
## Other One-Off Scripts
- add-assignable-column.mjs
- export-projects-to-excel.mjs
- fix-notes-columns.mjs
- fix-task-columns.mjs
- init-db-temp.mjs
- update-admin-username.js
- update-queries.ps1
- verify-audit-fix.mjs
- verify-project.mjs
## Implementation/Status Documentation (temporary notes)
- AUDIT_LOGGING_IMPLEMENTATION.md
- AUTHORIZATION_IMPLEMENTATION.md
- DEPLOYMENT_TIMEZONE_FIX.md
- DOCKER_GIT_DEPLOYMENT.md
- DOCKER_TIMEZONE_FIX.md
- DROPDOWN_COMPLETION_STATUS.md
- DROPDOWN_IMPLEMENTATION_SUMMARY.md
- EDGE_RUNTIME_FIX_FINAL.md
- EDGE_RUNTIME_FIX.md
- INTEGRATION_COMPLETE.md
- INTEGRATION_SUMMARY.md
- MERGE_COMPLETE.md
- MERGE_PREPARATION_SUMMARY.md
- POLISH_LAYERS_IMPLEMENTATION.md
## Development-Only Files
- start-dev.bat
## Potentially Keep (but review)
- deploy.bat / deploy.sh (if used for production deployment)
- geoportal-capabilities.xml (if it's configuration data)
This list focuses on files that seem to be development artifacts, temporary fixes, or test utilities. Before deletion, verify if any are still referenced in the codebase or needed for specific workflows. The core application code in `src/`, configuration files, and essential docs like `README.md` should remain.

5
init-db-temp.mjs Normal file
View File

@@ -0,0 +1,5 @@
import initializeDatabase from './src/lib/init-db.js';
console.log('Initializing database...');
initializeDatabase();
console.log('Database initialized successfully!');

View File

@@ -0,0 +1,29 @@
import db from "./src/lib/db.js";
export default function migrateAddCompletionDate() {
try {
// First, check if actual_completion_date exists and rename it to completion_date
const columns = db.prepare("PRAGMA table_info(projects)").all();
const hasActualCompletionDate = columns.some(col => col.name === 'actual_completion_date');
const hasCompletionDate = columns.some(col => col.name === 'completion_date');
if (hasActualCompletionDate && !hasCompletionDate) {
// Rename the column
db.exec(`
ALTER TABLE projects RENAME COLUMN actual_completion_date TO completion_date;
`);
console.log("Migration completed: Renamed actual_completion_date to completion_date");
} else if (!hasActualCompletionDate && !hasCompletionDate) {
// Add the column if it doesn't exist
db.exec(`
ALTER TABLE projects ADD COLUMN completion_date TEXT;
`);
console.log("Migration completed: Added completion_date column to projects table");
} else if (hasCompletionDate) {
console.log("Migration skipped: completion_date column already exists");
}
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}

View File

@@ -0,0 +1,27 @@
import db from "./src/lib/db.js";
export default function migrateAddEditedAtToNotes() {
try {
// Check if edited_at column already exists
const columns = db.prepare("PRAGMA table_info(notes)").all();
const hasEditedAt = columns.some(col => col.name === 'edited_at');
if (!hasEditedAt) {
// Add the edited_at column
db.exec(`
ALTER TABLE notes ADD COLUMN edited_at TEXT;
`);
console.log("Migration completed: Added edited_at column to notes table");
} else {
console.log("Migration skipped: edited_at column already exists");
}
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}
// Run the migration if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
migrateAddEditedAtToNotes();
}

View File

@@ -0,0 +1,43 @@
import Database from "better-sqlite3";
// Migration script to add 'initial' column to users table
// Run this on your live server to apply the database changes
const dbPath = process.argv[2] || "./data/database.sqlite"; // Allow custom path via command line
console.log(`Applying migration to database: ${dbPath}`);
try {
const db = new Database(dbPath);
// Check if initial column already exists
const schema = db.prepare("PRAGMA table_info(users)").all();
const hasInitialColumn = schema.some(column => column.name === 'initial');
if (hasInitialColumn) {
console.log("✅ Initial column already exists in users table");
} else {
// Add the initial column
db.prepare("ALTER TABLE users ADD COLUMN initial TEXT").run();
console.log("✅ Added 'initial' column to users table");
}
// Verify the column was added
const updatedSchema = db.prepare("PRAGMA table_info(users)").all();
const initialColumn = updatedSchema.find(column => column.name === 'initial');
if (initialColumn) {
console.log("✅ Migration completed successfully");
console.log(`Column details: ${JSON.stringify(initialColumn, null, 2)}`);
} else {
console.error("❌ Migration failed - initial column not found");
process.exit(1);
}
db.close();
console.log("Database connection closed");
} catch (error) {
console.error("❌ Migration failed:", error.message);
process.exit(1);
}

View File

@@ -0,0 +1,75 @@
import db from './src/lib/db.js';
console.log('Starting migration to add team_lead role to users table constraint...');
try {
// Disable foreign key constraints temporarily
db.pragma('foreign_keys = OFF');
console.log('Disabled foreign key constraints');
// Since SQLite doesn't support modifying CHECK constraints directly,
// we need to recreate the table with the new constraint
// First, create a backup table with current data
db.exec('CREATE TABLE users_backup AS SELECT * FROM users');
console.log('Created backup table');
// Drop the original table
db.exec('DROP TABLE users');
console.log('Dropped original table');
// Recreate the table with the updated constraint
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT CHECK(role IN ('admin', 'team_lead', 'project_manager', 'user', 'read_only')) DEFAULT 'user',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
is_active INTEGER DEFAULT 1,
last_login TEXT,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TEXT,
can_be_assigned INTEGER DEFAULT 1,
initial TEXT
)
`);
console.log('Created new table with updated constraint');
// Copy data back from backup
db.exec(`
INSERT INTO users (
id, name, username, password_hash, role, created_at, updated_at,
is_active, last_login, failed_login_attempts, locked_until,
can_be_assigned, initial
)
SELECT
id, name, username, password_hash, role, created_at, updated_at,
is_active, last_login, failed_login_attempts, locked_until,
can_be_assigned, initial
FROM users_backup
`);
console.log('Copied data back from backup');
// Drop the backup table
db.exec('DROP TABLE users_backup');
console.log('Dropped backup table');
// Re-enable foreign key constraints
db.pragma('foreign_keys = ON');
console.log('Re-enabled foreign key constraints');
// Verify the migration
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get();
console.log(`✅ Migration completed successfully! Users table now has ${userCount.count} records`);
// Verify the constraint allows the new role
console.log('✅ CHECK constraint now includes: admin, team_lead, project_manager, user, read_only');
} catch (error) {
console.error('❌ Migration failed:', error.message);
console.error('You may need to restore from backup manually');
process.exit(1);
}

View File

@@ -0,0 +1,36 @@
import db from './src/lib/db.js';
console.log('Starting migration to add wartosc_zlecenia field to projects table...');
try {
// Check if wartosc_zlecenia column already exists
const schema = db.prepare("PRAGMA table_info(projects)").all();
const hasWartoscZleceniaColumn = schema.some(column => column.name === 'wartosc_zlecenia');
if (hasWartoscZleceniaColumn) {
console.log("✅ wartosc_zlecenia column already exists in projects table");
} else {
// Add the wartosc_zlecenia column
db.prepare("ALTER TABLE projects ADD COLUMN wartosc_zlecenia REAL").run();
console.log("✅ Added 'wartosc_zlecenia' column to projects table");
}
// Verify the column was added
const updatedSchema = db.prepare("PRAGMA table_info(projects)").all();
const wartoscZleceniaColumn = updatedSchema.find(column => column.name === 'wartosc_zlecenia');
if (wartoscZleceniaColumn) {
console.log("✅ Migration completed successfully");
console.log(`Column details: ${JSON.stringify(wartoscZleceniaColumn, null, 2)}`);
} else {
console.error("❌ Migration failed - wartosc_zlecenia column not found");
process.exit(1);
}
db.close();
console.log("Database connection closed");
} catch (error) {
console.error("❌ Migration failed:", error.message);
process.exit(1);
}

60
migrate-to-username.js Normal file
View File

@@ -0,0 +1,60 @@
import Database from "better-sqlite3";
const db = new Database("./data/database.sqlite");
console.log("🔄 Migrating database to username-based authentication...\n");
try {
// Check current table structure
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
console.log("Current users table columns:");
tableInfo.forEach(col => console.log(` - ${col.name}: ${col.type}`));
const hasUsername = tableInfo.some(col => col.name === 'username');
const hasEmail = tableInfo.some(col => col.name === 'email');
if (hasUsername) {
console.log("✅ Username column already exists!");
} else if (hasEmail) {
console.log("\n📝 Adding username column...");
// Add username column
db.exec(`ALTER TABLE users ADD COLUMN username TEXT;`);
console.log("✅ Username column added");
// Copy email data to username for existing users
console.log("📋 Migrating existing email data to username...");
const result = db.exec(`UPDATE users SET username = email WHERE username IS NULL;`);
console.log("✅ Data migrated");
// Create unique index on username
console.log("🔍 Creating unique index on username...");
try {
db.exec(`CREATE UNIQUE INDEX idx_users_username_unique ON users(username);`);
console.log("✅ Unique index created");
} catch (e) {
console.log(" Index already exists or couldn't be created:", e.message);
}
// Verify migration
console.log("\n🔍 Verifying migration...");
const users = db.prepare("SELECT id, name, username, email FROM users LIMIT 3").all();
console.log("Sample users after migration:");
users.forEach(user => {
console.log(` - ${user.name}: username="${user.username}", email="${user.email || 'NULL'}"`);
});
console.log("\n✅ Migration completed successfully!");
console.log(" You can now log in using usernames instead of emails");
} else {
console.log("❌ Neither username nor email column found. Database may be corrupted.");
process.exit(1);
}
} catch (error) {
console.error("❌ Migration failed:", error.message);
process.exit(1);
} finally {
db.close();
}

2781
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"create-admin": "node scripts/create-admin.js",
"export-projects": "node export-projects-to-excel.mjs",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
@@ -15,9 +17,14 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@mapbox/polyline": "^1.2.1",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^11.10.0",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"leaflet": "^1.9.4",
"next": "15.1.8",
"next-auth": "^5.0.0-beta.29",
@@ -28,6 +35,7 @@
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"recharts": "^2.15.3",
"xlsx": "^0.18.5",
"zod": "^3.25.67"
},
"devDependencies": {
@@ -38,6 +46,7 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@types/leaflet": "^1.9.18",
"concurrently": "^9.2.1",
"eslint": "^9",
"eslint-config-next": "15.1.8",
"jest": "^29.7.0",

308
route_planning_readme.md Normal file
View File

@@ -0,0 +1,308 @@
# Route Planning Feature with Optimization
This feature allows you to plan routes between multiple project locations using OpenRouteService, with automatic optimization to find the fastest route regardless of point addition order.
## Setup
1. **Get an API Key**:
- Visit [OpenRouteService](https://openrouteservice.org/)
- Sign up for a free account
- Generate an API key
2. **Configure Environment**:
- Copy `.env.example` to `.env.local`
- Add your API key: `NEXT_PUBLIC_ORS_API_KEY=your_actual_api_key`
3. **Install Dependencies**:
```bash
npm install @mapbox/polyline
```
4. **Restart Development Server**:
```bash
npm run dev
```
## How to Use
### Basic Routing (2 Points)
1. **Select Route Tool**: Click the route icon in the tool panel (looks like a path)
2. **Add Projects**: Click on project markers to add them to your route
3. **Calculate Route**: Click "Calculate Route" to get directions
4. **View Results**: See distance, duration, and route path on the map
### Optimized Routing (3+ Points)
1. **Select Route Tool**: Click the route icon in the tool panel
2. **Add Projects**: Click on project markers (order doesn't matter)
3. **Find Optimal Route**: Click "Find Optimal Route" - system automatically finds fastest path
4. **View Optimization Results**: See which route order was selected and performance stats
## Features
### Core Features
- **Multi-point routing**: Plan routes through multiple project locations
- **Visual route display**: Blue dashed line shows the calculated route
- **Route markers**: Green start marker, red end marker
- **Route information**: Distance and estimated travel time
- **Interactive management**: Add/remove projects from route
- **Map auto-fit**: Automatically adjusts map view to show entire route
### Optimization Features ✨
- **Hybrid Optimization**: Uses ORS Optimization API first, falls back to permutation testing
- **Smart Fallback**: Automatically switches to proven permutation method if ORS fails
- **Order Detection**: Clearly shows when route order was actually optimized vs unchanged
- **Large Point Support**: Can handle up to 50+ points with ORS API
- **Performance Monitoring**: Detailed logging of optimization approach and results
- **Real-time Progress**: Shows "Finding Optimal Route..." during calculation
## Technical Implementation
### Core Functions
#### `calculateRoute()`
Main function that handles both basic and optimized routing with hybrid approach:
```javascript
const calculateRoute = async () => {
// For 2 points: direct calculation
if (coordinates.length === 2) {
const routeData = await calculateRouteForCoordinates(coordinates);
setRouteData({...routeData, optimized: false});
return;
}
// For 3+ points: try ORS Optimization API first
let optimizationRequest = {
jobs: coordinates.map((coord, index) => ({
id: index,
location: coord,
service: 0
})),
vehicles: [{
id: 0,
profile: 'driving-car',
// No fixed start/end for true optimization
capacity: [coordinates.length]
}],
options: { g: true }
};
try {
const optimizationResponse = await fetch('https://api.openrouteservice.org/optimization', {
method: 'POST',
headers: {
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(optimizationRequest)
});
const optimizationData = await optimizationResponse.json();
// Extract optimized order from ORS response
const optimizedCoordinates = extractOptimizedOrder(optimizationData, coordinates);
// Check if order actually changed
const orderChanged = detectOrderChange(coordinates, optimizedCoordinates);
if (orderChanged) {
// Use optimized order
const routeData = await calculateRouteForCoordinates(optimizedCoordinates);
setRouteData({...routeData, optimized: true, optimizationStats: {
method: 'ORS_Optimization_API',
totalJobs: coordinates.length,
duration: optimizationData.routes[0].duration,
distance: optimizationData.routes[0].distance
}});
} else {
// Fallback to permutation testing
console.log('ORS optimization did not change order, trying permutations...');
const bestRoute = await findOptimalRouteByPermutations(coordinates);
const routeData = await calculateRouteForCoordinates(bestRoute);
setRouteData({...routeData, optimized: true, optimizationStats: {
method: 'Permutation_Testing',
totalJobs: coordinates.length,
duration: routeData.summary.total_duration,
distance: routeData.summary.total_distance
}});
}
} catch (error) {
// Complete fallback to permutations
console.log('ORS optimization failed, using permutation fallback...');
const bestRoute = await findOptimalRouteByPermutations(coordinates);
const routeData = await calculateRouteForCoordinates(bestRoute);
setRouteData({...routeData, optimized: true, optimizationStats: {
method: 'Permutation_Testing',
totalJobs: coordinates.length,
duration: routeData.summary.total_duration,
distance: routeData.summary.total_distance
}});
}
};
```
#### `calculateRouteForCoordinates(coordinates)`
Handles individual OpenRouteService Directions API calls:
```javascript
const calculateRouteForCoordinates = async (coordinates) => {
const requestBody = {
coordinates: coordinates,
format: 'geojson',
instructions: true,
geometry_simplify: false,
continue_straight: false,
roundabout_exits: true,
attributes: ['avgspeed', 'percentage']
};
const response = await fetch('https://api.openrouteservice.org/v2/directions/driving-car', {
method: 'POST',
headers: {
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
return await response.json();
};
```
### UI Components
#### Dynamic Button Text
```javascript
{routeProjects.length > 2 ? 'Find Optimal Route' : 'Calculate Route'}
```
#### Optimization Status Display
```javascript
{routeData.optimized && (
<div className="mb-2 p-2 bg-green-50 border border-green-200 rounded">
<div className="flex items-center gap-1 font-medium">
✅ Route Optimized
</div>
<div className="mt-1">
Tested {routeData.optimizationStats.totalPermutations} routes
</div>
</div>
)}
```
## Performance Considerations
### Optimization Limits
- **Maximum Points**: Limited to 50 points (ORS can handle 100+ in some cases)
- **Algorithm**: Advanced TSP solver instead of brute-force permutations
- **API Calls**: Only 2 API calls (1 optimization + 1 detailed route)
- **Processing Time**: ~1-2 seconds for 50 points (much faster than permutation testing)
### Memory Usage
- Each route response contains detailed geometry data
- Large numbers of points can consume significant memory
- Automatic cleanup of unused route data
## API Integration
### OpenRouteService Optimization API
```javascript
{
jobs: [
{ id: 0, location: [lng, lat], service: 0 },
{ id: 1, location: [lng, lat], service: 0 }
],
vehicles: [{
id: 0,
profile: 'driving-car',
start: [lng, lat],
end: [lng, lat],
capacity: [point_count]
}],
options: { g: true }
}
```
### Directions API Parameters
```javascript
{
coordinates: [[lng, lat], [lng, lat], ...],
format: 'geojson',
instructions: true,
geometry_simplify: false,
continue_straight: false,
roundabout_exits: true,
attributes: ['avgspeed', 'percentage']
}
```
### Response Handling
- **Optimization API**: `data.routes[0].steps[]` for optimized order
- **Directions API**: `data.routes[0].summary` for route details
- **Fallback Path**: `data.features[0].properties.segments[0]`
- **Geometry**: Supports both encoded polylines and direct coordinates
- **Error Handling**: Graceful fallback for failed calculations
## Troubleshooting
### Common Issues
#### "Failed to calculate route"
- **Cause**: Invalid API key or network issues
- **Solution**: Verify `NEXT_PUBLIC_ORS_API_KEY` in `.env.local`
#### "Too many points for optimization"
- **Cause**: Selected more than 50 points
- **Solution**: Reduce to 50 or fewer points, or use manual routing
#### Optimization taking too long
- **Cause**: Large number of points or slow API responses
- **Solution**: Reduce points or wait for completion (much faster than before)
#### Optimization API unavailable
- **Cause**: ORS Optimization API temporarily unavailable
- **Solution**: Falls back to direct routing without optimization
#### Route order not optimized
- **Cause**: ORS Optimization API returned same order or failed
- **Solution**: System automatically falls back to permutation testing for guaranteed optimization
#### Optimization shows "Order unchanged"
- **Cause**: Points may already be in optimal order, or API returned original sequence
- **Solution**: Check browser console for detailed optimization logs
#### Permutation fallback activated
- **Cause**: ORS API unavailable or returned suboptimal results
- **Solution**: This is normal behavior - permutation testing ensures optimization
### Debug Information
Check browser console for detailed logs:
- Coordinate parsing details
- API request/response structures
- **Optimization approach used** (ORS API vs permutation fallback)
- **Order change detection** (whether optimization actually improved the route)
- Performance timing information
- **Original vs optimized coordinate sequences**
## File Structure
```
src/app/projects/map/page.js # Main map page with routing logic
src/components/ui/LeafletMap.js # Map component with route rendering
src/components/ui/mapLayers.js # Map layer configurations
```
## Dependencies
- `@mapbox/polyline`: For decoding route geometry
- `leaflet`: Map rendering library
- `react-leaflet`: React integration for Leaflet
- OpenRouteService API key (free tier available)
## Future Enhancements
- **Advanced Vehicle Constraints**: Multiple vehicles, capacity limits, time windows
- **Route Preferences**: Allow users to prioritize distance vs time vs fuel efficiency
- **Real-time Traffic**: Integration with live traffic data
- **Route History**: Save and compare previously optimized routes
- **Mobile Optimization**: Optimize routes considering current location
- **Multi-stop Services**: Add service times at each location

31
run-migrations.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Database migration runner for deployment
# This script runs all pending migrations in order
echo "🔄 Running database migrations..."
# List of migration scripts to run (in order)
MIGRATIONS=(
"migrate-add-team-lead-role.mjs"
"migrate-add-wartosc-zlecenia.mjs"
)
for migration in "${MIGRATIONS[@]}"; do
if [ -f "$migration" ]; then
echo "Running migration: $migration"
if node "$migration"; then
echo "✅ Migration $migration completed successfully"
# Optionally move completed migration to a completed folder
# mkdir -p migrations/completed
# mv "$migration" "migrations/completed/"
else
echo "❌ Migration $migration failed"
exit 1
fi
else
echo "Migration file $migration not found, skipping..."
fi
done
echo "✅ All migrations completed"

View File

@@ -10,13 +10,13 @@ async function createInitialAdmin() {
const adminUser = await createUser({
name: "Administrator",
email: "admin@localhost.com",
username: "admin",
password: "admin123456", // Change this in production!
role: "admin"
})
console.log("✅ Initial admin user created successfully!")
console.log("📧 Email: admin@localhost.com")
console.log("<EFBFBD> Username: admin")
console.log("🔑 Password: admin123456")
console.log("⚠️ Please change the password after first login!")
console.log("👤 User ID:", adminUser.id)

View File

@@ -0,0 +1,206 @@
import db from '../src/lib/db.js';
import initializeDatabase from '../src/lib/init-db.js';
// Initialize the database
initializeDatabase();
// Sample projects data
const sampleProjects = [
{
contract_id: 1,
project_name: 'Residential Complex Alpha',
address: 'ul. Główna 123',
plot: 'Plot 45/6',
district: 'Śródmieście',
unit: 'Unit A',
city: 'Warszawa',
investment_number: 'INV-2025-001',
finish_date: '2025-06-30',
wp: 'WP-001',
contact: 'Jan Kowalski, tel. 123-456-789',
notes: 'Modern residential building with 50 apartments',
coordinates: '52.2297,21.0122',
project_type: 'design+construction',
project_status: 'registered'
},
{
contract_id: 1,
project_name: 'Office Building Beta',
address: 'al. Jerozolimskie 50',
plot: 'Plot 12/8',
district: 'Mokotów',
unit: 'Unit B',
city: 'Warszawa',
investment_number: 'INV-2025-002',
finish_date: '2025-09-15',
wp: 'WP-002',
contact: 'Anna Nowak, tel. 987-654-321',
notes: 'Commercial office space, 10 floors',
coordinates: '52.2215,21.0071',
project_type: 'construction',
project_status: 'in_progress_design'
},
{
contract_id: 2,
project_name: 'Shopping Mall Gamma',
address: 'pl. Centralny 1',
plot: 'Plot 78/3',
district: 'Centrum',
unit: 'Unit C',
city: 'Kraków',
investment_number: 'INV-2025-003',
finish_date: '2025-12-20',
wp: 'WP-003',
contact: 'Piotr Wiśniewski, tel. 555-123-456',
notes: 'Large shopping center with parking',
coordinates: '50.0647,19.9450',
project_type: 'design+construction',
project_status: 'in_progress_construction'
},
{
contract_id: 2,
project_name: 'Industrial Warehouse Delta',
address: 'ul. Przemysłowa 100',
plot: 'Plot 200/15',
district: 'Przemysłowa',
unit: 'Unit D',
city: 'Łódź',
investment_number: 'INV-2025-004',
finish_date: '2025-08-10',
wp: 'WP-004',
contact: 'Maria Lewandowska, tel. 444-789-012',
notes: 'Logistics warehouse facility',
coordinates: '51.7592,19.4600',
project_type: 'design',
project_status: 'fulfilled'
},
{
contract_id: 1,
project_name: 'Hotel Complex Epsilon',
address: 'ul. Morska 25',
plot: 'Plot 5/2',
district: 'Nadmorze',
unit: 'Unit E',
city: 'Gdańsk',
investment_number: 'INV-2025-005',
finish_date: '2025-11-05',
wp: 'WP-005',
contact: 'Tomasz Malinowski, tel. 333-456-789',
notes: 'Luxury hotel with conference facilities',
coordinates: '54.3520,18.6466',
project_type: 'design+construction',
project_status: 'registered'
},
{
contract_id: 2,
project_name: 'School Complex Zeta',
address: 'ul. Edukacyjna 15',
plot: 'Plot 30/4',
district: 'Edukacyjny',
unit: 'Unit F',
city: 'Poznań',
investment_number: 'INV-2025-006',
finish_date: '2025-07-20',
wp: 'WP-006',
contact: 'Ewa Dombrowska, tel. 222-333-444',
notes: 'Modern educational facility with sports complex',
coordinates: '52.4064,16.9252',
project_type: 'design',
project_status: 'in_progress_design'
},
{
contract_id: 1,
project_name: 'Medical Center Eta',
address: 'al. Zdrowia 8',
plot: 'Plot 67/9',
district: 'Medyczny',
unit: 'Unit G',
city: 'Wrocław',
investment_number: 'INV-2025-007',
finish_date: '2025-10-30',
wp: 'WP-007',
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
notes: 'Specialized medical center with emergency department',
coordinates: '51.1079,17.0385',
project_type: 'construction',
project_status: 'in_progress_construction'
},
{
contract_id: 2,
project_name: 'Sports Stadium Theta',
address: 'ul. Sportowa 50',
plot: 'Plot 150/20',
district: 'Sportowy',
unit: 'Unit H',
city: 'Szczecin',
investment_number: 'INV-2025-008',
finish_date: '2025-05-15',
wp: 'WP-008',
contact: 'Katarzyna Wojcik, tel. 999-888-777',
notes: 'Multi-purpose sports stadium with seating for 20,000',
coordinates: '53.4289,14.5530',
project_type: 'design+construction',
project_status: 'fulfilled'
},
{
contract_id: 1,
project_name: 'Library Complex Iota',
address: 'pl. Wiedzy 3',
plot: 'Plot 25/7',
district: 'Kulturalny',
unit: 'Unit I',
city: 'Lublin',
investment_number: 'INV-2025-009',
finish_date: '2025-08-25',
wp: 'WP-009',
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
notes: 'Modern library with digital archives and community spaces',
coordinates: '51.2465,22.5684',
project_type: 'design',
project_status: 'registered'
}
];
console.log('Creating sample test projects...\n');
sampleProjects.forEach((projectData, index) => {
try {
// Generate project number based on contract
const contractInfo = db.prepare('SELECT contract_number FROM contracts WHERE contract_id = ?').get(projectData.contract_id);
const existingProjects = db.prepare('SELECT COUNT(*) as count FROM projects WHERE contract_id = ?').get(projectData.contract_id);
const sequentialNumber = existingProjects.count + 1;
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`;
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,
project_type, project_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`).run(
projectData.contract_id,
projectData.project_name,
projectNumber,
projectData.address,
projectData.plot,
projectData.district,
projectData.unit,
projectData.city,
projectData.investment_number,
projectData.finish_date,
projectData.wp,
projectData.contact,
projectData.notes,
projectData.coordinates,
projectData.project_type,
projectData.project_status
);
console.log(`✓ Created project: ${projectData.project_name} (ID: ${result.lastInsertRowid}, Number: ${projectNumber})`);
} catch (error) {
console.error(`✗ Error creating project ${projectData.project_name}:`, error.message);
}
});
console.log('\nSample test projects created successfully!');

View File

@@ -15,9 +15,10 @@ export default function EditUserPage() {
const [user, setUser] = useState(null);
const [formData, setFormData] = useState({
name: "",
email: "",
username: "",
role: "user",
is_active: true,
initial: "",
password: ""
});
const [loading, setLoading] = useState(true);
@@ -62,9 +63,10 @@ export default function EditUserPage() {
setUser(userData);
setFormData({
name: userData.name,
email: userData.email,
username: userData.username,
role: userData.role,
is_active: userData.is_active,
initial: userData.initial || "",
password: "" // Never populate password field
});
} catch (err) {
@@ -84,9 +86,10 @@ export default function EditUserPage() {
// Prepare update data (exclude empty password)
const updateData = {
name: formData.name,
email: formData.email,
username: formData.username,
role: formData.role,
is_active: formData.is_active
is_active: formData.is_active,
initial: formData.initial.trim() || null
};
// Only include password if it's provided
@@ -209,12 +212,12 @@ export default function EditUserPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email *
Username *
</label>
<Input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
@@ -232,6 +235,7 @@ export default function EditUserPage() {
<option value="read_only">Read Only</option>
<option value="user">User</option>
<option value="project_manager">Project Manager</option>
<option value="team_lead">Team Lead</option>
<option value="admin">Admin</option>
</select>
</div>
@@ -253,6 +257,23 @@ export default function EditUserPage() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Initial
</label>
<Input
type="text"
value={formData.initial}
onChange={(e) => setFormData({ ...formData, initial: e.target.value })}
placeholder="1-2 letter identifier"
maxLength={2}
className="w-full md:w-1/2"
/>
<p className="text-xs text-gray-500 mt-1">
Optional 1-2 letter identifier for the user
</p>
</div>
<div className="flex items-center">
<input
type="checkbox"

View File

@@ -12,8 +12,10 @@ import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function UserManagementPage() {
const { t } = useTranslation();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -54,7 +56,7 @@ export default function UserManagementPage() {
};
const handleDeleteUser = async (userId) => {
if (!confirm("Are you sure you want to delete this user?")) return;
if (!confirm(t('admin.deleteUser') + "?")) return;
try {
const response = await fetch(`/api/admin/users/${userId}`, {
@@ -95,10 +97,36 @@ export default function UserManagementPage() {
}
};
const handleToggleAssignable = async (userId, canBeAssigned) => {
try {
const response = await fetch(`/api/admin/users/${userId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ can_be_assigned: !canBeAssigned }),
});
if (!response.ok) {
throw new Error("Failed to update user");
}
setUsers(users.map(user =>
user.id === userId
? { ...user, can_be_assigned: !canBeAssigned }
: user
));
} catch (err) {
setError(err.message);
}
};
const getRoleColor = (role) => {
switch (role) {
case "admin":
return "red";
case "team_lead":
return "purple";
case "project_manager":
return "blue";
case "user":
@@ -114,6 +142,8 @@ export default function UserManagementPage() {
switch (role) {
case "project_manager":
return "Project Manager";
case "team_lead":
return "Team Lead";
case "read_only":
return "Read Only";
default:
@@ -141,7 +171,7 @@ export default function UserManagementPage() {
return (
<PageContainer>
<PageHeader title="User Management" description="Manage system users and permissions">
<PageHeader title={t('admin.userManagement')} description={t('admin.subtitle')}>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
@@ -149,7 +179,7 @@ export default function UserManagementPage() {
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add User
{t('admin.newUser')}
</Button>
</PageHeader>
@@ -192,7 +222,10 @@ export default function UserManagementPage() {
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-sm text-gray-500">{user.username}</p>
{user.initial && (
<p className="text-xs text-blue-600 font-medium mt-1">Initial: {user.initial}</p>
)}
</div>
</div>
<div className="flex items-center space-x-2">
@@ -202,6 +235,9 @@ export default function UserManagementPage() {
<Badge color={user.is_active ? "green" : "red"}>
{user.is_active ? "Active" : "Inactive"}
</Badge>
<Badge color={user.can_be_assigned ? "blue" : "gray"}>
{user.can_be_assigned ? "Assignable" : "Not Assignable"}
</Badge>
</div>
</div>
</CardHeader>
@@ -232,6 +268,20 @@ export default function UserManagementPage() {
)}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id={`assignable-${user.id}`}
checked={user.can_be_assigned || false}
onChange={() => handleToggleAssignable(user.id, user.can_be_assigned)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor={`assignable-${user.id}`} className="text-sm text-gray-700">
Can be assigned to projects/tasks
</label>
</div>
</div>
<div className="flex space-x-2">
<Button
variant="outline"
@@ -282,10 +332,11 @@ export default function UserManagementPage() {
function CreateUserModal({ onClose, onUserCreated }) {
const [formData, setFormData] = useState({
name: "",
email: "",
username: "",
password: "",
role: "user",
is_active: true
is_active: true,
can_be_assigned: true
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -351,12 +402,12 @@ function CreateUserModal({ onClose, onUserCreated }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
Username
</label>
<Input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
@@ -386,6 +437,7 @@ function CreateUserModal({ onClose, onUserCreated }) {
<option value="read_only">Read Only</option>
<option value="user">User</option>
<option value="project_manager">Project Manager</option>
<option value="team_lead">Team Lead</option>
<option value="admin">Admin</option>
</select>
</div>
@@ -403,6 +455,19 @@ function CreateUserModal({ onClose, onUserCreated }) {
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="can_be_assigned"
checked={formData.can_be_assigned}
onChange={(e) => setFormData({ ...formData, can_be_assigned: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="can_be_assigned" className="ml-2 block text-sm text-gray-900">
Can be assigned to projects/tasks
</label>
</div>
<div className="flex space-x-3 pt-4">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? "Creating..." : "Create User"}

View File

@@ -4,8 +4,9 @@ import { withAdminAuth } from "@/lib/middleware/auth";
// GET: Get user by ID (admin only)
async function getUserHandler(req, { params }) {
const { id } = await params;
try {
const user = getUserById(params.id);
const user = getUserById(id);
if (!user) {
return NextResponse.json(
@@ -29,9 +30,10 @@ async function getUserHandler(req, { params }) {
// PUT: Update user (admin only)
async function updateUserHandler(req, { params }) {
const { id } = await params;
try {
const data = await req.json();
const userId = params.id;
const userId = id;
// Prevent admin from deactivating themselves
if (data.is_active === false && userId === req.user.id) {
@@ -43,7 +45,7 @@ async function updateUserHandler(req, { params }) {
// Validate role if provided
if (data.role) {
const validRoles = ["read_only", "user", "project_manager", "admin"];
const validRoles = ["read_only", "user", "project_manager", "team_lead", "admin"];
if (!validRoles.includes(data.role)) {
return NextResponse.json(
{ error: "Invalid role specified" },
@@ -78,7 +80,7 @@ async function updateUserHandler(req, { params }) {
if (error.message.includes("already exists")) {
return NextResponse.json(
{ error: "A user with this email already exists" },
{ error: "A user with this username already exists" },
{ status: 409 }
);
}
@@ -92,8 +94,9 @@ async function updateUserHandler(req, { params }) {
// DELETE: Delete user (admin only)
async function deleteUserHandler(req, { params }) {
const { id } = await params;
try {
const userId = params.id;
const userId = id;
// Prevent admin from deleting themselves
if (userId === req.user.id) {

View File

@@ -27,9 +27,9 @@ async function createUserHandler(req) {
const data = await req.json();
// Validate required fields
if (!data.name || !data.email || !data.password) {
if (!data.name || !data.username || !data.password) {
return NextResponse.json(
{ error: "Name, email, and password are required" },
{ error: "Name, username, and password are required" },
{ status: 400 }
);
}
@@ -43,7 +43,7 @@ async function createUserHandler(req) {
}
// Validate role
const validRoles = ["read_only", "user", "project_manager", "admin"];
const validRoles = ["read_only", "user", "project_manager", "team_lead", "admin"];
if (data.role && !validRoles.includes(data.role)) {
return NextResponse.json(
{ error: "Invalid role specified" },
@@ -53,7 +53,7 @@ async function createUserHandler(req) {
const newUser = await createUser({
name: data.name,
email: data.email,
username: data.username,
password: data.password,
role: data.role || "user",
is_active: data.is_active !== undefined ? data.is_active : true
@@ -68,7 +68,7 @@ async function createUserHandler(req) {
if (error.message.includes("already exists")) {
return NextResponse.json(
{ error: "A user with this email already exists" },
{ error: "A user with this username already exists" },
{ status: 409 }
);
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import bcrypt from "bcryptjs";
import { z } from "zod";
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
});
export async function POST(request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const body = await request.json();
const { currentPassword, newPassword } = changePasswordSchema.parse(body);
// Import database here to avoid edge runtime issues
const { default: db } = await import("@/lib/db.js");
// Get current user password hash
const user = db
.prepare("SELECT password_hash FROM users WHERE id = ?")
.get(session.user.id);
if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!isValidPassword) {
return NextResponse.json(
{ error: "Current password is incorrect" },
{ status: 400 }
);
}
// Hash the new password
const hashedNewPassword = await bcrypt.hash(newPassword, 12);
// Update password
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(hashedNewPassword, session.user.id);
// Log audit event
try {
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
await logAuditEventSafe({
action: AUDIT_ACTIONS.USER_UPDATE,
userId: session.user.id,
resourceType: RESOURCE_TYPES.USER,
details: { field: "password", username: session.user.username },
});
} catch (auditError) {
console.error("Failed to log audit event:", auditError);
}
return NextResponse.json({
message: "Password changed successfully",
});
} catch (error) {
console.error("Change password error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { z } from "zod";
const requestSchema = z.object({
username: z.string().min(1, "Username is required"),
});
export async function POST(request) {
try {
const body = await request.json();
const { username } = requestSchema.parse(body);
// Import database here to avoid edge runtime issues
const { default: db } = await import("@/lib/db.js");
// Check if user exists and is active
const user = db
.prepare("SELECT id, username, name FROM users WHERE username = ? AND is_active = 1")
.get(username);
if (!user) {
// Don't reveal if user exists or not for security
return NextResponse.json(
{ message: "If the username exists, a password reset link has been sent." },
{ status: 200 }
);
}
// Generate reset token
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
// Delete any existing tokens for this user
db.prepare("DELETE FROM password_reset_tokens WHERE user_id = ?").run(user.id);
// Insert new token
db.prepare(
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
).run(user.id, token, expiresAt);
// TODO: Send email with reset link
// For now, return the token for testing purposes
console.log(`Password reset token for ${username}: ${token}`);
// Log audit event
try {
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
await logAuditEventSafe({
action: AUDIT_ACTIONS.PASSWORD_RESET_REQUEST,
userId: user.id,
resourceType: RESOURCE_TYPES.USER,
details: { username: user.username },
});
} catch (auditError) {
console.error("Failed to log audit event:", auditError);
}
return NextResponse.json(
{ message: "If the username exists, a password reset link has been sent." },
{ status: 200 }
);
} catch (error) {
console.error("Password reset request error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
const resetSchema = z.object({
token: z.string().min(1, "Token is required"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
export async function POST(request) {
try {
const body = await request.json();
const { token, password } = resetSchema.parse(body);
// Import database here to avoid edge runtime issues
const { default: db } = await import("@/lib/db.js");
// Check if token exists and is valid
const resetToken = db
.prepare(
`
SELECT prt.*, u.username, u.name
FROM password_reset_tokens prt
JOIN users u ON prt.user_id = u.id
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
`
)
.get(token);
if (!resetToken) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 }
);
}
// Hash the new password
const hashedPassword = await bcrypt.hash(password, 12);
// Update user password
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(hashedPassword, resetToken.user_id);
// Mark token as used
db.prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?")
.run(resetToken.id);
// Log audit event
try {
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
await logAuditEventSafe({
action: AUDIT_ACTIONS.PASSWORD_RESET,
userId: resetToken.user_id,
resourceType: RESOURCE_TYPES.USER,
details: { username: resetToken.username },
});
} catch (auditError) {
console.error("Failed to log audit event:", auditError);
}
return NextResponse.json({
message: "Password has been reset successfully",
});
} catch (error) {
console.error("Password reset error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { z } from "zod";
const verifySchema = z.object({
token: z.string().min(1, "Token is required"),
});
export async function POST(request) {
try {
const body = await request.json();
const { token } = verifySchema.parse(body);
// Import database here to avoid edge runtime issues
const { default: db } = await import("@/lib/db.js");
// Check if token exists and is valid
const resetToken = db
.prepare(
`
SELECT prt.*, u.username, u.name
FROM password_reset_tokens prt
JOIN users u ON prt.user_id = u.id
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
`
)
.get(token);
if (!resetToken) {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 400 }
);
}
return NextResponse.json({
valid: true,
username: resetToken.username,
name: resetToken.name,
});
} catch (error) {
console.error("Token verification error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,262 @@
// Force this API route to use Node.js runtime
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getAllProjects } from "@/lib/queries/projects";
export async function GET(request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only team leads can access dashboard data
if (session.user.role !== 'team_lead') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const selectedYear = searchParams.get('year') ? parseInt(searchParams.get('year')) : null;
// Get all projects
const projects = getAllProjects();
// Calculate realised and unrealised values by project type
const projectTypes = ['design', 'design+construction', 'construction'];
const typeSummary = {};
projectTypes.forEach(type => {
typeSummary[type] = {
realisedValue: 0,
unrealisedValue: 0
};
});
projects.forEach(project => {
const value = parseFloat(project.wartosc_zlecenia) || 0;
const type = project.project_type;
if (!type || !projectTypes.includes(type)) return;
if (project.project_status === 'fulfilled' && project.completion_date && project.wartosc_zlecenia) {
typeSummary[type].realisedValue += value;
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
typeSummary[type].unrealisedValue += value;
}
});
// Calculate overall totals
let realisedValue = 0;
let unrealisedValue = 0;
Object.values(typeSummary).forEach(summary => {
realisedValue += summary.realisedValue;
unrealisedValue += summary.unrealisedValue;
});
// Filter completed projects (those with completion_date and fulfilled status)
const completedProjects = projects.filter(project =>
project.completion_date &&
project.wartosc_zlecenia &&
project.project_status === 'fulfilled'
);
// If no data, return sample data for demonstration
let chartData;
let summary;
if (completedProjects.length === 0) {
// Generate continuous sample data based on selected year or default range
const currentDate = new Date();
let startDate, endDate;
if (selectedYear) {
startDate = new Date(selectedYear, 0, 1); // Jan 1st of selected year
endDate = new Date(selectedYear, 11, 31); // Dec 31st of selected year
if (endDate > currentDate) endDate = currentDate;
} else {
startDate = new Date(2024, 0, 1); // Jan 2024
endDate = currentDate;
}
chartData = [];
let cumulative = 0;
let tempDate = new Date(startDate);
while (tempDate <= endDate) {
const monthName = tempDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
let monthlyValue = 0;
// Add some sample values for certain months (only if they match the selected year or no year selected)
const shouldAddData = !selectedYear || tempDate.getFullYear() === selectedYear;
if (shouldAddData) {
if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2024) monthlyValue = 50000; // Jan 2024
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2024) monthlyValue = 75000; // Feb 2024
else if (tempDate.getMonth() === 2 && tempDate.getFullYear() === 2024) monthlyValue = 60000; // Mar 2024
else if (tempDate.getMonth() === 7 && tempDate.getFullYear() === 2024) monthlyValue = 10841; // Aug 2024 (real data)
else if (tempDate.getMonth() === 8 && tempDate.getFullYear() === 2024) monthlyValue = 18942; // Sep 2024
else if (tempDate.getMonth() === 9 && tempDate.getFullYear() === 2024) monthlyValue = 13945; // Oct 2024
else if (tempDate.getMonth() === 10 && tempDate.getFullYear() === 2024) monthlyValue = 12542; // Nov 2024
else if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2025) monthlyValue = 25000; // Jan 2025
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2025) monthlyValue = 35000; // Feb 2025
}
cumulative += monthlyValue;
chartData.push({
month: monthName,
value: monthlyValue,
cumulative: cumulative
});
tempDate.setMonth(tempDate.getMonth() + 1);
}
summary = {
total: {
realisedValue: 958000,
unrealisedValue: 1242000
},
byType: {
design: {
realisedValue: 320000,
unrealisedValue: 480000
},
'design+construction': {
realisedValue: 480000,
unrealisedValue: 520000
},
construction: {
realisedValue: 158000,
unrealisedValue: 242000
}
}
};
} else {
// Group by month and calculate monthly totals first
const monthlyData = {};
// Sort projects by completion date
completedProjects.sort((a, b) => new Date(a.completion_date) - new Date(b.completion_date));
// First pass: calculate monthly totals
completedProjects.forEach(project => {
const date = new Date(project.completion_date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const monthName = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
if (!monthlyData[monthKey]) {
monthlyData[monthKey] = {
month: monthName,
value: 0
};
}
const projectValue = parseFloat(project.wartosc_zlecenia) || 0;
monthlyData[monthKey].value += projectValue;
});
// Generate continuous timeline from earliest completion to current date
let startDate = new Date();
let endDate = new Date();
if (completedProjects.length > 0) {
// Find earliest completion date
const earliestCompletion = completedProjects.reduce((earliest, project) => {
const projectDate = new Date(project.completion_date);
return projectDate < earliest ? projectDate : earliest;
}, new Date());
startDate = new Date(earliestCompletion.getFullYear(), earliestCompletion.getMonth(), 1);
} else {
// If no completed projects, start from 6 months ago
startDate = new Date();
startDate.setMonth(startDate.getMonth() - 6);
startDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
}
// If a specific year is selected, adjust the date range
if (selectedYear) {
startDate = new Date(selectedYear, 0, 1); // January 1st of selected year
endDate = new Date(selectedYear, 11, 31); // December 31st of selected year
// Don't go beyond current date
if (endDate > new Date()) {
endDate = new Date();
}
}
// Generate all months from start to current
const allMonths = {};
let currentDate = new Date(startDate);
while (currentDate <= endDate) {
const monthKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
const monthName = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
allMonths[monthKey] = {
month: monthName,
value: monthlyData[monthKey]?.value || 0
};
currentDate.setMonth(currentDate.getMonth() + 1);
}
// Calculate cumulative values
let cumulativeValue = 0;
const sortedMonths = Object.keys(allMonths).sort((a, b) => a.localeCompare(b));
sortedMonths.forEach(monthKey => {
cumulativeValue += allMonths[monthKey].value;
allMonths[monthKey].cumulative = cumulativeValue;
});
// Convert to array
chartData = sortedMonths.map(monthKey => ({
month: allMonths[monthKey].month,
value: Math.round(allMonths[monthKey].value),
cumulative: Math.round(allMonths[monthKey].cumulative)
}));
summary = {
total: {
realisedValue: Math.round(realisedValue),
unrealisedValue: Math.round(unrealisedValue)
},
byType: Object.fromEntries(
Object.entries(typeSummary).map(([type, data]) => [
type,
{
realisedValue: Math.round(data.realisedValue),
unrealisedValue: Math.round(data.unrealisedValue)
}
])
)
};
}
return NextResponse.json({
chartData,
summary: {
total: {
realisedValue: Math.round(realisedValue),
unrealisedValue: Math.round(unrealisedValue)
},
byType: Object.fromEntries(
Object.entries(typeSummary).map(([type, data]) => [
type,
{
realisedValue: Math.round(data.realisedValue),
unrealisedValue: Math.round(data.unrealisedValue)
}
])
)
}
});
} catch (error) {
console.error('Dashboard API error:', error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
// Force this API route to use Node.js runtime for database access
export const runtime = "nodejs";
import { getFieldHistory, hasFieldHistory } from "@/lib/queries/fieldHistory";
import { NextResponse } from "next/server";
import { withReadAuth } from "@/lib/middleware/auth";
import initializeDatabase from "@/lib/init-db";
// Make sure the DB is initialized before queries run
initializeDatabase();
async function getFieldHistoryHandler(req) {
const { searchParams } = new URL(req.url);
const tableName = searchParams.get("table_name");
const recordId = searchParams.get("record_id");
const fieldName = searchParams.get("field_name");
const checkOnly = searchParams.get("check_only") === "true";
if (!tableName || !recordId || !fieldName) {
return NextResponse.json(
{ error: "Missing required parameters: table_name, record_id, field_name" },
{ status: 400 }
);
}
try {
if (checkOnly) {
// Just check if history exists
const exists = hasFieldHistory(tableName, parseInt(recordId), fieldName);
return NextResponse.json({ hasHistory: exists });
} else {
// Get full history
const history = getFieldHistory(tableName, parseInt(recordId), fieldName);
return NextResponse.json(history);
}
} catch (error) {
console.error("Error fetching field history:", error);
return NextResponse.json(
{ error: "Failed to fetch field history" },
{ status: 500 }
);
}
}
// Protected route - require read authentication
export const GET = withReadAuth(getFieldHistoryHandler);

View File

@@ -0,0 +1,186 @@
import { NextResponse } from "next/server";
import { readFile } from "fs/promises";
import { existsSync } from "fs";
import { unlink } from "fs/promises";
import path from "path";
import db from "@/lib/db";
export async function GET(request, { params }) {
const { fileId } = await params;
try {
// Get file info from database
const file = db.prepare(`
SELECT * FROM file_attachments WHERE file_id = ?
`).get(parseInt(fileId));
if (!file) {
return NextResponse.json(
{ error: "File not found" },
{ status: 404 }
);
}
// Construct the full file path
const fullPath = path.join(process.cwd(), "public", file.file_path);
// Check if file exists
if (!existsSync(fullPath)) {
return NextResponse.json(
{ error: "File not found on disk" },
{ status: 404 }
);
}
// Read the file
const fileBuffer = await readFile(fullPath);
// Return the file with appropriate headers
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": file.mime_type || "application/octet-stream",
"Content-Disposition": `attachment; filename="${encodeURIComponent(file.original_filename)}"`,
"Content-Length": fileBuffer.length.toString(),
},
});
} catch (error) {
console.error("Error downloading file:", error);
return NextResponse.json(
{ error: "Failed to download file" },
{ status: 500 }
);
}
}
export async function PUT(request, { params }) {
const { fileId } = await params;
try {
const body = await request.json();
const { description, original_filename } = body;
// Validate input
if (description !== undefined && typeof description !== 'string') {
return NextResponse.json(
{ error: "Description must be a string" },
{ status: 400 }
);
}
if (original_filename !== undefined && typeof original_filename !== 'string') {
return NextResponse.json(
{ error: "Original filename must be a string" },
{ status: 400 }
);
}
// Check if file exists
const existingFile = db.prepare(`
SELECT * FROM file_attachments WHERE file_id = ?
`).get(parseInt(fileId));
if (!existingFile) {
return NextResponse.json(
{ error: "File not found" },
{ status: 404 }
);
}
// Build update query
const updates = [];
const values = [];
if (description !== undefined) {
updates.push('description = ?');
values.push(description);
}
if (original_filename !== undefined) {
updates.push('original_filename = ?');
values.push(original_filename);
}
if (updates.length === 0) {
return NextResponse.json(
{ error: "No valid fields to update" },
{ status: 400 }
);
}
values.push(parseInt(fileId));
const result = db.prepare(`
UPDATE file_attachments
SET ${updates.join(', ')}
WHERE file_id = ?
`).run(...values);
if (result.changes === 0) {
return NextResponse.json(
{ error: "File not found" },
{ status: 404 }
);
}
// Get updated file
const updatedFile = db.prepare(`
SELECT * FROM file_attachments WHERE file_id = ?
`).get(parseInt(fileId));
return NextResponse.json(updatedFile);
} catch (error) {
console.error("Error updating file:", error);
return NextResponse.json(
{ error: "Failed to update file" },
{ status: 500 }
);
}
}
export async function DELETE(request, { params }) {
const { fileId } = await params;
try {
// Get file info from database
const file = db.prepare(`
SELECT * FROM file_attachments WHERE file_id = ?
`).get(parseInt(fileId));
if (!file) {
return NextResponse.json(
{ error: "File not found" },
{ status: 404 }
);
}
// Delete physical file
try {
const fullPath = path.join(process.cwd(), "public", file.file_path);
await unlink(fullPath);
} catch (fileError) {
console.warn("Could not delete physical file:", fileError.message);
// Continue with database deletion even if file doesn't exist
}
// Delete from database
const result = db.prepare(`
DELETE FROM file_attachments WHERE file_id = ?
`).run(parseInt(fileId));
if (result.changes === 0) {
return NextResponse.json(
{ error: "File not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting file:", error);
return NextResponse.json(
{ error: "Failed to delete file" },
{ status: 500 }
);
}
}

162
src/app/api/files/route.js Normal file
View File

@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
import db from "@/lib/db";
import { auditLog } from "@/lib/middleware/auditLog";
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"image/jpeg",
"image/png",
"image/gif",
"text/plain"
];
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get("file");
const entityType = formData.get("entityType");
const entityId = formData.get("entityId");
const description = formData.get("description") || "";
if (!file || !entityType || !entityId) {
return NextResponse.json(
{ error: "File, entityType, and entityId are required" },
{ status: 400 }
);
}
// Validate entity type
if (!["contract", "project", "task"].includes(entityType)) {
return NextResponse.json(
{ error: "Invalid entity type" },
{ status: 400 }
);
}
// Validate file
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "File size too large (max 10MB)" },
{ status: 400 }
);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "File type not allowed" },
{ status: 400 }
);
}
// Create upload directory structure
const entityDir = path.join(UPLOAD_DIR, entityType + "s", entityId);
if (!existsSync(entityDir)) {
await mkdir(entityDir, { recursive: true });
}
// Generate unique filename
const timestamp = Date.now();
const sanitizedOriginalName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const storedFilename = `${timestamp}_${sanitizedOriginalName}`;
const filePath = path.join(entityDir, storedFilename);
const relativePath = `/uploads/${entityType}s/${entityId}/${storedFilename}`;
// Save file
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(filePath, buffer);
// Save to database
const stmt = db.prepare(`
INSERT INTO file_attachments (
entity_type, entity_id, original_filename, stored_filename,
file_path, file_size, mime_type, description, uploaded_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
entityType,
parseInt(entityId),
file.name,
storedFilename,
relativePath,
file.size,
file.type,
description,
null // TODO: Get from session when auth is implemented
);
const newFile = {
file_id: result.lastInsertRowid,
entity_type: entityType,
entity_id: parseInt(entityId),
original_filename: file.name,
stored_filename: storedFilename,
file_path: relativePath,
file_size: file.size,
mime_type: file.type,
description: description,
upload_date: new Date().toISOString()
};
return NextResponse.json(newFile, { status: 201 });
} catch (error) {
console.error("File upload error:", error);
return NextResponse.json(
{ error: "Failed to upload file" },
{ status: 500 }
);
}
}
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const entityType = searchParams.get("entityType");
const entityId = searchParams.get("entityId");
if (!entityType || !entityId) {
return NextResponse.json(
{ error: "entityType and entityId are required" },
{ status: 400 }
);
}
const files = db.prepare(`
SELECT
file_id,
entity_type,
entity_id,
original_filename,
stored_filename,
file_path,
file_size,
mime_type,
description,
upload_date,
uploaded_by
FROM file_attachments
WHERE entity_type = ? AND entity_id = ?
ORDER BY upload_date DESC
`).all(entityType, parseInt(entityId));
return NextResponse.json(files);
} catch (error) {
console.error("Error fetching files:", error);
return NextResponse.json(
{ error: "Failed to fetch files" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,137 @@
// Force this API route to use Node.js runtime for database access
export const runtime = "nodejs";
import db from "@/lib/db";
import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth";
import {
logApiActionSafe,
AUDIT_ACTIONS,
RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js";
import initializeDatabase from "@/lib/init-db";
// Make sure the DB is initialized before queries run
initializeDatabase();
async function deleteNoteHandler(req, { params }) {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Note ID is required" }, { status: 400 });
}
try {
// Get note data before deletion for audit log
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
if (!note) {
return NextResponse.json({ error: "Note not found" }, { status: 404 });
}
// Check if user has permission to delete this note
// Users can delete their own notes, or admins can delete any note
const userRole = req.user?.role;
const userId = req.user?.id;
if (userRole !== 'admin' && note.created_by !== userId) {
return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 });
}
// Delete the note
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
// Log note deletion
await logApiActionSafe(
req,
AUDIT_ACTIONS.NOTE_DELETE,
RESOURCE_TYPES.NOTE,
id,
req.auth,
{
deletedNote: {
project_id: note?.project_id,
task_id: note?.task_id,
note_length: note?.note?.length || 0,
created_by: note?.created_by,
},
}
);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting note:", error);
return NextResponse.json(
{ error: "Failed to delete note", details: error.message },
{ status: 500 }
);
}
}
async function updateNoteHandler(req, { params }) {
const { id } = await params;
const noteId = id;
const { note: noteText } = await req.json();
if (!noteText || !noteId) {
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
}
try {
// Get original note for audit log and permission check
const originalNote = db
.prepare("SELECT * FROM notes WHERE note_id = ?")
.get(noteId);
if (!originalNote) {
return NextResponse.json({ error: "Note not found" }, { status: 404 });
}
// Check if user has permission to update this note
// Users can update their own notes, or admins can update any note
const userRole = req.user?.role;
const userId = req.user?.id;
if (userRole !== 'admin' && originalNote.created_by !== userId) {
return NextResponse.json({ error: "Unauthorized to update this note" }, { status: 403 });
}
// Update the note
db.prepare(
`
UPDATE notes SET note = ?, edited_at = datetime('now', 'localtime') WHERE note_id = ?
`
).run(noteText, noteId);
// Log note update
await logApiActionSafe(
req,
AUDIT_ACTIONS.NOTE_UPDATE,
RESOURCE_TYPES.NOTE,
noteId,
req.auth,
{
originalNote: {
note_length: originalNote?.note?.length || 0,
project_id: originalNote?.project_id,
task_id: originalNote?.task_id,
},
updatedNote: {
note_length: noteText.length,
},
}
);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating note:", error);
return NextResponse.json(
{ error: "Failed to update note", details: error.message },
{ status: 500 }
);
}
}
// Protected route - require user authentication
export const DELETE = withUserAuth(deleteNoteHandler);
export const PUT = withUserAuth(updateNoteHandler);

View File

@@ -3,13 +3,59 @@ export const runtime = "nodejs";
import db from "@/lib/db";
import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth";
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
import {
logApiActionSafe,
AUDIT_ACTIONS,
RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js";
async function getNotesHandler(req) {
const { searchParams } = new URL(req.url);
const projectId = searchParams.get("project_id");
const taskId = searchParams.get("task_id");
let query;
let params;
if (projectId) {
query = `
SELECT n.*,
u.name as created_by_name,
u.username as created_by_username
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.project_id = ?
ORDER BY n.note_date DESC
`;
params = [projectId];
} else if (taskId) {
query = `
SELECT n.*,
u.name as created_by_name,
u.username as created_by_username
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.task_id = ?
ORDER BY n.note_date DESC
`;
params = [taskId];
} else {
return NextResponse.json({ error: "project_id or task_id is required" }, { status: 400 });
}
try {
const notes = db.prepare(query).all(...params);
return NextResponse.json(notes);
} catch (error) {
console.error("Error fetching notes:", error);
return NextResponse.json(
{ error: "Failed to fetch notes" },
{ status: 500 }
);
}
}
async function createNoteHandler(req) {
const { project_id, task_id, note } = await req.json();
@@ -22,11 +68,25 @@ async function createNoteHandler(req) {
.prepare(
`
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
`
)
.run(project_id || null, task_id || null, note, req.user?.id || null);
// Get the created note with user info
const createdNote = db
.prepare(
`
SELECT n.*,
u.name as created_by_name,
u.username as created_by_username
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.note_id = ?
`
)
.get(result.lastInsertRowid);
// Log note creation
await logApiActionSafe(
req,
@@ -39,7 +99,7 @@ async function createNoteHandler(req) {
}
);
return NextResponse.json({ success: true });
return NextResponse.json(createdNote);
} catch (error) {
console.error("Error creating note:", error);
return NextResponse.json(
@@ -50,7 +110,7 @@ async function createNoteHandler(req) {
}
async function deleteNoteHandler(req, { params }) {
const { id } = params;
const { id } = await params;
// Get note data before deletion for audit log
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
@@ -76,48 +136,7 @@ async function deleteNoteHandler(req, { params }) {
return NextResponse.json({ success: true });
}
async function updateNoteHandler(req, { params }) {
const noteId = params.id;
const { note } = await req.json();
if (!note || !noteId) {
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
}
// Get original note for audit log
const originalNote = db
.prepare("SELECT * FROM notes WHERE note_id = ?")
.get(noteId);
db.prepare(
`
UPDATE notes SET note = ? WHERE note_id = ?
`
).run(note, noteId);
// Log note update
await logApiActionSafe(
req,
AUDIT_ACTIONS.NOTE_UPDATE,
RESOURCE_TYPES.NOTE,
noteId,
req.auth, // Use req.auth instead of req.session
{
originalNote: {
note_length: originalNote?.note?.length || 0,
project_id: originalNote?.project_id,
task_id: originalNote?.task_id,
},
updatedNote: {
note_length: note.length,
},
}
);
return NextResponse.json({ success: true });
}
// Protected routes - require authentication
export const GET = withReadAuth(getNotesHandler);
export const POST = withUserAuth(createNoteHandler);
export const DELETE = withUserAuth(deleteNoteHandler);
export const PUT = withUserAuth(updateNoteHandler);

View File

@@ -1,13 +1,45 @@
import {
updateProjectTaskStatus,
deleteProjectTask,
updateProjectTask,
} from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth";
// PATCH: Update project task status
// PUT: Update project task (general update)
async function updateProjectTaskHandler(req, { params }) {
try {
const { id } = await params;
const updates = await req.json();
// Validate that we have at least one field to update
const allowedFields = ["priority", "status", "assigned_to", "date_started"];
const hasValidFields = Object.keys(updates).some((key) =>
allowedFields.includes(key)
);
if (!hasValidFields) {
return NextResponse.json(
{ error: "No valid fields provided for update" },
{ status: 400 }
);
}
updateProjectTask(id, updates, req.user?.id || null);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating task:", error);
return NextResponse.json(
{ error: "Failed to update project task", details: error.message },
{ status: 500 }
);
}
}
// PATCH: Update project task status
async function updateProjectTaskStatusHandler(req, { params }) {
try {
const { id } = await params;
const { status } = await req.json();
if (!status) {
@@ -17,7 +49,15 @@ async function updateProjectTaskHandler(req, { params }) {
);
}
updateProjectTaskStatus(params.id, status, req.user?.id || null);
const allowedStatuses = ['not_started', 'pending', 'in_progress', 'completed', 'cancelled'];
if (!allowedStatuses.includes(status)) {
return NextResponse.json(
{ error: "Invalid status. Must be one of: " + allowedStatuses.join(', ') },
{ status: 400 }
);
}
updateProjectTaskStatus(id, status, req.user?.id || null);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating task status:", error);
@@ -31,16 +71,19 @@ async function updateProjectTaskHandler(req, { params }) {
// DELETE: Delete a project task
async function deleteProjectTaskHandler(req, { params }) {
try {
deleteProjectTask(params.id);
const { id } = await params;
const result = deleteProjectTask(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error in deleteProjectTaskHandler:", error);
return NextResponse.json(
{ error: "Failed to delete project task" },
{ error: "Failed to delete project task", details: error.message },
{ status: 500 }
);
}
}
// Protected routes - require authentication
export const PATCH = withUserAuth(updateProjectTaskHandler);
export const PUT = withUserAuth(updateProjectTaskHandler);
export const PATCH = withUserAuth(updateProjectTaskStatusHandler);
export const DELETE = withUserAuth(deleteProjectTaskHandler);

View File

@@ -47,10 +47,19 @@ async function createProjectTaskHandler(req) {
const taskData = {
...data,
created_by: req.user?.id || null,
// If no assigned_to is specified, default to the creator
assigned_to: data.assigned_to || req.user?.id || null,
};
// Set assigned_to: if specified, use it; otherwise default to creator only if they're not admin
if (data.assigned_to) {
taskData.assigned_to = data.assigned_to;
} else if (req.user?.id) {
// Check if the creator is an admin - if so, don't assign to them
const userRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
taskData.assigned_to = userRole?.role === 'admin' ? null : req.user.id;
} else {
taskData.assigned_to = null;
}
const result = createProjectTask(taskData);
return NextResponse.json({ success: true, id: result.lastInsertRowid });
} catch (error) {

View File

@@ -0,0 +1,24 @@
// Force this API route to use Node.js runtime for database access
export const runtime = "nodejs";
import { getFinishDateUpdates } from "@/lib/queries/projects";
import { NextResponse } from "next/server";
import { withReadAuth } from "@/lib/middleware/auth";
async function getFinishDateUpdatesHandler(req, { params }) {
const { id } = await params;
try {
const updates = getFinishDateUpdates(parseInt(id));
return NextResponse.json(updates);
} catch (error) {
console.error("Error fetching finish date updates:", error);
return NextResponse.json(
{ error: "Failed to fetch finish date updates" },
{ status: 500 }
);
}
}
// Protected route - require authentication
export const GET = withReadAuth(getFinishDateUpdatesHandler);

View File

@@ -3,9 +3,12 @@ export const runtime = "nodejs";
import {
getProjectById,
getProjectWithContract,
updateProject,
deleteProject,
} from "@/lib/queries/projects";
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";
@@ -14,13 +17,14 @@ import {
AUDIT_ACTIONS,
RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js";
import { getUserLanguage, serverT } from "@/lib/serverTranslations";
// Make sure the DB is initialized before queries run
initializeDatabase();
async function getProjectHandler(req, { params }) {
const { id } = await params;
const project = getProjectById(parseInt(id));
const project = getProjectWithContract(parseInt(id));
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
@@ -40,35 +44,86 @@ async function getProjectHandler(req, { params }) {
}
async function updateProjectHandler(req, { params }) {
const { id } = await params;
const data = await req.json();
try {
const { id } = await params;
const data = await req.json();
// Get user ID from authenticated request
const userId = req.user?.id;
// Get user ID from authenticated request
const userId = req.user?.id;
// Get original project data for audit log
const originalProject = getProjectById(parseInt(id));
// Get original project data for audit log and field tracking
const originalProject = getProjectById(parseInt(id));
updateProject(parseInt(id), data, userId);
// Get updated project
const updatedProject = getProjectById(parseInt(id));
// Log project update
await logApiActionSafe(
req,
AUDIT_ACTIONS.PROJECT_UPDATE,
RESOURCE_TYPES.PROJECT,
id,
req.auth, // Use req.auth instead of req.session
{
originalData: originalProject,
updatedData: data,
changedFields: Object.keys(data),
if (!originalProject) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
);
return NextResponse.json(updatedProject);
// Track field changes for specific fields we want to monitor
const fieldsToTrack = ['finish_date', 'project_status', 'assigned_to', 'contract_id', 'wartosc_zlecenia'];
for (const fieldName of fieldsToTrack) {
if (data.hasOwnProperty(fieldName)) {
const oldValue = originalProject[fieldName];
const newValue = data[fieldName];
if (oldValue !== newValue) {
try {
logFieldChange('projects', parseInt(id), fieldName, oldValue, newValue, userId);
} catch (error) {
console.error(`Failed to log field change for ${fieldName}:`, error);
}
}
}
}
// Special handling for project cancellation
if (data.project_status === 'cancelled' && originalProject.project_status !== 'cancelled') {
const now = new Date();
const cancellationDate = now.toLocaleDateString('pl-PL', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const language = getUserLanguage();
const cancellationNote = `${serverT("Project cancelled on", language)} ${cancellationDate}`;
try {
addNoteToProject(parseInt(id), cancellationNote, userId, true); // true for is_system
} catch (error) {
console.error('Failed to log project cancellation:', error);
}
}
updateProject(parseInt(id), data, userId);
// Get updated project
const updatedProject = getProjectById(parseInt(id));
// Log project update
await logApiActionSafe(
req,
AUDIT_ACTIONS.PROJECT_UPDATE,
RESOURCE_TYPES.PROJECT,
id,
req.auth, // Use req.auth instead of req.session
{
originalData: originalProject,
updatedData: data,
changedFields: Object.keys(data),
}
);
return NextResponse.json(updatedProject);
} catch (error) {
console.error("Error in updateProjectHandler:", error);
return NextResponse.json(
{ error: "Internal server error", details: error.message },
{ status: 500 }
);
}
}
async function deleteProjectHandler(req, { params }) {

View File

@@ -0,0 +1,97 @@
// Force this API route to use Node.js runtime for database access and file operations
export const runtime = "nodejs";
import * as XLSX from 'xlsx';
import { getAllProjects } from "@/lib/queries/projects";
import initializeDatabase from "@/lib/init-db";
import { NextResponse } from "next/server";
import { withReadAuth } from "@/lib/middleware/auth";
import {
logApiActionSafe,
AUDIT_ACTIONS,
RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js";
// Make sure the DB is initialized before queries run
initializeDatabase();
async function exportProjectsHandler(req) {
try {
// Get all projects
const projects = getAllProjects();
// Group projects by status
const groupedProjects = projects.reduce((acc, project) => {
const status = project.project_status || 'unknown';
if (!acc[status]) {
acc[status] = [];
}
acc[status].push({
'Nazwa projektu': project.project_name,
'Adres': project.address || '',
'Działka': project.plot || '',
'WP': project.wp || '',
'Data zakończenia': project.finish_date || '',
'Przypisany do': project.assigned_to_initial || ''
});
return acc;
}, {});
// Polish status translations for sheet names
const statusTranslations = {
'registered': 'Zarejestrowany',
'in_progress_design': 'W realizacji (projektowanie)',
'in_progress_construction': 'W realizacji (budowa)',
'fulfilled': 'Zakończony',
'cancelled': 'Wycofany',
'unknown': 'Nieznany'
};
// Create workbook
const workbook = XLSX.utils.book_new();
// Create a sheet for each status
Object.keys(groupedProjects).forEach(status => {
const sheetName = statusTranslations[status] || status;
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
// Generate buffer
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
// Generate filename with current date
const filename = `eksport_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
// Log the export action
await logApiActionSafe(
req,
AUDIT_ACTIONS.DATA_EXPORT,
RESOURCE_TYPES.PROJECT,
null,
req.auth,
{
exportType: 'excel',
totalProjects: projects.length,
statuses: Object.keys(groupedProjects)
}
);
// Return the Excel file
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error('Error exporting projects to Excel:', error);
return NextResponse.json(
{ error: 'Failed to export projects' },
{ status: 500 }
);
}
}
export const GET = withReadAuth(exportProjectsHandler);

View File

@@ -0,0 +1,192 @@
import { NextResponse } from 'next/server';
import ExcelJS from 'exceljs';
import { getAllProjects } from '@/lib/queries/projects';
import { parseISO, isAfter, isBefore, startOfDay, addWeeks, differenceInDays } from 'date-fns';
export const dynamic = 'force-dynamic';
export async function GET(request) {
try {
const today = startOfDay(new Date());
const nextMonth = addWeeks(today, 5); // Next 5 weeks
// Get all projects
const allProjects = getAllProjects();
// Filter for upcoming projects (not fulfilled, not cancelled, have finish dates)
const upcomingProjects = allProjects
.filter(project => {
if (!project.finish_date) return false;
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
try {
const projectDate = parseISO(project.finish_date);
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
} catch (error) {
return false;
}
})
.sort((a, b) => {
const dateA = parseISO(a.finish_date);
const dateB = parseISO(b.finish_date);
return dateA - dateB;
});
// Filter for overdue projects
const overdueProjects = allProjects
.filter(project => {
if (!project.finish_date) return false;
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
try {
const projectDate = parseISO(project.finish_date);
return isBefore(projectDate, today);
} catch (error) {
return false;
}
})
.sort((a, b) => {
const dateA = parseISO(a.finish_date);
const dateB = parseISO(b.finish_date);
return dateB - dateA; // Most recently overdue first
});
// Create workbook
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Panel Zarządzania Projektami';
workbook.created = new Date();
// Status translations
const statusTranslations = {
registered: 'Zarejestrowany',
approved: 'Zatwierdzony',
pending: 'Oczekujący',
in_progress: 'W trakcie',
in_progress_design: 'W realizacji (projektowanie)',
in_progress_construction: 'W realizacji (realizacja)',
fulfilled: 'Zakończony',
cancelled: 'Wycofany',
};
// Create Upcoming Projects sheet
const upcomingSheet = workbook.addWorksheet('Nadchodzące terminy');
upcomingSheet.columns = [
{ header: 'Nazwa projektu', key: 'name', width: 35 },
{ header: 'Klient', key: 'customer', width: 25 },
{ header: 'Adres', key: 'address', width: 30 },
{ header: 'Działka', key: 'plot', width: 15 },
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
{ header: 'Dni do terminu', key: 'days_until', width: 15 },
{ header: 'Status', key: 'status', width: 25 },
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
];
// Style header row
upcomingSheet.getRow(1).font = { bold: true };
upcomingSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF4472C4' }
};
upcomingSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Add upcoming projects data
upcomingProjects.forEach(project => {
const daysUntil = differenceInDays(parseISO(project.finish_date), today);
const row = upcomingSheet.addRow({
name: project.project_name,
customer: project.customer || '',
address: project.address || '',
plot: project.plot || '',
finish_date: project.finish_date,
days_until: daysUntil,
status: statusTranslations[project.project_status] || project.project_status,
assigned_to: project.assigned_to || ''
});
// Color code based on urgency
if (daysUntil <= 7) {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFE0E0' } // Light red
};
} else if (daysUntil <= 14) {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFF4E0' } // Light orange
};
}
});
// Create Overdue Projects sheet
if (overdueProjects.length > 0) {
const overdueSheet = workbook.addWorksheet('Przeterminowane');
overdueSheet.columns = [
{ header: 'Nazwa projektu', key: 'name', width: 35 },
{ header: 'Klient', key: 'customer', width: 25 },
{ header: 'Adres', key: 'address', width: 30 },
{ header: 'Działka', key: 'plot', width: 15 },
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
{ header: 'Dni po terminie', key: 'days_overdue', width: 15 },
{ header: 'Status', key: 'status', width: 25 },
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
];
// Style header row
overdueSheet.getRow(1).font = { bold: true };
overdueSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE74C3C' }
};
overdueSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
// Add overdue projects data
overdueProjects.forEach(project => {
const daysOverdue = Math.abs(differenceInDays(parseISO(project.finish_date), today));
const row = overdueSheet.addRow({
name: project.project_name,
customer: project.customer || '',
address: project.address || '',
plot: project.plot || '',
finish_date: project.finish_date,
days_overdue: daysOverdue,
status: statusTranslations[project.project_status] || project.project_status,
assigned_to: project.assigned_to || ''
});
// Color code based on severity
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFE0E0' } // Light red
};
});
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
// Generate filename with current date
const filename = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
// Return response with Excel file
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error('Error generating upcoming projects report:', error);
return NextResponse.json(
{ error: 'Failed to generate report', details: error.message },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { deleteNote } from "@/lib/queries/notes";
import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth";
import db from "@/lib/db";
// DELETE: Delete a specific task note
async function deleteTaskNoteHandler(req, { params }) {
try {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Note ID is required" }, { status: 400 });
}
// Get note data before deletion for permission checking
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
if (!note) {
return NextResponse.json({ error: "Note not found" }, { status: 404 });
}
// Check if user has permission to delete this note
// Users can delete their own notes, or admins can delete any note
const userRole = req.user?.role;
const userId = req.user?.id;
if (userRole !== 'admin' && note.created_by !== userId) {
return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 });
}
// Don't allow deletion of system notes by regular users
if (note.is_system && userRole !== 'admin') {
return NextResponse.json({ error: "Cannot delete system notes" }, { status: 403 });
}
deleteNote(id);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting task note:", error);
return NextResponse.json(
{ error: "Failed to delete task note" },
{ status: 500 }
);
}
}
// Protected route - require user authentication
export const DELETE = withUserAuth(deleteTaskNoteHandler);

View File

@@ -0,0 +1,35 @@
import { applyTaskSetToProject } from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth";
// POST: Apply a task set to a project (bulk create project tasks)
async function applyTaskSetHandler(req, { params }) {
try {
const { id } = await params;
const { project_id } = await req.json();
if (!project_id) {
return NextResponse.json(
{ error: "project_id is required" },
{ status: 400 }
);
}
const createdTaskIds = applyTaskSetToProject(id, project_id, req.user?.id || null);
return NextResponse.json({
success: true,
message: `Task set applied successfully. Created ${createdTaskIds.length} tasks.`,
createdTaskIds
});
} catch (error) {
console.error("Error applying task set:", error);
return NextResponse.json(
{ error: "Failed to apply task set", details: error.message },
{ status: 500 }
);
}
}
// Protected route - require authentication
export const POST = withUserAuth(applyTaskSetHandler);

View File

@@ -0,0 +1,130 @@
import {
getTaskSetById,
updateTaskSet,
deleteTaskSet,
addTaskTemplateToSet,
removeTaskTemplateFromSet,
} from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
import initializeDatabase from "@/lib/init-db";
// GET: Get a specific task set with its templates
async function getTaskSetHandler(req, { params }) {
initializeDatabase();
try {
const { id } = await params;
const taskSet = getTaskSetById(id);
if (!taskSet) {
return NextResponse.json(
{ error: "Task set not found" },
{ status: 404 }
);
}
return NextResponse.json(taskSet);
} catch (error) {
console.error("Error fetching task set:", error);
return NextResponse.json(
{ error: "Failed to fetch task set" },
{ status: 500 }
);
}
}
// PUT: Update a task set
async function updateTaskSetHandler(req, { params }) {
initializeDatabase();
try {
const { id } = await params;
const updates = await req.json();
// Validate required fields
if (updates.name !== undefined && !updates.name.trim()) {
return NextResponse.json(
{ error: "Name cannot be empty" },
{ status: 400 }
);
}
if (updates.task_category !== undefined) {
const validTypes = ["design", "construction"];
if (!validTypes.includes(updates.task_category)) {
return NextResponse.json(
{ error: "Invalid task_category. Must be one of: design, construction" },
{ status: 400 }
);
}
}
// Handle template updates
if (updates.templates !== undefined) {
// Clear existing templates
// Note: This is a simple implementation. In a real app, you might want to handle this more efficiently
const currentSet = getTaskSetById(id);
if (currentSet) {
for (const template of currentSet.templates) {
removeTaskTemplateFromSet(id, template.task_id);
}
}
// Add new templates
if (Array.isArray(updates.templates)) {
for (let i = 0; i < updates.templates.length; i++) {
const template = updates.templates[i];
addTaskTemplateToSet(id, template.task_id, i);
}
}
// Remove templates from updates object so it doesn't interfere with task set update
delete updates.templates;
}
const result = updateTaskSet(id, updates);
if (result.changes === 0) {
return NextResponse.json(
{ error: "Task set not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error updating task set:", error);
return NextResponse.json(
{ error: "Failed to update task set", details: error.message },
{ status: 500 }
);
}
}
// DELETE: Delete a task set
async function deleteTaskSetHandler(req, { params }) {
initializeDatabase();
try {
const { id } = await params;
const result = deleteTaskSet(id);
if (result.changes === 0) {
return NextResponse.json(
{ error: "Task set not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting task set:", error);
return NextResponse.json(
{ error: "Failed to delete task set", details: error.message },
{ status: 500 }
);
}
}
// Protected routes - require authentication
export const GET = withReadAuth(getTaskSetHandler);
export const PUT = withUserAuth(updateTaskSetHandler);
export const DELETE = withUserAuth(deleteTaskSetHandler);

View File

@@ -0,0 +1,60 @@
import {
getAllTaskSets,
getTaskSetsByProjectType,
createTaskSet,
} from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
import initializeDatabase from "@/lib/init-db";
// GET: Get all task sets or filter by task category
async function getTaskSetsHandler(req) {
initializeDatabase();
const { searchParams } = new URL(req.url);
const taskCategory = searchParams.get("task_category");
if (taskCategory) {
const taskSets = getTaskSetsByTaskCategory(taskCategory);
return NextResponse.json(taskSets);
} else {
const taskSets = getAllTaskSets();
return NextResponse.json(taskSets);
}
}
// POST: Create a new task set
async function createTaskSetHandler(req) {
initializeDatabase();
try {
const data = await req.json();
if (!data.name || !data.task_category) {
return NextResponse.json(
{ error: "Name and task_category are required" },
{ status: 400 }
);
}
// Validate task_category
const validTypes = ["design", "construction"];
if (!validTypes.includes(data.task_category)) {
return NextResponse.json(
{ error: "Invalid task_category. Must be one of: design, construction" },
{ status: 400 }
);
}
const setId = createTaskSet(data);
return NextResponse.json({ success: true, id: setId });
} catch (error) {
console.error("Error creating task set:", error);
return NextResponse.json(
{ error: "Failed to create task set", details: error.message },
{ status: 500 }
);
}
}
// Protected routes - require authentication
export const GET = withReadAuth(getTaskSetsHandler);
export const POST = withUserAuth(createTaskSetHandler);

View File

@@ -4,10 +4,11 @@ import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
// GET: Get a specific task template
async function getTaskHandler(req, { params }) {
const { id } = await params;
try {
const template = db
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
.get(params.id);
.get(id);
if (!template) {
return NextResponse.json(
@@ -27,20 +28,25 @@ async function getTaskHandler(req, { params }) {
// PUT: Update a task template
async function updateTaskHandler(req, { params }) {
const { id } = await params;
try {
const { name, max_wait_days, description } = await req.json();
const { name, max_wait_days, description, task_category } = await req.json();
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (task_category && !['design', 'construction'].includes(task_category)) {
return NextResponse.json({ error: "Invalid task_category (must be design or construction)" }, { status: 400 });
}
const result = db
.prepare(
`UPDATE tasks
SET name = ?, max_wait_days = ?, description = ?
SET name = ?, max_wait_days = ?, description = ?, task_category = ?
WHERE task_id = ? AND is_standard = 1`
)
.run(name, max_wait_days || 0, description || null, params.id);
.run(name, max_wait_days || 0, description || null, task_category, id);
if (result.changes === 0) {
return NextResponse.json(
@@ -60,10 +66,11 @@ async function updateTaskHandler(req, { params }) {
// DELETE: Delete a task template
async function deleteTaskHandler(req, { params }) {
const { id } = await params;
try {
const result = db
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
.run(params.id);
.run(id);
if (result.changes === 0) {
return NextResponse.json(

View File

@@ -5,18 +5,22 @@ import { getAllTaskTemplates } from "@/lib/queries/tasks";
// POST: create new template
async function createTaskHandler(req) {
const { name, max_wait_days, description } = await req.json();
const { name, max_wait_days, description, task_category } = await req.json();
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (!task_category || !['design', 'construction'].includes(task_category)) {
return NextResponse.json({ error: "Valid task_category is required (design or construction)" }, { status: 400 });
}
db.prepare(
`
INSERT INTO tasks (name, max_wait_days, description, is_standard)
VALUES (?, ?, ?, 1)
INSERT INTO tasks (name, max_wait_days, description, is_standard, task_category)
VALUES (?, ?, ?, 1, ?)
`
).run(name, max_wait_days || 0, description || null);
).run(name, max_wait_days || 0, description || null, task_category);
return NextResponse.json({ success: true });
}

View File

@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation"
function SignInContent() {
const [email, setEmail] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
@@ -21,13 +21,13 @@ function SignInContent() {
try {
const result = await signIn("credentials", {
email,
username,
password,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
setError("Invalid username or password")
} else {
// Successful login
router.push(callbackUrl)
@@ -45,10 +45,10 @@ function SignInContent() {
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
Zaloguj się do swojego konta
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Access the Project Management Panel
Dostęp do panelu
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
@@ -60,24 +60,24 @@ function SignInContent() {
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
Email address
<label htmlFor="username" className="sr-only">
Nazwa użytkownika
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
id="username"
name="username"
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Nazwa użytkownika"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
Hasło
</label>
<input
id="password"
@@ -105,7 +105,7 @@ function SignInContent() {
<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>
Signing in...
Zaloguj...
</span>
) : (
"Sign in"
@@ -113,13 +113,13 @@ function SignInContent() {
</button>
</div>
<div className="text-center">
{/* <div className="text-center">
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
<p className="font-medium">Default Admin Account:</p>
<p>Email: admin@localhost</p>
<p>Password: admin123456</p>
</div>
</div>
</div> */}
</form>
</div>
</div>

434
src/app/calendar/page.js Normal file
View File

@@ -0,0 +1,434 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addDays,
isSameMonth,
isSameDay,
addMonths,
subMonths,
parseISO,
isAfter,
isBefore,
startOfDay,
addWeeks
} from "date-fns";
import { pl } from "date-fns/locale";
const statusColors = {
registered: "bg-blue-100 text-blue-800",
approved: "bg-green-100 text-green-800",
pending: "bg-yellow-100 text-yellow-800",
in_progress: "bg-orange-100 text-orange-800",
in_progress_design: "bg-purple-100 text-purple-800",
in_progress_construction: "bg-indigo-100 text-indigo-800",
fulfilled: "bg-gray-100 text-gray-800",
cancelled: "bg-red-100 text-red-800",
};
const getStatusTranslation = (status) => {
const translations = {
registered: "Zarejestrowany",
approved: "Zatwierdzony",
pending: "Oczekujący",
in_progress: "W trakcie",
in_progress_design: "W realizacji (projektowanie)",
in_progress_construction: "W realizacji (realizacja)",
fulfilled: "Zakończony",
cancelled: "Wycofany",
};
return translations[status] || status;
};
export default function ProjectCalendarPage() {
const { t } = useTranslation();
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState('month'); // 'month' or 'upcoming'
const [downloading, setDownloading] = useState(false);
useEffect(() => {
fetch("/api/projects")
.then((res) => res.json())
.then((data) => {
// Filter projects that have finish dates and are not fulfilled
const projectsWithDates = data.filter(p =>
p.finish_date && p.project_status !== 'fulfilled'
);
setProjects(projectsWithDates);
setLoading(false);
})
.catch((error) => {
console.error("Error fetching projects:", error);
setLoading(false);
});
}, []);
const getProjectsForDate = (date) => {
return projects.filter(project => {
if (!project.finish_date) return false;
try {
const projectDate = parseISO(project.finish_date);
return isSameDay(projectDate, date);
} catch (error) {
return false;
}
});
};
const getUpcomingProjects = () => {
const today = startOfDay(new Date());
const nextMonth = addWeeks(today, 5); // Next 5 weeks
return projects
.filter(project => {
if (!project.finish_date) return false;
try {
const projectDate = parseISO(project.finish_date);
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
} catch (error) {
return false;
}
})
.sort((a, b) => {
const dateA = parseISO(a.finish_date);
const dateB = parseISO(b.finish_date);
return dateA - dateB;
});
};
const getOverdueProjects = () => {
const today = startOfDay(new Date());
return projects
.filter(project => {
if (!project.finish_date) return false;
try {
const projectDate = parseISO(project.finish_date);
return isBefore(projectDate, today);
} catch (error) {
return false;
}
})
.sort((a, b) => {
const dateA = parseISO(a.finish_date);
const dateB = parseISO(b.finish_date);
return dateB - dateA; // Most recently overdue first
});
};
const handleDownloadReport = async () => {
setDownloading(true);
try {
const response = await fetch('/api/reports/upcoming-projects');
if (!response.ok) {
throw new Error('Failed to download report');
}
// Get the blob from the response
const blob = await response.blob();
// Create a download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(link);
link.click();
// Clean up
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading report:', error);
alert('Błąd podczas pobierania raportu');
} finally {
setDownloading(false);
}
};
const renderCalendarGrid = () => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = [];
let day = calendarStart;
while (day <= calendarEnd) {
days.push(day);
day = addDays(day, 1);
}
const weekdays = ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nie'];
return (
<div className="bg-white rounded-lg shadow">
{/* Calendar Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{format(currentDate, 'LLLL yyyy', { locale: pl })}
</h2>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(new Date())}
>
Dziś
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
>
</Button>
</div>
</div>
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 border-b border-gray-200">
{weekdays.map(weekday => (
<div key={weekday} className="p-2 text-sm font-medium text-gray-500 text-center">
{weekday}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7">
{days.map((day, index) => {
const dayProjects = getProjectsForDate(day);
const isCurrentMonth = isSameMonth(day, currentDate);
const isToday = isSameDay(day, new Date());
return (
<div
key={index}
className={`min-h-[120px] p-2 border-r border-b border-gray-100 ${
!isCurrentMonth ? 'bg-gray-50' : 'bg-white'
} ${isToday ? 'bg-blue-50' : ''}`}
>
<div className={`text-sm font-medium mb-2 ${
!isCurrentMonth ? 'text-gray-400' : isToday ? 'text-blue-600' : 'text-gray-900'
}`}>
{format(day, 'd')}
</div>
{dayProjects.length > 0 && (
<div className="space-y-1">
{dayProjects.slice(0, 3).map(project => (
<Link
key={project.project_id}
href={`/projects/${project.project_id}`}
className="block"
>
<div className={`text-xs p-1 rounded truncate ${
statusColors[project.project_status] || statusColors.registered
} hover:opacity-80 transition-opacity`}>
{project.project_name}
</div>
</Link>
))}
{dayProjects.length > 3 && (
<div className="relative group">
<div className="text-xs text-gray-500 p-1 cursor-pointer">
+{dayProjects.length - 3} więcej
</div>
<div className="absolute left-0 top-full mt-1 bg-white border border-gray-200 rounded shadow-lg p-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto max-w-xs">
<div className="space-y-1">
{dayProjects.slice(3).map(project => (
<Link
key={project.project_id}
href={`/projects/${project.project_id}`}
className="block"
>
<div className={`text-xs p-1 rounded truncate ${
statusColors[project.project_status] || statusColors.registered
} hover:opacity-80 transition-opacity`}>
{project.project_name}
</div>
</Link>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
const renderUpcomingView = () => {
const upcomingProjects = getUpcomingProjects().filter(project => project.project_status !== 'cancelled');
const overdueProjects = getOverdueProjects().filter(project => project.project_status !== 'cancelled');
return (
<div className="space-y-6">
{/* Upcoming Projects */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold text-gray-900">
Nadchodzące terminy ({upcomingProjects.length})
</h3>
</CardHeader>
<CardContent>
{upcomingProjects.length > 0 ? (
<div className="space-y-3">
{upcomingProjects.map(project => {
const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24));
return (
<div key={project.project_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<Link
href={`/projects/${project.project_id}`}
className="font-medium text-gray-900 hover:text-blue-600"
>
{project.project_name}
</Link>
<div className="text-sm text-gray-600 mt-1">
{project.customer && `${project.customer}`}
{project.address}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{formatDate(project.finish_date)}
</div>
<div className="text-xs text-gray-500">
za {daysUntilDeadline} dni
</div>
<Badge className={statusColors[project.project_status] || statusColors.registered}>
{getStatusTranslation(project.project_status) || project.project_status}
</Badge>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-center py-8">
Brak nadchodzących projektów w następnych 4 tygodniach
</p>
)}
</CardContent>
</Card>
{/* Overdue Projects */}
{overdueProjects.length > 0 && (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold text-red-600">
Projekty przeterminowane ({overdueProjects.length})
</h3>
</CardHeader>
<CardContent>
<div className="space-y-3">
{overdueProjects.map(project => (
<div key={project.project_id} className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-200">
<div className="flex-1">
<Link
href={`/projects/${project.project_id}`}
className="font-medium text-gray-900 hover:text-blue-600"
>
{project.project_name}
</Link>
<div className="text-sm text-gray-600 mt-1">
{project.customer && `${project.customer}`}
{project.address}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-red-600">
{formatDate(project.finish_date)}
</div>
<Badge className={statusColors[project.project_status] || statusColors.registered}>
{getStatusTranslation(project.project_status) || project.project_status}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
};
if (loading) {
return <LoadingState />;
}
return (
<PageContainer>
<PageHeader
title="Kalendarz projektów"
subtitle={`${projects.length} aktywnych projektów z terminami`}
>
<div className="flex space-x-2">
<Button
variant={viewMode === 'month' ? 'primary' : 'outline'}
onClick={() => setViewMode('month')}
>
Kalendarz
</Button>
<Button
variant={viewMode === 'upcoming' ? 'primary' : 'outline'}
onClick={() => setViewMode('upcoming')}
>
Lista terminów
</Button>
</div>
</PageHeader>
<div className="mb-4 flex justify-end">
<button
onClick={handleDownloadReport}
disabled={downloading}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
title={downloading ? 'Pobieranie...' : 'Eksportuj raport nadchodzących projektów do Excel'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
</div>
{viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()}
</PageContainer>
);
}

View File

@@ -10,6 +10,9 @@ import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils";
import FileUploadModal from "@/components/FileUploadModal";
import FileAttachmentsList from "@/components/FileAttachmentsList";
import { useTranslation } from "@/lib/i18n";
export default function ContractDetailsPage() {
const params = useParams();
@@ -17,6 +20,9 @@ export default function ContractDetailsPage() {
const [contract, setContract] = useState(null);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [showUploadModal, setShowUploadModal] = useState(false);
const [attachments, setAttachments] = useState([]);
const { t } = useTranslation();
useEffect(() => {
async function fetchContractDetails() {
@@ -52,10 +58,18 @@ export default function ContractDetailsPage() {
fetchContractDetails();
}
}, [contractId]);
const handleFileUploaded = (newFile) => {
setAttachments(prev => [newFile, ...prev]);
};
const handleFilesChange = (files) => {
setAttachments(files);
};
if (loading) {
return (
<PageContainer>
<LoadingState message="Loading contract details..." />
<LoadingState message={t('contracts.loadingContractDetails')} />
</PageContainer>
);
}
@@ -65,9 +79,9 @@ export default function ContractDetailsPage() {
<PageContainer>
<Card>
<CardContent className="text-center py-12">
<p className="text-red-600 text-lg mb-4">Contract not found.</p>
<p className="text-red-600 text-lg mb-4">{t('contracts.contractNotFound')}</p>
<Link href="/contracts">
<Button variant="primary">Back to Contracts</Button>
<Button variant="primary">{t('contracts.backToContracts')}</Button>
</Link>
</CardContent>
</Card>
@@ -77,8 +91,8 @@ export default function ContractDetailsPage() {
return (
<PageContainer>
<PageHeader
title={`Contract ${contract.contract_number}`}
description={contract.contract_name || "Contract Details"}
title={`${t('contracts.contract')} ${contract.contract_number}`}
description={contract.contract_name || t('contracts.contractInformation')}
action={
<div className="flex items-center gap-3">
<Link href="/contracts">
@@ -96,7 +110,7 @@ export default function ContractDetailsPage() {
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Contracts
{t('contracts.backToContracts')}
</Button>
</Link>
<Link href={`/projects/new?contract_id=${contractId}`}>
@@ -114,7 +128,7 @@ export default function ContractDetailsPage() {
d="M12 4v16m8-8H4"
/>
</svg>
Add Project
{t('contracts.addProject')}
</Button>
</Link>
</div>
@@ -127,14 +141,14 @@ export default function ContractDetailsPage() {
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Contract Information
{t('contracts.contractInformation')}
</h2>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Contract Number
{t('contracts.contractNumber')}
</span>
<p className="text-gray-900 font-medium">
{contract.contract_number}
@@ -143,7 +157,7 @@ export default function ContractDetailsPage() {
{contract.contract_name && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Contract Name
{t('contracts.contractName')}
</span>
<p className="text-gray-900 font-medium">
{contract.contract_name}
@@ -153,7 +167,7 @@ export default function ContractDetailsPage() {
{contract.customer_contract_number && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Customer Contract Number
{t('contracts.customerContractNumber')}
</span>
<p className="text-gray-900 font-medium">
{contract.customer_contract_number}
@@ -163,7 +177,7 @@ export default function ContractDetailsPage() {
{contract.customer && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Customer
{t('contracts.customer')}
</span>
<p className="text-gray-900 font-medium">
{contract.customer}
@@ -173,7 +187,7 @@ export default function ContractDetailsPage() {
{contract.investor && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Investor
{t('contracts.investor')}
</span>
<p className="text-gray-900 font-medium">
{contract.investor}
@@ -183,7 +197,7 @@ export default function ContractDetailsPage() {
{contract.date_signed && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Date Signed
{t('contracts.dateSigned')}
</span>
<p className="text-gray-900 font-medium">
{formatDate(contract.date_signed)}
@@ -193,7 +207,7 @@ export default function ContractDetailsPage() {
{contract.finish_date && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Finish Date
{t('contracts.finishDate')}
</span>
<p className="text-gray-900 font-medium">
{formatDate(contract.finish_date)}
@@ -209,22 +223,22 @@ export default function ContractDetailsPage() {
<div>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900">Summary</h2>
<h2 className="text-lg font-semibold text-gray-900">{t('contracts.summary')}</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-sm font-medium text-gray-500 block mb-2">
Projects Count
{t('contracts.projectsCount')}
</span>
<Badge variant="primary" size="lg">
{projects.length} Projects
{projects.length} {t('contracts.projects')}
</Badge>
</div>
{contract.finish_date && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2">
Contract Status
{t('contracts.contractStatus')}
</span>
<Badge
variant={
@@ -235,8 +249,8 @@ export default function ContractDetailsPage() {
size="md"
>
{new Date(contract.finish_date) > new Date()
? "Active"
: "Expired"}
? t('contracts.active')
: t('contracts.expired')}
</Badge>
</div>
)}
@@ -245,12 +259,50 @@ export default function ContractDetailsPage() {
</div>
</div>
{/* Contract Documents */}
<Card className="mb-8">
<CardHeader>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900">
{t('contracts.contractDocuments')} ({attachments.length})
</h2>
<Button
variant="primary"
size="sm"
onClick={() => setShowUploadModal(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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{t('contracts.uploadDocument')}
</Button>
</div>
</CardHeader>
<CardContent>
<FileAttachmentsList
entityType="contract"
entityId={contractId}
onFilesChange={handleFilesChange}
/>
</CardContent>
</Card>
{/* Associated Projects */}
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900">
Associated Projects ({projects.length})
{t('contracts.associatedProjects')} ({projects.length})
</h2>
<Link href={`/projects/new?contract_id=${contractId}`}>
<Button variant="outline" size="sm">
@@ -267,7 +319,7 @@ export default function ContractDetailsPage() {
d="M12 4v16m8-8H4"
/>
</svg>
Add Project
{t('contracts.addProject')}
</Button>
</Link>
</div>
@@ -289,13 +341,13 @@ export default function ContractDetailsPage() {
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No projects yet
{t('contracts.noProjectsYet')}
</h3>
<p className="text-gray-500 mb-6">
Get started by creating your first project for this contract
{t('contracts.getStartedMessage')}
</p>
<Link href={`/projects/new?contract_id=${contractId}`}>
<Button variant="primary">Create First Project</Button>
<Button variant="primary">{t('contracts.createFirstProject')}</Button>
</Link>
</div>
) : (
@@ -361,22 +413,22 @@ export default function ContractDetailsPage() {
size="sm"
>
{project.project_status === "registered"
? "Registered"
? t('projectStatus.registered')
: project.project_status === "in_progress_design"
? "In Progress (Design)"
? t('projectStatus.in_progress_design')
: project.project_status ===
"in_progress_construction"
? "In Progress (Construction)"
? t('projectStatus.in_progress_construction')
: project.project_status === "fulfilled"
? "Completed"
: "Unknown"}
? t('projectStatus.fulfilled')
: t('projectStatus.unknown')}
</Badge>
</div>
</div>
</div>
<Link href={`/projects/${project.project_id}`}>
<Button variant="outline" size="sm">
View Details
{t('contracts.viewDetails')}
</Button>
</Link>
</div>
@@ -386,6 +438,15 @@ export default function ContractDetailsPage() {
)}
</CardContent>
</Card>
{/* File Upload Modal */}
<FileUploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
entityType="contract"
entityId={contractId}
onFileUploaded={handleFileUploaded}
/>
</PageContainer>
);
}

View File

@@ -1,15 +1,19 @@
"use client";
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 { useTranslation } from "@/lib/i18n";
export default function NewContractPage() {
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader
title="Create New Contract"
description="Add a new contract to your portfolio"
title={t('contracts.createNewContract')}
description={t('contracts.addNewContractDescription')}
action={
<Link href="/contracts">
<Button variant="outline" size="sm">
@@ -26,7 +30,7 @@ export default function NewContractPage() {
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Contracts
{t('contracts.backToContracts')}
</Button>
</Link>
}

View File

@@ -11,8 +11,10 @@ import SearchBar from "@/components/ui/SearchBar";
import FilterBar from "@/components/ui/FilterBar";
import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ContractsMainPage() {
const { t } = useTranslation();
const [contracts, setContracts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
@@ -133,13 +135,13 @@ export default function ContractsMainPage() {
const getStatusBadge = (status) => {
switch (status) {
case "active":
return <Badge variant="success">Aktywna</Badge>;
return <Badge variant="success">{t('contracts.active')}</Badge>;
case "completed":
return <Badge variant="secondary">Zakończona</Badge>;
return <Badge variant="secondary">{t('common.completed')}</Badge>;
case "ongoing":
return <Badge variant="primary">W trakcie</Badge>;
return <Badge variant="primary">{t('contracts.withoutEndDate')}</Badge>;
default:
return <Badge>Nieznany</Badge>;
return <Badge>{t('common.unknown')}</Badge>;
}
};
@@ -170,17 +172,29 @@ export default function ContractsMainPage() {
return (
<PageContainer>
<PageHeader
title="Umowy"
description="Zarządzaj swoimi umowami i kontraktami"
title={t('contracts.title')}
description={t('contracts.subtitle')}
>
<Link href="/contracts/new">
<Button variant="primary" size="lg">
<span className="mr-2"></span>
Nowa umowa
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
{t('contracts.newContract')}
</Button>
</Link>
</PageHeader>
<LoadingState message="Ładowanie umów..." />
<LoadingState message={t('navigation.loading')} />
</PageContainer>
);
}
@@ -225,13 +239,25 @@ export default function ContractsMainPage() {
return (
<PageContainer>
<PageHeader
title="Umowy"
description="Zarządzaj swoimi umowami i kontraktami"
title={t('contracts.title')}
description={t('contracts.subtitle')}
>
<Link href="/contracts/new">
<Button variant="primary" size="lg">
<span className="mr-2"></span>
Nowa umowa
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
{t('contracts.newContract')}
</Button>
</Link>{" "}
</PageHeader>

289
src/app/dashboard/page.js Normal file
View File

@@ -0,0 +1,289 @@
"use client";
import { useState, useEffect } from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, ComposedChart, PieChart, Pie, Cell } from 'recharts';
import { useTranslation } from "@/lib/i18n";
export default function TeamLeadsDashboard() {
const { t } = useTranslation();
const [chartData, setChartData] = useState([]);
const [summaryData, setSummaryData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedYear, setSelectedYear] = useState(null);
useEffect(() => {
fetchDashboardData();
}, [selectedYear]);
const fetchDashboardData = async () => {
try {
const params = new URLSearchParams();
if (selectedYear) {
params.append('year', selectedYear.toString());
}
const url = `/api/dashboard${params.toString() ? '?' + params.toString() : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch dashboard data');
}
const data = await response.json();
setChartData(data.chartData || []);
setSummaryData(data.summary || null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const currentYear = new Date().getFullYear();
const availableYears = [];
for (let year = currentYear; year >= 2023; year--) {
availableYears.push(year);
}
const handleYearChange = (year) => {
setSelectedYear(year === 'all' ? null : parseInt(year));
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('pl-PL', {
style: 'currency',
currency: 'PLN',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
// Find the monthly and cumulative values
const monthlyData = payload.find(p => p.dataKey === 'value');
const cumulativeData = payload.find(p => p.dataKey === 'cumulative');
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">{`${t('teamDashboard.monthLabel')} ${label}`}</p>
<p className="text-blue-600 dark:text-blue-400 font-semibold">
{`${t('teamDashboard.monthlyValue')} ${monthlyData ? formatCurrency(monthlyData.value) : t('teamDashboard.na')}`}
</p>
<p className="text-green-600 dark:text-green-400 text-sm">
{`${t('teamDashboard.cumulative')} ${cumulativeData ? formatCurrency(cumulativeData.value) : t('teamDashboard.na')}`}
</p>
</div>
);
}
return null;
};
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t('teamDashboard.title')}
</h1>
{/* Year Filter */}
<div className="flex items-center space-x-2">
<label htmlFor="year-select" className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('teamDashboard.yearLabel')}
</label>
<select
id="year-select"
value={selectedYear || 'all'}
onChange={(e) => handleYearChange(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">{t('teamDashboard.allYears')}</option>
{availableYears.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('teamDashboard.projectCompletionTitle')}
</h2>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.loadingChart')}</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-64">
<div className="text-red-500 dark:text-red-400">{t('teamDashboard.errorPrefix')} {error}</div>
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.noData')}</div>
</div>
) : (
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{
top: 20,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis
dataKey="month"
className="text-gray-600 dark:text-gray-400"
fontSize={12}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
fontSize={12}
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
<Bar
dataKey="value"
fill="#3b82f6"
name={t('teamDashboard.monthlyValue').replace(':', '')}
radius={[4, 4, 0, 0]}
/>
<Line
type="monotone"
dataKey="cumulative"
stroke="#10b981"
strokeWidth={3}
name={t('teamDashboard.cumulative').replace(':', '')}
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: '#10b981', strokeWidth: 2 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
<div className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Total Chart */}
<div className="lg:col-span-1">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('teamDashboard.totalPortfolio')}
</h2>
{summaryData?.total ? (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{
name: t('teamDashboard.realised'),
value: summaryData.total.realisedValue,
color: '#10b981'
},
{
name: t('teamDashboard.unrealised'),
value: summaryData.total.unrealisedValue,
color: '#f59e0b'
}
]}
cx="50%"
cy="50%"
outerRadius={80}
dataKey="value"
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
>
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</Pie>
<Tooltip formatter={(value) => formatCurrency(value)} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.noSummaryData')}</div>
</div>
)}
{summaryData?.total && (
<div className="mt-4 grid grid-cols-1 gap-3">
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg">
<div className="text-sm text-green-600 dark:text-green-400 font-medium">{t('teamDashboard.realisedValue')}</div>
<div className="text-xl font-bold text-green-700 dark:text-green-300">
{formatCurrency(summaryData.total.realisedValue)}
</div>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg">
<div className="text-sm text-amber-600 dark:text-amber-400 font-medium">{t('teamDashboard.unrealisedValue')}</div>
<div className="text-xl font-bold text-amber-700 dark:text-amber-300">
{formatCurrency(summaryData.total.unrealisedValue)}
</div>
</div>
</div>
)}
</div>
{/* Project Type Charts */}
<div className="lg:col-span-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
{t('teamDashboard.byProjectType')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{summaryData?.byType && Object.entries(summaryData.byType).map(([type, data]) => (
<div key={type} className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4 capitalize text-center">
{type.replace('+', ' + ')}
</h3>
{/* Mini pie chart */}
<div className="h-32 mb-4">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: t('teamDashboard.realised'), value: data.realisedValue, color: '#10b981' },
{ name: t('teamDashboard.unrealised'), value: data.unrealisedValue, color: '#f59e0b' }
]}
cx="50%"
cy="50%"
outerRadius={45}
dataKey="value"
label={({ percent }) => percent > 0.1 ? `${(percent * 100).toFixed(0)}%` : ''}
>
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</Pie>
<Tooltip formatter={(value) => formatCurrency(value)} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-green-600 dark:text-green-400">{t('teamDashboard.realised')}</span>
<span className="text-sm font-semibold text-green-700 dark:text-green-300">
{formatCurrency(data.realisedValue)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-amber-600 dark:text-amber-400">{t('teamDashboard.unrealised')}</span>
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">
{formatCurrency(data.unrealisedValue)}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,91 @@
:root {
--background: #ffffff;
--foreground: #171717;
/* Surface colors */
--surface-primary: #ffffff;
--surface-secondary: #f9fafb;
--surface-tertiary: #f3f4f6;
--surface-modal: #ffffff;
--surface-card: #ffffff;
--surface-hover: #f9fafb;
--surface-active: #f3f4f6;
/* Text colors */
--text-primary: #111827;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--text-inverse: #ffffff;
--text-muted: #6b7280;
/* Border colors */
--border-default: #d1d5db;
--border-hover: #9ca3af;
--border-focus: #3b82f6;
--border-divider: #e5e7eb;
/* Interactive colors */
--interactive-primary: #3b82f6;
--interactive-primary-hover: #2563eb;
--interactive-secondary: #6b7280;
--interactive-secondary-hover: #4b5563;
--interactive-danger: #ef4444;
--interactive-danger-hover: #dc2626;
/* Status colors */
--status-success: #10b981;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-info: #3b82f6;
/* Shadow colors */
--shadow-default: rgba(0, 0, 0, 0.1);
--shadow-hover: rgba(0, 0, 0, 0.15);
}
.dark {
--background: #0a0a0a;
--foreground: #ededed;
/* Surface colors */
--surface-primary: #1f2937;
--surface-secondary: #374151;
--surface-tertiary: #4b5563;
--surface-modal: #1f2937;
--surface-card: #374151;
--surface-hover: #4b5563;
--surface-active: #6b7280;
/* Text colors */
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-tertiary: #9ca3af;
--text-inverse: #111827;
--text-muted: #9ca3af;
/* Border colors */
--border-default: #4b5563;
--border-hover: #6b7280;
--border-focus: #60a5fa;
--border-divider: #374151;
/* Interactive colors */
--interactive-primary: #3b82f6;
--interactive-primary-hover: #60a5fa;
--interactive-secondary: #6b7280;
--interactive-secondary-hover: #9ca3af;
--interactive-danger: #ef4444;
--interactive-danger-hover: #f87171;
/* Status colors */
--status-success: #10b981;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-info: #3b82f6;
/* Shadow colors */
--shadow-default: rgba(0, 0, 0, 0.3);
--shadow-hover: rgba(0, 0, 0, 0.4);
}
/* @media (prefers-color-scheme: dark) {
@@ -29,6 +114,18 @@ body {
background: #f1f1f1;
}
::-webkit-scrollbar-track:window-inactive {
background: #f1f1f1;
}
.dark ::-webkit-scrollbar-track {
background: #374151;
}
.dark ::-webkit-scrollbar-track:window-inactive {
background: #374151;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
@@ -38,6 +135,14 @@ body {
background: #a8a8a8;
}
.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Focus styles */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
@@ -92,5 +197,66 @@ body {
/* Map controls positioning */
.leaflet-control-container .leaflet-top.leaflet-right {
top: 80px !important; /* Account for floating header */
top: 10px !important; /* Position closer to top for project page */
right: 10px !important;
}
/* Style the layer control to make it prettier */
.leaflet-control-layers {
border-radius: 6px !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
font-family: inherit !important;
font-size: 13px !important;
min-width: 180px !important;
max-width: 220px !important;
}
.leaflet-control-layers-toggle {
background-color: #3b82f6 !important;
color: white !important;
border-radius: 4px !important;
width: 30px !important;
height: 30px !important;
border: none !important;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
transition: all 0.2s ease !important;
font-size: 16px !important;
line-height: 1 !important;
}
.leaflet-control-layers-toggle:hover {
background-color: #2563eb !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
transform: scale(1.05) !important;
}
.leaflet-control-layers-list {
border-radius: 4px !important;
padding: 6px !important;
}
.leaflet-control-layers-base label,
.leaflet-control-layers-overlays label {
padding: 4px 8px !important;
border-radius: 3px !important;
margin: 1px 0 !important;
transition: background-color 0.2s ease !important;
cursor: pointer !important;
font-size: 12px !important;
line-height: 1.3 !important;
}
.leaflet-control-layers-base label:hover,
.leaflet-control-layers-overlays label:hover {
background-color: rgba(59, 130, 246, 0.08) !important;
}
.leaflet-control-layers input[type="radio"],
.leaflet-control-layers input[type="checkbox"] {
margin-right: 6px !important;
transform: scale(0.9) !important;
}

View File

@@ -2,6 +2,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navigation from "@/components/ui/Navigation";
import { AuthProvider } from "@/components/auth/AuthProvider";
import { TranslationProvider } from "@/lib/i18n";
import { ThemeProvider } from "@/components/ThemeProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -14,20 +16,24 @@ const geistMono = Geist_Mono({
});
export const metadata = {
title: "Project Management Panel",
description: "Professional project management dashboard",
title: "eProjektant Wastpol",
description: "Panel Wastpol",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<html lang="pl">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<AuthProvider>
<Navigation />
<main>{children}</main>
</AuthProvider>
<ThemeProvider>
<TranslationProvider initialLanguage="pl">
<AuthProvider>
<Navigation />
<main>{children}</main>
</AuthProvider>
</TranslationProvider>
</ThemeProvider>
</body>
</html>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,53 @@
import ProjectTasksList from "@/components/ProjectTasksList";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import Button from "@/components/ui/Button";
import Link from "next/link";
export default function ProjectTasksPage() {
return (
<PageContainer>
<PageHeader
title="Project Tasks"
description="View and manage tasks across all projects in a structured list format"
title="Zadania"
description="Zarządzaj zadaniami projektów"
actions={[
<Link href="/tasks/templates" key="templates">
<Button variant="secondary" size="md">
<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="M4 7v10c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V7M4 7c0-2.21 1.79-4 4-4h8c2.21 0 4 1.79 4 4M4 7h16M9 11v4m6-4v4"
/>
</svg>
Szablony zadań
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="md">
<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 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
]}
/>
<ProjectTasksList />
</PageContainer>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { useParams } from "next/navigation";
import ProjectForm from "@/components/ProjectForm";
import PageContainer from "@/components/ui/PageContainer";
@@ -8,6 +8,7 @@ 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 EditProjectPage() {
const params = useParams();
@@ -15,6 +16,8 @@ export default function EditProjectPage() {
const [project, setProject] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { t } = useTranslation();
const formRef = useRef();
useEffect(() => {
const fetchProject = async () => {
@@ -62,10 +65,30 @@ export default function EditProjectPage() {
return (
<PageContainer>
<PageHeader
title="Edit Project"
description={`Editing: ${project.project_name || "Untitled Project"}`}
title={t('projects.editProject')}
description={`${t('projects.editing')}: ${project.project_name || "Untitled Project"}`}
action={
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
onClick={() => formRef.current?.saveProject()}
>
<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="M5 13l4 4L19 7"
/>
</svg>
{t('common.save')}
</Button>
<Link href={`/projects/${id}`}>
<Button variant="outline" size="sm">
<svg
@@ -81,7 +104,7 @@ export default function EditProjectPage() {
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Cancel
{t('common.cancel')}
</Button>
</Link>
<Link href="/projects">
@@ -99,14 +122,14 @@ export default function EditProjectPage() {
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Projects
{t('projects.backToProjects')}
</Button>
</Link>
</div>
}
/>
<div className="max-w-2xl">
<ProjectForm initialData={project} />
<ProjectForm ref={formRef} initialData={project} />
</div>
</PageContainer>
);

View File

@@ -1,33 +1,209 @@
import {
getProjectWithContract,
getNotesForProject,
} from "@/lib/queries/projects";
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import NoteForm from "@/components/NoteForm";
import ProjectTasksSection from "@/components/ProjectTasksSection";
import FieldWithHistory from "@/components/FieldWithHistory";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import Link from "next/link";
import { differenceInCalendarDays, parseISO } from "date-fns";
import { formatDate } from "@/lib/utils";
import { formatDate, formatCoordinates } from "@/lib/utils";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
import ProjectAssigneeDropdown from "@/components/ProjectAssigneeDropdown";
import ClientProjectMap from "@/components/ui/ClientProjectMap";
import FileUploadBox from "@/components/FileUploadBox";
import FileItem from "@/components/FileItem";
import proj4 from "proj4";
export default async function ProjectViewPage({ params }) {
const { id } = await params;
const project = await getProjectWithContract(id);
const notes = await getNotesForProject(id);
export default function ProjectViewPage() {
const params = useParams();
const { data: session } = useSession();
const [project, setProject] = useState(null);
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editText, setEditText] = useState('');
// Helper function to parse note text with links
const parseNoteText = (text) => {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(text)) !== null) {
// Add text before the link
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// Add the link
parts.push(
<a
key={match.index}
href={match[2]}
className="text-blue-600 hover:text-blue-800 underline"
target="_blank"
rel="noopener noreferrer"
>
{match[1]}
</a>
);
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
};
// Helper function to add a new note to the list
const addNote = (newNote) => {
setNotes(prevNotes => [newNote, ...prevNotes]);
};
// Helper function to check if user can modify a note (edit or delete)
const canModifyNote = (note) => {
if (!session?.user) return false;
// Admins can modify any note
if (session.user.role === 'admin') return true;
// Users can modify their own notes
return note.created_by === session.user.id;
};
// Helper function to handle file upload
const handleFileUploaded = (newFile) => {
setUploadedFiles(prevFiles => [newFile, ...prevFiles]);
};
// Helper function to handle file deletion
const handleFileDelete = async (fileId) => {
if (confirm('Czy na pewno chcesz usunąć ten plik?')) {
try {
const res = await fetch(`/api/files/${fileId}`, {
method: 'DELETE',
});
if (res.ok) {
setUploadedFiles(prevFiles => prevFiles.filter(file => file.file_id !== fileId));
} else {
alert('Błąd podczas usuwania pliku');
}
} catch (error) {
console.error('Error deleting file:', error);
alert('Błąd podczas usuwania pliku');
}
}
};
// Helper function to handle file update (edit)
const handleFileUpdate = async (updatedFile) => {
setUploadedFiles(prevFiles =>
prevFiles.map(file =>
file.file_id === updatedFile.file_id ? updatedFile : file
)
);
};
// Helper function to save edited note
const handleSaveNote = async (noteId) => {
try {
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ note: editText }),
});
if (res.ok) {
// Update the note in local state
setNotes(prevNotes =>
prevNotes.map(note =>
note.note_id === noteId ? { ...note, note: editText, edited_at: new Date().toISOString() } : note
)
);
setEditingNoteId(null);
setEditText('');
} else {
alert('Błąd podczas aktualizacji notatki');
}
} catch (error) {
console.error('Error updating note:', error);
alert('Błąd podczas aktualizacji notatki');
}
};
useEffect(() => {
const fetchData = async () => {
if (!params.id) return;
try {
// Fetch project data
const projectRes = await fetch(`/api/projects/${params.id}`);
if (!projectRes.ok) {
throw new Error('Project not found');
}
const projectData = await projectRes.json();
// Fetch notes data
const notesRes = await fetch(`/api/notes?project_id=${params.id}`);
const notesData = notesRes.ok ? await notesRes.json() : [];
// Fetch files data
const filesRes = await fetch(`/api/files?entityType=project&entityId=${params.id}`);
const filesData = filesRes.ok ? await filesRes.json() : [];
setProject(projectData);
setNotes(notesData);
setUploadedFiles(filesData);
} catch (error) {
console.error('Error fetching data:', error);
setProject(null);
setNotes([]);
} finally {
setLoading(false);
}
};
fetchData();
}, [params.id]);
useEffect(() => {
if (project?.project_name) {
document.title = `${project.project_name} - Panel`;
} else {
document.title = 'Panel';
}
}, [project]);
if (loading) {
return (
<PageContainer>
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading...</div>
</div>
</PageContainer>
);
}
if (!project) {
return (
<PageContainer>
<Card>
<CardContent className="text-center py-8">
<p className="text-red-600 text-lg">Project not found.</p>
<p className="text-red-600 text-lg">Projekt nie został znaleziony.</p>
<Link href="/projects" className="mt-4 inline-block">
<Button variant="primary">Back to Projects</Button>
<Button variant="primary">Powrót do projektów</Button>
</Link>
</CardContent>
</Card>
@@ -44,62 +220,140 @@ export default async function ProjectViewPage({ params }) {
if (days <= 7) return "warning";
return "success";
};
return (
<PageContainer>
<PageHeader
title={project.project_name}
description={`${project.city}${project.address}`}
action={
<div className="flex items-center gap-3">
<ProjectStatusDropdown project={project} size="sm" />
{daysRemaining !== null && (
<Badge variant={getDeadlineVariant(daysRemaining)} size="md">
{daysRemaining === 0
? "Due Today"
: daysRemaining > 0
? `${daysRemaining} days left`
: `${Math.abs(daysRemaining)} days overdue`}
</Badge>
)}
<Link href="/projects">
<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>
Back to Projects
</Button>
</Link>
<Link href={`/projects/${id}/edit`}>
<Button variant="primary">
<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>
Edit Project
</Button>
</Link>
{/* Mobile: Full-width title, Desktop: Standard PageHeader */}
<div className="block sm:hidden mb-6">
{/* Mobile Layout */}
<div className="space-y-4">
{/* Full-width title */}
<div className="w-full">
<h1 className="text-2xl font-bold text-gray-900 break-words">
{project.project_name}
</h1>
<p className="text-sm text-gray-600 mt-1">
{project.city} {project.address} {project.project_number}
</p>
</div>
}
/>{" "}
{/* Mobile action bar */}
<div className="flex flex-col space-y-3">
{/* Status and deadline badges */}
<div className="flex items-center gap-2 flex-wrap">
<ProjectStatusDropdown project={project} size="sm" />
{daysRemaining !== null && (
<Badge variant={getDeadlineVariant(daysRemaining)} size="sm" className="text-xs">
{daysRemaining === 0
? "Termin dzisiaj"
: daysRemaining > 0
? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} dni po terminie`}
</Badge>
)}
</div>
{/* Action buttons - full width */}
<div className="flex gap-2 w-full">
<Link href="/projects" className="flex-1">
<Button variant="outline" size="sm" className="w-full text-xs">
<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="M15 19l-7-7 7-7"
/>
</svg>
Powrót
</Button>
</Link>
<Link href={`/projects/${params.id}/edit`} className="flex-1">
<Button variant="primary" size="sm" className="w-full text-xs">
<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="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
</Button>
</Link>
</div>
</div>
</div>
</div>
{/* Desktop: Standard PageHeader */}
<div className="hidden sm:block">
<PageHeader
title={project.project_name}
description={`${project.city}${project.address}${project.project_number}`}
action={
<div className="flex items-center gap-3">
<ProjectStatusDropdown project={project} size="sm" />
{daysRemaining !== null && (
<Badge variant={getDeadlineVariant(daysRemaining)} size="md">
{daysRemaining === 0
? "Termin dzisiaj"
: daysRemaining > 0
? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} dni po terminie`}
</Badge>
)}
<Link href="/projects">
<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>
Powrót do projektów
</Button>
</Link>
<Link href={`/projects/${params.id}/edit`}>
<Button variant="primary">
<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>
</div>
}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Main Project Information */}
<div className="lg:col-span-2 space-y-6">
@@ -108,7 +362,7 @@ export default async function ProjectViewPage({ params }) {
{" "}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
Project Information
Informacje o projekcie
</h2>
<Badge
variant={
@@ -123,12 +377,12 @@ export default async function ProjectViewPage({ params }) {
size="sm"
>
{project.project_type === "design"
? "Design (P)"
? "Projektowanie (P)"
: project.project_type === "construction"
? "Construction (R)"
? "Budowa (B)"
: project.project_type === "design+construction"
? "Design + Construction (P+R)"
: "Unknown"}
? "Projektowanie + Budowa (P+B)"
: "Nieznany"}
</Badge>
</div>
</CardHeader>
@@ -136,7 +390,7 @@ export default async function ProjectViewPage({ params }) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Location
Lokalizacja
</span>
<p className="text-gray-900 font-medium">
{project.city || "N/A"}
@@ -144,7 +398,7 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Address
Adres
</span>
<p className="text-gray-900 font-medium">
{project.address || "N/A"}
@@ -152,7 +406,7 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Plot
Działka
</span>
<p className="text-gray-900 font-medium">
{project.plot || "N/A"}
@@ -160,7 +414,7 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
District
Jednostka ewidencyjna
</span>
<p className="text-gray-900 font-medium">
{project.district || "N/A"}
@@ -168,22 +422,29 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Unit
Obręb
</span>
<p className="text-gray-900 font-medium">
{project.unit || "N/A"}
</p>
</div>{" "}
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Deadline
</span>
<p className="text-gray-900 font-medium">
{project.finish_date
? formatDate(project.finish_date)
: "N/A"}
</p>
</div>
<FieldWithHistory
tableName="projects"
recordId={project.project_id}
fieldName="finish_date"
currentValue={project.finish_date}
label="Termin zakończenia"
/>
{project.completion_date && (
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Data zakończenia projektu
</span>
<p className="text-gray-900 font-medium">
{formatDate(project.completion_date)}
</p>
</div>
)}
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
WP
@@ -194,35 +455,110 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Investment Number
Numer inwestycji
</span>
<p className="text-gray-900 font-medium">
{project.investment_number || "N/A"}
</p>
</div>
{session?.user?.role === 'team_lead' && project.wartosc_zlecenia && (
<FieldWithHistory
tableName="projects"
recordId={project.project_id}
fieldName="wartosc_zlecenia"
currentValue={project.wartosc_zlecenia}
displayValue={parseFloat(project.wartosc_zlecenia).toLocaleString('pl-PL', {
style: 'currency',
currency: 'PLN'
})}
label="Wartość zlecenia"
/>
)}
</div>
{project.contact && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1">
Contact
Kontakt
</span>
<p className="text-gray-900 font-medium">{project.contact}</p>
</div>
)}
{project.coordinates && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1">
Coordinates
</span>
{project.coordinates && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1">
Współrzędne
</span>
<div className="flex items-center gap-2">
<p className="text-gray-900 font-medium font-mono text-sm">
{project.coordinates}
{formatCoordinates(project.coordinates)}
</p>
</div>
)}
<a
href={`https://www.google.com/maps/place/${project.coordinates}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 transition-colors"
title="Otwórz w Google Maps"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</a>
<a
href={(() => {
// Define EPSG:2180 projection (Poland CS92)
proj4.defs("EPSG:2180", "+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
{project.notes && (
const [lat, lng] = project.coordinates.split(',').map(c => parseFloat(c.trim()));
// Convert WGS84 to EPSG:2180
const [x, y] = proj4('EPSG:4326', 'EPSG:2180', [lng, lat]);
// Create bbox with ~100m offset in each direction
const offset = 100;
const bbox = `${x - offset},${y - offset},${x + offset},${y + offset}`;
return `https://mapy.geoportal.gov.pl/imap/Imgp_2.html?gpmap=gp0&bbox=${bbox}&variant=KATASTER`;
})()}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:text-green-800 transition-colors"
title="Otwórz w Geoportal"
>
<svg
className="w-5 h-5"
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>
</a>
</div>
</div>
)} {project.notes && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1">
Notes
@@ -237,14 +573,14 @@ export default async function ProjectViewPage({ params }) {
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Contract Details
Szczegóły umowy
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Contract Number
Numer umowy
</span>
<p className="text-gray-900 font-medium">
{project.contract_number || "N/A"}
@@ -252,7 +588,7 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Contract Name
Nazwa umowy
</span>
<p className="text-gray-900 font-medium">
{project.contract_name || "N/A"}
@@ -260,7 +596,7 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Customer
Klient
</span>
<p className="text-gray-900 font-medium">
{project.customer || "N/A"}
@@ -268,7 +604,7 @@ export default async function ProjectViewPage({ params }) {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Investor
Inwestor
</span>
<p className="text-gray-900 font-medium">
{project.investor || "N/A"}
@@ -284,21 +620,27 @@ export default async function ProjectViewPage({ params }) {
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900">
Project Status
Status projektu
</h2>
</CardHeader>
<CardContent className="space-y-4">
{" "}
<div>
<span className="text-sm font-medium text-gray-500 block mb-2">
Current Status
Aktualny status
</span>
<ProjectStatusDropdown project={project} size="md" />
</div>
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2">
Przypisany do
</span>
<ProjectAssigneeDropdown project={project} size="md" />
</div>
{daysRemaining !== null && (
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2">
Timeline
Harmonogram
</span>
<div className="text-center">
<Badge
@@ -306,10 +648,10 @@ export default async function ProjectViewPage({ params }) {
size="lg"
>
{daysRemaining === 0
? "Due Today"
? "Termin dzisiaj"
: daysRemaining > 0
? `${daysRemaining} days remaining`
: `${Math.abs(daysRemaining)} days overdue`}
? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} dni po terminie`}
</Badge>
</div>
</div>
@@ -321,11 +663,11 @@ export default async function ProjectViewPage({ params }) {
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900">
Quick Actions
Szybkie akcje
</h2>
</CardHeader>
<CardContent className="space-y-3">
<Link href={`/projects/${id}/edit`} className="block">
<Link href={`/projects/${params.id}/edit`} className="block">
<Button
variant="outline"
size="sm"
@@ -344,13 +686,13 @@ export default async function ProjectViewPage({ params }) {
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>
Edit Project
Edytuj projekt
</Button>
</Link>{" "}
<Link href="/projects" className="block">
<Button
variant="outline"
size="sm"
size="sm"
className="w-full justify-start"
>
<svg
@@ -366,7 +708,7 @@ export default async function ProjectViewPage({ params }) {
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Projects
Powrót do projektów
</Button>
</Link>
<Link href="/projects/map" className="block">
@@ -388,13 +730,38 @@ export default async function ProjectViewPage({ params }) {
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
View All on Map
Zobacz wszystkie na mapie
</Button>
</Link>
</CardContent>
</Card>
{/* File Upload */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900">
Załączniki
</h2>
</CardHeader>
<CardContent className="space-y-4">
<FileUploadBox projectId={params.id} onFileUploaded={handleFileUploaded} />
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700">Przesłane pliki:</h3>
{uploadedFiles.map((file) => (
<FileItem
key={file.file_id}
file={file}
onDelete={handleFileDelete}
onUpdate={handleFileUpdate}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>{" "}
</div>
{/* Project Location Map */}
{project.coordinates && (
<div className="mb-8">
@@ -404,7 +771,7 @@ export default async function ProjectViewPage({ params }) {
{" "}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
Project Location
Lokalizacja projektu
</h2>
{project.coordinates && (
<Link
@@ -428,7 +795,7 @@ export default async function ProjectViewPage({ params }) {
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
View on Full Map
Zobacz na pełnej mapie
</Button>
</Link>
)}
@@ -442,6 +809,7 @@ export default async function ProjectViewPage({ params }) {
showLayerControl={true}
mapHeight="h-80"
defaultLayer="Polish Geoportal Orthophoto"
showOverlays={false}
/>
</CardContent>
</Card>
@@ -449,36 +817,39 @@ export default async function ProjectViewPage({ params }) {
)}
{/* Project Tasks Section */}
<div className="mb-8">
<ProjectTasksSection projectId={id} />
<ProjectTasksSection projectId={params.id} />
</div>
{/* Notes Section */}
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">Notes</h2>
<h2 className="text-xl font-semibold text-gray-900">Notatki</h2>
</CardHeader>
<CardContent>
<div className="mb-6">
<NoteForm projectId={id} />
<NoteForm projectId={params.id} onNoteAdded={addNote} />
</div>
{notes.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 110 2h-3a1 1 0 01-1-1v-1H8v1a1 1 0 01-1 1H4a1 1 0 110-2V4zm3 1h2v4a1 1 0 001 1h1a1 1 0 100-2v-1a2 2 0 00-2-2H7a1 1 0 000 2z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No notes yet
Brak notatek
</h3>
<p className="text-gray-500">
Add your first note using the form above.
Dodaj swoją pierwszą notatkę używając formularza powyżej.
</p>
</div>
) : (
@@ -486,21 +857,121 @@ export default async function ProjectViewPage({ params }) {
{notes.map((n) => (
<div
key={n.note_id}
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors group"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500">
{n.note_date}
{formatDate(n.note_date, { includeTime: true })}
</span>
{n.created_by_name && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
{n.created_by_name}
</span>
)}
{n.edited_at && (
<span className="text-xs text-gray-400 italic">
edytowane
</span>
)}
</div>
{canModifyNote(n) && (
<div className="flex gap-1">
<button
onClick={() => {
setEditingNoteId(n.note_id);
setEditText(n.note);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"
title="Edytuj notatkę"
>
<svg
className="w-4 h-4"
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>
</button>
<button
onClick={async () => {
if (confirm('Czy na pewno chcesz usunąć tę notatkę?')) {
try {
const res = await fetch(`/api/notes/${n.note_id}`, {
method: 'DELETE',
});
if (res.ok) {
// Remove the note from local state instead of full page reload
setNotes(prevNotes => prevNotes.filter(note => note.note_id !== n.note_id));
} else {
alert('Błąd podczas usuwania notatki');
}
} catch (error) {
console.error('Error deleting note:', error);
alert('Błąd podczas usuwania notatki');
}
}
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded"
title="Usuń notatkę"
>
<svg
className="w-4 h-4"
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>
<p className="text-gray-900 leading-relaxed">{n.note}</p>
{editingNoteId === n.note_id ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
placeholder="Wpisz notatkę..."
/>
<div className="flex gap-2">
<Button
onClick={() => handleSaveNote(n.note_id)}
variant="primary"
size="sm"
>
Zapisz
</Button>
<Button
onClick={() => {
setEditingNoteId(null);
setEditText('');
}}
variant="outline"
size="sm"
>
Anuluj
</Button>
</div>
</div>
) : (
<div className="text-gray-900 leading-relaxed">
{parseNoteText(n.note)}
</div>
)}
</div>
))}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
"use client";
import { useTranslation } from "@/lib/i18n";
import ProjectForm from "@/components/ProjectForm";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
@@ -5,11 +8,13 @@ import Button from "@/components/ui/Button";
import Link from "next/link";
export default function NewProjectPage() {
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader
title="Create New Project"
description="Add a new project to your portfolio"
title={t("projects.newProject")}
// description={t("projects.noProjectsMessage")}
action={
<Link href="/projects">
<Button variant="outline" size="sm">
@@ -26,7 +31,7 @@ export default function NewProjectPage() {
d="M15 19l-7-7 7-7"
/>
</svg>
Back to Projects
{t("common.back")}
</Button>
</Link>
}

View File

@@ -11,41 +11,125 @@ import PageHeader from "@/components/ui/PageHeader";
import SearchBar from "@/components/ui/SearchBar";
import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useSession } from "next-auth/react";
export default function ProjectListPage() {
const { t } = useTranslation();
const { data: session } = useSession();
const [projects, setProjects] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [filteredProjects, setFilteredProjects] = useState([]);
const [filters, setFilters] = useState({
status: 'all',
type: 'all',
customer: 'all',
mine: false,
phoneOnly: false
});
const [customers, setCustomers] = useState([]);
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
useEffect(() => {
const savedPhoneOnly = localStorage.getItem('projectsPhoneOnlyFilter') === 'true';
if (savedPhoneOnly) {
setFilters(prev => ({
...prev,
phoneOnly: savedPhoneOnly
}));
}
}, []);
const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist
const [searchMatchType, setSearchMatchType] = useState(null); // Track what type of match was found
// Helper function to normalize strings by removing spaces
const normalizeString = (str) => {
return str?.replace(/\s+/g, '') || '';
};
useEffect(() => {
fetch("/api/projects")
.then((res) => res.json())
.then((data) => {
setProjects(data);
setFilteredProjects(data);
// Extract unique customers for filter
const uniqueCustomers = [...new Set(data.map(p => p.customer).filter(Boolean))];
setCustomers(uniqueCustomers);
});
}, []);
// Filter projects based on search term
// Filter projects based on search term and filters
useEffect(() => {
if (!searchTerm.trim()) {
setFilteredProjects(projects);
} else {
const filtered = projects.filter((project) => {
const searchLower = searchTerm.toLowerCase();
return (
let filtered = projects;
// Apply status filter
if (filters.status !== 'all') {
if (filters.status === 'not_finished') {
filtered = filtered.filter(project => project.project_status !== 'fulfilled');
} else {
filtered = filtered.filter(project => project.project_status === filters.status);
}
}
// Apply type filter
if (filters.type !== 'all') {
filtered = filtered.filter(project => project.project_type === filters.type);
}
// Apply customer filter
if (filters.customer !== 'all') {
filtered = filtered.filter(project => project.customer === filters.customer);
}
// Apply mine filter
if (filters.mine && session?.user?.id) {
filtered = filtered.filter(project => project.assigned_to === session.user.id);
}
// Apply search term
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
const searchNormalized = normalizeString(searchLower);
let hasContactMatch = false;
filtered = filtered.map((project) => {
const isContactMatch = normalizeString(project.contact?.toLowerCase()).includes(searchNormalized);
if (isContactMatch) hasContactMatch = true;
// Add a flag to mark projects that matched on contact
return {
...project,
_matchedOnContact: isContactMatch
};
}).filter((project) => {
const baseMatches =
project.project_name?.toLowerCase().includes(searchLower) ||
project.wp?.toLowerCase().includes(searchLower) ||
project.plot?.toLowerCase().includes(searchLower) ||
project.investment_number?.toLowerCase().includes(searchLower) ||
project.address?.toLowerCase().includes(searchLower)
);
project.address?.toLowerCase().includes(searchLower) ||
project.customer?.toLowerCase().includes(searchLower) ||
project.investor?.toLowerCase().includes(searchLower);
// Include contact matches only if phoneOnly is enabled
const contactMatch = filters.phoneOnly ? project._matchedOnContact : false;
return baseMatches || contactMatch;
});
setFilteredProjects(filtered);
setSearchMatchType(hasContactMatch ? 'contact' : null);
} else {
setSearchMatchType(null);
}
}, [searchTerm, projects]);
setFilteredProjects(filtered);
}, [searchTerm, projects, filters, session]);
async function handleDelete(id) {
const confirmed = confirm("Are you sure you want to delete this project?");
const confirmed = confirm(t('projects.deleteConfirm'));
if (!confirmed) return;
const res = await fetch(`/api/projects/${id}`, {
@@ -59,32 +143,100 @@ export default function ProjectListPage() {
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
const handleFilterChange = (filterType, value) => {
setFilters(prev => {
const newFilters = {
...prev,
[filterType]: value
};
// Save phoneOnly filter to localStorage
if (filterType === 'phoneOnly') {
localStorage.setItem('projectsPhoneOnlyFilter', value.toString());
}
return newFilters;
});
};
const clearAllFilters = () => {
setFilters(prev => ({
status: 'all',
type: 'all',
customer: 'all',
mine: false,
phoneOnly: prev.phoneOnly // Preserve phone toggle state
}));
setSearchTerm('');
};
const handleExportExcel = async () => {
try {
const response = await fetch('/api/projects/export');
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
alert('Failed to export projects. Please try again.');
}
} catch (error) {
console.error('Export error:', error);
alert('An error occurred during export. Please try again.');
}
};
const toggleFilters = () => {
setFiltersExpanded(!filtersExpanded);
};
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm.trim() !== '';
const getActiveFilterCount = () => {
let count = 0;
if (filters.status !== 'all') count++;
if (filters.type !== 'all') count++;
if (filters.customer !== 'all') count++;
if (filters.mine) count++;
if (searchTerm.trim()) count++;
return count;
};
const getStatusLabel = (status) => {
switch(status) {
case "registered": return t('projectStatus.registered');
case "in_progress_design": return t('projectStatus.in_progress_design');
case "in_progress_construction": return t('projectStatus.in_progress_construction');
case "fulfilled": return t('projectStatus.fulfilled');
case "cancelled": return t('projectStatus.cancelled');
default: return "-";
}
};
const getTypeLabel = (type) => {
switch(type) {
case "design": return t('projectType.design');
case "construction": return t('projectType.construction');
case "design+construction": return t('projectType.design+construction');
default: return "-";
}
};
return (
<PageContainer>
<PageHeader title="Projects" description="Manage and track your projects">
<div className="flex gap-2">
<Link href="/projects/map">
<Button variant="outline" size="lg">
<svg
className="w-5 h-5 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-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
Map View
</Button>
</Link>
<Link href="/projects/new">
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
<div className="flex items-center gap-2 sm:gap-3">
{/* Primary Action - New Project */}
<Link href="/projects/new" className="flex-shrink-0">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
className="w-5 h-5 sm:mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -96,19 +248,378 @@ export default function ProjectListPage() {
d="M12 4v16m8-8H4"
/>
</svg>
Add Project
<span className="hidden sm:inline">{t('projects.newProject')}</span>
</Button>
</Link>
{/* Spacer */}
<div className="flex-1"></div>
{/* Utility Actions - Icon Buttons */}
<div className="flex items-center gap-2">
<Link href="/projects/map" title={t('projects.mapView') || 'Widok mapy'}>
<Button variant="ghost" size="icon" className="h-10 w-10">
<svg
className="w-5 h-5"
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-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={handleExportExcel}
title={t('projects.exportExcel') || 'Export to Excel'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</Button>
</div>
</div>
</PageHeader>
<SearchBar
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
placeholder="Search by project name, WP, plot, or investment number..."
placeholder={t('projects.searchPlaceholder')}
resultsCount={filteredProjects.length}
resultsText="projects"
resultsText={t('projects.projects') || 'projektów'}
/>
{/* Filters */}
<Card className="mb-6">
{/* Mobile collapsible header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 transition-colors md:hidden"
onClick={toggleFilters}
>
<div className="flex items-center space-x-3">
<svg
className={`w-5 h-5 text-gray-500 transition-transform duration-200 ${filtersExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
<h3 className="text-sm font-medium text-gray-900">
{t('common.filters') || 'Filtry'}
{hasActiveFilters && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{getActiveFilterCount()}
</span>
)}
</h3>
</div>
<div className="flex items-center space-x-2">
{hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
clearAllFilters();
}}
className="text-xs"
>
{t('common.clearAll') || 'Wyczyść'}
</Button>
)}
<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>
</div>
</div>
{/* Mobile collapsible content */}
<div className={`overflow-hidden transition-all duration-300 ease-in-out md:hidden ${filtersExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="px-4 pb-4 border-t border-gray-100">
<div className="flex flex-col space-y-4 md:flex-row md:flex-wrap md:gap-4 md:space-y-0 md:items-center pt-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.status') || 'Status'}:
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
<option value="registered">{t('projectStatus.registered')}</option>
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.type') || 'Typ'}:
</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">{t('projectType.design+construction')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('contracts.customer') || 'Klient'}:
</label>
<select
value={filters.customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
{customers.map((customer) => (
<option key={customer} value={customer}>
{customer}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
className={`
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
${filters.phoneOnly
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
</button>
{session?.user && (
<button
onClick={() => handleFilterChange('mine', !filters.mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${filters.mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
</div>
</div>
</div>
{/* Desktop always visible content */}
<div className="hidden md:block">
<div className="p-4">
<div className="flex flex-col space-y-3">
{/* Filters row */}
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('common.status') || 'Status'}:
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{t('common.all')}</option>
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
<option value="registered">{t('projectStatus.registered')}</option>
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
</select>
</div>
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('common.type') || 'Typ'}:
</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{t('common.all')}</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">{t('projectType.design+construction')}</option>
</select>
</div>
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('contracts.customer') || 'Klient'}:
</label>
<select
value={filters.customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{t('common.all')}</option>
{customers.map((customer) => (
<option key={customer} value={customer}>
{customer}
</option>
))}
</select>
</div>
<button
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
className={`
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
${filters.phoneOnly
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
</button>
{session?.user && (
<button
onClick={() => handleFilterChange('mine', !filters.mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${filters.mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
{/* 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>
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-xs"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
{t('common.clearAllFilters') || 'Wyczyść wszystkie filtry'}
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
{filteredProjects.length === 0 && searchTerm ? (
<Card>
<CardContent className="text-center py-12">
@@ -126,14 +637,13 @@ export default function ProjectListPage() {
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No projects found
{t('common.noResults')}
</h3>
<p className="text-gray-500 mb-6">
No projects match your search criteria. Try adjusting your search
terms.
{t('projects.noMatchingResults') || 'Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.'}
</p>
<Button variant="outline" onClick={() => setSearchTerm("")}>
Clear Search
{t('common.clearSearch') || 'Wyczyść wyszukiwanie'}
</Button>
</CardContent>
</Card>
@@ -154,160 +664,170 @@ export default function ProjectListPage() {
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No projects yet
{t('projects.noProjects')}
</h3>
<p className="text-gray-500 mb-6">
Get started by creating your first project
{t('projects.noProjectsMessage')}
</p>
<Link href="/projects/new">
<Button variant="primary">Create First Project</Button>
<Button variant="primary">{t('projects.createFirstProject') || 'Utwórz pierwszy projekt'}</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full table-fixed">
<thead>
<tr className="bg-gray-100 border-b">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-32">
No.
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700">
Project Name
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40">
WP
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
City
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40">
Address
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
Plot
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
Finish
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-12">
Type
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
Status
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
Created By
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
Assigned To
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
Actions
</th>
</tr>
</thead>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{/* Mobile scroll container */}
<div className="overflow-x-auto">
<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>
</tr>
</thead>
<tbody>
{filteredProjects.map((project, index) => (
<tr
key={project.project_id}
className={`border-b hover:bg-gray-50 transition-colors ${
index % 2 === 0 ? "bg-white" : "bg-gray-25"
}`}
className={`border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
index % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-25 dark:bg-gray-750"
} ${project._matchedOnContact && filters.phoneOnly ? 'border-l-4 border-l-blue-500' : ''}`}
>
<td className="px-2 py-3">
<Badge variant="primary" size="sm" className="text-xs">
<td className="px-1 py-3">
<div className="flex items-center gap-1">
<Badge variant="secondary" size="sm" className="text-xs">
{project.project_number}
</Badge>
</td>
<td className="px-2 py-3">
{project._matchedOnContact && filters.phoneOnly && (
<span
className="inline-flex items-center justify-center w-5 h-5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full"
title={t('projects.contactNumberMatch') || 'Znaleziono numer kontaktowy'}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</span>
)}
</div>
</td>
<td className="px-2 py-3 w-[200px] md:w-[250px]">
<Link
href={`/projects/${project.project_id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block"
title={project.project_name}
>
{project.project_name}
<span className="block sm:hidden">
{project.project_name.length > 20
? `${project.project_name.substring(0, 20)}...`
: project.project_name}
</span>
<span className="hidden sm:block">
{project.project_name}
</span>
</Link>
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.wp}
>
{project.wp || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.city}
>
{project.city || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden lg:table-cell"
title={project.address}
>
{project.address || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
title={project.wp}
>
{project.wp || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
title={project.city}
>
{project.city || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
title={project.plot}
>
{project.plot || "N/A"}
</td>{" "}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
title={project.finish_date}
>
{project.finish_date
? formatDate(project.finish_date)
: "N/A"}
</td>
<td className="px-2 py-3 text-xs text-gray-600 text-center font-semibold">
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 text-center font-semibold">
{project.project_type === "design"
? "P"
: project.project_type === "construction"
? "R"
? "B"
: project.project_type === "design+construction"
? "P+R"
? "P+B"
: "-"}
</td>
<td className="px-2 py-3 text-xs text-gray-600 truncate">
{project.project_status === "registered"
? "Zarejestr."
: project.project_status === "in_progress_design"
? "W real. (P)"
: project.project_status === "in_progress_construction"
? "W real. (R)"
: project.project_status === "fulfilled"
? "Zakończony"
: "-"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.created_by_name || "Unknown"}
>
{project.created_by_name || "Unknown"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.assigned_to_name || "Unassigned"}
>
{project.assigned_to_name || "Unassigned"}
<td className="px-2 py-3 text-xs text-gray-600">
<div className="flex justify-center items-center h-full">
{project.project_status === 'registered' ? (
<span className="text-red-500 font-bold text-sm" title={t('projectStatus.registered')}>N</span>
) : project.project_status === 'in_progress_design' ? (
<span className="inline-block w-3 h-3 bg-blue-500 rounded-full" title={t('projectStatus.in_progress_design')}></span>
) : project.project_status === 'in_progress_construction' ? (
<span className="inline-block w-3 h-3 bg-yellow-400 rounded-full" title={t('projectStatus.in_progress_construction')}></span>
) : project.project_status === 'fulfilled' ? (
<span className="inline-block w-3 h-3 bg-green-500 rounded-full" title={t('projectStatus.fulfilled')}></span>
) : project.project_status === 'cancelled' ? (
<span className="text-red-500 font-bold text-lg" title={t('projectStatus.cancelled')}>×</span>
) : (
<span title="Unknown status">-</span>
)}
</div>
</td>
<td className="px-2 py-3">
<Link href={`/projects/${project.project_id}`}>
<Button
variant="outline"
size="sm"
className="text-xs px-2 py-1"
>
View
</Button>
</Link>
{project.assigned_to_initial ? (
<div className="flex items-center justify-center">
<span className="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs font-semibold">
{project.assigned_to_initial}
</span>
</div>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</PageContainer>

77
src/app/settings/page.js Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import { useTranslation } from "@/lib/i18n";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import ThemeToggle from "@/components/ui/ThemeToggle";
import LanguageSwitcher from "@/components/ui/LanguageSwitcher";
import PasswordReset from "@/components/settings/PasswordReset";
export default function SettingsPage() {
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader title={t('settings.title') || 'Settings'} />
<div className="max-w-2xl mx-auto space-y-6">
{/* Appearance Settings */}
<Card className="hidden">
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.appearance') || 'Appearance'}
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.theme') || 'Theme'}
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('settings.themeDescription') || 'Choose your preferred theme'}
</p>
</div>
<ThemeToggle />
</div>
</CardContent>
</Card>
{/* Language Settings */}
<Card className="hidden">
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.language') || 'Language'}
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.language') || 'Language'}
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('settings.languageDescription') || 'Select your preferred language'}
</p>
</div>
<LanguageSwitcher />
</div>
</CardContent>
</Card>
{/* Password Settings */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.password') || 'Password'}
</h2>
</CardHeader>
<CardContent>
<PasswordReset />
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,245 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function ApplyTaskSetPage() {
const { t } = useTranslation();
const router = useRouter();
const params = useParams();
const setId = params.id;
const [taskSet, setTaskSet] = useState(null);
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState("");
const [loading, setLoading] = useState(true);
const [isApplying, setIsApplying] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
// Fetch task set
const setResponse = await fetch(`/api/task-sets/${setId}`);
if (setResponse.ok) {
const setData = await setResponse.json();
setTaskSet(setData);
} else {
console.error('Failed to fetch task set');
router.push('/task-sets');
return;
}
// Fetch projects
const projectsResponse = await fetch('/api/projects');
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
if (setId) {
fetchData();
}
}, [setId, router]);
const handleApply = async () => {
if (!selectedProject) {
alert("Wybierz projekt");
return;
}
setIsApplying(true);
try {
const response = await fetch(`/api/task-sets/${setId}/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: parseInt(selectedProject) })
});
if (response.ok) {
const result = await response.json();
alert(`Zestaw zadań został pomyślnie zastosowany. Utworzono ${result.createdTaskIds.length} zadań.`);
router.push(`/projects/${selectedProject}`);
} else {
const error = await response.json();
alert(`Błąd: ${error.details || 'Nie udało się zastosować zestawu zadań'}`);
}
} catch (error) {
console.error('Error applying task set:', error);
alert('Wystąpił błąd podczas stosowania zestawu zadań');
} finally {
setIsApplying(false);
}
};
if (loading) {
return (
<PageContainer>
<PageHeader title="Zastosuj zestaw zadań" />
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
if (!taskSet) {
return (
<PageContainer>
<PageHeader title="Zastosuj zestaw zadań" />
<div className="text-center py-12">
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Zastosuj zestaw zadań"
description={`Zastosuj zestaw "${taskSet.name}" do projektu`}
/>
<div className="max-w-4xl">
{/* Task set info */}
<Card className="mb-6">
<CardHeader>
<h3 className="text-lg font-semibold">Informacje o zestawie</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="font-medium text-gray-900">{taskSet.name}</div>
{taskSet.description && (
<div className="text-sm text-gray-600 mt-1">{taskSet.description}</div>
)}
<div className="text-sm text-gray-500 mt-2">
Typ projektu: <span className="capitalize">{taskSet.project_type}</span>
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-700 mb-2">
Zawarte szablony zadań ({taskSet.templates?.length || 0}):
</div>
{taskSet.templates && taskSet.templates.length > 0 ? (
<ul className="text-sm text-gray-600 space-y-1">
{taskSet.templates.map((template, index) => (
<li key={template.task_id} className="flex items-center">
<span className="text-gray-400 mr-2">{index + 1}.</span>
{template.name}
{template.max_wait_days > 0 && (
<span className="text-xs text-gray-500 ml-2">
({template.max_wait_days} dni)
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">Brak szablonów w zestawie</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Project selection */}
<Card className="mb-6">
<CardHeader>
<h3 className="text-lg font-semibold">Wybierz projekt</h3>
<p className="text-sm text-gray-600">
Wybierz projekt, do którego chcesz zastosować ten zestaw zadań
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Projekt *
</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Wybierz projekt...</option>
{projects.map((project) => (
<option key={project.project_id} value={project.project_id}>
{project.project_name} - {project.city} ({project.project_type})
</option>
))}
</select>
</div>
{projects.length === 0 && (
<p className="text-gray-500 text-sm">
Brak dostępnych projektów dla tego typu zestawu zadań.
{taskSet.project_type !== 'design+construction' &&
" Spróbuj utworzyć projekt typu 'Projekt + Budowa' lub zmienić typ zestawu."
}
</p>
)}
</div>
</CardContent>
</Card>
{/* Warning */}
<Card className="mb-6 bg-yellow-50 border-yellow-200">
<CardContent className="pt-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Informacja
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
Zastosowanie tego zestawu utworzy {taskSet.templates?.length || 0} nowych zadań w wybranym projekcie.
Zadania będą miały status "Oczekujące" i zostaną przypisane zgodnie z domyślnymi regułami.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isApplying}
>
Anuluj
</Button>
<Button
type="button"
variant="primary"
onClick={handleApply}
disabled={isApplying || !selectedProject}
>
{isApplying ? "Stosowanie..." : "Zastosuj zestaw"}
</Button>
</div>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function EditTaskSetPage() {
const { t } = useTranslation();
const router = useRouter();
const params = useParams();
const setId = params.id;
const [taskTemplates, setTaskTemplates] = useState([]);
const [taskSet, setTaskSet] = useState(null);
const [formData, setFormData] = useState({
name: "",
description: "",
project_type: "design",
selectedTemplates: []
});
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
// Fetch task set
const setResponse = await fetch(`/api/task-sets/${setId}`);
if (setResponse.ok) {
const setData = await setResponse.json();
setTaskSet(setData);
setFormData({
name: setData.name,
description: setData.description || "",
project_type: setData.project_type,
selectedTemplates: setData.templates?.map(t => t.task_id) || []
});
} else {
console.error('Failed to fetch task set');
router.push('/task-sets');
return;
}
// Fetch available task templates
const templatesResponse = await fetch('/api/tasks/templates');
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
setTaskTemplates(templatesData);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
if (setId) {
fetchData();
}
}, [setId, router]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
alert("Nazwa zestawu jest wymagana");
return;
}
if (formData.selectedTemplates.length === 0) {
alert("Wybierz przynajmniej jeden szablon zadania");
return;
}
setIsSubmitting(true);
try {
// Update the task set
const updateData = {
name: formData.name.trim(),
description: formData.description.trim(),
task_category: formData.task_category,
templates: formData.selectedTemplates.map((templateId, index) => ({
task_id: templateId,
sort_order: index
}))
};
const response = await fetch(`/api/task-sets/${setId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (!response.ok) {
throw new Error('Failed to update task set');
}
router.push('/task-sets');
} catch (error) {
console.error('Error updating task set:', error);
alert('Wystąpił błąd podczas aktualizacji zestawu zadań');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!confirm('Czy na pewno chcesz usunąć ten zestaw zadań?')) {
return;
}
try {
const response = await fetch(`/api/task-sets/${setId}`, {
method: 'DELETE'
});
if (response.ok) {
router.push('/task-sets');
} else {
alert('Wystąpił błąd podczas usuwania zestawu zadań');
}
} catch (error) {
console.error('Error deleting task set:', error);
alert('Wystąpił błąd podczas usuwania zestawu zadań');
}
};
const toggleTemplate = (templateId) => {
setFormData(prev => ({
...prev,
selectedTemplates: prev.selectedTemplates.includes(templateId)
? prev.selectedTemplates.filter(id => id !== templateId)
: [...prev.selectedTemplates, templateId]
}));
};
const moveTemplate = (fromIndex, toIndex) => {
const newSelected = [...formData.selectedTemplates];
const [moved] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, moved);
setFormData(prev => ({
...prev,
selectedTemplates: newSelected
}));
};
if (loading) {
return (
<PageContainer>
<PageHeader title="Edycja zestawu zadań" />
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
if (!taskSet) {
return (
<PageContainer>
<PageHeader title="Edycja zestawu zadań" />
<div className="text-center py-12">
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Edycja zestawu zadań"
description={`Edytuj zestaw: ${taskSet.name}`}
/>
<form onSubmit={handleSubmit} className="max-w-4xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic info */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nazwa zestawu *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="np. Standardowe zadania projektowe"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opis
</label>
<Input
type="text"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Opcjonalny opis zestawu"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategoria zadań *
</label>
<select
value={formData.task_category}
onChange={(e) => setFormData(prev => ({ ...prev, task_category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="design">Zadania projektowe</option>
<option value="construction">Zadania budowlane</option>
</select>
</div>
</CardContent>
</Card>
{/* Template selection */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybrano: {formData.selectedTemplates.length} szablonów
</p>
</CardHeader>
<CardContent>
{formData.selectedTemplates.length > 0 ? (
<div className="space-y-2">
{formData.selectedTemplates.map((templateId, index) => {
const template = taskTemplates.find(t => t.task_id === templateId);
return (
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
</div>
<div className="flex space-x-1">
<button
type="button"
onClick={() => index > 0 && moveTemplate(index, index - 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === 0}
>
</button>
<button
type="button"
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === formData.selectedTemplates.length - 1}
>
</button>
<button
type="button"
onClick={() => toggleTemplate(templateId)}
className="text-red-400 hover:text-red-600"
>
×
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
)}
</CardContent>
</Card>
</div>
{/* Available templates */}
<Card className="mt-6">
<CardHeader>
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybierz szablony, które chcesz dodać do zestawu
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{taskTemplates.map((template) => (
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.selectedTemplates.includes(template.task_id)}
onChange={() => toggleTemplate(template.task_id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
{template.description && (
<div className="text-xs text-gray-600">{template.description}</div>
)}
</div>
</label>
))}
</div>
{taskTemplates.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
</p>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-between mt-6">
<Button
type="button"
variant="danger"
onClick={handleDelete}
disabled={isSubmitting}
>
Usuń zestaw
</Button>
<div className="flex space-x-4">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
>
{isSubmitting ? "Zapisywanie..." : "Zapisz zmiany"}
</Button>
</div>
</div>
</form>
</PageContainer>
);
}

View File

@@ -0,0 +1,289 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function NewTaskSetPage() {
const { t } = useTranslation();
const router = useRouter();
const [taskTemplates, setTaskTemplates] = useState([]);
const [formData, setFormData] = useState({
name: "",
description: "",
task_category: "design",
selectedTemplates: []
});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// Fetch available task templates
const fetchTemplates = async () => {
try {
const response = await fetch('/api/tasks/templates');
if (response.ok) {
const data = await response.json();
setTaskTemplates(data);
}
} catch (error) {
console.error('Error fetching templates:', error);
}
};
fetchTemplates();
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
alert("Nazwa zestawu jest wymagana");
return;
}
if (formData.selectedTemplates.length === 0) {
alert("Wybierz przynajmniej jeden szablon zadania");
return;
}
setIsSubmitting(true);
try {
// Create the task set
const createResponse = await fetch('/api/task-sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name.trim(),
description: formData.description.trim(),
task_category: formData.task_category
})
});
if (!createResponse.ok) {
throw new Error('Failed to create task set');
}
const { id: setId } = await createResponse.json();
// Add templates to the set
const templatesData = {
templates: formData.selectedTemplates.map((templateId, index) => ({
task_id: templateId,
sort_order: index
}))
};
const updateResponse = await fetch(`/api/task-sets/${setId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templatesData)
});
if (!updateResponse.ok) {
throw new Error('Failed to add templates to task set');
}
router.push('/task-sets');
} catch (error) {
console.error('Error creating task set:', error);
alert('Wystąpił błąd podczas tworzenia zestawu zadań');
} finally {
setIsSubmitting(false);
}
};
const toggleTemplate = (templateId) => {
setFormData(prev => ({
...prev,
selectedTemplates: prev.selectedTemplates.includes(templateId)
? prev.selectedTemplates.filter(id => id !== templateId)
: [...prev.selectedTemplates, templateId]
}));
};
const moveTemplate = (fromIndex, toIndex) => {
const newSelected = [...formData.selectedTemplates];
const [moved] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, moved);
setFormData(prev => ({
...prev,
selectedTemplates: newSelected
}));
};
return (
<PageContainer>
<PageHeader
title="Nowy zestaw zadań"
description="Utwórz nowy zestaw zadań z szablonów"
/>
<form onSubmit={handleSubmit} className="max-w-4xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic info */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nazwa zestawu *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="np. Standardowe zadania projektowe"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opis
</label>
<Input
type="text"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Opcjonalny opis zestawu"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategoria zadań *
</label>
<select
value={formData.task_category}
onChange={(e) => setFormData(prev => ({ ...prev, task_category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="design">Zadania projektowe</option>
<option value="construction">Zadania budowlane</option>
</select>
</div>
</CardContent>
</Card>
{/* Template selection */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybrano: {formData.selectedTemplates.length} szablonów
</p>
</CardHeader>
<CardContent>
{formData.selectedTemplates.length > 0 ? (
<div className="space-y-2">
{formData.selectedTemplates.map((templateId, index) => {
const template = taskTemplates.find(t => t.task_id === templateId);
return (
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
</div>
<div className="flex space-x-1">
<button
type="button"
onClick={() => index > 0 && moveTemplate(index, index - 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === 0}
>
</button>
<button
type="button"
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === formData.selectedTemplates.length - 1}
>
</button>
<button
type="button"
onClick={() => toggleTemplate(templateId)}
className="text-red-400 hover:text-red-600"
>
×
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
)}
</CardContent>
</Card>
</div>
{/* Available templates */}
<Card className="mt-6">
<CardHeader>
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybierz szablony, które chcesz dodać do zestawu
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{taskTemplates.map((template) => (
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.selectedTemplates.includes(template.task_id)}
onChange={() => toggleTemplate(template.task_id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
{template.description && (
<div className="text-xs text-gray-600">{template.description}</div>
)}
</div>
</label>
))}
</div>
{taskTemplates.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
</p>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-4 mt-6">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
>
{isSubmitting ? "Tworzenie..." : "Utwórz zestaw"}
</Button>
</div>
</form>
</PageContainer>
);
}

189
src/app/task-sets/page.js Normal file
View File

@@ -0,0 +1,189 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function TaskSetsPage() {
const { t } = useTranslation();
const [taskSets, setTaskSets] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
useEffect(() => {
const fetchTaskSets = async () => {
try {
const response = await fetch('/api/task-sets');
if (response.ok) {
const data = await response.json();
setTaskSets(data);
} else {
console.error('Failed to fetch task sets');
}
} catch (error) {
console.error('Error fetching task sets:', error);
} finally {
setLoading(false);
}
};
fetchTaskSets();
}, []);
const filteredTaskSets = taskSets.filter(taskSet => {
if (filter === "all") return true;
return taskSet.task_category === filter;
});
const getTaskCategoryBadge = (taskCategory) => {
const colors = {
design: "bg-blue-100 text-blue-800",
construction: "bg-green-100 text-green-800"
};
return (
<Badge className={colors[taskCategory] || "bg-gray-100 text-gray-800"}>
{taskCategory === "design" ? "Zadania projektowe" : taskCategory === "construction" ? "Zadania budowlane" : taskCategory}
</Badge>
);
};
if (loading) {
return (
<PageContainer>
<PageHeader
title="Zestawy zadań"
description="Zarządzaj zestawami zadań"
/>
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Zestawy zadań"
description="Zarządzaj zestawami zadań"
action={
<Link href="/task-sets/new">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Nowy zestaw
</Button>
</Link>
}
/>
{/* Filter buttons */}
<div className="mb-6">
<div className="flex space-x-2">
{["all", "design", "construction"].map(type => (
<Button
key={type}
variant={filter === type ? "primary" : "secondary"}
size="sm"
onClick={() => setFilter(type)}
>
{type === "all" ? "Wszystkie" :
type === "design" ? "Projektowanie" :
"Budowa"}
</Button>
))}
</div>
</div>
{/* Task sets grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTaskSets.map((taskSet) => (
<Card key={taskSet.set_id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{taskSet.name}
</h3>
{taskSet.description && (
<p className="text-sm text-gray-600 mt-1">
{taskSet.description}
</p>
)}
</div>
{getTaskCategoryBadge(taskSet.task_category)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="text-sm text-gray-600">
<span className="font-medium">Szablony zadań:</span>{" "}
{taskSet.templates?.length || 0}
</div>
{taskSet.templates && taskSet.templates.length > 0 && (
<div className="text-xs text-gray-500">
<ul className="list-disc list-inside space-y-1">
{taskSet.templates.slice(0, 3).map((template) => (
<li key={template.task_id}>{template.name}</li>
))}
{taskSet.templates.length > 3 && (
<li>...i {taskSet.templates.length - 3} więcej</li>
)}
</ul>
</div>
)}
<div className="flex space-x-2 pt-2">
<Link href={`/task-sets/${taskSet.set_id}`}>
<Button variant="secondary" size="sm">
Edytuj
</Button>
</Link>
<Link href={`/task-sets/${taskSet.set_id}/apply`}>
<Button variant="primary" size="sm">
Zastosuj
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{filteredTaskSets.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500 mb-4">
{filter === "all"
? "Brak zestawów zadań"
: `Brak zestawów zadań dla typu "${filter}"`
}
</div>
<Link href="/task-sets/new">
<Button variant="primary">
Utwórz pierwszy zestaw
</Button>
</Link>
</div>
)}
</PageContainer>
);
}

View File

@@ -8,14 +8,20 @@ import Badge from "@/components/ui/Badge";
import TaskStatusDropdownSimple from "@/components/TaskStatusDropdownSimple";
import { Input } from "@/components/ui/Input";
import { formatDistanceToNow, parseISO } from "date-fns";
import { pl, enUS } from "date-fns/locale";
import { formatDate } from "@/lib/utils";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import SearchBar from "@/components/ui/SearchBar";
import FilterBar from "@/components/ui/FilterBar";
import { LoadingState } from "@/components/ui/States";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTasksPage() {
const { t, language } = useTranslation();
// Get locale for date-fns
const locale = language === 'pl' ? pl : enUS;
const [allTasks, setAllTasks] = useState([]);
const [filteredTasks, setFilteredTasks] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
@@ -148,25 +154,25 @@ export default function ProjectTasksPage() {
const filterOptions = [
{
label: "Status",
label: t('tasks.status'),
value: statusFilter,
onChange: (e) => setStatusFilter(e.target.value),
options: [
{ value: "all", label: "All" },
{ value: "pending", label: "Pending" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "all", label: t('common.all') },
{ value: "pending", label: t('taskStatus.pending') },
{ value: "in_progress", label: t('taskStatus.in_progress') },
{ value: "completed", label: t('taskStatus.completed') },
],
},
{
label: "Priority",
label: t('tasks.priority'),
value: priorityFilter,
onChange: (e) => setPriorityFilter(e.target.value),
options: [
{ value: "all", label: "All" },
{ value: "high", label: "High" },
{ value: "normal", label: "Normal" },
{ value: "low", label: "Low" },
{ value: "all", label: t('common.all') },
{ value: "high", label: t('tasks.high') },
{ value: "normal", label: t('tasks.medium') },
{ value: "low", label: t('tasks.low') },
],
},
];
@@ -174,8 +180,8 @@ export default function ProjectTasksPage() {
return (
<PageContainer>
<PageHeader
title="Project Tasks"
description="Monitor and manage tasks across all projects"
title={t('tasks.title')}
description={t('tasks.subtitle')}
/>
<SearchBar
searchTerm={searchTerm}
@@ -206,7 +212,7 @@ export default function ProjectTasksPage() {
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Tasks</p>
<p className="text-sm font-medium text-gray-600">{t('dashboard.totalTasks')}</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.all}
</p>
@@ -233,7 +239,7 @@ export default function ProjectTasksPage() {
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Pending</p>
<p className="text-sm font-medium text-gray-600">{t('taskStatus.pending')}</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.pending}
</p>
@@ -260,7 +266,7 @@ export default function ProjectTasksPage() {
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">In Progress</p>
<p className="text-sm font-medium text-gray-600">{t('taskStatus.in_progress')}</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.in_progress}
</p>
@@ -287,7 +293,7 @@ export default function ProjectTasksPage() {
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Completed</p>
<p className="text-sm font-medium text-gray-600">{t('taskStatus.completed')}</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.completed}
</p>
@@ -379,6 +385,7 @@ export default function ProjectTasksPage() {
Added{" "}
{formatDistanceToNow(parseISO(task.date_added), {
addSuffix: true,
locale: locale
})}
</span>
{task.max_wait_days > 0 && (

View File

@@ -1,9 +1,6 @@
import db from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import TaskTemplateForm from "@/components/TaskTemplateForm";
import EditTaskTemplateClient from "@/components/EditTaskTemplateClient";
export default async function EditTaskTemplatePage({ params }) {
const { id } = await params;
@@ -16,52 +13,5 @@ export default async function EditTaskTemplatePage({ params }) {
notFound();
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
<div className="flex items-center gap-4 mb-8">
<Link href="/tasks/templates">
<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>
Back to Templates
</Button>
</Link>{" "}
<div>
<h1 className="text-3xl font-bold text-gray-900">
Edit Task Template
</h1>
<p className="text-gray-600 mt-1">
Update the details for &ldquo;{template.name}&rdquo;
</p>
</div>
</div>
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Template Details
</h2>
<p className="text-gray-600">
Modify the template information below
</p>
</CardHeader>
<CardContent>
<TaskTemplateForm templateId={params.id} initialData={template} />
</CardContent>
</Card>
</div>
</div>
);
return <EditTaskTemplateClient template={template} />;
}

View File

@@ -1,9 +1,13 @@
"use client";
import TaskTemplateForm from "@/components/TaskTemplateForm";
import { useTranslation } from "@/lib/i18n";
export default function NewTaskTemplatePage() {
const { t } = useTranslation();
return (
<div className="p-4 max-w-xl mx-auto">
<h1 className="text-xl font-bold mb-4">New Task Template</h1>
<h1 className="text-xl font-bold mb-4">{t('taskTemplates.newTemplate')}</h1>
<TaskTemplateForm />
</div>
);

View File

@@ -1,26 +1,111 @@
import db from "@/lib/db";
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function TaskTemplatesPage() {
const templates = db
.prepare(
`
SELECT * FROM tasks WHERE is_standard = 1 ORDER BY name ASC
`
)
.all();
const { t } = useTranslation();
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchTemplates = async () => {
try {
const response = await fetch('/api/tasks/templates');
if (response.ok) {
const data = await response.json();
setTemplates(data);
} else {
console.error('Failed to fetch templates');
}
} catch (error) {
console.error('Error fetching templates:', error);
} finally {
setLoading(false);
}
};
fetchTemplates();
}, []);
const getTaskCategoryBadge = (taskCategory) => {
const colors = {
design: "bg-blue-100 text-blue-800",
construction: "bg-green-100 text-green-800"
};
return (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${colors[taskCategory] || "bg-gray-100 text-gray-800"}`}>
{taskCategory === "design" ? "Projektowe" : taskCategory === "construction" ? "Budowlane" : taskCategory}
</span>
);
};
if (loading) {
return (
<PageContainer>
<PageHeader
title={t('taskTemplates.title')}
description={t('taskTemplates.subtitle')}
actions={[
<Link href="/tasks/templates/new" key="new-template">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
{t('taskTemplates.newTemplate')}
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
]}
/>
<div className="text-center py-12">
<div className="text-gray-500">{t('common.loading')}</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Task Templates"
description="Manage reusable task templates"
action={
<Link href="/tasks/templates/new">
title={t('taskTemplates.title')}
description={t('taskTemplates.subtitle')}
actions={[
<Link href="/tasks/templates/new" key="new-template">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
@@ -35,10 +120,28 @@ export default function TaskTemplatesPage() {
d="M12 4v16m8-8H4"
/>
</svg>
New Template
{t('taskTemplates.newTemplate')}
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
}
]}
/>
{templates.length === 0 ? (
@@ -58,13 +161,13 @@ export default function TaskTemplatesPage() {
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No task templates yet
{t('taskTemplates.noTemplates')}
</h3>
<p className="text-gray-500 mb-6">
Create reusable task templates to streamline your workflow
{t('taskTemplates.noTemplatesMessage')}
</p>
<Link href="/tasks/templates/new">
<Button variant="primary">Create First Template</Button>
<Button variant="primary">{t('taskTemplates.newTemplate')}</Button>
</Link>
</CardContent>
</Card>
@@ -80,29 +183,24 @@ export default function TaskTemplatesPage() {
<h3 className="text-lg font-semibold text-gray-900 truncate pr-2">
{template.name}
</h3>
<Badge variant="primary" size="sm">
{template.max_wait_days} days
</Badge>
<div className="flex flex-col gap-1">
<Badge variant="primary" size="sm">
{template.max_wait_days} {t('common.days')}
</Badge>
{getTaskCategoryBadge(template.task_category)}
</div>
</div>
{template.description && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{template.description}
</p>
)}{" "}
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
Template ID: {template.task_id}
</span>
<div className="flex space-x-2">
<Link href={`/tasks/templates/${template.task_id}/edit`}>
<Button variant="outline" size="sm">
Edit
</Button>
</Link>
<Button variant="secondary" size="sm">
Duplicate
<div className="flex items-center justify-end">
<Link href={`/tasks/templates/${template.task_id}/edit`}>
<Button variant="outline" size="sm">
{t('taskTemplates.editTemplate')}
</Button>
</div>
</Link>
</div>
</CardContent>
</Card>

View File

@@ -132,7 +132,17 @@ export default function AuditLogViewer() {
const formatTimestamp = (timestamp) => {
try {
return format(new Date(timestamp), "yyyy-MM-dd HH:mm:ss");
const date = new Date(timestamp);
// Format in Polish timezone
return date.toLocaleString("pl-PL", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return timestamp;
}
@@ -280,7 +290,7 @@ export default function AuditLogViewer() {
</div>
{/* Statistics */}
{stats && (
{stats && stats.total > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Total Events</h3>
@@ -289,22 +299,22 @@ export default function AuditLogViewer() {
<div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Top Action</h3>
<p className="text-sm font-medium">
{stats.actionBreakdown[0]?.action || "N/A"}
{stats.actionBreakdown && stats.actionBreakdown[0]?.action || "N/A"}
</p>
<p className="text-lg font-bold text-green-600">
{stats.actionBreakdown[0]?.count || 0}
{stats.actionBreakdown && stats.actionBreakdown[0]?.count || 0}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Active Users</h3>
<p className="text-2xl font-bold text-purple-600">
{stats.userBreakdown.length}
{stats.userBreakdown ? stats.userBreakdown.length : 0}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Resource Types</h3>
<p className="text-2xl font-bold text-orange-600">
{stats.resourceBreakdown.length}
{stats.resourceBreakdown ? stats.resourceBreakdown.length : 0}
</p>
</div>
</div>
@@ -318,43 +328,43 @@ export default function AuditLogViewer() {
)}
{/* Audit Logs Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Timestamp
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Action
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Resource
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
IP Address
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Details
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatTimestamp(log.timestamp)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div className="font-medium">
{log.user_name || "Anonymous"}
</div>
<div className="text-gray-500">{log.user_email}</div>
<div className="text-gray-500 dark:text-gray-400">{log.user_email}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
@@ -364,26 +374,26 @@ export default function AuditLogViewer() {
{log.action.replace(/_/g, " ").toUpperCase()}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div className="font-medium">
{log.resource_type || "N/A"}
</div>
<div className="text-gray-500">
<div className="text-gray-500 dark:text-gray-400">
ID: {log.resource_id || "N/A"}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.ip_address || "Unknown"}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{log.details && (
<details className="cursor-pointer">
<summary className="text-blue-600 hover:text-blue-800">
<summary className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
View Details
</summary>
<pre className="mt-2 text-xs bg-gray-100 p-2 rounded overflow-auto max-w-md">
<pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded overflow-auto max-w-md">
{JSON.stringify(log.details, null, 2)}
</pre>
</details>
@@ -396,7 +406,7 @@ export default function AuditLogViewer() {
</div>
{logs.length === 0 && !loading && (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No audit logs found matching your criteria.
</div>
)}

View File

@@ -6,8 +6,10 @@ import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { formatDateForInput } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ContractForm() {
const { t } = useTranslation();
const [form, setForm] = useState({
contract_number: "",
contract_name: "",
@@ -42,13 +44,11 @@ export default function ContractForm() {
const contract = await res.json();
router.push(`/contracts/${contract.contract_id}`);
} else {
alert(
"Failed to create contract. Please check the data and try again."
);
alert(t('contracts.failedToCreateContract'));
}
} catch (error) {
console.error("Error creating contract:", error);
alert("Failed to create contract. Please try again.");
alert(t('contracts.failedToCreateContract'));
} finally {
setLoading(false);
}
@@ -58,7 +58,7 @@ export default function ContractForm() {
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Contract Details
{t('contracts.contractDetails')}
</h2>
</CardHeader>
<CardContent>
@@ -67,73 +67,73 @@ export default function ContractForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contract Number <span className="text-red-500">*</span>
{t('contracts.contractNumber')} <span className="text-red-500">*</span>
</label>
<Input
type="text"
name="contract_number"
value={form.contract_number || ""}
onChange={handleChange}
placeholder="Enter contract number"
placeholder={t('contracts.enterContractNumber')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contract Name
{t('contracts.contractName')}
</label>
<Input
type="text"
name="contract_name"
value={form.contract_name || ""}
onChange={handleChange}
placeholder="Enter contract name"
placeholder={t('contracts.enterContractName')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Customer Contract Number
{t('contracts.customerContractNumber')}
</label>
<Input
type="text"
name="customer_contract_number"
value={form.customer_contract_number || ""}
onChange={handleChange}
placeholder="Enter customer contract number"
placeholder={t('contracts.enterCustomerContractNumber')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Customer
{t('contracts.customer')}
</label>
<Input
type="text"
name="customer"
value={form.customer || ""}
onChange={handleChange}
placeholder="Enter customer name"
placeholder={t('contracts.enterCustomerName')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Investor
{t('contracts.investor')}
</label>
<Input
type="text"
name="investor"
value={form.investor || ""}
onChange={handleChange}
placeholder="Enter investor name"
placeholder={t('contracts.enterInvestorName')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date Signed
{t('contracts.dateSigned')}
</label>{" "}
<Input
type="date"
@@ -145,7 +145,7 @@ export default function ContractForm() {
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Finish Date
{t('contracts.finishDate')}
</label>{" "}
<Input
type="date"
@@ -164,7 +164,7 @@ export default function ContractForm() {
onClick={() => router.back()}
disabled={loading}
>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" variant="primary" disabled={loading}>
{loading ? (
@@ -189,7 +189,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>
Creating...
{t('common.creating')}
</>
) : (
<>
@@ -206,7 +206,7 @@ export default function ContractForm() {
d="M12 4v16m8-8H4"
/>
</svg>
Create Contract
{t('contracts.createContract')}
</>
)}
</Button>

View File

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import TaskTemplateForm from "@/components/TaskTemplateForm";
import { useTranslation } from "@/lib/i18n";
export default function EditTaskTemplateClient({ template }) {
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
<div className="flex items-center gap-4 mb-8">
<Link href="/tasks/templates">
<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('taskTemplates.title')}
</Button>
</Link>{" "}
<div>
<h1 className="text-3xl font-bold text-gray-900">
{t('taskTemplates.editTemplate')}
</h1>
<p className="text-gray-600 mt-1">
{t('taskTemplates.subtitle')} &ldquo;{template.name}&rdquo;
</p>
</div>
</div>
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
{t('taskTemplates.templateName')}
</h2>
<p className="text-gray-600">
{t('taskTemplates.subtitle')}
</p>
</CardHeader>
<CardContent>
<TaskTemplateForm templateId={template.task_id} initialData={template} />
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { useState, useEffect } from "react";
import Tooltip from "@/components/ui/Tooltip";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { formatDistanceToNow } from "date-fns";
import { pl, enUS } from "date-fns/locale";
export default function FieldWithHistory({
tableName,
recordId,
fieldName,
currentValue,
displayValue = null,
label = null,
className = "",
}) {
const { t, language } = useTranslation();
const [hasHistory, setHasHistory] = useState(false);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
// Get locale for date-fns
const locale = language === 'pl' ? pl : enUS;
useEffect(() => {
const fetchHistory = async () => {
try {
const response = await fetch(
`/api/field-history?table_name=${tableName}&record_id=${recordId}&field_name=${fieldName}`
);
if (response.ok) {
const historyData = await response.json();
setHistory(historyData);
setHasHistory(historyData.length > 0);
}
} catch (error) {
console.error("Failed to fetch field history:", error);
} finally {
setLoading(false);
}
};
if (tableName && recordId && fieldName) {
fetchHistory();
} else {
setLoading(false);
}
}, [tableName, recordId, fieldName]);
// Format value for display
const getDisplayValue = (value) => {
if (displayValue !== null) return displayValue;
if (value && fieldName.includes("date")) {
try {
return formatDate(value);
} catch {
return value;
}
}
return value || t("common.na");
};
// Create tooltip content
const tooltipContent = history.length > 0 && (
<div className="space-y-2 max-w-sm">
<div className="flex items-center gap-2 font-semibold text-white mb-3 pb-2 border-b border-gray-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t("common.changeHistory")} ({history.length})
</div>
{history.map((change, index) => (
<div
key={change.id}
className="text-xs border-b border-gray-700 pb-2 last:border-b-0 hover:bg-gray-800/30 p-2 rounded transition-colors"
>
<div className="flex justify-between items-start gap-3 mb-2">
<div className="flex-1">
{/* New Value */}
<div className="flex items-start gap-2 mb-1">
<svg className="w-3.5 h-3.5 text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<div>
<span className="text-green-400 font-medium text-[10px] uppercase tracking-wide block mb-0.5">
{t("common.changedTo")}
</span>
<span className="text-white font-semibold">
{getDisplayValue(change.new_value)}
</span>
</div>
</div>
{/* Old Value */}
{change.old_value && (
<div className="flex items-start gap-2 mb-1">
<svg className="w-3.5 h-3.5 text-red-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<div>
<span className="text-red-400 font-medium text-[10px] uppercase tracking-wide block mb-0.5">
{t("common.from")}
</span>
<span className="text-gray-300 line-through">
{getDisplayValue(change.old_value)}
</span>
</div>
</div>
)}
{/* Changed By */}
{change.changed_by_name && (
<div className="flex items-center gap-1.5 mt-2 text-gray-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="text-xs">
{t("common.by")} <span className="text-gray-200 font-medium">{change.changed_by_name}</span>
</span>
</div>
)}
</div>
{/* Timestamp */}
<div className="text-right flex-shrink-0">
<div className="text-gray-400 text-[10px] whitespace-nowrap">
{formatDistanceToNow(new Date(change.changed_at), {
addSuffix: true,
locale: locale
})}
</div>
<div className="text-gray-500 text-[9px] mt-0.5">
{formatDate(change.changed_at)}
</div>
</div>
</div>
{/* Change Reason */}
{change.change_reason && (
<div className="mt-2 pt-2 border-t border-gray-700">
<div className="flex items-start gap-1.5">
<svg className="w-3 h-3 text-amber-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span className="text-amber-400 font-medium text-[10px] uppercase tracking-wide block mb-0.5">
{t("common.reason")}
</span>
<span className="text-gray-300 text-xs italic">
{change.change_reason}
</span>
</div>
</div>
</div>
)}
</div>
))}
</div>
);
if (loading) {
return (
<div className={className}>
{label && <span className="text-sm font-medium text-gray-500 block mb-1">{label}</span>}
<p className="text-gray-900 font-medium">{getDisplayValue(currentValue)}</p>
</div>
);
}
return (
<div className={className}>
{label && <span className="text-sm font-medium text-gray-500 block mb-1">{label}</span>}
<div className="flex items-center gap-2">
<p className="text-gray-900 font-medium">{getDisplayValue(currentValue)}</p>
{hasHistory && (
<Tooltip content={tooltipContent}>
<div className="relative group cursor-help">
{/* History Icon with Badge */}
<div className="flex items-center gap-1 px-2 py-1 rounded-full bg-blue-50 hover:bg-blue-100 transition-colors border border-blue-200">
<svg
className="w-3.5 h-3.5 text-blue-600 group-hover:text-blue-700 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-[10px] font-semibold text-blue-600 group-hover:text-blue-700 transition-colors">
{history.length}
</span>
</div>
</div>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useState, useEffect } from "react";
import Button from "@/components/ui/Button";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function FileAttachmentsList({ entityType, entityId, onFilesChange }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const { t } = useTranslation();
const fetchFiles = async () => {
try {
const response = await fetch(`/api/files?entityType=${entityType}&entityId=${entityId}`);
if (response.ok) {
const filesData = await response.json();
setFiles(filesData);
if (onFilesChange) {
onFilesChange(filesData);
}
}
} catch (error) {
console.error("Error fetching files:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFiles();
}, [entityType, entityId]);
const handleDelete = async (fileId) => {
if (!confirm(t('contracts.confirmDeleteFile'))) {
return;
}
try {
const response = await fetch(`/api/files/${fileId}`, {
method: "DELETE",
});
if (response.ok) {
setFiles(files.filter(file => file.file_id !== fileId));
if (onFilesChange) {
onFilesChange(files.filter(file => file.file_id !== fileId));
}
} else {
alert(t('contracts.failedToDeleteFile'));
}
} catch (error) {
console.error("Error deleting file:", error);
alert(t('contracts.failedToDeleteFile'));
}
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (mimeType) => {
if (mimeType.startsWith('image/')) {
return (
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
);
} else if (mimeType === 'application/pdf') {
return (
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
} else if (mimeType.includes('word') || mimeType.includes('document')) {
return (
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
} else if (mimeType.includes('excel') || mimeType.includes('sheet')) {
return (
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
} else {
return (
<svg className="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<svg className="animate-spin h-6 w-6 text-gray-400" 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>
<span className="ml-2 text-gray-500">{t('contracts.loadingFiles')}</span>
</div>
);
}
if (files.length === 0) {
return (
<div className="text-center py-8">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-gray-500">{t('contracts.noDocumentsUploaded')}</p>
</div>
);
}
return (
<div className="space-y-3">
{files.map((file) => (
<div
key={file.file_id}
className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div className="flex items-center flex-1 min-w-0">
<div className="flex-shrink-0 mr-3">
{getFileIcon(file.mime_type)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">
{file.original_filename}
</div>
<div className="text-xs text-gray-500 flex items-center gap-2">
<span>{formatFileSize(file.file_size)}</span>
<span></span>
<span>{formatDate(file.upload_date, { includeTime: true })}</span>
</div>
{file.description && (
<div className="text-xs text-gray-600 mt-1 truncate">
{file.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-3">
<a
href={`/api/files/${file.file_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
<Button variant="outline" size="sm">
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('contracts.download')}
</Button>
</a>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(file.file_id)}
className="text-red-600 hover:text-red-800 hover:border-red-300"
>
<svg className="w-4 h-4" 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>
</Button>
</div>
</div>
))}
</div>
);
}

181
src/components/FileItem.js Normal file
View File

@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import Button from "@/components/ui/Button";
export default function FileItem({ file, onDelete, onUpdate }) {
const [isEditing, setIsEditing] = useState(false);
const [editFilename, setEditFilename] = useState(file.original_filename);
const [editDescription, setEditDescription] = useState(file.description || "");
const handleSave = async () => {
try {
const res = await fetch(`/api/files/${file.file_id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
original_filename: editFilename,
description: editDescription,
}),
});
if (res.ok) {
const updatedFile = await res.json();
onUpdate(updatedFile);
setIsEditing(false);
} else {
alert('Błąd podczas aktualizacji pliku');
}
} catch (error) {
console.error('Error updating file:', error);
alert('Błąd podczas aktualizacji pliku');
}
};
const handleCancel = () => {
setEditFilename(file.original_filename);
setEditDescription(file.description || "");
setIsEditing(false);
};
const formatFileSize = (bytes) => {
if (!bytes) return '';
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (filename) => {
const ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) {
return (
<svg className="w-4 h-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
);
} else if (ext === 'pdf') {
return (
<svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
} else if (['doc', 'docx'].includes(ext)) {
return (
<svg className="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
} else if (['xls', 'xlsx'].includes(ext)) {
return (
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
} else {
return (
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
</svg>
);
}
};
if (isEditing) {
return (
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Nazwa pliku
</label>
<input
type="text"
value={editFilename}
onChange={(e) => setEditFilename(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Opis (opcjonalny)
</label>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="Dodaj opis pliku..."
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave}>
Zapisz
</Button>
<Button size="sm" variant="outline" onClick={handleCancel}>
Anuluj
</Button>
</div>
</div>
</div>
);
}
return (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors group">
<div className="flex items-center flex-1 min-w-0">
<div className="flex-shrink-0 mr-3">
{getFileIcon(file.original_filename)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">
{file.original_filename}
</div>
<div className="text-xs text-gray-500 flex items-center gap-2">
<span>{formatFileSize(file.file_size)}</span>
<span></span>
<span>{new Date(file.upload_date).toLocaleDateString('pl-PL')}</span>
</div>
{file.description && (
<div className="text-xs text-gray-600 mt-1 truncate">
{file.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-3">
<a
href={`/api/files/${file.file_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 p-1 rounded"
title="Pobierz"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
<button
onClick={() => setIsEditing(true)}
className="text-gray-600 hover:text-gray-800 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
title="Edytuj"
>
<svg className="w-4 h-4" 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>
</button>
<button
onClick={() => onDelete(file.file_id)}
className="text-red-600 hover:text-red-800 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
title="Usuń"
>
<svg className="w-4 h-4" 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>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useState, useRef, useCallback } from "react";
import Button from "@/components/ui/Button";
export default function FileUploadBox({ projectId, onFileUploaded }) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef(null);
const acceptedTypes = ".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt,.dwg,.zip";
const uploadFiles = async (files) => {
const validFiles = Array.from(files).filter(file => {
const isValidType = acceptedTypes.split(',').some(type =>
file.name.toLowerCase().endsWith(type.replace('*', ''))
);
const isValidSize = file.size <= 10 * 1024 * 1024; // 10MB limit
return isValidType && isValidSize;
});
if (validFiles.length === 0) {
alert('No valid files selected (invalid type or size > 10MB)');
return;
}
if (validFiles.length !== files.length) {
alert('Some files were skipped due to invalid type or size (max 10MB)');
}
setUploading(true);
const uploadPromises = validFiles.map(async (file) => {
const formData = new FormData();
formData.append("file", file);
formData.append("entityType", "project");
formData.append("entityId", projectId.toString());
try {
const response = await fetch("/api/files", {
method: "POST",
body: formData,
});
if (response.ok) {
const uploadedFile = await response.json();
onFileUploaded?.(uploadedFile);
return { success: true };
} else {
const error = await response.json();
return { success: false, error: error.error || "Upload failed" };
}
} catch (error) {
return { success: false, error: "Network error" };
}
});
const results = await Promise.all(uploadPromises);
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
alert(`Failed to upload ${failed.length} file(s)`);
}
setUploading(false);
};
const handleInputChange = (e) => {
if (e.target.files.length > 0) {
uploadFiles(e.target.files);
}
e.target.value = ''; // Reset input
};
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files.length > 0) {
uploadFiles(e.dataTransfer.files);
}
}, []);
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors cursor-pointer">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleInputChange}
accept={acceptedTypes}
disabled={uploading}
/>
{uploading ? (
<div className="flex flex-col items-center">
<svg className="animate-spin h-6 w-6 text-blue-600 mb-2" 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>
<span className="text-xs text-gray-600">Przesyłanie plików...</span>
</div>
) : (
<div
className={`flex flex-col items-center ${dragOver ? 'scale-105' : ''} transition-transform`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
>
<svg className="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="text-sm font-medium text-gray-900 mb-1">
{dragOver ? 'Upuść pliki tutaj' : 'Przeciągnij pliki lub kliknij'}
</span>
<span className="text-xs text-gray-500">
PDF, DOC, XLS, obrazki, DWG, ZIP
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useState, useRef } from "react";
import Button from "@/components/ui/Button";
import { useTranslation } from "@/lib/i18n";
export default function FileUploadModal({
isOpen,
onClose,
entityType,
entityId,
onFileUploaded
}) {
const [dragActive, setDragActive] = useState(false);
const [uploading, setUploading] = useState(false);
const [description, setDescription] = useState("");
const fileInputRef = useRef(null);
const { t } = useTranslation();
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFiles(e.dataTransfer.files);
}
};
const handleChange = (e) => {
e.preventDefault();
if (e.target.files && e.target.files[0]) {
handleFiles(e.target.files);
}
};
const handleFiles = async (files) => {
const file = files[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("entityType", entityType);
formData.append("entityId", entityId.toString());
formData.append("description", description);
const response = await fetch("/api/files", {
method: "POST",
body: formData,
});
if (response.ok) {
const uploadedFile = await response.json();
onFileUploaded(uploadedFile);
setDescription("");
onClose();
} else {
const error = await response.json();
alert(error.error || t('contracts.failedToUploadFile'));
}
} catch (error) {
console.error("Upload error:", error);
alert(t('contracts.failedToUploadFile'));
} finally {
setUploading(false);
}
};
const onButtonClick = () => {
fileInputRef.current?.click();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-surface-modal rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-text-primary">
{t('contracts.uploadDocumentTitle')}
</h3>
<button
onClick={onClose}
className="text-text-tertiary hover:text-text-secondary"
disabled={uploading}
>
<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="space-y-4">
{/* Description Input */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
{t('contracts.descriptionOptional')}
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('contracts.descriptionPlaceholder')}
className="w-full px-3 py-2 border border-border-default rounded-md shadow-sm bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-interactive-primary focus:border-border-focus"
disabled={uploading}
/>
</div>
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive
? "border-interactive-primary bg-surface-hover"
: "border-border-default hover:border-border-hover"
} ${uploading ? "opacity-50 pointer-events-none" : ""}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleChange}
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt"
disabled={uploading}
/>
{uploading ? (
<div className="flex flex-col items-center">
<svg className="animate-spin h-8 w-8 text-blue-600 mb-2" 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>
<span className="text-sm text-text-secondary">{t('contracts.uploading')}</span>
</div>
) : (
<div className="flex flex-col items-center">
<svg className="w-12 h-12 text-text-tertiary mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="text-sm font-medium text-text-primary mb-2">
{t('contracts.dropFilesHere')}
</span>
<span className="text-xs text-text-secondary mb-4">
{t('contracts.supportedFiles')}
</span>
<Button
type="button"
variant="outline"
onClick={onButtonClick}
disabled={uploading}
>
{t('contracts.chooseFile')}
</Button>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={uploading}
>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState, useEffect } from "react";
import Tooltip from "@/components/ui/Tooltip";
import { formatDate } from "@/lib/utils";
export default function FinishDateWithHistory({ projectId, finishDate }) {
const [hasUpdates, setHasUpdates] = useState(false);
const [updates, setUpdates] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchUpdates = async () => {
try {
const res = await fetch(`/api/projects/${projectId}/finish-date-updates`);
if (res.ok) {
const data = await res.json();
setUpdates(data);
setHasUpdates(data.length > 0);
}
} catch (error) {
console.error("Failed to fetch finish date updates:", error);
} finally {
setLoading(false);
}
};
if (projectId) {
fetchUpdates();
}
}, [projectId]);
const formatDateTime = (dateString) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("pl-PL", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch (error) {
return dateString;
}
};
const tooltipContent = (
<div className="space-y-2">
<div className="font-medium text-xs text-gray-300 mb-2">
Historia zmian terminu:
</div>
{updates.map((update, index) => (
<div key={update.id} className="text-xs">
<div className="flex justify-between items-start gap-2">
<div>
<div className="font-medium">
{update.new_finish_date
? formatDate(update.new_finish_date)
: "Usunięto termin"}
</div>
{update.old_finish_date && (
<div className="text-gray-400 text-xs">
poprzednio: {formatDate(update.old_finish_date)}
</div>
)}
</div>
</div>
<div className="text-gray-400 text-xs mt-1">
{update.updated_by_name && (
<span>przez {update.updated_by_name} </span>
)}
<span>{formatDateTime(update.updated_at)}</span>
</div>
{update.reason && (
<div className="text-gray-300 text-xs mt-1 italic">
"{update.reason}"
</div>
)}
{index < updates.length - 1 && (
<div className="border-b border-gray-600 my-2"></div>
)}
</div>
))}
</div>
);
if (loading) {
return (
<p className="text-gray-900 font-medium">
{finishDate ? formatDate(finishDate) : "N/A"}
</p>
);
}
return (
<div className="flex items-center gap-1">
<p className="text-gray-900 font-medium">
{finishDate ? formatDate(finishDate) : "N/A"}
</p>
{hasUpdates && (
<Tooltip content={tooltipContent} position="top">
<svg
className="w-4 h-4 text-blue-500 cursor-help hover:text-blue-600 transition-colors"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</Tooltip>
)}
</div>
);
}

View File

@@ -1,10 +1,56 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect, useRef } from "react";
import { useTranslation } from "@/lib/i18n";
export default function NoteForm({ projectId }) {
export default function NoteForm({ projectId, onNoteAdded }) {
const { t } = useTranslation();
const [note, setNote] = useState("");
const [status, setStatus] = useState(null);
const [projects, setProjects] = useState([]);
const [showDropdown, setShowDropdown] = useState(false);
const [filteredProjects, setFilteredProjects] = useState([]);
const [cursorPosition, setCursorPosition] = useState(0);
const [triggerChar, setTriggerChar] = useState(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const textareaRef = useRef(null);
useEffect(() => {
async function fetchProjects() {
try {
const res = await fetch("/api/projects");
if (res.ok) {
const data = await res.json();
setProjects(data);
}
} catch (error) {
console.error("Failed to fetch projects:", error);
}
}
fetchProjects();
}, []);
useEffect(() => {
if (note && cursorPosition > 0) {
const beforeCursor = note.slice(0, cursorPosition);
const match = beforeCursor.match(/([@#])([^@#]*)$/);
if (match) {
const char = match[1];
const query = match[2].toLowerCase();
setTriggerChar(char);
const filtered = projects.filter(project =>
project.project_name.toLowerCase().includes(query)
);
setFilteredProjects(filtered);
setShowDropdown(filtered.length > 0);
setSelectedIndex(0);
} else {
setShowDropdown(false);
}
} else {
setShowDropdown(false);
}
}, [note, cursorPosition, projects]);
async function handleSubmit(e) {
e.preventDefault();
@@ -16,29 +62,107 @@ export default function NoteForm({ projectId }) {
});
if (res.ok) {
const newNote = await res.json();
setNote("");
setStatus("Note added");
window.location.reload();
setStatus(t("common.addNoteSuccess"));
// Call the callback to add the new note to the parent component's state
if (onNoteAdded) {
onNoteAdded(newNote);
}
// Clear status message after 3 seconds
setTimeout(() => setStatus(null), 3000);
} else {
setStatus("Failed to save note");
setStatus(t("common.addNoteError"));
}
}
function handleKeyDown(e) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleSubmit(e);
}
if (showDropdown) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % filteredProjects.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + filteredProjects.length) % filteredProjects.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (filteredProjects.length > 0) {
selectProject(filteredProjects[selectedIndex]);
}
} else if (e.key === 'Escape') {
setShowDropdown(false);
}
}
}
function handleInputChange(e) {
setNote(e.target.value);
setCursorPosition(e.target.selectionStart);
}
function handleInputBlur() {
// Delay hiding dropdown to allow for clicks on dropdown items
setTimeout(() => setShowDropdown(false), 150);
}
function selectProject(project) {
const beforeCursor = note.slice(0, cursorPosition);
const afterCursor = note.slice(cursorPosition);
const match = beforeCursor.match(/([@#])([^@#]*)$/);
if (match) {
const start = match.index;
const end = cursorPosition;
const link = `[${triggerChar}${project.project_name}](/projects/${project.project_id})`;
const newNote = note.slice(0, start) + link + afterCursor;
setNote(newNote);
setShowDropdown(false);
// Set cursor after the inserted link
setTimeout(() => {
textareaRef.current.focus();
textareaRef.current.setSelectionRange(start + link.length, start + link.length);
}, 0);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-2 mb-4">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add a new note..."
className="border p-2 w-full"
rows={3}
required
/>
<form onSubmit={handleSubmit} className="space-y-2 mb-4 relative">
<div className="relative">
<textarea
ref={textareaRef}
value={note}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onClick={(e) => setCursorPosition(e.target.selectionStart)}
onKeyUp={(e) => setCursorPosition(e.target.selectionStart)}
onBlur={handleInputBlur}
placeholder={t("common.addNotePlaceholder")}
className="border p-2 w-full"
rows={3}
required
/>
{showDropdown && (
<div className="absolute top-full left-0 right-0 bg-white border border-gray-300 rounded shadow-lg z-10 max-h-40 overflow-y-auto">
{filteredProjects.map((project, index) => (
<div
key={project.project_id}
className={`p-2 cursor-pointer ${index === selectedIndex ? 'bg-blue-100' : 'hover:bg-gray-100'}`}
onClick={() => selectProject(project)}
>
{triggerChar}{project.project_name}
</div>
))}
</div>
)}
</div>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded"
>
Add Note
{t("common.addNote")}
</button>
{status && <p className="text-sm text-gray-600">{status}</p>}
</form>

View File

@@ -0,0 +1,219 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Badge from "@/components/ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectAssigneeDropdown({
project,
size = "md",
showDropdown = true,
}) {
const { t } = useTranslation();
const [assignee, setAssignee] = useState({
id: project.assigned_to,
name: project.assigned_to_name,
username: project.assigned_to_username,
initial: project.assigned_to_initial,
});
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const buttonRef = useRef(null);
// Update assignee state when project prop changes
useEffect(() => {
setAssignee({
id: project.assigned_to,
name: project.assigned_to_name,
username: project.assigned_to_username,
initial: project.assigned_to_initial,
});
}, [project.assigned_to, project.assigned_to_name, project.assigned_to_username, project.assigned_to_initial]);
// Fetch users for assignment
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await fetch("/api/projects/users");
if (response.ok) {
const userData = await response.json();
setUsers(userData);
}
} catch (error) {
console.error("Failed to fetch users:", error);
}
};
if (isOpen && users.length === 0) {
fetchUsers();
}
}, [isOpen, users.length]);
const handleChange = async (newAssigneeId) => {
if (newAssigneeId === assignee.id) {
setIsOpen(false);
return;
}
const newAssignee = users.find(u => u.id === newAssigneeId) || null;
setAssignee(newAssignee);
setLoading(true);
setIsOpen(false);
try {
const updateData = { ...project, assigned_to: newAssigneeId };
const response = await fetch(`/api/projects/${project.project_id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updateData),
});
if (!response.ok) {
const errorData = await response.json();
console.error('Update failed:', errorData);
}
window.location.reload();
} catch (error) {
console.error("Failed to update assignee:", error);
setAssignee({
id: project.assigned_to,
name: project.assigned_to_name,
username: project.assigned_to_username,
initial: project.assigned_to_initial,
}); // Revert on error
} finally {
setLoading(false);
}
};
const updateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width,
});
}
};
const handleOpen = () => {
setIsOpen(true);
};
useEffect(() => {
if (isOpen) {
const handleResize = () => updateDropdownPosition();
const handleScroll = () => updateDropdownPosition();
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleScroll, true);
updateDropdownPosition();
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleScroll, true);
};
}
}, [isOpen]);
const displayText = loading
? "Updating..."
: assignee?.name
? assignee.name
: isOpen && users.length === 0
? "Loading..."
: t("projects.unassigned");
if (!showDropdown) {
return (
<Badge variant="default" size={size}>
{displayText}
</Badge>
);
}
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => {
setIsOpen(!isOpen);
}}
disabled={loading || (isOpen && users.length === 0)}
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded-md"
>
<Badge
variant="default"
size={size}
className={`cursor-pointer hover:opacity-80 transition-opacity ${
loading || (isOpen && users.length === 0) ? "opacity-50 cursor-not-allowed" : ""
}`}
>
{loading ? "Updating..." : displayText}
<svg
className={`w-3 h-3 ml-1 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Badge>
</button>{" "}
{/* Assignee Options Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-[9999] min-w-[200px] max-h-60 overflow-y-auto">
{/* Unassigned option */}
<button
onClick={() => {
handleChange(null);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700"
>
<span className="text-gray-500 italic">{t("projects.unassigned")}</span>
</button>
{/* User options */}
{users.map((user) => (
<button
key={user.id}
onClick={() => {
handleChange(user.id);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<span>{user.name}</span>
</button>
))}
</div>
)}{" "}
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[9998]"
onClick={() => {
setIsOpen(false);
}}
/>
)}
</div>
);
}

View File

@@ -1,13 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { formatDateForInput } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectForm({ initialData = null }) {
const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref) {
const { t } = useTranslation();
const { data: session } = useSession();
const [form, setForm] = useState({
contract_id: "",
project_name: "",
@@ -18,10 +22,12 @@ export default function ProjectForm({ initialData = null }) {
city: "",
investment_number: "",
finish_date: "",
completion_date: "",
wp: "",
contact: "",
notes: "",
coordinates: "",
wartosc_zlecenia: "",
project_type: "design",
assigned_to: "",
});
@@ -57,20 +63,26 @@ export default function ProjectForm({ initialData = null }) {
city: "",
investment_number: "",
finish_date: "",
completion_date: "",
wp: "",
contact: "",
notes: "",
coordinates: "",
wartosc_zlecenia: "",
project_type: "design",
assigned_to: "",
...initialData,
// Ensure these defaults are preserved if not in initialData
project_type: initialData.project_type || "design",
assigned_to: initialData.assigned_to || "",
// Format finish_date for input if it exists
wartosc_zlecenia: initialData.wartosc_zlecenia || "",
// Format dates for input if they exist
finish_date: initialData.finish_date
? formatDateForInput(initialData.finish_date)
: "",
completion_date: initialData.completion_date
? formatDateForInput(initialData.completion_date)
: "",
});
}
}, [initialData]);
@@ -79,8 +91,7 @@ export default function ProjectForm({ initialData = null }) {
setForm({ ...form, [e.target.name]: e.target.value });
}
async function handleSubmit(e) {
e.preventDefault();
async function saveProject() {
setLoading(true);
try {
@@ -101,20 +112,30 @@ export default function ProjectForm({ initialData = null }) {
router.push("/projects");
}
} else {
alert("Failed to save project.");
alert(t('projects.saveError'));
}
} catch (error) {
console.error("Error saving project:", error);
alert("Failed to save project.");
alert(t('projects.saveError'));
} finally {
setLoading(false);
}
}
async function handleSubmit(e) {
e.preventDefault();
await saveProject();
}
// Expose save function to parent component
useImperativeHandle(ref, () => ({
saveProject
}));
return (
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
{isEdit ? "Edit Project Details" : "Project Details"}
{isEdit ? t('projects.editProjectDetails') : t('projects.projectDetails')}
</h2>
</CardHeader>
<CardContent>
@@ -123,7 +144,7 @@ export default function ProjectForm({ initialData = null }) {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contract <span className="text-red-500">*</span>
{t('projects.contract')} <span className="text-red-500">*</span>
</label>
<select
name="contract_id"
@@ -132,7 +153,7 @@ export default function ProjectForm({ initialData = null }) {
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Select Contract</option>
<option value="">{t('projects.selectContract')}</option>
{contracts.map((contract) => (
<option
key={contract.contract_id}
@@ -146,7 +167,7 @@ export default function ProjectForm({ initialData = null }) {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Project Type <span className="text-red-500">*</span>
{t('projects.type')} <span className="text-red-500">*</span>
</label>
<select
name="project_type"
@@ -155,17 +176,17 @@ export default function ProjectForm({ initialData = null }) {
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="design">Design (Projektowanie)</option>
<option value="construction">Construction (Realizacja)</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">
Design + Construction (Projektowanie + Realizacja)
{t('projectType.design+construction')}
</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Assigned To
{t('projects.assignedTo')}
</label>
<select
name="assigned_to"
@@ -173,10 +194,10 @@ export default function ProjectForm({ initialData = null }) {
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Unassigned</option>
<option value="">{t('projects.unassigned')}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
{user.name} ({user.username})
</option>
))}
</select>
@@ -186,92 +207,92 @@ export default function ProjectForm({ initialData = null }) {
{/* Basic Information Section */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Basic Information
{t('projects.basicInformation')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Project Name <span className="text-red-500">*</span>
{t('projects.projectName')} <span className="text-red-500">*</span>
</label>
<Input
type="text"
name="project_name"
value={form.project_name || ""}
onChange={handleChange}
placeholder="Enter project name"
placeholder={t('projects.enterProjectName')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
City
{t('projects.city')}
</label>
<Input
type="text"
name="city"
value={form.city || ""}
onChange={handleChange}
placeholder="Enter city"
placeholder={t('projects.enterCity')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Address
{t('projects.address')}
</label>
<Input
type="text"
name="address"
value={form.address || ""}
onChange={handleChange}
placeholder="Enter address"
placeholder={t('projects.enterAddress')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Plot
{t('projects.plot')}
</label>
<Input
type="text"
name="plot"
value={form.plot || ""}
onChange={handleChange}
placeholder="Enter plot number"
placeholder={t('projects.enterPlotNumber')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
District
{t('projects.district')}
</label>
<Input
type="text"
name="district"
value={form.district || ""}
onChange={handleChange}
placeholder="Enter district"
placeholder={t('projects.enterDistrict')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Unit
{t('projects.unit')}
</label>
<Input
type="text"
name="unit"
value={form.unit || ""}
onChange={handleChange}
placeholder="Enter unit"
placeholder={t('projects.enterUnit')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Finish Date
</label>{" "}
{t('projects.finishDate')}
</label>
<Input
type="date"
name="finish_date"
@@ -279,25 +300,37 @@ export default function ProjectForm({ initialData = null }) {
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data zakończenia projektu
</label>
<Input
type="date"
name="completion_date"
value={formatDateForInput(form.completion_date)}
onChange={handleChange}
/>
</div>
</div>
</div>
{/* Additional Information Section */}
<div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Additional Information
{t('projects.additionalInfo')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Investment Number
{t('projects.investmentNumber')}
</label>
<Input
type="text"
name="investment_number"
value={form.investment_number || ""}
onChange={handleChange}
placeholder="Enter investment number"
placeholder={t('projects.placeholders.investmentNumber')}
/>
</div>
@@ -310,39 +343,56 @@ export default function ProjectForm({ initialData = null }) {
name="wp"
value={form.wp || ""}
onChange={handleChange}
placeholder="Enter WP"
placeholder={t('projects.placeholders.wp')}
/>
</div>
{session?.user?.role === 'team_lead' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Wartość zlecenia
</label>
<Input
type="number"
name="wartosc_zlecenia"
value={form.wartosc_zlecenia || ""}
onChange={handleChange}
placeholder="0.00"
step="0.01"
min="0"
/>
</div>
)}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Contact Information
{t('projects.contact')}
</label>
<Input
type="text"
name="contact"
value={form.contact || ""}
onChange={handleChange}
placeholder="Enter contact details"
placeholder={t('projects.placeholders.contact')}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Coordinates
{t('projects.coordinates')}
</label>
<Input
type="text"
name="coordinates"
value={form.coordinates || ""}
onChange={handleChange}
placeholder="e.g., 49.622958,20.629562"
placeholder={t('projects.placeholders.coordinates')}
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes
{t('projects.notes')}
</label>
<textarea
name="notes"
@@ -350,7 +400,7 @@ export default function ProjectForm({ initialData = null }) {
onChange={handleChange}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter any additional notes"
placeholder={t('projects.placeholders.notes')}
/>
</div>
</div>
@@ -364,7 +414,7 @@ export default function ProjectForm({ initialData = null }) {
onClick={() => router.back()}
disabled={loading}
>
Cancel
{t('common.cancel')}
</Button>
<Button type="submit" variant="primary" disabled={loading}>
{loading ? (
@@ -389,7 +439,7 @@ export default function ProjectForm({ initialData = null }) {
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>
{isEdit ? "Updating..." : "Creating..."}
{isEdit ? t('projects.updating') : t('projects.creating')}
</>
) : (
<>
@@ -408,7 +458,7 @@ export default function ProjectForm({ initialData = null }) {
d="M5 13l4 4L19 7"
/>
</svg>
Update Project
{t('projects.updateProject')}
</>
) : (
<>
@@ -425,7 +475,7 @@ export default function ProjectForm({ initialData = null }) {
d="M12 4v16m8-8H4"
/>
</svg>
Create Project
{t('projects.createProject')}
</>
)}
</>
@@ -436,4 +486,6 @@ export default function ProjectForm({ initialData = null }) {
</CardContent>
</Card>
);
}
});
export default ProjectForm;

View File

@@ -3,12 +3,14 @@
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import Badge from "@/components/ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectStatusDropdown({
project,
size = "md",
showDropdown = true,
}) {
const { t } = useTranslation();
const [status, setStatus] = useState(project.project_status);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
@@ -21,21 +23,25 @@ export default function ProjectStatusDropdown({
const statusConfig = {
registered: {
label: "Registered",
label: t("projectStatus.registered"),
variant: "secondary",
},
in_progress_design: {
label: "In Progress (Design)",
label: t("projectStatus.in_progress_design"),
variant: "primary",
},
in_progress_construction: {
label: "In Progress (Construction)",
label: t("projectStatus.in_progress_construction"),
variant: "primary",
},
fulfilled: {
label: "Completed",
label: t("projectStatus.fulfilled"),
variant: "success",
},
cancelled: {
label: t("projectStatus.cancelled"),
variant: "danger",
},
};
const handleChange = async (newStatus) => {
if (newStatus === status) {
@@ -48,11 +54,19 @@ export default function ProjectStatusDropdown({
setIsOpen(false);
try {
await fetch(`/api/projects/${project.project_id}`, {
const updateData = { ...project, project_status: newStatus };
const response = await fetch(`/api/projects/${project.project_id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...project, project_status: newStatus }),
body: JSON.stringify(updateData),
});
if (!response.ok) {
const errorData = await response.json();
console.error('Update failed:', errorData);
}
window.location.reload();
} catch (error) {
console.error("Failed to update status:", error);
@@ -73,9 +87,6 @@ export default function ProjectStatusDropdown({
}
};
const handleOpen = () => {
console.log(
"ProjectStatusDropdown handleOpen called, setting isOpen to true"
);
setIsOpen(true);
};
@@ -111,10 +122,6 @@ export default function ProjectStatusDropdown({
<button
ref={buttonRef}
onClick={() => {
console.log(
"ProjectStatusDropdown button clicked, current isOpen:",
isOpen
);
setIsOpen(!isOpen);
}}
disabled={loading}
@@ -145,20 +152,16 @@ export default function ProjectStatusDropdown({
</svg>
</Badge>
</button>{" "}
{/* Simple dropdown for debugging */}
{/* Status Options Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
<div className="bg-yellow-100 p-2 text-xs text-center border-b">
DEBUG: ProjectStatus Dropdown is visible
</div>
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-[9999] min-w-[140px]">
{Object.entries(statusConfig).map(([statusKey, config]) => (
<button
key={statusKey}
onClick={() => {
console.log("ProjectStatus Option clicked:", statusKey);
handleChange(statusKey);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 transition-colors first:rounded-t-md last:rounded-b-md"
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last:rounded-b-md"
>
<Badge variant={config.variant} size="sm">
{config.label}
@@ -170,9 +173,8 @@ export default function ProjectStatusDropdown({
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[9998] bg-black bg-opacity-10"
className="fixed inset-0 z-[9998]"
onClick={() => {
console.log("ProjectStatus Backdrop clicked");
setIsOpen(false);
}}
/>

View File

@@ -29,6 +29,10 @@ export default function ProjectStatusDropdownDebug({
label: "Completed",
variant: "success",
},
cancelled: {
label: "Cancelled",
variant: "danger",
},
};
const handleChange = async (newStatus) => {
@@ -107,8 +111,8 @@ export default function ProjectStatusDropdownDebug({
{/* Simple visible dropdown for debugging */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
<div className="bg-yellow-100 p-2 text-xs text-center border-b">
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
<div className="bg-yellow-100 dark:bg-yellow-900 p-2 text-xs text-center border-b dark:border-gray-600">
DEBUG: Project Status Dropdown is visible
</div>
{Object.entries(statusConfig).map(([statusKey, config]) => (
@@ -118,7 +122,7 @@ export default function ProjectStatusDropdownDebug({
console.log("Project Status Option clicked:", statusKey);
handleChange(statusKey);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 transition-colors first:rounded-t-md last:rounded-b-md"
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last:rounded-b-md"
>
<Badge variant={config.variant} size="sm">
{config.label}

View File

@@ -29,6 +29,10 @@ export default function ProjectStatusDropdownSimple({
label: "Completed",
variant: "success",
},
cancelled: {
label: "Cancelled",
variant: "danger",
},
};
const handleChange = async (newStatus) => {
@@ -110,8 +114,8 @@ export default function ProjectStatusDropdownSimple({
{/* Simple dropdown for debugging */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
<div className="bg-yellow-100 p-2 text-xs text-center border-b">
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
<div className="bg-yellow-100 dark:bg-yellow-900 p-2 text-xs text-center border-b dark:border-gray-600">
DEBUG: ProjectStatus Dropdown is visible
</div>
{Object.entries(statusConfig).map(([statusKey, config]) => (
@@ -121,7 +125,7 @@ export default function ProjectStatusDropdownSimple({
console.log("ProjectStatus Option clicked:", statusKey);
handleChange(statusKey);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 transition-colors first:rounded-t-md last-rounded-b-md"
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last-rounded-b-md"
>
<Badge variant={config.variant} size="sm">
{config.label}

View File

@@ -3,12 +3,17 @@
import { useState, useEffect } from "react";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const { t } = useTranslation();
const [taskTemplates, setTaskTemplates] = useState([]);
const [taskSets, setTaskSets] = useState([]);
const [users, setUsers] = useState([]);
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
const [project, setProject] = useState(null);
const [taskType, setTaskType] = useState("template"); // "template", "custom", or "task_set"
const [selectedTemplate, setSelectedTemplate] = useState("");
const [selectedTaskSet, setSelectedTaskSet] = useState("");
const [customTaskName, setCustomTaskName] = useState("");
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
const [customDescription, setCustomDescription] = useState("");
@@ -17,6 +22,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// Fetch project details
fetch(`/api/projects/${projectId}`)
.then((res) => res.json())
.then(setProject);
// Fetch available task templates
fetch("/api/tasks/templates")
.then((res) => res.json())
@@ -26,7 +36,23 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
fetch("/api/project-tasks/users")
.then((res) => res.json())
.then(setUsers);
}, []);
}, [projectId]);
useEffect(() => {
// Fetch task sets when project type is available
if (project?.project_type) {
let apiUrl = '/api/task-sets';
if (project.project_type === 'design+construction') {
// For design+construction projects, don't filter - show all task sets
// User can choose which type to apply
} else {
apiUrl += `?project_type=${project.project_type}`;
}
fetch(apiUrl)
.then((res) => res.json())
.then(setTaskSets);
}
}, [project?.project_type]);
async function handleSubmit(e) {
e.preventDefault();
@@ -34,43 +60,64 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
// Validate based on task type
if (taskType === "template" && !selectedTemplate) return;
if (taskType === "custom" && !customTaskName.trim()) return;
if (taskType === "task_set" && !selectedTaskSet) return;
setIsSubmitting(true);
try {
const requestData = {
project_id: parseInt(projectId),
priority,
assigned_to: assignedTo || null,
};
if (taskType === "task_set") {
// Apply task set
const response = await fetch(`/api/task-sets/${selectedTaskSet}/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: parseInt(projectId) }),
});
if (taskType === "template") {
requestData.task_template_id = parseInt(selectedTemplate);
if (response.ok) {
// Reset form
setSelectedTaskSet("");
setPriority("normal");
setAssignedTo("");
if (onTaskAdded) onTaskAdded();
} else {
alert(t("tasks.addTaskError"));
}
} else {
requestData.custom_task_name = customTaskName.trim();
requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0;
requestData.custom_description = customDescription.trim();
}
// Create single task
const requestData = {
project_id: parseInt(projectId),
priority,
assigned_to: assignedTo || null,
};
const res = await fetch("/api/project-tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (res.ok) {
// Reset form
setSelectedTemplate("");
setCustomTaskName("");
setCustomMaxWaitDays("");
setCustomDescription("");
setPriority("normal");
setAssignedTo("");
if (onTaskAdded) onTaskAdded();
} else {
alert("Failed to add task to project.");
if (taskType === "template") {
requestData.task_template_id = parseInt(selectedTemplate);
} else {
requestData.custom_task_name = customTaskName.trim();
requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0;
requestData.custom_description = customDescription.trim();
}
const res = await fetch("/api/project-tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (res.ok) {
// Reset form
setSelectedTemplate("");
setCustomTaskName("");
setCustomMaxWaitDays("");
setCustomDescription("");
setPriority("normal");
setAssignedTo("");
if (onTaskAdded) onTaskAdded();
} else {
alert(t("tasks.addTaskError"));
}
}
} catch (error) {
alert("Error adding task to project.");
alert(t("tasks.addTaskError"));
} finally {
setIsSubmitting(false);
}
@@ -79,9 +126,9 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Task Type
{t("tasks.taskType")}
</label>
<div className="flex space-x-6">
<div className="flex flex-wrap gap-6">
<label className="flex items-center">
<input
type="radio"
@@ -90,7 +137,17 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-900">From Template</span>
<span className="ml-2 text-sm text-gray-900">{t("tasks.fromTemplate")}</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="task_set"
checked={taskType === "task_set"}
onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-900">Z zestawu zadań</span>
</label>
<label className="flex items-center">
<input
@@ -100,7 +157,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-900">Custom Task</span>
<span className="ml-2 text-sm text-gray-900">{t("tasks.customTask")}</span>
</label>
</div>
</div>
@@ -108,7 +165,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
{taskType === "template" ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Task Template
{t("tasks.selectTemplate")}
</label>{" "}
<select
value={selectedTemplate}
@@ -116,32 +173,67 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Choose a task template...</option>
<option value="">{t("tasks.chooseTemplate")}</option>
{taskTemplates.map((template) => (
<option key={template.task_id} value={template.task_id}>
{template.name} ({template.max_wait_days} days)
{template.name} ({template.max_wait_days} {t("tasks.days")})
</option>
))}
</select>
</div>
) : taskType === "task_set" ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Wybierz zestaw zadań
</label>
<select
value={selectedTaskSet}
onChange={(e) => setSelectedTaskSet(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Wybierz zestaw...</option>
{taskSets.map((taskSet) => (
<option key={taskSet.set_id} value={taskSet.set_id}>
{taskSet.name} ({taskSet.task_category === 'design' ? 'Zadania projektowe' : 'Zadania budowlane'}) - {taskSet.templates?.length || 0} zadań
</option>
))}
</select>
{selectedTaskSet && (
<div className="mt-2 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>Podgląd zestawu:</strong>
</p>
<ul className="text-sm text-blue-700 mt-1 list-disc list-inside">
{taskSets
.find(ts => ts.set_id === parseInt(selectedTaskSet))
?.templates?.map((template, index) => (
<li key={template.task_id}>
{template.name} ({template.max_wait_days} dni)
</li>
)) || []}
</ul>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Task Name
{t("tasks.taskName")}
</label>
<input
type="text"
value={customTaskName}
onChange={(e) => setCustomTaskName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter custom task name..."
placeholder={t("tasks.enterTaskName")}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Wait Days
{t("tasks.maxWait")}
</label>
<input
type="number"
@@ -154,52 +246,56 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
{t("tasks.description")}
</label>
<textarea
value={customDescription}
onChange={(e) => setCustomDescription(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter task description (optional)..."
placeholder={t("tasks.enterDescription")}
rows={3}
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Assign To <span className="text-gray-500 text-xs">(optional)</span>
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Unassigned</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
</div>
{taskType !== "task_set" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t("projects.unassigned")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("tasks.priority")}
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="low">{t("tasks.low")}</option>
<option value="normal">{t("tasks.normal")}</option>
<option value="high">{t("tasks.high")}</option>
<option value="urgent">{t("tasks.urgent")}</option>
</select>
</div>
</>
)}
<div className="flex justify-end">
<Button
@@ -208,10 +304,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
disabled={
isSubmitting ||
(taskType === "template" && !selectedTemplate) ||
(taskType === "custom" && !customTaskName.trim())
(taskType === "custom" && !customTaskName.trim()) ||
(taskType === "task_set" && !selectedTaskSet)
}
>
{isSubmitting ? "Adding..." : "Add Task"}
{isSubmitting ? t("tasks.adding") : taskType === "task_set" ? "Zastosuj zestaw" : t("tasks.addTask")}
</Button>
</div>
</form>

View File

@@ -13,11 +13,17 @@ import {
parseISO,
formatDistanceToNow,
} from "date-fns";
import { pl, enUS } from "date-fns/locale";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTasksDashboard() {
const { language } = useTranslation();
const [allTasks, setAllTasks] = useState([]);
const [loading, setLoading] = useState(true);
// Get locale for date-fns
const locale = language === 'pl' ? pl : enUS;
const [filter, setFilter] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
@@ -283,7 +289,7 @@ export default function ProjectTasksDashboard() {
const addedDate = task.date_added.includes("T")
? parseISO(task.date_added)
: new Date(task.date_added + "T00:00:00");
return formatDistanceToNow(addedDate, { addSuffix: true });
return formatDistanceToNow(addedDate, { addSuffix: true, locale: locale });
} catch (error) {
return task.date_added;
}
@@ -331,7 +337,7 @@ export default function ProjectTasksDashboard() {
</CardHeader>
<CardContent className="space-y-3">
{tasks.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p className="text-sm">No tasks in this category</p>
</div>
) : (

View File

@@ -5,6 +5,7 @@ import { Card, CardHeader, CardContent } from "./ui/Card";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import TaskStatusDropdownSimple from "./TaskStatusDropdownSimple";
import TaskCommentsModal from "./TaskCommentsModal";
import SearchBar from "./ui/SearchBar";
import { Select } from "./ui/Input";
import Link from "next/link";
@@ -13,13 +14,24 @@ import {
parseISO,
formatDistanceToNow,
} from "date-fns";
import { pl, enUS } from "date-fns/locale";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useSession } from "next-auth/react";
export default function ProjectTasksList() {
const { t, language } = useTranslation();
// Get locale for date-fns
const locale = language === 'pl' ? pl : enUS;
const { data: session } = useSession();
const [allTasks, setAllTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [groupBy, setGroupBy] = useState("none");
const [selectedTask, setSelectedTask] = useState(null);
const [showCommentsModal, setShowCommentsModal] = useState(false);
const [mine, setMine] = useState(true);
useEffect(() => {
const fetchAllTasks = async () => {
@@ -35,7 +47,19 @@ export default function ProjectTasksList() {
};
fetchAllTasks();
}, []); // Calculate task status based on date_added and max_wait_days
}, []);
// Handle escape key to close modal
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && showCommentsModal) {
handleCloseComments();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [showCommentsModal]); // Calculate task status based on date_added and max_wait_days
const getTaskStatus = (task) => {
if (task.status === "completed" || task.status === "cancelled") {
return { type: "completed", days: 0 };
@@ -118,9 +142,10 @@ export default function ProjectTasksList() {
groups.completed.push(taskWithStatus);
} else if (task.status === "in_progress") {
groups.in_progress.push(taskWithStatus);
} else {
} else if (task.status === "pending") {
groups.pending.push(taskWithStatus);
}
// not_started tasks are not displayed in the UI
});
// Sort pending tasks by date_added (newest first)
@@ -183,19 +208,30 @@ export default function ProjectTasksList() {
const taskGroups = groupTasksByStatus();
// Filter tasks based on search term
const filterTasks = (tasks) => {
if (!searchTerm) return tasks;
return tasks.filter(
(task) =>
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.city?.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.address?.toLowerCase().includes(searchTerm.toLowerCase())
);
let filtered = tasks;
// Apply mine filter
if (mine && session?.user?.id) {
filtered = filtered.filter(task => task.assigned_to === session.user.id);
}
// Apply search term
if (searchTerm) {
filtered = filtered.filter(
(task) =>
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.city?.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.address?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
};
// Group tasks by task name when groupBy is set to "task_name"
const groupTasksByName = (tasks) => {
if (groupBy !== "task_name") return { "All Tasks": tasks };
if (groupBy !== "task_name") return { [t("tasks.allTasks")]: tasks };
const groups = {};
tasks.forEach((task) => {
@@ -223,13 +259,23 @@ export default function ProjectTasksList() {
const tasks = await res2.json();
setAllTasks(tasks);
} else {
alert("Failed to update task status");
alert(t("errors.generic"));
}
} catch (error) {
alert("Error updating task status");
alert(t("errors.generic"));
}
};
const handleShowComments = (task) => {
setSelectedTask(task);
setShowCommentsModal(true);
};
const handleCloseComments = () => {
setShowCommentsModal(false);
setSelectedTask(null);
};
const getPriorityVariant = (priority) => {
switch (priority) {
case "urgent":
@@ -250,13 +296,13 @@ export default function ProjectTasksList() {
if (days > 3) return "warning";
return "high";
};
const TaskRow = ({ task, showTimeLeft = false }) => (
<tr className="hover:bg-gray-50 border-b border-gray-200">
const TaskRow = ({ task, showTimeLeft = false, showMaxWait = true }) => (
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{task.task_name}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{task.task_name}</span>
<Badge variant={getPriorityVariant(task.priority)} size="sm">
{task.priority}
{t(`tasks.${task.priority}`)}
</Badge>
</div>
</td>
@@ -264,35 +310,25 @@ export default function ProjectTasksList() {
{" "}
<Link
href={`/projects/${task.project_id}`}
className="text-blue-600 hover:text-blue-800 font-medium"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium"
>
{task.project_name}
</Link>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{task.city || "N/A"}</td>
<td className="px-4 py-3 text-sm text-gray-600">
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{task.city || "N/A"}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{task.address || "N/A"}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
{task.created_by_name ? (
<div>
<div className="font-medium">{task.created_by_name}</div>
<div className="text-xs text-gray-500">{task.created_by_email}</div>
</div>
) : (
"N/A"
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{task.assigned_to_name ? (
<div>
<div className="font-medium">{task.assigned_to_name}</div>
<div className="text-xs text-gray-500">
<div className="font-medium text-gray-900 dark:text-gray-100">{task.assigned_to_name}</div>
{/* <div className="text-xs text-gray-500">
{task.assigned_to_email}
</div>
</div> */}
</div>
) : (
<span className="text-gray-400 italic">Unassigned</span>
<span className="text-gray-400 dark:text-gray-500 italic">{t("projects.unassigned")}</span>
)}
</td>
{showTimeLeft && (
@@ -307,9 +343,9 @@ export default function ProjectTasksList() {
>
{!isNaN(task.statusInfo.daysRemaining)
? task.statusInfo.daysRemaining > 0
? `${task.statusInfo.daysRemaining}d left`
: `${Math.abs(task.statusInfo.daysRemaining)}d overdue`
: "Calculating..."}
? `${task.statusInfo.daysRemaining}${t("tasks.daysLeft")}`
: `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
: t("common.loading")}
</Badge>
)}
{task.statusInfo &&
@@ -317,23 +353,24 @@ export default function ProjectTasksList() {
task.status === "in_progress" && (
<Badge variant="danger" size="sm">
{!isNaN(task.statusInfo.daysRemaining)
? `${Math.abs(task.statusInfo.daysRemaining)}d overdue`
: "Overdue"}
? `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
: t("tasks.overdue")}
</Badge>
)}
</div>
</td>
)}
<td className="px-4 py-3 text-sm text-gray-500">
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{task.status === "completed" && task.date_completed ? (
<div>
<div>
Completed:{" "}
{t("taskStatus.completed")}:{" "}
{(() => {
try {
const completedDate = new Date(task.date_completed);
return formatDistanceToNow(completedDate, {
addSuffix: true,
locale: locale
});
} catch (error) {
return task.date_completed;
@@ -344,11 +381,11 @@ export default function ProjectTasksList() {
) : task.status === "in_progress" && task.date_started ? (
<div>
<div>
Started:{" "}
{t("tasks.dateStarted")}:{" "}
{(() => {
try {
const startedDate = new Date(task.date_started);
return formatDistanceToNow(startedDate, { addSuffix: true });
return formatDistanceToNow(startedDate, { addSuffix: true, locale: locale });
} catch (error) {
return task.date_started;
}
@@ -361,79 +398,90 @@ export default function ProjectTasksList() {
const addedDate = task.date_added.includes("T")
? parseISO(task.date_added)
: new Date(task.date_added);
return formatDistanceToNow(addedDate, { addSuffix: true });
return formatDistanceToNow(addedDate, { addSuffix: true, locale: locale });
} catch (error) {
return task.date_added;
}
})()
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{task.max_wait_days} days
</td>
{showMaxWait && (
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{task.max_wait_days} {t("tasks.days")}
</td>
)}
<td className="px-4 py-3">
<TaskStatusDropdownSimple
task={task}
size="sm"
onStatusChange={handleStatusChange}
/>
<div className="flex items-center gap-2">
<TaskStatusDropdownSimple
task={task}
size="sm"
onStatusChange={handleStatusChange}
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleShowComments(task)}
title={t("tasks.comments")}
>
💬
</Button>
</div>
</td>
</tr>
);
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => {
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false, showMaxWait = true }) => {
const filteredTasks = filterTasks(tasks);
const groupedTasks = groupTasksByName(filteredTasks);
const colSpan = showTimeLeft ? "10" : "9";
const colSpan = showTimeLeft && showMaxWait ? "9" : showTimeLeft || showMaxWait ? "8" : "7";
return (
<div className="overflow-x-auto">
<table className="w-full bg-white rounded-lg shadow-sm">
<thead className="bg-gray-50">
<table className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Task Name
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.taskName")}
</th>{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Project
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.project")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
City
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("projects.city")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Address
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("projects.address")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Created By
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Assigned To
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.assignedTo")}
</th>
{showTimeLeft && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Time Left
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.daysLeft")}
</th>
)}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Date Info
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.dateCreated")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Max Wait
</th>{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Actions
{showMaxWait && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.maxWait")}
</th>
)}{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.actions")}
</th>
</tr>
</thead>
<tbody>
{Object.entries(groupedTasks).map(([groupName, groupTasks]) => (
<Fragment key={`group-fragment-${groupName}`}>
{showGrouped && groupName !== "All Tasks" && (
{showGrouped && groupName !== t("tasks.allTasks") && (
<tr key={`group-${groupName}`}>
<td
colSpan={colSpan}
className="px-4 py-2 bg-gray-100 font-medium text-gray-800 text-sm"
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 font-medium text-gray-800 dark:text-gray-200 text-sm"
>
{groupName} ({groupTasks.length} tasks)
{groupName} ({groupTasks.length} {t("tasks.tasks")})
</td>
</tr>
)}
@@ -442,6 +490,7 @@ export default function ProjectTasksList() {
key={task.id}
task={task}
showTimeLeft={showTimeLeft}
showMaxWait={showMaxWait}
/>
))}
</Fragment>
@@ -449,8 +498,8 @@ export default function ProjectTasksList() {
</tbody>
</table>
{filteredTasks.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p>No tasks found</p>
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>{t("tasks.noTasks")}</p>
</div>
)}
</div>
@@ -476,7 +525,7 @@ export default function ProjectTasksList() {
<div className="text-2xl font-bold text-blue-600">
{taskGroups.pending.length}
</div>
<div className="text-sm text-gray-600">Pending</div>
<div className="text-sm text-gray-600">{t("taskStatus.pending")}</div>
</CardContent>
</Card>
<Card>
@@ -484,7 +533,7 @@ export default function ProjectTasksList() {
<div className="text-2xl font-bold text-purple-600">
{taskGroups.in_progress.length}
</div>
<div className="text-sm text-gray-600">In Progress</div>
<div className="text-sm text-gray-600">{t("taskStatus.in_progress")}</div>
</CardContent>
</Card>
<Card>
@@ -492,7 +541,7 @@ export default function ProjectTasksList() {
<div className="text-2xl font-bold text-green-600">
{taskGroups.completed.length}
</div>
<div className="text-sm text-gray-600">Completed</div>
<div className="text-sm text-gray-600">{t("taskStatus.completed")}</div>
</CardContent>
</Card>
</div>{" "}
@@ -500,28 +549,55 @@ export default function ProjectTasksList() {
<SearchBar
searchTerm={searchTerm}
onSearchChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search tasks, projects, city, or address..."
placeholder={t("tasks.searchPlaceholder")}
resultsCount={
filterTasks(taskGroups.pending).length +
filterTasks(taskGroups.in_progress).length +
filterTasks(taskGroups.completed).length
}
resultsText="tasks"
resultsText={t("tasks.tasks")}
filters={
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">
Group by:
{t("tasks.sortBy")}:
</label>
<Select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="min-w-[120px]"
>
<option value="none">None</option>
<option value="task_name">Task Name</option>
<option value="none">{t("common.none")}</option>
<option value="task_name">{t("tasks.taskName")}</option>
</Select>
</div>
{session?.user && (
<button
onClick={() => setMine(!mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
}
/>{" "}
@@ -531,19 +607,20 @@ export default function ProjectTasksList() {
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
Pending Tasks
{t("taskStatus.pending")} {t("tasks.tasks")}
<Badge variant="primary" size="md">
{taskGroups.pending.length}
</Badge>
</h2>
<p className="text-sm text-gray-600 mt-1">
Tasks waiting to be started
{t("tasks.noTasksMessage")}
</p>
</div>
<TaskTable
tasks={taskGroups.pending}
showGrouped={groupBy === "task_name"}
showTimeLeft={false}
showMaxWait={true}
/>
</div>
@@ -551,19 +628,20 @@ export default function ProjectTasksList() {
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
In Progress Tasks
{t("taskStatus.in_progress")} {t("tasks.tasks")}
<Badge variant="secondary" size="md">
{taskGroups.in_progress.length}
</Badge>
</h2>
<p className="text-sm text-gray-600 mt-1">
Tasks currently being worked on - showing time left for completion
Zadania aktualnie w trakcie realizacji - pokazujący pozostały czas do ukończenia
</p>
</div>
<TaskTable
tasks={taskGroups.in_progress}
showGrouped={groupBy === "task_name"}
showTimeLeft={true}
showMaxWait={false}
/>
</div>
@@ -571,22 +649,28 @@ export default function ProjectTasksList() {
<div>
<div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
Completed Tasks
{t("taskStatus.completed")} {t("tasks.tasks")}
<Badge variant="success" size="md">
{taskGroups.completed.length}
</Badge>
</h2>
<p className="text-sm text-gray-600 mt-1">
Recently completed and cancelled tasks
Ostatnio ukończone i anulowane zadania
</p>
</div>
<TaskTable
tasks={taskGroups.completed}
showGrouped={groupBy === "task_name"}
showTimeLeft={false}
showMaxWait={false}
/>
</div>
</div>
</div> {/* Comments Modal */}
<TaskCommentsModal
task={selectedTask}
isOpen={showCommentsModal}
onClose={handleCloseComments}
/>
</div>
);
}

View File

@@ -7,8 +7,10 @@ import { Card, CardHeader, CardContent } from "./ui/Card";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTasksSection({ projectId }) {
const { t } = useTranslation();
const [projectTasks, setProjectTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [taskNotes, setTaskNotes] = useState({});
@@ -17,6 +19,15 @@ export default function ProjectTasksSection({ projectId }) {
const [showAddTaskModal, setShowAddTaskModal] = useState(false);
const [expandedDescriptions, setExpandedDescriptions] = useState({});
const [expandedNotes, setExpandedNotes] = useState({});
const [editingTask, setEditingTask] = useState(null);
const [showEditTaskModal, setShowEditTaskModal] = useState(false);
const [editTaskForm, setEditTaskForm] = useState({
priority: "",
date_started: "",
status: "",
assigned_to: "",
});
const [users, setUsers] = useState([]);
useEffect(() => {
const fetchProjectTasks = async () => {
try {
@@ -49,22 +60,38 @@ export default function ProjectTasksSection({ projectId }) {
}
};
// Fetch users for assignment dropdown
const fetchUsers = async () => {
try {
const res = await fetch("/api/project-tasks/users");
const usersData = await res.json();
setUsers(usersData);
} catch (error) {
console.error("Failed to fetch users:", error);
}
};
fetchProjectTasks();
fetchUsers();
}, [projectId]);
// Handle escape key to close modal
// Handle escape key to close modals
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && showAddTaskModal) {
setShowAddTaskModal(false);
if (e.key === "Escape") {
if (showEditTaskModal) {
handleCloseEditModal();
} else if (showAddTaskModal) {
setShowAddTaskModal(false);
}
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [showAddTaskModal]);
}, [showAddTaskModal, showEditTaskModal]);
// Prevent body scroll when modal is open and handle map z-index
useEffect(() => {
if (showAddTaskModal) {
if (showAddTaskModal || showEditTaskModal) {
// Prevent body scroll
document.body.style.overflow = "hidden";
@@ -111,7 +138,7 @@ export default function ProjectTasksSection({ projectId }) {
nav.style.zIndex = "";
});
};
}, [showAddTaskModal]);
}, [showAddTaskModal, showEditTaskModal]);
const refetchTasks = async () => {
try {
const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
@@ -155,14 +182,14 @@ export default function ProjectTasksSection({ projectId }) {
if (res.ok) {
refetchTasks(); // Refresh the list
} else {
alert("Failed to update task status");
alert(t("errors.generic"));
}
} catch (error) {
alert("Error updating task status");
alert(t("errors.generic"));
}
};
const handleDeleteTask = async (taskId) => {
if (!confirm("Are you sure you want to delete this task?")) return;
if (!confirm(t("common.deleteConfirm"))) return;
try {
const res = await fetch(`/api/project-tasks/${taskId}`, {
method: "DELETE",
@@ -171,10 +198,11 @@ export default function ProjectTasksSection({ projectId }) {
if (res.ok) {
refetchTasks(); // Refresh the list
} else {
alert("Failed to delete task");
const errorData = await res.json();
alert(t("errors.generic") + ": " + (errorData.error || t("errors.unknown")));
}
} catch (error) {
alert("Error deleting task");
alert(t("tasks.deleteError") + ": " + error.message);
}
};
@@ -201,20 +229,20 @@ export default function ProjectTasksSection({ projectId }) {
setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
setNewNote((prev) => ({ ...prev, [taskId]: "" }));
} else {
alert("Failed to add note");
alert(t("errors.generic"));
}
} catch (error) {
alert("Error adding note");
alert(t("errors.generic"));
} finally {
setLoadingNotes((prev) => ({ ...prev, [taskId]: false }));
}
};
const handleDeleteNote = async (noteId, taskId) => {
if (!confirm("Are you sure you want to delete this note?")) return;
if (!confirm(t("common.deleteConfirm"))) return;
try {
const res = await fetch(`/api/task-notes?note_id=${noteId}`, {
const res = await fetch(`/api/task-notes/${noteId}`, {
method: "DELETE",
});
@@ -224,12 +252,74 @@ export default function ProjectTasksSection({ projectId }) {
const notes = await notesRes.json();
setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
} else {
alert("Failed to delete note");
alert(t("errors.generic"));
}
} catch (error) {
alert("Error deleting note");
console.error("Error deleting task note:", error);
alert(t("errors.generic"));
}
};
const handleEditTask = (task) => {
setEditingTask(task);
// Format date for HTML input (YYYY-MM-DD)
let dateStarted = "";
if (task.date_started) {
const date = new Date(task.date_started);
if (!isNaN(date.getTime())) {
dateStarted = date.toISOString().split("T")[0];
}
}
const formData = {
priority: task.priority || "",
date_started: dateStarted,
status: task.status || "",
assigned_to: task.assigned_to || "",
};
setEditTaskForm(formData);
setShowEditTaskModal(true);
};
const handleUpdateTask = async () => {
if (!editingTask) return;
try {
const res = await fetch(`/api/project-tasks/${editingTask.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priority: editTaskForm.priority,
date_started: editTaskForm.date_started || null,
status: editTaskForm.status,
assigned_to: editTaskForm.assigned_to || null,
}),
});
if (res.ok) {
refetchTasks();
handleCloseEditModal();
} else {
alert(t("errors.generic"));
}
} catch (error) {
alert(t("errors.generic"));
}
};
const handleCloseEditModal = () => {
setShowEditTaskModal(false);
setEditingTask(null);
setEditTaskForm({
priority: "",
date_started: "",
status: "",
assigned_to: "",
});
};
const getPriorityVariant = (priority) => {
switch (priority) {
case "urgent":
@@ -261,10 +351,10 @@ export default function ProjectTasksSection({ projectId }) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Project Tasks</h2>
<h2 className="text-xl font-semibold text-gray-900">{t("tasks.title")}</h2>
<div className="flex items-center gap-3">
<Badge variant="default" className="text-sm">
{projectTasks.length} {projectTasks.length === 1 ? "task" : "tasks"}
{projectTasks.length} {projectTasks.length === 1 ? t("tasks.task") : t("tasks.tasks")}
</Badge>
<Button
variant="primary"
@@ -284,7 +374,7 @@ export default function ProjectTasksSection({ projectId }) {
d="M12 4v16m8-8H4"
/>
</svg>
Add Task
{t("tasks.newTask")}
</Button>
</div>
</div>{" "}
@@ -305,7 +395,7 @@ export default function ProjectTasksSection({ projectId }) {
>
<div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-semibold text-gray-900">
Add New Task
{t("tasks.newTask")}
</h3>
<button
onClick={() => setShowAddTaskModal(false)}
@@ -338,7 +428,7 @@ export default function ProjectTasksSection({ projectId }) {
{/* Current Tasks */}
<Card>
<CardHeader>
<h3 className="text-lg font-medium text-gray-900">Current Tasks</h3>
<h3 className="text-lg font-medium text-gray-900">{t("tasks.title")}</h3>
</CardHeader>
<CardContent className="p-0">
{loading ? (
@@ -353,62 +443,64 @@ export default function ProjectTasksSection({ projectId }) {
<div className="text-gray-400 mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
clipRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</div>
<p className="text-gray-500 text-sm">
No tasks assigned to this project yet.
{t("tasks.noTasksMessage")}
</p>
<p className="text-gray-400 text-xs mt-1">
Add a task above to get started.
{t("tasks.addTaskMessage")}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Task
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("tasks.taskName")}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priority
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("tasks.priority")}
</th>{" "}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Max Wait
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("tasks.maxWait")}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Started
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("tasks.dateStarted")}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("tasks.status")}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-48">
{t("tasks.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
{projectTasks.map((task) => (
<React.Fragment key={task.id}>
{/* Main task row */}
<tr className="hover:bg-gray-50 transition-colors">
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
{task.task_name}
</h4>
{task.description && (
<button
onClick={() => toggleDescription(task.id)}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="Toggle description"
>
<svg
@@ -437,17 +529,17 @@ export default function ProjectTasksSection({ projectId }) {
variant={getPriorityVariant(task.priority)}
size="sm"
>
{task.priority}
{t(`tasks.${task.priority}`)}
</Badge>
</td>
<td className="px-4 py-4 text-sm text-gray-600">
{task.max_wait_days} days
<td className="px-4 py-4 text-sm text-gray-600 dark:text-gray-400">
{task.max_wait_days} {t("tasks.days")}
</td>{" "}
<td className="px-4 py-4 text-sm text-gray-600">
<td className="px-4 py-4 text-sm text-gray-600 dark:text-gray-400">
{task.date_started
? formatDate(task.date_started)
: "Not started"}
</td>{" "}
: t("tasks.notStarted")}
</td>
<td className="px-4 py-4">
<TaskStatusDropdownSimple
task={task}
@@ -456,21 +548,70 @@ export default function ProjectTasksSection({ projectId }) {
/>
</td>
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<button
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => toggleNotes(task.id)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
title={`${taskNotes[task.id]?.length || 0} notes`}
className="text-xs px-2 py-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 border-0"
title={`${taskNotes[task.id]?.length || 0} ${t("tasks.notes")}`}
>
Notes ({taskNotes[task.id]?.length || 0})
</button>
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
{taskNotes[task.id]?.length || 0}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEditTask(task)}
className="text-xs px-2 py-1 min-w-[60px]"
>
<svg
className="w-3 h-3 mr-1"
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("common.edit")}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteTask(task.id)}
className="text-xs"
className="text-xs px-2 py-1 min-w-[60px]"
>
Delete
<svg
className="w-3 h-3 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="19 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>
{t("common.delete")}
</Button>
</div>
</td>
@@ -481,7 +622,7 @@ export default function ProjectTasksSection({ projectId }) {
<td colSpan="6" className="px-4 py-3">
<div className="text-sm text-gray-700">
<span className="font-medium text-gray-900">
Description:
{t("tasks.description")}:
</span>
<p className="mt-1">{task.description}</p>
</div>
@@ -494,7 +635,7 @@ export default function ProjectTasksSection({ projectId }) {
<td colSpan="6" className="px-4 py-4">
<div className="space-y-3">
<h5 className="text-sm font-medium text-gray-900">
Notes ({taskNotes[task.id]?.length || 0})
{t("tasks.comments")} ({taskNotes[task.id]?.length || 0})
</h5>
{/* Existing Notes */}
@@ -512,11 +653,11 @@ export default function ProjectTasksSection({ projectId }) {
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{note.is_system && (
{note.is_system ? (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
System
{t("admin.system")}
</span>
)}
) : null}
{note.created_by_name && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full font-medium">
{note.created_by_name}
@@ -530,11 +671,6 @@ export default function ProjectTasksSection({ projectId }) {
{formatDate(note.note_date, {
includeTime: true,
})}
{note.created_by_name && (
<span className="ml-2">
by {note.created_by_name}
</span>
)}
</p>
</div>
{!note.is_system && (
@@ -546,7 +682,7 @@ export default function ProjectTasksSection({ projectId }) {
)
}
className="ml-2 text-red-500 hover:text-red-700 text-xs font-bold"
title="Delete note"
title={t("tasks.deleteNote")}
>
×
</button>
@@ -560,7 +696,7 @@ export default function ProjectTasksSection({ projectId }) {
<div className="flex gap-2">
<input
type="text"
placeholder="Add a note..."
placeholder={t("tasks.addComment")}
value={newNote[task.id] || ""}
onChange={(e) =>
setNewNote((prev) => ({
@@ -584,7 +720,7 @@ export default function ProjectTasksSection({ projectId }) {
!newNote[task.id]?.trim()
}
>
{loadingNotes[task.id] ? "Adding..." : "Add"}
{loadingNotes[task.id] ? t("common.saving") : t("common.add")}
</Button>
</div>
</div>
@@ -599,6 +735,133 @@ export default function ProjectTasksSection({ projectId }) {
)}
</CardContent>
</Card>
{/* Edit Task Modal */}
{showEditTaskModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
{t("tasks.editTask")}:{" "}
{editingTask?.custom_task_name || editingTask?.task_name}
</h3>
<button
onClick={handleCloseEditModal}
className="text-gray-400 hover:text-gray-600"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
<div className="space-y-4">
{/* Assignment */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("tasks.assignedTo")}
</label>
<select
value={editTaskForm.assigned_to}
onChange={(e) =>
setEditTaskForm((prev) => ({
...prev,
assigned_to: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t("projects.unassigned")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("tasks.priority")}
</label>
<select
value={editTaskForm.priority}
onChange={(e) =>
setEditTaskForm((prev) => ({
...prev,
priority: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t("common.selectOption")}</option>
<option value="low">{t("tasks.low")}</option>
<option value="normal">{t("tasks.normal")}</option>
<option value="high">{t("tasks.high")}</option>
<option value="urgent">{t("tasks.urgent")}</option>
</select>
</div>
{/* Date Started */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("tasks.dateStarted")}
</label>
<input
type="date"
value={editTaskForm.date_started}
onChange={(e) =>
setEditTaskForm((prev) => ({
...prev,
date_started: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("tasks.status")}
</label>
<select
value={editTaskForm.status}
onChange={(e) =>
setEditTaskForm((prev) => ({
...prev,
status: e.target.value,
}))
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t("common.selectOption")}</option>
<option value="not_started">{t("taskStatus.not_started")}</option>
<option value="pending">{t("taskStatus.pending")}</option>
<option value="in_progress">{t("taskStatus.in_progress")}</option>
<option value="completed">{t("taskStatus.completed")}</option>
<option value="cancelled">{t("taskStatus.cancelled")}</option>
</select>
</div>
</div> <div className="flex items-center justify-end gap-3 mt-6">
<Button variant="outline" onClick={handleCloseEditModal}>
{t("common.cancel")}
</Button>
<Button variant="primary" onClick={handleUpdateTask}>
{t("tasks.updateTask")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,409 @@
"use client";
import React, { useState, useEffect } from "react";
import Button from "./ui/Button";
import Badge from "./ui/Badge";
import { formatDate } from "@/lib/utils";
import { formatDistanceToNow, parseISO } from "date-fns";
import { pl, enUS } from "date-fns/locale";
import { useTranslation } from "@/lib/i18n";
export default function TaskCommentsModal({ task, isOpen, onClose }) {
const { t, language } = useTranslation();
// Get locale for date-fns
const locale = language === 'pl' ? pl : enUS;
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [newNote, setNewNote] = useState("");
const [loadingAdd, setLoadingAdd] = useState(false);
const parseNoteText = (text) => {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(text)) !== null) {
// Add text before the link
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// Add the link
parts.push(
<a
key={match.index}
href={match[2]}
className="text-blue-600 hover:text-blue-800 underline"
target="_blank"
rel="noopener noreferrer"
>
{match[1]}
</a>
);
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
};
useEffect(() => {
if (isOpen && task) {
fetchNotes();
}
}, [isOpen, task]);
const fetchNotes = async () => {
if (!task?.id) return;
try {
setLoading(true);
const res = await fetch(`/api/task-notes?task_id=${task.id}`);
const data = await res.json();
setNotes(data);
} catch (error) {
console.error("Failed to fetch notes:", error);
} finally {
setLoading(false);
}
};
const handleAddNote = async () => {
if (!newNote.trim() || !task?.id) return;
try {
setLoadingAdd(true);
const res = await fetch("/api/task-notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
task_id: task.id,
note: newNote.trim(),
}),
});
if (res.ok) {
setNewNote("");
await fetchNotes(); // Refresh notes
} else {
alert("Failed to add note");
}
} catch (error) {
alert("Error adding note");
} finally {
setLoadingAdd(false);
}
};
const handleDeleteNote = async (noteId) => {
if (!confirm("Are you sure you want to delete this note?")) return;
try {
const res = await fetch("/api/task-notes", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note_id: noteId }),
});
if (res.ok) {
await fetchNotes(); // Refresh notes
} else {
alert("Failed to delete note");
}
} catch (error) {
alert("Error deleting note");
}
};
const handleKeyDown = (e) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "Enter" && e.ctrlKey) {
handleAddNote();
}
};
const getPriorityVariant = (priority) => {
switch (priority) {
case "urgent":
return "urgent";
case "high":
return "high";
case "normal":
return "normal";
case "low":
return "low";
default:
return "default";
}
};
const getStatusVariant = (status) => {
switch (status) {
case "completed":
case "cancelled":
return "success";
case "in_progress":
return "secondary";
case "pending":
return "primary";
default:
return "default";
}
};
const formatTaskDate = (dateString, label) => {
if (!dateString) return null;
try {
const date = dateString.includes("T") ? parseISO(dateString) : new Date(dateString);
return {
label,
relative: formatDistanceToNow(date, { addSuffix: true, locale: locale }),
absolute: formatDate(date, { includeTime: true })
};
} catch (error) {
return { label, relative: dateString, absolute: dateString };
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="bg-white dark:bg-gray-800 rounded-lg w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{task?.task_name}
</h3>
<Badge variant={getPriorityVariant(task?.priority)} size="sm">
{t(`tasks.${task?.priority}`)}
</Badge>
<Badge variant={getStatusVariant(task?.status)} size="sm">
{t(`taskStatus.${task?.status}`)}
</Badge>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{t("tasks.project")}: <span className="font-medium">{task?.project_name}</span>
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-xl font-semibold ml-4"
onKeyDown={handleKeyDown}
>
×
</button>
</div>
{/* Task Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
{/* Location Information */}
{(task?.city || task?.address) && (
<div className="space-y-1">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("projects.locationDetails")}</h4>
{task?.city && (
<p className="text-sm text-gray-900 dark:text-gray-100">{task.city}</p>
)}
{task?.address && (
<p className="text-xs text-gray-600 dark:text-gray-400">{task.address}</p>
)}
</div>
)}
{/* Assignment Information */}
<div className="space-y-1">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("tasks.assignedTo")}</h4>
{task?.assigned_to_name ? (
<div>
<p className="text-sm text-gray-900 dark:text-gray-100">{task.assigned_to_name}</p>
<p className="text-xs text-gray-600 dark:text-gray-400">{task.assigned_to_email}</p>
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">{t("projects.unassigned")}</p>
)}
</div>
{/* Task Timing */}
<div className="space-y-1">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("tasks.dateCreated")}</h4>
{task?.max_wait_days && (
<p className="text-xs text-gray-600 dark:text-gray-400">{t("tasks.maxWait")}: {task.max_wait_days} {t("tasks.days")}</p>
)}
{(() => {
if (task?.status === "completed" && task?.date_completed) {
const dateInfo = formatTaskDate(task.date_completed, t("taskStatus.completed"));
return (
<div>
<p className="text-sm text-green-700 font-medium">{dateInfo.relative}</p>
<p className="text-xs text-gray-600">{dateInfo.absolute}</p>
</div>
);
} else if (task?.status === "in_progress" && task?.date_started) {
const dateInfo = formatTaskDate(task.date_started, t("tasks.dateStarted"));
return (
<div>
<p className="text-sm text-blue-700 font-medium">{t("tasks.dateStarted")} {dateInfo.relative}</p>
<p className="text-xs text-gray-600">{dateInfo.absolute}</p>
</div>
);
} else if (task?.date_added) {
const dateInfo = formatTaskDate(task.date_added, t("tasks.dateCreated"));
return (
<div>
<p className="text-sm text-gray-700 font-medium">{t("tasks.dateCreated")} {dateInfo.relative}</p>
<p className="text-xs text-gray-600">{dateInfo.absolute}</p>
</div>
);
}
return null;
})()}
</div>
{/* Task Description */}
{task?.description && (
<div className="space-y-1 md:col-span-2 lg:col-span-3">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("tasks.description")}</h4>
<p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed">{task.description}</p>
</div>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="text-center py-8">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/2"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-2/3"></div>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<h5 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t("tasks.comments")}
</h5>
<Badge variant="secondary" size="sm">
{notes.length}
</Badge>
</div>
{notes.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-2xl">💬</span>
</div>
<p className="text-lg font-medium mb-1">{t("tasks.noComments")}</p>
<p className="text-sm">Bądź pierwszy, który doda komentarz!</p>
</div>
) : (
<div className="space-y-4">
{notes.map((note) => (
<div
key={note.note_id}
className={`p-4 rounded-lg border flex justify-between items-start transition-colors ${
note.is_system
? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/30"
: "bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 shadow-sm"
}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
{note.is_system ? (
<span className="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-full font-medium flex items-center gap-1">
<span>🤖</span>
{t("admin.system")}
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-full font-medium flex items-center gap-1">
<span>👤</span>
{note.created_by_name || t("userRoles.user")}
</span>
)}
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
{formatDate(note.note_date, {
includeTime: true,
})}
</span>
</div>
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
{parseNoteText(note.note)}
</div>
</div>
{!note.is_system && (
<button
onClick={() => handleDeleteNote(note.note_id)}
className="ml-3 p-1 text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title={t("common.delete")}
>
<span className="text-sm">🗑</span>
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer - Add new comment */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-600">
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-lg">💬</span>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("tasks.addComment")}
</label>
</div>
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder={t("common.addNotePlaceholder")}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none shadow-sm"
rows={3}
onKeyDown={handleKeyDown}
/>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<span></span>
Naciśnij Ctrl+Enter aby wysłać lub Escape aby zamknąć
</p>
<div className="flex gap-3">
<Button
variant="secondary"
size="sm"
onClick={onClose}
>
{t("common.close")}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleAddNote}
disabled={loadingAdd || !newNote.trim()}
>
{loadingAdd ? t("common.saving") : `💾 ${t("tasks.addComment")}`}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More