Compare commits

...

31 Commits

Author SHA1 Message Date
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
77 changed files with 6431 additions and 1782 deletions

3
.gitignore vendored
View File

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

201
DOCKER_GIT_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,201 @@
# 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.
### 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

View File

@@ -1,20 +1,43 @@
# Use Node.js 22.11.0 as the base image # Use Node.js 22.11.0 as the base image
FROM node:22.11.0 FROM node:22.11.0
# Install git
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# Set the working directory # Set the working directory
WORKDIR /app 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 ./ COPY package*.json ./
# Install dependencies # Install dependencies
RUN npm install 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 . . 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 the default Next.js port
EXPOSE 3000 EXPOSE 3000
# Start the dev server # Use the entrypoint script
CMD ["npm", "run", "dev"] ENTRYPOINT ["/docker-entrypoint.sh"]

27
Dockerfile.dev Normal file
View File

@@ -0,0 +1,27 @@
# Use Node.js 22.11.0 as the base image
FROM node:22.11.0
# 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"]

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"

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

@@ -0,0 +1,21 @@
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
environment:
- NODE_ENV=production
- 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,9 +2,11 @@ version: "3.9"
services: services:
app: app:
build: . build:
context: .
dockerfile: Dockerfile.dev
ports: ports:
- "3000:3000" - "3001:3000"
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules

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

@@ -0,0 +1,17 @@
#!/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
# 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

17
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/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
# Create admin account if it doesn't exist
echo "🔧 Setting up admin account..."
node scripts/create-admin.js
# Start the application
echo "✅ Starting production server..."
exec npm start

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!');

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

View File

@@ -8,6 +8,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"create-admin": "node scripts/create-admin.js",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",

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

View File

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

View File

@@ -15,7 +15,7 @@ export default function EditUserPage() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
email: "", username: "",
role: "user", role: "user",
is_active: true, is_active: true,
password: "" password: ""
@@ -62,7 +62,7 @@ export default function EditUserPage() {
setUser(userData); setUser(userData);
setFormData({ setFormData({
name: userData.name, name: userData.name,
email: userData.email, username: userData.username,
role: userData.role, role: userData.role,
is_active: userData.is_active, is_active: userData.is_active,
password: "" // Never populate password field password: "" // Never populate password field
@@ -84,7 +84,7 @@ export default function EditUserPage() {
// Prepare update data (exclude empty password) // Prepare update data (exclude empty password)
const updateData = { const updateData = {
name: formData.name, name: formData.name,
email: formData.email, username: formData.username,
role: formData.role, role: formData.role,
is_active: formData.is_active is_active: formData.is_active
}; };
@@ -209,12 +209,12 @@ export default function EditUserPage() {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Email * Username *
</label> </label>
<Input <Input
type="email" type="text"
value={formData.email} value={formData.username}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required required
/> />
</div> </div>

View File

@@ -12,8 +12,10 @@ import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader"; import PageHeader from "@/components/ui/PageHeader";
import { LoadingState } from "@/components/ui/States"; import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function UserManagementPage() { export default function UserManagementPage() {
const { t } = useTranslation();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -54,7 +56,7 @@ export default function UserManagementPage() {
}; };
const handleDeleteUser = async (userId) => { const handleDeleteUser = async (userId) => {
if (!confirm("Are you sure you want to delete this user?")) return; if (!confirm(t('admin.deleteUser') + "?")) return;
try { try {
const response = await fetch(`/api/admin/users/${userId}`, { const response = await fetch(`/api/admin/users/${userId}`, {
@@ -141,7 +143,7 @@ export default function UserManagementPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader title="User Management" description="Manage system users and permissions"> <PageHeader title={t('admin.userManagement')} description={t('admin.subtitle')}>
<Button <Button
variant="primary" variant="primary"
onClick={() => setShowCreateForm(true)} onClick={() => setShowCreateForm(true)}
@@ -149,7 +151,7 @@ export default function UserManagementPage() {
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg> </svg>
Add User {t('admin.newUser')}
</Button> </Button>
</PageHeader> </PageHeader>
@@ -192,7 +194,7 @@ export default function UserManagementPage() {
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3> <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>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -282,7 +284,7 @@ export default function UserManagementPage() {
function CreateUserModal({ onClose, onUserCreated }) { function CreateUserModal({ onClose, onUserCreated }) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
email: "", username: "",
password: "", password: "",
role: "user", role: "user",
is_active: true is_active: true
@@ -351,12 +353,12 @@ function CreateUserModal({ onClose, onUserCreated }) {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Email Username
</label> </label>
<Input <Input
type="email" type="text"
value={formData.email} value={formData.username}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required required
/> />
</div> </div>

View File

@@ -78,7 +78,7 @@ async function updateUserHandler(req, { params }) {
if (error.message.includes("already exists")) { if (error.message.includes("already exists")) {
return NextResponse.json( return NextResponse.json(
{ error: "A user with this email already exists" }, { error: "A user with this username already exists" },
{ status: 409 } { status: 409 }
); );
} }

View File

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

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,79 @@
import { NextResponse } from "next/server";
import { unlink } from "fs/promises";
import path from "path";
import db from "@/lib/db";
export async function DELETE(request, { params }) {
try {
const fileId = params.fileId;
// 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 }
);
}
}
export async function GET(request, { params }) {
try {
const fileId = params.fileId;
// 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 }
);
}
return NextResponse.json(file);
} catch (error) {
console.error("Error fetching file:", error);
return NextResponse.json(
{ error: "Failed to fetch 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,72 @@
// 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 }
);
}
}
// Protected route - require user authentication
export const DELETE = withUserAuth(deleteNoteHandler);

View File

@@ -3,13 +3,59 @@ export const runtime = "nodejs";
import db from "@/lib/db"; import db from "@/lib/db";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth"; import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
import { import {
logApiActionSafe, logApiActionSafe,
AUDIT_ACTIONS, AUDIT_ACTIONS,
RESOURCE_TYPES, RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js"; } 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) { async function createNoteHandler(req) {
const { project_id, task_id, note } = await req.json(); const { project_id, task_id, note } = await req.json();
@@ -118,6 +164,7 @@ async function updateNoteHandler(req, { params }) {
} }
// Protected routes - require authentication // Protected routes - require authentication
export const GET = withReadAuth(getNotesHandler);
export const POST = withUserAuth(createNoteHandler); export const POST = withUserAuth(createNoteHandler);
export const DELETE = withUserAuth(deleteNoteHandler); export const DELETE = withUserAuth(deleteNoteHandler);
export const PUT = withUserAuth(updateNoteHandler); export const PUT = withUserAuth(updateNoteHandler);

View File

@@ -1,13 +1,45 @@
import { import {
updateProjectTaskStatus, updateProjectTaskStatus,
deleteProjectTask, deleteProjectTask,
updateProjectTask,
} from "@/lib/queries/tasks"; } from "@/lib/queries/tasks";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth"; import { withUserAuth } from "@/lib/middleware/auth";
// PATCH: Update project task status // PUT: Update project task (general update)
async function updateProjectTaskHandler(req, { params }) { async function updateProjectTaskHandler(req, { params }) {
try { 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(); const { status } = await req.json();
if (!status) { if (!status) {
@@ -17,7 +49,7 @@ async function updateProjectTaskHandler(req, { params }) {
); );
} }
updateProjectTaskStatus(params.id, status, req.user?.id || null); updateProjectTaskStatus(id, status, req.user?.id || null);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error updating task status:", error); console.error("Error updating task status:", error);
@@ -31,16 +63,19 @@ async function updateProjectTaskHandler(req, { params }) {
// DELETE: Delete a project task // DELETE: Delete a project task
async function deleteProjectTaskHandler(req, { params }) { async function deleteProjectTaskHandler(req, { params }) {
try { try {
deleteProjectTask(params.id); const { id } = await params;
const result = deleteProjectTask(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error in deleteProjectTaskHandler:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to delete project task" }, { error: "Failed to delete project task", details: error.message },
{ status: 500 } { status: 500 }
); );
} }
} }
// Protected routes - require authentication // Protected routes - require authentication
export const PATCH = withUserAuth(updateProjectTaskHandler); export const PUT = withUserAuth(updateProjectTaskHandler);
export const PATCH = withUserAuth(updateProjectTaskStatusHandler);
export const DELETE = withUserAuth(deleteProjectTaskHandler); export const DELETE = withUserAuth(deleteProjectTaskHandler);

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 { import {
getProjectById, getProjectById,
getProjectWithContract,
updateProject, updateProject,
deleteProject, deleteProject,
} from "@/lib/queries/projects"; } from "@/lib/queries/projects";
import { logFieldChange } from "@/lib/queries/fieldHistory";
import { addNoteToProject } from "@/lib/queries/notes";
import initializeDatabase from "@/lib/init-db"; import initializeDatabase from "@/lib/init-db";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth"; import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
@@ -20,7 +23,7 @@ initializeDatabase();
async function getProjectHandler(req, { params }) { async function getProjectHandler(req, { params }) {
const { id } = await params; const { id } = await params;
const project = getProjectById(parseInt(id)); const project = getProjectWithContract(parseInt(id));
if (!project) { if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 }); return NextResponse.json({ error: "Project not found" }, { status: 404 });
@@ -40,35 +43,85 @@ async function getProjectHandler(req, { params }) {
} }
async function updateProjectHandler(req, { params }) { async function updateProjectHandler(req, { params }) {
const { id } = await params; try {
const data = await req.json(); const { id } = await params;
const data = await req.json();
// Get user ID from authenticated request // Get user ID from authenticated request
const userId = req.user?.id; const userId = req.user?.id;
// Get original project data for audit log // Get original project data for audit log and field tracking
const originalProject = getProjectById(parseInt(id)); const originalProject = getProjectById(parseInt(id));
updateProject(parseInt(id), data, userId); if (!originalProject) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
// 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); // Track field changes for specific fields we want to monitor
const fieldsToTrack = ['finish_date', 'project_status', 'assigned_to', 'contract_id'];
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 cancellationNote = `Projekt został wycofany w dniu ${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 }) { async function deleteProjectHandler(req, { params }) {

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

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

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

@@ -0,0 +1,389 @@
"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'
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, 4);
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 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">
{/* 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>
)}
{/* 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>
</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>
{viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()}
</PageContainer>
);
}

View File

@@ -10,6 +10,8 @@ import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader"; import PageHeader from "@/components/ui/PageHeader";
import { LoadingState } from "@/components/ui/States"; import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import FileUploadModal from "@/components/FileUploadModal";
import FileAttachmentsList from "@/components/FileAttachmentsList";
export default function ContractDetailsPage() { export default function ContractDetailsPage() {
const params = useParams(); const params = useParams();
@@ -17,6 +19,8 @@ export default function ContractDetailsPage() {
const [contract, setContract] = useState(null); const [contract, setContract] = useState(null);
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showUploadModal, setShowUploadModal] = useState(false);
const [attachments, setAttachments] = useState([]);
useEffect(() => { useEffect(() => {
async function fetchContractDetails() { async function fetchContractDetails() {
@@ -52,6 +56,14 @@ export default function ContractDetailsPage() {
fetchContractDetails(); fetchContractDetails();
} }
}, [contractId]); }, [contractId]);
const handleFileUploaded = (newFile) => {
setAttachments(prev => [newFile, ...prev]);
};
const handleFilesChange = (files) => {
setAttachments(files);
};
if (loading) { if (loading) {
return ( return (
<PageContainer> <PageContainer>
@@ -245,6 +257,44 @@ export default function ContractDetailsPage() {
</div> </div>
</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">
Contract Documents ({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>
Upload Document
</Button>
</div>
</CardHeader>
<CardContent>
<FileAttachmentsList
entityType="contract"
entityId={contractId}
onFilesChange={handleFilesChange}
/>
</CardContent>
</Card>
{/* Associated Projects */} {/* Associated Projects */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -386,6 +436,15 @@ export default function ContractDetailsPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* File Upload Modal */}
<FileUploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
entityType="contract"
entityId={contractId}
onFileUploaded={handleFileUploaded}
/>
</PageContainer> </PageContainer>
); );
} }

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@ export default function ProjectTasksPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Project Tasks" title="Zadania projektów"
description="View and manage tasks across all projects in a structured list format" description="---"
/> />
<ProjectTasksList /> <ProjectTasksList />
</PageContainer> </PageContainer>

View File

@@ -1,33 +1,96 @@
import { "use client";
getProjectWithContract,
getNotesForProject, import { useState, useEffect } from "react";
} from "@/lib/queries/projects"; import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import NoteForm from "@/components/NoteForm"; import NoteForm from "@/components/NoteForm";
import ProjectTasksSection from "@/components/ProjectTasksSection"; import ProjectTasksSection from "@/components/ProjectTasksSection";
import FieldWithHistory from "@/components/FieldWithHistory";
import { Card, CardHeader, CardContent } from "@/components/ui/Card"; import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
import Link from "next/link"; import Link from "next/link";
import { differenceInCalendarDays, parseISO } from "date-fns"; import { differenceInCalendarDays, parseISO } from "date-fns";
import { formatDate } from "@/lib/utils"; import { formatDate, formatCoordinates } from "@/lib/utils";
import PageContainer from "@/components/ui/PageContainer"; import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader"; import PageHeader from "@/components/ui/PageHeader";
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown"; import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
import ClientProjectMap from "@/components/ui/ClientProjectMap"; import ClientProjectMap from "@/components/ui/ClientProjectMap";
export default async function ProjectViewPage({ params }) { export default function ProjectViewPage() {
const { id } = await params; const params = useParams();
const project = await getProjectWithContract(id); const { data: session } = useSession();
const notes = await getNotesForProject(id); const [project, setProject] = useState(null);
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
// Helper function to check if user can delete a note
const canDeleteNote = (note) => {
if (!session?.user) return false;
// Admins can delete any note
if (session.user.role === 'admin') return true;
// Users can delete their own notes
return note.created_by === session.user.id;
};
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() : [];
setProject(projectData);
setNotes(notesData);
} 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) { if (!project) {
return ( return (
<PageContainer> <PageContainer>
<Card> <Card>
<CardContent className="text-center py-8"> <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"> <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> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -44,62 +107,140 @@ export default async function ProjectViewPage({ params }) {
if (days <= 7) return "warning"; if (days <= 7) return "warning";
return "success"; return "success";
}; };
return ( return (
<PageContainer> <PageContainer>
<PageHeader {/* Mobile: Full-width title, Desktop: Standard PageHeader */}
title={project.project_name} <div className="block sm:hidden mb-6">
description={`${project.city}${project.address}`} {/* Mobile Layout */}
action={ <div className="space-y-4">
<div className="flex items-center gap-3"> {/* Full-width title */}
<ProjectStatusDropdown project={project} size="sm" /> <div className="w-full">
{daysRemaining !== null && ( <h1 className="text-2xl font-bold text-gray-900 break-words">
<Badge variant={getDeadlineVariant(daysRemaining)} size="md"> {project.project_name}
{daysRemaining === 0 </h1>
? "Due Today" <p className="text-sm text-gray-600 mt-1">
: daysRemaining > 0 {project.city} {project.address}
? `${daysRemaining} days left` </p>
: `${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>
</div> </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}`}
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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Main Project Information */} {/* Main Project Information */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
@@ -108,7 +249,7 @@ export default async function ProjectViewPage({ params }) {
{" "} {" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Project Information Informacje o projekcie
</h2> </h2>
<Badge <Badge
variant={ variant={
@@ -123,12 +264,12 @@ export default async function ProjectViewPage({ params }) {
size="sm" size="sm"
> >
{project.project_type === "design" {project.project_type === "design"
? "Design (P)" ? "Projektowanie (P)"
: project.project_type === "construction" : project.project_type === "construction"
? "Construction (R)" ? "Realizacja (R)"
: project.project_type === "design+construction" : project.project_type === "design+construction"
? "Design + Construction (P+R)" ? "Projektowanie + Realizacja (P+R)"
: "Unknown"} : "Nieznany"}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@@ -136,7 +277,7 @@ export default async function ProjectViewPage({ params }) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Location Lokalizacja
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.city || "N/A"} {project.city || "N/A"}
@@ -144,7 +285,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Address Adres
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.address || "N/A"} {project.address || "N/A"}
@@ -152,7 +293,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Plot Działka
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.plot || "N/A"} {project.plot || "N/A"}
@@ -160,7 +301,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
District Dzielnica
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.district || "N/A"} {project.district || "N/A"}
@@ -168,22 +309,19 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Unit Jednostka
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.unit || "N/A"} {project.unit || "N/A"}
</p> </p>
</div>{" "} </div>{" "}
<div> <FieldWithHistory
<span className="text-sm font-medium text-gray-500 block mb-1"> tableName="projects"
Deadline recordId={project.project_id}
</span> fieldName="finish_date"
<p className="text-gray-900 font-medium"> currentValue={project.finish_date}
{project.finish_date label="Termin zakończenia"
? formatDate(project.finish_date) />
: "N/A"}
</p>
</div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
WP WP
@@ -194,7 +332,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Investment Number Numer inwestycji
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.investment_number || "N/A"} {project.investment_number || "N/A"}
@@ -205,7 +343,7 @@ export default async function ProjectViewPage({ params }) {
{project.contact && ( {project.contact && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Contact Kontakt
</span> </span>
<p className="text-gray-900 font-medium">{project.contact}</p> <p className="text-gray-900 font-medium">{project.contact}</p>
</div> </div>
@@ -214,10 +352,10 @@ export default async function ProjectViewPage({ params }) {
{project.coordinates && ( {project.coordinates && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Coordinates Współrzędne
</span> </span>
<p className="text-gray-900 font-medium font-mono text-sm"> <p className="text-gray-900 font-medium font-mono text-sm">
{project.coordinates} {formatCoordinates(project.coordinates)}
</p> </p>
</div> </div>
)} )}
@@ -237,14 +375,14 @@ export default async function ProjectViewPage({ params }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Contract Details Szczegóły umowy
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Contract Number Numer umowy
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.contract_number || "N/A"} {project.contract_number || "N/A"}
@@ -252,7 +390,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Contract Name Nazwa umowy
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.contract_name || "N/A"} {project.contract_name || "N/A"}
@@ -260,7 +398,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Customer Klient
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.customer || "N/A"} {project.customer || "N/A"}
@@ -268,7 +406,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Investor Inwestor
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.investor || "N/A"} {project.investor || "N/A"}
@@ -284,21 +422,21 @@ export default async function ProjectViewPage({ params }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Project Status Status projektu
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{" "} {" "}
<div> <div>
<span className="text-sm font-medium text-gray-500 block mb-2"> <span className="text-sm font-medium text-gray-500 block mb-2">
Current Status Aktualny status
</span> </span>
<ProjectStatusDropdown project={project} size="md" /> <ProjectStatusDropdown project={project} size="md" />
</div> </div>
{daysRemaining !== null && ( {daysRemaining !== null && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2"> <span className="text-sm font-medium text-gray-500 block mb-2">
Timeline Harmonogram
</span> </span>
<div className="text-center"> <div className="text-center">
<Badge <Badge
@@ -306,10 +444,10 @@ export default async function ProjectViewPage({ params }) {
size="lg" size="lg"
> >
{daysRemaining === 0 {daysRemaining === 0
? "Due Today" ? "Termin dzisiaj"
: daysRemaining > 0 : daysRemaining > 0
? `${daysRemaining} days remaining` ? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} days overdue`} : `${Math.abs(daysRemaining)} dni po terminie`}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -321,11 +459,11 @@ export default async function ProjectViewPage({ params }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Quick Actions Szybkie akcje
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Link href={`/projects/${id}/edit`} className="block"> <Link href={`/projects/${params.id}/edit`} className="block">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -344,7 +482,7 @@ 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" 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> </svg>
Edit Project Edytuj projekt
</Button> </Button>
</Link>{" "} </Link>{" "}
<Link href="/projects" className="block"> <Link href="/projects" className="block">
@@ -366,7 +504,7 @@ export default async function ProjectViewPage({ params }) {
d="M15 19l-7-7 7-7" d="M15 19l-7-7 7-7"
/> />
</svg> </svg>
Back to Projects Powrót do projektów
</Button> </Button>
</Link> </Link>
<Link href="/projects/map" className="block"> <Link href="/projects/map" className="block">
@@ -388,13 +526,13 @@ 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" 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> </svg>
View All on Map Zobacz wszystkie na mapie
</Button> </Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>{" "} </div>
{/* Project Location Map */} {/* Project Location Map */}
{project.coordinates && ( {project.coordinates && (
<div className="mb-8"> <div className="mb-8">
@@ -404,7 +542,7 @@ export default async function ProjectViewPage({ params }) {
{" "} {" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Project Location Lokalizacja projektu
</h2> </h2>
{project.coordinates && ( {project.coordinates && (
<Link <Link
@@ -428,7 +566,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" 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> </svg>
View on Full Map Zobacz na pełnej mapie
</Button> </Button>
</Link> </Link>
)} )}
@@ -449,16 +587,16 @@ export default async function ProjectViewPage({ params }) {
)} )}
{/* Project Tasks Section */} {/* Project Tasks Section */}
<div className="mb-8"> <div className="mb-8">
<ProjectTasksSection projectId={id} /> <ProjectTasksSection projectId={params.id} />
</div> </div>
{/* Notes Section */} {/* Notes Section */}
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold text-gray-900">Notes</h2> <h2 className="text-xl font-semibold text-gray-900">Notatki</h2>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-6"> <div className="mb-6">
<NoteForm projectId={id} /> <NoteForm projectId={params.id} />
</div> </div>
{notes.length === 0 ? ( {notes.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
@@ -475,10 +613,10 @@ export default async function ProjectViewPage({ params }) {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-gray-900 mb-2">
No notes yet Brak notatek
</h3> </h3>
<p className="text-gray-500"> <p className="text-gray-500">
Add your first note using the form above. Dodaj swoją pierwszą notatkę używając formularza powyżej.
</p> </p>
</div> </div>
) : ( ) : (
@@ -486,7 +624,7 @@ export default async function ProjectViewPage({ params }) {
{notes.map((n) => ( {notes.map((n) => (
<div <div
key={n.note_id} 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 justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -499,6 +637,44 @@ export default async function ProjectViewPage({ params }) {
</span> </span>
)} )}
</div> </div>
{canDeleteNote(n) && (
<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> <p className="text-gray-900 leading-relaxed">{n.note}</p>
</div> </div>

View File

@@ -29,6 +29,7 @@ function ProjectsMapPageContent() {
in_progress_design: true, in_progress_design: true,
in_progress_construction: true, in_progress_construction: true,
fulfilled: true, fulfilled: true,
cancelled: true,
}); });
const [activeBaseLayer, setActiveBaseLayer] = useState("OpenStreetMap"); const [activeBaseLayer, setActiveBaseLayer] = useState("OpenStreetMap");
const [activeOverlays, setActiveOverlays] = useState([]); const [activeOverlays, setActiveOverlays] = useState([]);
@@ -57,6 +58,11 @@ function ProjectsMapPageContent() {
label: "Completed", label: "Completed",
shortLabel: "Zakończony", shortLabel: "Zakończony",
}, },
cancelled: {
color: "#EF4444",
label: "Cancelled",
shortLabel: "Wycofany",
},
}; };
// Toggle all status filters // Toggle all status filters

View File

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

View File

@@ -11,41 +11,79 @@ import PageHeader from "@/components/ui/PageHeader";
import SearchBar from "@/components/ui/SearchBar"; import SearchBar from "@/components/ui/SearchBar";
import { LoadingState } from "@/components/ui/States"; import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectListPage() { export default function ProjectListPage() {
const { t } = useTranslation();
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filteredProjects, setFilteredProjects] = useState([]); const [filteredProjects, setFilteredProjects] = useState([]);
const [filters, setFilters] = useState({
status: 'all',
type: 'all',
customer: 'all'
});
const [customers, setCustomers] = useState([]);
const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist
useEffect(() => { useEffect(() => {
fetch("/api/projects") fetch("/api/projects")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
setProjects(data); setProjects(data);
setFilteredProjects(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(() => { useEffect(() => {
if (!searchTerm.trim()) { let filtered = projects;
setFilteredProjects(projects);
} else { // Apply status filter
const filtered = projects.filter((project) => { if (filters.status !== 'all') {
const searchLower = searchTerm.toLowerCase(); 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 search term
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter((project) => {
return ( return (
project.project_name?.toLowerCase().includes(searchLower) || project.project_name?.toLowerCase().includes(searchLower) ||
project.wp?.toLowerCase().includes(searchLower) || project.wp?.toLowerCase().includes(searchLower) ||
project.plot?.toLowerCase().includes(searchLower) || project.plot?.toLowerCase().includes(searchLower) ||
project.investment_number?.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)
); );
}); });
setFilteredProjects(filtered);
} }
}, [searchTerm, projects]);
setFilteredProjects(filtered);
}, [searchTerm, projects, filters]);
async function handleDelete(id) { 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; if (!confirmed) return;
const res = await fetch(`/api/projects/${id}`, { const res = await fetch(`/api/projects/${id}`, {
@@ -59,12 +97,63 @@ export default function ProjectListPage() {
const handleSearchChange = (e) => { const handleSearchChange = (e) => {
setSearchTerm(e.target.value); setSearchTerm(e.target.value);
}; };
const handleFilterChange = (filterType, value) => {
setFilters(prev => ({
...prev,
[filterType]: value
}));
};
const clearAllFilters = () => {
setFilters({
status: 'all',
type: 'all',
customer: 'all'
});
setSearchTerm('');
};
const toggleFilters = () => {
setFiltersExpanded(!filtersExpanded);
};
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || searchTerm.trim() !== '';
const getActiveFilterCount = () => {
let count = 0;
if (filters.status !== 'all') count++;
if (filters.type !== 'all') count++;
if (filters.customer !== 'all') 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 ( return (
<PageContainer> <PageContainer>
<PageHeader title="Projects" description="Manage and track your projects"> <PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
<div className="flex gap-2"> <div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-2 sm:gap-2">
<Link href="/projects/map"> <Link href="/projects/map" className="w-full sm:w-auto">
<Button variant="outline" size="lg"> <Button variant="outline" size="lg" className="w-full">
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 mr-2"
fill="none" fill="none"
@@ -78,11 +167,11 @@ export default function ProjectListPage() {
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" 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> </svg>
Map View {t('projects.mapView') || 'Widok mapy'}
</Button> </Button>
</Link> </Link>
<Link href="/projects/new"> <Link href="/projects/new" className="w-full sm:w-auto">
<Button variant="primary" size="lg"> <Button variant="primary" size="lg" className="w-full">
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 mr-2"
fill="none" fill="none"
@@ -96,7 +185,7 @@ export default function ProjectListPage() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Project {t('projects.newProject')}
</Button> </Button>
</Link> </Link>
</div> </div>
@@ -105,10 +194,194 @@ export default function ProjectListPage() {
<SearchBar <SearchBar
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
placeholder="Search by project name, WP, plot, or investment number..." placeholder={t('projects.searchPlaceholder')}
resultsCount={filteredProjects.length} 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 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 rounded-md text-sm 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 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 rounded-md text-sm 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 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 rounded-md text-sm 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>
</div>
</div>
{/* Desktop always visible content */}
<div className="hidden md:block">
<div className="p-4">
<div className="flex flex-col space-y-4 md:flex-row md:flex-wrap md:gap-4 md:space-y-0 md:items-center">
<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 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 rounded-md text-sm 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 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 rounded-md text-sm 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 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 rounded-md text-sm 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>
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || searchTerm) && (
<Button
variant="outline"
size="sm"
onClick={clearAllFilters}
className="text-xs self-start md:self-auto"
>
{t('common.clearAllFilters') || 'Wyczyść wszystkie filtry'}
</Button>
)}
<div className="text-sm text-gray-500 md:ml-auto md:text-right">
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
</div>
</div>
</div>
</div>
</Card>
{filteredProjects.length === 0 && searchTerm ? ( {filteredProjects.length === 0 && searchTerm ? (
<Card> <Card>
<CardContent className="text-center py-12"> <CardContent className="text-center py-12">
@@ -126,14 +399,13 @@ export default function ProjectListPage() {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-gray-900 mb-2">
No projects found {t('common.noResults')}
</h3> </h3>
<p className="text-gray-500 mb-6"> <p className="text-gray-500 mb-6">
No projects match your search criteria. Try adjusting your search {t('projects.noMatchingResults') || 'Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.'}
terms.
</p> </p>
<Button variant="outline" onClick={() => setSearchTerm("")}> <Button variant="outline" onClick={() => setSearchTerm("")}>
Clear Search {t('common.clearSearch') || 'Wyczyść wyszukiwanie'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -154,59 +426,55 @@ export default function ProjectListPage() {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2"> <h3 className="text-lg font-medium text-gray-900 mb-2">
No projects yet {t('projects.noProjects')}
</h3> </h3>
<p className="text-gray-500 mb-6"> <p className="text-gray-500 mb-6">
Get started by creating your first project {t('projects.noProjectsMessage')}
</p> </p>
<Link href="/projects/new"> <Link href="/projects/new">
<Button variant="primary">Create First Project</Button> <Button variant="primary">{t('projects.createFirstProject') || 'Utwórz pierwszy projekt'}</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full table-fixed"> {/* Mobile scroll container */}
<thead> <div className="overflow-x-auto">
<tr className="bg-gray-100 border-b"> <table className="w-full min-w-[600px] table-fixed">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-32"> <thead>
No. <tr className="bg-gray-100 border-b">
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20 md:w-24">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700"> Nr.
Project Name </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-[200px] md:w-[250px]">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40"> {t('projects.projectName')}
WP </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-16 md:w-20 hidden sm:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20"> WP
City </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-14 md:w-16 hidden md:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40"> {t('projects.city')}
Address </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20 md:w-24 hidden lg:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20"> {t('projects.address')}
Plot </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-14 md:w-16 hidden sm:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('projects.plot')}
Finish </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-18 md:w-20 hidden md:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-12"> {t('projects.finishDate')}
Type </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-10">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('common.type') || 'Typ'}
Status </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-16 md:w-20">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('common.status') || 'Status'}
Created By </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-14 md:w-16">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('common.actions') || 'Akcje'}
Assigned To </th>
</th> </tr>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20"> </thead>
Actions
</th>
</tr>
</thead>
<tbody> <tbody>
{filteredProjects.map((project, index) => ( {filteredProjects.map((project, index) => (
<tr <tr
@@ -220,41 +488,48 @@ export default function ProjectListPage() {
{project.project_number} {project.project_number}
</Badge> </Badge>
</td> </td>
<td className="px-2 py-3"> <td className="px-2 py-3 w-[200px] md:w-[250px]">
<Link <Link
href={`/projects/${project.project_id}`} href={`/projects/${project.project_id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block" className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block"
title={project.project_name} 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> </Link>
</td> </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 truncate hidden sm:table-cell"
title={project.wp} title={project.wp}
> >
{project.wp || "N/A"} {project.wp || "N/A"}
</td> </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 truncate hidden md:table-cell"
title={project.city} title={project.city}
> >
{project.city || "N/A"} {project.city || "N/A"}
</td> </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 truncate hidden lg:table-cell"
title={project.address} title={project.address}
> >
{project.address || "N/A"} {project.address || "N/A"}
</td> </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 truncate hidden sm:table-cell"
title={project.plot} title={project.plot}
> >
{project.plot || "N/A"} {project.plot || "N/A"}
</td>{" "} </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 truncate hidden md:table-cell"
title={project.finish_date} title={project.finish_date}
> >
{project.finish_date {project.finish_date
@@ -271,36 +546,16 @@ export default function ProjectListPage() {
: "-"} : "-"}
</td> </td>
<td className="px-2 py-3 text-xs text-gray-600 truncate"> <td className="px-2 py-3 text-xs text-gray-600 truncate">
{project.project_status === "registered" {getStatusLabel(project.project_status)}
? "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> </td>
<td className="px-2 py-3"> <td className="px-2 py-3">
<Link href={`/projects/${project.project_id}`}> <Link href={`/projects/${project.project_id}`}>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-xs px-2 py-1" className="text-xs px-2 py-1 w-full sm:w-auto"
> >
View {t('common.view') || 'Wyświetl'}
</Button> </Button>
</Link> </Link>
</td> </td>
@@ -308,6 +563,7 @@ export default function ProjectListPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
)} )}
</PageContainer> </PageContainer>

View File

@@ -14,8 +14,10 @@ import PageHeader from "@/components/ui/PageHeader";
import SearchBar from "@/components/ui/SearchBar"; import SearchBar from "@/components/ui/SearchBar";
import FilterBar from "@/components/ui/FilterBar"; import FilterBar from "@/components/ui/FilterBar";
import { LoadingState } from "@/components/ui/States"; import { LoadingState } from "@/components/ui/States";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTasksPage() { export default function ProjectTasksPage() {
const { t } = useTranslation();
const [allTasks, setAllTasks] = useState([]); const [allTasks, setAllTasks] = useState([]);
const [filteredTasks, setFilteredTasks] = useState([]); const [filteredTasks, setFilteredTasks] = useState([]);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -148,25 +150,25 @@ export default function ProjectTasksPage() {
const filterOptions = [ const filterOptions = [
{ {
label: "Status", label: t('tasks.status'),
value: statusFilter, value: statusFilter,
onChange: (e) => setStatusFilter(e.target.value), onChange: (e) => setStatusFilter(e.target.value),
options: [ options: [
{ value: "all", label: "All" }, { value: "all", label: t('common.all') },
{ value: "pending", label: "Pending" }, { value: "pending", label: t('taskStatus.pending') },
{ value: "in_progress", label: "In Progress" }, { value: "in_progress", label: t('taskStatus.in_progress') },
{ value: "completed", label: "Completed" }, { value: "completed", label: t('taskStatus.completed') },
], ],
}, },
{ {
label: "Priority", label: t('tasks.priority'),
value: priorityFilter, value: priorityFilter,
onChange: (e) => setPriorityFilter(e.target.value), onChange: (e) => setPriorityFilter(e.target.value),
options: [ options: [
{ value: "all", label: "All" }, { value: "all", label: t('common.all') },
{ value: "high", label: "High" }, { value: "high", label: t('tasks.high') },
{ value: "normal", label: "Normal" }, { value: "normal", label: t('tasks.medium') },
{ value: "low", label: "Low" }, { value: "low", label: t('tasks.low') },
], ],
}, },
]; ];
@@ -174,8 +176,8 @@ export default function ProjectTasksPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Project Tasks" title={t('tasks.title')}
description="Monitor and manage tasks across all projects" description={t('tasks.subtitle')}
/> />
<SearchBar <SearchBar
searchTerm={searchTerm} searchTerm={searchTerm}
@@ -206,7 +208,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <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"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.all} {statusCounts.all}
</p> </p>
@@ -233,7 +235,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <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"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.pending} {statusCounts.pending}
</p> </p>
@@ -260,7 +262,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <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"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.in_progress} {statusCounts.in_progress}
</p> </p>
@@ -287,7 +289,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <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"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.completed} {statusCounts.completed}
</p> </p>

View File

@@ -89,20 +89,12 @@ export default function TaskTemplatesPage() {
{template.description} {template.description}
</p> </p>
)}{" "} )}{" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<span className="text-xs text-gray-500"> <Link href={`/tasks/templates/${template.task_id}/edit`}>
Template ID: {template.task_id} <Button variant="outline" size="sm">
</span> Edit
<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
</Button> </Button>
</div> </Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -280,7 +280,7 @@ export default function AuditLogViewer() {
</div> </div>
{/* Statistics */} {/* Statistics */}
{stats && ( {stats && stats.total > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Total Events</h3> <h3 className="text-lg font-semibold">Total Events</h3>
@@ -289,22 +289,22 @@ export default function AuditLogViewer() {
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Top Action</h3> <h3 className="text-lg font-semibold">Top Action</h3>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{stats.actionBreakdown[0]?.action || "N/A"} {stats.actionBreakdown && stats.actionBreakdown[0]?.action || "N/A"}
</p> </p>
<p className="text-lg font-bold text-green-600"> <p className="text-lg font-bold text-green-600">
{stats.actionBreakdown[0]?.count || 0} {stats.actionBreakdown && stats.actionBreakdown[0]?.count || 0}
</p> </p>
</div> </div>
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Active Users</h3> <h3 className="text-lg font-semibold">Active Users</h3>
<p className="text-2xl font-bold text-purple-600"> <p className="text-2xl font-bold text-purple-600">
{stats.userBreakdown.length} {stats.userBreakdown ? stats.userBreakdown.length : 0}
</p> </p>
</div> </div>
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Resource Types</h3> <h3 className="text-lg font-semibold">Resource Types</h3>
<p className="text-2xl font-bold text-orange-600"> <p className="text-2xl font-bold text-orange-600">
{stats.resourceBreakdown.length} {stats.resourceBreakdown ? stats.resourceBreakdown.length : 0}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,126 @@
"use client";
import { useState, useEffect } from "react";
import Tooltip from "@/components/ui/Tooltip";
import { formatDate } from "@/lib/utils";
export default function FieldWithHistory({
tableName,
recordId,
fieldName,
currentValue,
displayValue = null,
label = null,
className = "",
}) {
const [hasHistory, setHasHistory] = useState(false);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
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 || "N/A";
};
// Create tooltip content
const tooltipContent = history.length > 0 && (
<div className="space-y-2">
<div className="font-medium text-white mb-2">Change History:</div>
{history.map((change, index) => (
<div key={change.id} className="text-xs border-b border-gray-600 pb-1 last:border-b-0">
<div className="flex justify-between items-start gap-2">
<div className="flex-1">
<div className="text-white font-medium">
Changed to: {getDisplayValue(change.new_value)}
</div>
{change.old_value && (
<div className="text-gray-400 text-xs">
From: {getDisplayValue(change.old_value)}
</div>
)}
{change.changed_by_name && (
<div className="text-gray-300">
by {change.changed_by_name}
</div>
)}
</div>
<div className="text-gray-400 text-right text-xs">
{formatDate(change.changed_at)}
</div>
</div>
{change.change_reason && (
<div className="text-gray-400 text-xs mt-1">
Reason: {change.change_reason}
</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}>
<svg
className="w-4 h-4 text-blue-500 hover:text-blue-700 cursor-help"
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>
</div>
);
}

View File

@@ -0,0 +1,177 @@
"use client";
import { useState, useEffect } from "react";
import Button from "@/components/ui/Button";
import { formatDate } from "@/lib/utils";
export default function FileAttachmentsList({ entityType, entityId, onFilesChange }) {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
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("Are you sure you want to delete this file?")) {
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("Failed to delete file");
}
} catch (error) {
console.error("Error deleting file:", error);
alert("Failed to delete file");
}
};
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">Loading files...</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">No documents uploaded yet</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={file.file_path}
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>
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>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useState, useRef } from "react";
import Button from "@/components/ui/Button";
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 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 || "Failed to upload file");
}
} catch (error) {
console.error("Upload error:", error);
alert("Failed to upload file");
} 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-white 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-gray-900">
Upload Document
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
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-gray-700 mb-2">
Description (optional)
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the document..."
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"
disabled={uploading}
/>
</div>
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
dragActive
? "border-blue-400 bg-blue-50"
: "border-gray-300 hover:border-gray-400"
} ${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-gray-600">Uploading...</span>
</div>
) : (
<div className="flex flex-col items-center">
<svg className="w-12 h-12 text-gray-400 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-gray-900 mb-2">
Drop files here or click to browse
</span>
<span className="text-xs text-gray-500 mb-4">
PDF, DOC, XLS, Images up to 10MB
</span>
<Button
type="button"
variant="outline"
onClick={onButtonClick}
disabled={uploading}
>
Choose File
</Button>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={uploading}
>
Cancel
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"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) => {
const date = new Date(dateString);
return date.toLocaleDateString("pl-PL", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
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,8 +1,10 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "@/lib/i18n";
export default function NoteForm({ projectId }) { export default function NoteForm({ projectId }) {
const { t } = useTranslation();
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
@@ -17,10 +19,10 @@ export default function NoteForm({ projectId }) {
if (res.ok) { if (res.ok) {
setNote(""); setNote("");
setStatus("Note added"); setStatus(t("common.addNoteSuccess"));
window.location.reload(); window.location.reload();
} else { } else {
setStatus("Failed to save note"); setStatus(t("common.addNoteError"));
} }
} }
@@ -29,7 +31,7 @@ export default function NoteForm({ projectId }) {
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
placeholder="Add a new note..." placeholder={t("common.addNotePlaceholder")}
className="border p-2 w-full" className="border p-2 w-full"
rows={3} rows={3}
required required
@@ -38,7 +40,7 @@ export default function NoteForm({ projectId }) {
type="submit" type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded" className="bg-blue-600 text-white px-4 py-2 rounded"
> >
Add Note {t("common.addNote")}
</button> </button>
{status && <p className="text-sm text-gray-600">{status}</p>} {status && <p className="text-sm text-gray-600">{status}</p>}
</form> </form>

View File

@@ -6,8 +6,10 @@ import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { formatDateForInput } from "@/lib/utils"; import { formatDateForInput } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectForm({ initialData = null }) { export default function ProjectForm({ initialData = null }) {
const { t } = useTranslation();
const [form, setForm] = useState({ const [form, setForm] = useState({
contract_id: "", contract_id: "",
project_name: "", project_name: "",
@@ -101,11 +103,11 @@ export default function ProjectForm({ initialData = null }) {
router.push("/projects"); router.push("/projects");
} }
} else { } else {
alert("Failed to save project."); alert(t('projects.saveError'));
} }
} catch (error) { } catch (error) {
console.error("Error saving project:", error); console.error("Error saving project:", error);
alert("Failed to save project."); alert(t('projects.saveError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -114,7 +116,7 @@ export default function ProjectForm({ initialData = null }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
{isEdit ? "Edit Project Details" : "Project Details"} {isEdit ? t('projects.editProjectDetails') : t('projects.projectDetails')}
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -123,7 +125,7 @@ export default function ProjectForm({ initialData = null }) {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Contract <span className="text-red-500">*</span> {t('projects.contract')} <span className="text-red-500">*</span>
</label> </label>
<select <select
name="contract_id" name="contract_id"
@@ -132,7 +134,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" 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 required
> >
<option value="">Select Contract</option> <option value="">{t('projects.selectContract')}</option>
{contracts.map((contract) => ( {contracts.map((contract) => (
<option <option
key={contract.contract_id} key={contract.contract_id}
@@ -146,7 +148,7 @@ export default function ProjectForm({ initialData = null }) {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Project Type <span className="text-red-500">*</span> {t('projects.type')} <span className="text-red-500">*</span>
</label> </label>
<select <select
name="project_type" name="project_type"
@@ -155,17 +157,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" 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 required
> >
<option value="design">Design (Projektowanie)</option> <option value="design">{t('projectType.design')}</option>
<option value="construction">Construction (Realizacja)</option> <option value="construction">{t('projectType.construction')}</option>
<option value="design+construction"> <option value="design+construction">
Design + Construction (Projektowanie + Realizacja) {t('projectType.design+construction')}
</option> </option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Assigned To {t('projects.assignedTo')}
</label> </label>
<select <select
name="assigned_to" name="assigned_to"
@@ -173,10 +175,10 @@ export default function ProjectForm({ initialData = null }) {
onChange={handleChange} 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" 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) => ( {users.map((user) => (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.name} ({user.email}) {user.name} ({user.username})
</option> </option>
))} ))}
</select> </select>
@@ -186,92 +188,92 @@ export default function ProjectForm({ initialData = null }) {
{/* Basic Information Section */} {/* Basic Information Section */}
<div className="border-t pt-6"> <div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-gray-900 mb-4">
Basic Information {t('projects.basicInformation')}
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-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> </label>
<Input <Input
type="text" type="text"
name="project_name" name="project_name"
value={form.project_name || ""} value={form.project_name || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter project name" placeholder={t('projects.enterProjectName')}
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
City {t('projects.city')}
</label> </label>
<Input <Input
type="text" type="text"
name="city" name="city"
value={form.city || ""} value={form.city || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter city" placeholder={t('projects.enterCity')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Address {t('projects.address')}
</label> </label>
<Input <Input
type="text" type="text"
name="address" name="address"
value={form.address || ""} value={form.address || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter address" placeholder={t('projects.enterAddress')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Plot {t('projects.plot')}
</label> </label>
<Input <Input
type="text" type="text"
name="plot" name="plot"
value={form.plot || ""} value={form.plot || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter plot number" placeholder={t('projects.enterPlotNumber')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
District {t('projects.district')}
</label> </label>
<Input <Input
type="text" type="text"
name="district" name="district"
value={form.district || ""} value={form.district || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter district" placeholder={t('projects.enterDistrict')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Unit {t('projects.unit')}
</label> </label>
<Input <Input
type="text" type="text"
name="unit" name="unit"
value={form.unit || ""} value={form.unit || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter unit" placeholder={t('projects.enterUnit')}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Finish Date {t('projects.finishDate')}
</label>{" "} </label>
<Input <Input
type="date" type="date"
name="finish_date" name="finish_date"
@@ -285,19 +287,19 @@ export default function ProjectForm({ initialData = null }) {
{/* Additional Information Section */} {/* Additional Information Section */}
<div className="border-t pt-6"> <div className="border-t pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-gray-900 mb-4">
Additional Information {t('projects.additionalInfo')}
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Investment Number {t('projects.investmentNumber')}
</label> </label>
<Input <Input
type="text" type="text"
name="investment_number" name="investment_number"
value={form.investment_number || ""} value={form.investment_number || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter investment number" placeholder={t('projects.placeholders.investmentNumber')}
/> />
</div> </div>
@@ -310,39 +312,39 @@ export default function ProjectForm({ initialData = null }) {
name="wp" name="wp"
value={form.wp || ""} value={form.wp || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter WP" placeholder={t('projects.placeholders.wp')}
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Contact Information {t('projects.contact')}
</label> </label>
<Input <Input
type="text" type="text"
name="contact" name="contact"
value={form.contact || ""} value={form.contact || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter contact details" placeholder={t('projects.placeholders.contact')}
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Coordinates {t('projects.coordinates')}
</label> </label>
<Input <Input
type="text" type="text"
name="coordinates" name="coordinates"
value={form.coordinates || ""} value={form.coordinates || ""}
onChange={handleChange} onChange={handleChange}
placeholder="e.g., 49.622958,20.629562" placeholder={t('projects.placeholders.coordinates')}
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Notes {t('projects.notes')}
</label> </label>
<textarea <textarea
name="notes" name="notes"
@@ -350,7 +352,7 @@ export default function ProjectForm({ initialData = null }) {
onChange={handleChange} onChange={handleChange}
rows={4} 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" 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>
</div> </div>
@@ -364,7 +366,7 @@ export default function ProjectForm({ initialData = null }) {
onClick={() => router.back()} onClick={() => router.back()}
disabled={loading} disabled={loading}
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button type="submit" variant="primary" disabled={loading}> <Button type="submit" variant="primary" disabled={loading}>
{loading ? ( {loading ? (
@@ -389,7 +391,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" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
{isEdit ? "Updating..." : "Creating..."} {isEdit ? t('projects.updating') : t('projects.creating')}
</> </>
) : ( ) : (
<> <>
@@ -408,7 +410,7 @@ export default function ProjectForm({ initialData = null }) {
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
Update Project {t('projects.updateProject')}
</> </>
) : ( ) : (
<> <>
@@ -425,7 +427,7 @@ export default function ProjectForm({ initialData = null }) {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Create Project {t('projects.createProject')}
</> </>
)} )}
</> </>

View File

@@ -3,12 +3,14 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectStatusDropdown({ export default function ProjectStatusDropdown({
project, project,
size = "md", size = "md",
showDropdown = true, showDropdown = true,
}) { }) {
const { t } = useTranslation();
const [status, setStatus] = useState(project.project_status); const [status, setStatus] = useState(project.project_status);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -21,21 +23,25 @@ export default function ProjectStatusDropdown({
const statusConfig = { const statusConfig = {
registered: { registered: {
label: "Registered", label: t("projectStatus.registered"),
variant: "secondary", variant: "secondary",
}, },
in_progress_design: { in_progress_design: {
label: "In Progress (Design)", label: t("projectStatus.in_progress_design"),
variant: "primary", variant: "primary",
}, },
in_progress_construction: { in_progress_construction: {
label: "In Progress (Construction)", label: t("projectStatus.in_progress_construction"),
variant: "primary", variant: "primary",
}, },
fulfilled: { fulfilled: {
label: "Completed", label: t("projectStatus.fulfilled"),
variant: "success", variant: "success",
}, },
cancelled: {
label: t("projectStatus.cancelled"),
variant: "danger",
},
}; };
const handleChange = async (newStatus) => { const handleChange = async (newStatus) => {
if (newStatus === status) { if (newStatus === status) {
@@ -48,11 +54,19 @@ export default function ProjectStatusDropdown({
setIsOpen(false); setIsOpen(false);
try { 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", method: "PUT",
headers: { "Content-Type": "application/json" }, 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(); window.location.reload();
} catch (error) { } catch (error) {
console.error("Failed to update status:", error); console.error("Failed to update status:", error);
@@ -73,9 +87,6 @@ export default function ProjectStatusDropdown({
} }
}; };
const handleOpen = () => { const handleOpen = () => {
console.log(
"ProjectStatusDropdown handleOpen called, setting isOpen to true"
);
setIsOpen(true); setIsOpen(true);
}; };
@@ -111,10 +122,6 @@ export default function ProjectStatusDropdown({
<button <button
ref={buttonRef} ref={buttonRef}
onClick={() => { onClick={() => {
console.log(
"ProjectStatusDropdown button clicked, current isOpen:",
isOpen
);
setIsOpen(!isOpen); setIsOpen(!isOpen);
}} }}
disabled={loading} disabled={loading}
@@ -145,17 +152,13 @@ export default function ProjectStatusDropdown({
</svg> </svg>
</Badge> </Badge>
</button>{" "} </button>{" "}
{/* Simple dropdown for debugging */} {/* Status Options Dropdown */}
{isOpen && ( {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="absolute top-full left-0 mt-1 bg-white border border-gray-200 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>
{Object.entries(statusConfig).map(([statusKey, config]) => ( {Object.entries(statusConfig).map(([statusKey, config]) => (
<button <button
key={statusKey} key={statusKey}
onClick={() => { onClick={() => {
console.log("ProjectStatus Option clicked:", statusKey);
handleChange(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 transition-colors first:rounded-t-md last:rounded-b-md"
@@ -170,9 +173,8 @@ export default function ProjectStatusDropdown({
{/* Backdrop */} {/* Backdrop */}
{isOpen && ( {isOpen && (
<div <div
className="fixed inset-0 z-[9998] bg-black bg-opacity-10" className="fixed inset-0 z-[9998]"
onClick={() => { onClick={() => {
console.log("ProjectStatus Backdrop clicked");
setIsOpen(false); setIsOpen(false);
}} }}
/> />

View File

@@ -29,6 +29,10 @@ export default function ProjectStatusDropdownDebug({
label: "Completed", label: "Completed",
variant: "success", variant: "success",
}, },
cancelled: {
label: "Cancelled",
variant: "danger",
},
}; };
const handleChange = async (newStatus) => { const handleChange = async (newStatus) => {

View File

@@ -29,6 +29,10 @@ export default function ProjectStatusDropdownSimple({
label: "Completed", label: "Completed",
variant: "success", variant: "success",
}, },
cancelled: {
label: "Cancelled",
variant: "danger",
},
}; };
const handleChange = async (newStatus) => { const handleChange = async (newStatus) => {

View File

@@ -3,8 +3,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Button from "./ui/Button"; import Button from "./ui/Button";
import Badge from "./ui/Badge"; import Badge from "./ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTaskForm({ projectId, onTaskAdded }) { export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const { t } = useTranslation();
const [taskTemplates, setTaskTemplates] = useState([]); const [taskTemplates, setTaskTemplates] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [taskType, setTaskType] = useState("template"); // "template" or "custom" const [taskType, setTaskType] = useState("template"); // "template" or "custom"
@@ -67,10 +69,10 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
setAssignedTo(""); setAssignedTo("");
if (onTaskAdded) onTaskAdded(); if (onTaskAdded) onTaskAdded();
} else { } else {
alert("Failed to add task to project."); alert(t("tasks.addTaskError"));
} }
} catch (error) { } catch (error) {
alert("Error adding task to project."); alert(t("tasks.addTaskError"));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -79,7 +81,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-3"> <label className="block text-sm font-medium text-gray-700 mb-3">
Task Type {t("tasks.taskType")}
</label> </label>
<div className="flex space-x-6"> <div className="flex space-x-6">
<label className="flex items-center"> <label className="flex items-center">
@@ -90,7 +92,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
onChange={(e) => setTaskType(e.target.value)} onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" 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>
<label className="flex items-center"> <label className="flex items-center">
<input <input
@@ -100,7 +102,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
onChange={(e) => setTaskType(e.target.value)} onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" 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> </label>
</div> </div>
</div> </div>
@@ -108,7 +110,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
{taskType === "template" ? ( {taskType === "template" ? (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Select Task Template {t("tasks.selectTemplate")}
</label>{" "} </label>{" "}
<select <select
value={selectedTemplate} value={selectedTemplate}
@@ -116,10 +118,10 @@ 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" 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 required
> >
<option value="">Choose a task template...</option> <option value="">{t("tasks.chooseTemplate")}</option>
{taskTemplates.map((template) => ( {taskTemplates.map((template) => (
<option key={template.task_id} value={template.task_id}> <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> </option>
))} ))}
</select> </select>
@@ -128,20 +130,20 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Task Name {t("tasks.taskName")}
</label> </label>
<input <input
type="text" type="text"
value={customTaskName} value={customTaskName}
onChange={(e) => setCustomTaskName(e.target.value)} 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" 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 required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Max Wait Days {t("tasks.maxWait")}
</label> </label>
<input <input
type="number" type="number"
@@ -154,13 +156,13 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Description {t("tasks.description")}
</label> </label>
<textarea <textarea
value={customDescription} value={customDescription}
onChange={(e) => setCustomDescription(e.target.value)} 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" 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} rows={3}
/> />
</div> </div>
@@ -169,14 +171,14 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Assign To <span className="text-gray-500 text-xs">(optional)</span> {t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
</label> </label>
<select <select
value={assignedTo} value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)} 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" 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> <option value="">{t("projects.unassigned")}</option>
{users.map((user) => ( {users.map((user) => (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.name} ({user.email}) {user.name} ({user.email})
@@ -187,17 +189,17 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Priority {t("tasks.priority")}
</label> </label>
<select <select
value={priority} value={priority}
onChange={(e) => setPriority(e.target.value)} 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" 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="low">{t("tasks.low")}</option>
<option value="normal">Normal</option> <option value="normal">{t("tasks.normal")}</option>
<option value="high">High</option> <option value="high">{t("tasks.high")}</option>
<option value="urgent">Urgent</option> <option value="urgent">{t("tasks.urgent")}</option>
</select> </select>
</div> </div>
@@ -211,7 +213,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
(taskType === "custom" && !customTaskName.trim()) (taskType === "custom" && !customTaskName.trim())
} }
> >
{isSubmitting ? "Adding..." : "Add Task"} {isSubmitting ? t("tasks.adding") : t("tasks.addTask")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -5,6 +5,7 @@ import { Card, CardHeader, CardContent } from "./ui/Card";
import Button from "./ui/Button"; import Button from "./ui/Button";
import Badge from "./ui/Badge"; import Badge from "./ui/Badge";
import TaskStatusDropdownSimple from "./TaskStatusDropdownSimple"; import TaskStatusDropdownSimple from "./TaskStatusDropdownSimple";
import TaskCommentsModal from "./TaskCommentsModal";
import SearchBar from "./ui/SearchBar"; import SearchBar from "./ui/SearchBar";
import { Select } from "./ui/Input"; import { Select } from "./ui/Input";
import Link from "next/link"; import Link from "next/link";
@@ -14,12 +15,16 @@ import {
formatDistanceToNow, formatDistanceToNow,
} from "date-fns"; } from "date-fns";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTasksList() { export default function ProjectTasksList() {
const { t } = useTranslation();
const [allTasks, setAllTasks] = useState([]); const [allTasks, setAllTasks] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [groupBy, setGroupBy] = useState("none"); const [groupBy, setGroupBy] = useState("none");
const [selectedTask, setSelectedTask] = useState(null);
const [showCommentsModal, setShowCommentsModal] = useState(false);
useEffect(() => { useEffect(() => {
const fetchAllTasks = async () => { const fetchAllTasks = async () => {
@@ -35,7 +40,19 @@ export default function ProjectTasksList() {
}; };
fetchAllTasks(); 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) => { const getTaskStatus = (task) => {
if (task.status === "completed" || task.status === "cancelled") { if (task.status === "completed" || task.status === "cancelled") {
return { type: "completed", days: 0 }; return { type: "completed", days: 0 };
@@ -195,7 +212,7 @@ export default function ProjectTasksList() {
// Group tasks by task name when groupBy is set to "task_name" // Group tasks by task name when groupBy is set to "task_name"
const groupTasksByName = (tasks) => { const groupTasksByName = (tasks) => {
if (groupBy !== "task_name") return { "All Tasks": tasks }; if (groupBy !== "task_name") return { [t("tasks.allTasks")]: tasks };
const groups = {}; const groups = {};
tasks.forEach((task) => { tasks.forEach((task) => {
@@ -223,13 +240,23 @@ export default function ProjectTasksList() {
const tasks = await res2.json(); const tasks = await res2.json();
setAllTasks(tasks); setAllTasks(tasks);
} else { } else {
alert("Failed to update task status"); alert(t("errors.generic"));
} }
} catch (error) { } 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) => { const getPriorityVariant = (priority) => {
switch (priority) { switch (priority) {
case "urgent": case "urgent":
@@ -250,13 +277,13 @@ export default function ProjectTasksList() {
if (days > 3) return "warning"; if (days > 3) return "warning";
return "high"; return "high";
}; };
const TaskRow = ({ task, showTimeLeft = false }) => ( const TaskRow = ({ task, showTimeLeft = false, showMaxWait = true }) => (
<tr className="hover:bg-gray-50 border-b border-gray-200"> <tr className="hover:bg-gray-50 border-b border-gray-200">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <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">{task.task_name}</span>
<Badge variant={getPriorityVariant(task.priority)} size="sm"> <Badge variant={getPriorityVariant(task.priority)} size="sm">
{task.priority} {t(`tasks.${task.priority}`)}
</Badge> </Badge>
</div> </div>
</td> </td>
@@ -273,26 +300,16 @@ export default function ProjectTasksList() {
<td className="px-4 py-3 text-sm text-gray-600"> <td className="px-4 py-3 text-sm text-gray-600">
{task.address || "N/A"} {task.address || "N/A"}
</td> </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">
{task.assigned_to_name ? ( {task.assigned_to_name ? (
<div> <div>
<div className="font-medium">{task.assigned_to_name}</div> <div className="font-medium">{task.assigned_to_name}</div>
<div className="text-xs text-gray-500"> {/* <div className="text-xs text-gray-500">
{task.assigned_to_email} {task.assigned_to_email}
</div> </div> */}
</div> </div>
) : ( ) : (
<span className="text-gray-400 italic">Unassigned</span> <span className="text-gray-400 italic">{t("projects.unassigned")}</span>
)} )}
</td> </td>
{showTimeLeft && ( {showTimeLeft && (
@@ -307,9 +324,9 @@ export default function ProjectTasksList() {
> >
{!isNaN(task.statusInfo.daysRemaining) {!isNaN(task.statusInfo.daysRemaining)
? task.statusInfo.daysRemaining > 0 ? task.statusInfo.daysRemaining > 0
? `${task.statusInfo.daysRemaining}d left` ? `${task.statusInfo.daysRemaining}${t("tasks.daysLeft")}`
: `${Math.abs(task.statusInfo.daysRemaining)}d overdue` : `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
: "Calculating..."} : t("common.loading")}
</Badge> </Badge>
)} )}
{task.statusInfo && {task.statusInfo &&
@@ -317,8 +334,8 @@ export default function ProjectTasksList() {
task.status === "in_progress" && ( task.status === "in_progress" && (
<Badge variant="danger" size="sm"> <Badge variant="danger" size="sm">
{!isNaN(task.statusInfo.daysRemaining) {!isNaN(task.statusInfo.daysRemaining)
? `${Math.abs(task.statusInfo.daysRemaining)}d overdue` ? `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
: "Overdue"} : t("tasks.overdue")}
</Badge> </Badge>
)} )}
</div> </div>
@@ -328,7 +345,7 @@ export default function ProjectTasksList() {
{task.status === "completed" && task.date_completed ? ( {task.status === "completed" && task.date_completed ? (
<div> <div>
<div> <div>
Completed:{" "} {t("taskStatus.completed")}:{" "}
{(() => { {(() => {
try { try {
const completedDate = new Date(task.date_completed); const completedDate = new Date(task.date_completed);
@@ -344,7 +361,7 @@ export default function ProjectTasksList() {
) : task.status === "in_progress" && task.date_started ? ( ) : task.status === "in_progress" && task.date_started ? (
<div> <div>
<div> <div>
Started:{" "} {t("tasks.dateStarted")}:{" "}
{(() => { {(() => {
try { try {
const startedDate = new Date(task.date_started); const startedDate = new Date(task.date_started);
@@ -368,22 +385,34 @@ export default function ProjectTasksList() {
})() })()
)} )}
</td> </td>
<td className="px-4 py-3 text-sm text-gray-500"> {showMaxWait && (
{task.max_wait_days} days <td className="px-4 py-3 text-sm text-gray-500">
</td> {task.max_wait_days} {t("tasks.days")}
</td>
)}
<td className="px-4 py-3"> <td className="px-4 py-3">
<TaskStatusDropdownSimple <div className="flex items-center gap-2">
task={task} <TaskStatusDropdownSimple
size="sm" task={task}
onStatusChange={handleStatusChange} size="sm"
/> onStatusChange={handleStatusChange}
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleShowComments(task)}
title={t("tasks.comments")}
>
💬
</Button>
</div>
</td> </td>
</tr> </tr>
); );
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => { const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false, showMaxWait = true }) => {
const filteredTasks = filterTasks(tasks); const filteredTasks = filterTasks(tasks);
const groupedTasks = groupTasksByName(filteredTasks); const groupedTasks = groupTasksByName(filteredTasks);
const colSpan = showTimeLeft ? "10" : "9"; const colSpan = showTimeLeft && showMaxWait ? "9" : showTimeLeft || showMaxWait ? "8" : "7";
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -391,49 +420,48 @@ export default function ProjectTasksList() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Task Name {t("tasks.taskName")}
</th>{" "} </th>{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Project {t("tasks.project")}
</th> </th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
City {t("projects.city")}
</th> </th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Address {t("projects.address")}
</th> </th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Created By {t("tasks.assignedTo")}
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Assigned To
</th> </th>
{showTimeLeft && ( {showTimeLeft && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Time Left {t("tasks.daysLeft")}
</th> </th>
)} )}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Date Info {t("tasks.dateCreated")}
</th> </th>
{showMaxWait && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
{t("tasks.maxWait")}
</th>
)}{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700"> <th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Max Wait {t("tasks.actions")}
</th>{" "}
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{Object.entries(groupedTasks).map(([groupName, groupTasks]) => ( {Object.entries(groupedTasks).map(([groupName, groupTasks]) => (
<Fragment key={`group-fragment-${groupName}`}> <Fragment key={`group-fragment-${groupName}`}>
{showGrouped && groupName !== "All Tasks" && ( {showGrouped && groupName !== t("tasks.allTasks") && (
<tr key={`group-${groupName}`}> <tr key={`group-${groupName}`}>
<td <td
colSpan={colSpan} 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 font-medium text-gray-800 text-sm"
> >
{groupName} ({groupTasks.length} tasks) {groupName} ({groupTasks.length} {t("tasks.tasks")})
</td> </td>
</tr> </tr>
)} )}
@@ -442,6 +470,7 @@ export default function ProjectTasksList() {
key={task.id} key={task.id}
task={task} task={task}
showTimeLeft={showTimeLeft} showTimeLeft={showTimeLeft}
showMaxWait={showMaxWait}
/> />
))} ))}
</Fragment> </Fragment>
@@ -450,7 +479,7 @@ export default function ProjectTasksList() {
</table> </table>
{filteredTasks.length === 0 && ( {filteredTasks.length === 0 && (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
<p>No tasks found</p> <p>{t("tasks.noTasks")}</p>
</div> </div>
)} )}
</div> </div>
@@ -476,7 +505,7 @@ export default function ProjectTasksList() {
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
{taskGroups.pending.length} {taskGroups.pending.length}
</div> </div>
<div className="text-sm text-gray-600">Pending</div> <div className="text-sm text-gray-600">{t("taskStatus.pending")}</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -484,7 +513,7 @@ export default function ProjectTasksList() {
<div className="text-2xl font-bold text-purple-600"> <div className="text-2xl font-bold text-purple-600">
{taskGroups.in_progress.length} {taskGroups.in_progress.length}
</div> </div>
<div className="text-sm text-gray-600">In Progress</div> <div className="text-sm text-gray-600">{t("taskStatus.in_progress")}</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -492,7 +521,7 @@ export default function ProjectTasksList() {
<div className="text-2xl font-bold text-green-600"> <div className="text-2xl font-bold text-green-600">
{taskGroups.completed.length} {taskGroups.completed.length}
</div> </div>
<div className="text-sm text-gray-600">Completed</div> <div className="text-sm text-gray-600">{t("taskStatus.completed")}</div>
</CardContent> </CardContent>
</Card> </Card>
</div>{" "} </div>{" "}
@@ -500,26 +529,26 @@ export default function ProjectTasksList() {
<SearchBar <SearchBar
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={(e) => setSearchTerm(e.target.value)} onSearchChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search tasks, projects, city, or address..." placeholder={t("tasks.searchPlaceholder")}
resultsCount={ resultsCount={
filterTasks(taskGroups.pending).length + filterTasks(taskGroups.pending).length +
filterTasks(taskGroups.in_progress).length + filterTasks(taskGroups.in_progress).length +
filterTasks(taskGroups.completed).length filterTasks(taskGroups.completed).length
} }
resultsText="tasks" resultsText={t("tasks.tasks")}
filters={ filters={
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">
Group by: {t("tasks.sortBy")}:
</label> </label>
<Select <Select
value={groupBy} value={groupBy}
onChange={(e) => setGroupBy(e.target.value)} onChange={(e) => setGroupBy(e.target.value)}
className="min-w-[120px]" className="min-w-[120px]"
> >
<option value="none">None</option> <option value="none">{t("common.none")}</option>
<option value="task_name">Task Name</option> <option value="task_name">{t("tasks.taskName")}</option>
</Select> </Select>
</div> </div>
</div> </div>
@@ -531,19 +560,20 @@ export default function ProjectTasksList() {
<div> <div>
<div className="mb-4"> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2"> <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"> <Badge variant="primary" size="md">
{taskGroups.pending.length} {taskGroups.pending.length}
</Badge> </Badge>
</h2> </h2>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Tasks waiting to be started {t("tasks.noTasksMessage")}
</p> </p>
</div> </div>
<TaskTable <TaskTable
tasks={taskGroups.pending} tasks={taskGroups.pending}
showGrouped={groupBy === "task_name"} showGrouped={groupBy === "task_name"}
showTimeLeft={false} showTimeLeft={false}
showMaxWait={true}
/> />
</div> </div>
@@ -551,19 +581,20 @@ export default function ProjectTasksList() {
<div> <div>
<div className="mb-4"> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2"> <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"> <Badge variant="secondary" size="md">
{taskGroups.in_progress.length} {taskGroups.in_progress.length}
</Badge> </Badge>
</h2> </h2>
<p className="text-sm text-gray-600 mt-1"> <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> </p>
</div> </div>
<TaskTable <TaskTable
tasks={taskGroups.in_progress} tasks={taskGroups.in_progress}
showGrouped={groupBy === "task_name"} showGrouped={groupBy === "task_name"}
showTimeLeft={true} showTimeLeft={true}
showMaxWait={false}
/> />
</div> </div>
@@ -571,22 +602,28 @@ export default function ProjectTasksList() {
<div> <div>
<div className="mb-4"> <div className="mb-4">
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2"> <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"> <Badge variant="success" size="md">
{taskGroups.completed.length} {taskGroups.completed.length}
</Badge> </Badge>
</h2> </h2>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Recently completed and cancelled tasks Ostatnio ukończone i anulowane zadania
</p> </p>
</div> </div>
<TaskTable <TaskTable
tasks={taskGroups.completed} tasks={taskGroups.completed}
showGrouped={groupBy === "task_name"} showGrouped={groupBy === "task_name"}
showTimeLeft={false} showTimeLeft={false}
showMaxWait={false}
/> />
</div> </div>
</div> </div> {/* Comments Modal */}
<TaskCommentsModal
task={selectedTask}
isOpen={showCommentsModal}
onClose={handleCloseComments}
/>
</div> </div>
); );
} }

View File

@@ -7,8 +7,10 @@ import { Card, CardHeader, CardContent } from "./ui/Card";
import Button from "./ui/Button"; import Button from "./ui/Button";
import Badge from "./ui/Badge"; import Badge from "./ui/Badge";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ProjectTasksSection({ projectId }) { export default function ProjectTasksSection({ projectId }) {
const { t } = useTranslation();
const [projectTasks, setProjectTasks] = useState([]); const [projectTasks, setProjectTasks] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [taskNotes, setTaskNotes] = useState({}); const [taskNotes, setTaskNotes] = useState({});
@@ -17,6 +19,15 @@ export default function ProjectTasksSection({ projectId }) {
const [showAddTaskModal, setShowAddTaskModal] = useState(false); const [showAddTaskModal, setShowAddTaskModal] = useState(false);
const [expandedDescriptions, setExpandedDescriptions] = useState({}); const [expandedDescriptions, setExpandedDescriptions] = useState({});
const [expandedNotes, setExpandedNotes] = 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(() => { useEffect(() => {
const fetchProjectTasks = async () => { const fetchProjectTasks = async () => {
try { 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(); fetchProjectTasks();
fetchUsers();
}, [projectId]); }, [projectId]);
// Handle escape key to close modal // Handle escape key to close modals
useEffect(() => { useEffect(() => {
const handleEscape = (e) => { const handleEscape = (e) => {
if (e.key === "Escape" && showAddTaskModal) { if (e.key === "Escape") {
setShowAddTaskModal(false); if (showEditTaskModal) {
handleCloseEditModal();
} else if (showAddTaskModal) {
setShowAddTaskModal(false);
}
} }
}; };
document.addEventListener("keydown", handleEscape); document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);
}, [showAddTaskModal]); }, [showAddTaskModal, showEditTaskModal]);
// Prevent body scroll when modal is open and handle map z-index // Prevent body scroll when modal is open and handle map z-index
useEffect(() => { useEffect(() => {
if (showAddTaskModal) { if (showAddTaskModal || showEditTaskModal) {
// Prevent body scroll // Prevent body scroll
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@@ -111,7 +138,7 @@ export default function ProjectTasksSection({ projectId }) {
nav.style.zIndex = ""; nav.style.zIndex = "";
}); });
}; };
}, [showAddTaskModal]); }, [showAddTaskModal, showEditTaskModal]);
const refetchTasks = async () => { const refetchTasks = async () => {
try { try {
const res = await fetch(`/api/project-tasks?project_id=${projectId}`); const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
@@ -155,14 +182,14 @@ export default function ProjectTasksSection({ projectId }) {
if (res.ok) { if (res.ok) {
refetchTasks(); // Refresh the list refetchTasks(); // Refresh the list
} else { } else {
alert("Failed to update task status"); alert(t("errors.generic"));
} }
} catch (error) { } catch (error) {
alert("Error updating task status"); alert(t("errors.generic"));
} }
}; };
const handleDeleteTask = async (taskId) => { const handleDeleteTask = async (taskId) => {
if (!confirm("Are you sure you want to delete this task?")) return; if (!confirm(t("common.deleteConfirm"))) return;
try { try {
const res = await fetch(`/api/project-tasks/${taskId}`, { const res = await fetch(`/api/project-tasks/${taskId}`, {
method: "DELETE", method: "DELETE",
@@ -171,10 +198,11 @@ export default function ProjectTasksSection({ projectId }) {
if (res.ok) { if (res.ok) {
refetchTasks(); // Refresh the list refetchTasks(); // Refresh the list
} else { } else {
alert("Failed to delete task"); const errorData = await res.json();
alert(t("errors.generic") + ": " + (errorData.error || t("errors.unknown")));
} }
} catch (error) { } 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 })); setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
setNewNote((prev) => ({ ...prev, [taskId]: "" })); setNewNote((prev) => ({ ...prev, [taskId]: "" }));
} else { } else {
alert("Failed to add note"); alert(t("errors.generic"));
} }
} catch (error) { } catch (error) {
alert("Error adding note"); alert(t("errors.generic"));
} finally { } finally {
setLoadingNotes((prev) => ({ ...prev, [taskId]: false })); setLoadingNotes((prev) => ({ ...prev, [taskId]: false }));
} }
}; };
const handleDeleteNote = async (noteId, taskId) => { const handleDeleteNote = async (noteId, taskId) => {
if (!confirm("Are you sure you want to delete this note?")) return; if (!confirm(t("common.deleteConfirm"))) return;
try { try {
const res = await fetch(`/api/task-notes?note_id=${noteId}`, { const res = await fetch(`/api/task-notes/${noteId}`, {
method: "DELETE", method: "DELETE",
}); });
@@ -224,12 +252,74 @@ export default function ProjectTasksSection({ projectId }) {
const notes = await notesRes.json(); const notes = await notesRes.json();
setTaskNotes((prev) => ({ ...prev, [taskId]: notes })); setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
} else { } else {
alert("Failed to delete note"); alert(t("errors.generic"));
} }
} catch (error) { } 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) => { const getPriorityVariant = (priority) => {
switch (priority) { switch (priority) {
case "urgent": case "urgent":
@@ -261,10 +351,10 @@ export default function ProjectTasksSection({ projectId }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <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"> <div className="flex items-center gap-3">
<Badge variant="default" className="text-sm"> <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> </Badge>
<Button <Button
variant="primary" variant="primary"
@@ -284,7 +374,7 @@ export default function ProjectTasksSection({ projectId }) {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Task {t("tasks.newTask")}
</Button> </Button>
</div> </div>
</div>{" "} </div>{" "}
@@ -305,7 +395,7 @@ export default function ProjectTasksSection({ projectId }) {
> >
<div className="flex items-center justify-between p-6 border-b"> <div className="flex items-center justify-between p-6 border-b">
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
Add New Task {t("tasks.newTask")}
</h3> </h3>
<button <button
onClick={() => setShowAddTaskModal(false)} onClick={() => setShowAddTaskModal(false)}
@@ -338,7 +428,7 @@ export default function ProjectTasksSection({ projectId }) {
{/* Current Tasks */} {/* Current Tasks */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{loading ? ( {loading ? (
@@ -364,10 +454,10 @@ export default function ProjectTasksSection({ projectId }) {
</svg> </svg>
</div> </div>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
No tasks assigned to this project yet. {t("tasks.noTasksMessage")}
</p> </p>
<p className="text-gray-400 text-xs mt-1"> <p className="text-gray-400 text-xs mt-1">
Add a task above to get started. {t("tasks.addTaskMessage")}
</p> </p>
</div> </div>
) : ( ) : (
@@ -376,22 +466,22 @@ export default function ProjectTasksSection({ projectId }) {
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Task {t("tasks.taskName")}
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priority {t("tasks.priority")}
</th>{" "} </th>{" "}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Max Wait {t("tasks.maxWait")}
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Started {t("tasks.dateStarted")}
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status {t("tasks.status")}
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48">
Actions {t("tasks.actions")}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -437,17 +527,17 @@ export default function ProjectTasksSection({ projectId }) {
variant={getPriorityVariant(task.priority)} variant={getPriorityVariant(task.priority)}
size="sm" size="sm"
> >
{task.priority} {t(`tasks.${task.priority}`)}
</Badge> </Badge>
</td> </td>
<td className="px-4 py-4 text-sm text-gray-600"> <td className="px-4 py-4 text-sm text-gray-600">
{task.max_wait_days} days {task.max_wait_days} {t("tasks.days")}
</td>{" "} </td>{" "}
<td className="px-4 py-4 text-sm text-gray-600"> <td className="px-4 py-4 text-sm text-gray-600">
{task.date_started {task.date_started
? formatDate(task.date_started) ? formatDate(task.date_started)
: "Not started"} : t("tasks.notStarted")}
</td>{" "} </td>
<td className="px-4 py-4"> <td className="px-4 py-4">
<TaskStatusDropdownSimple <TaskStatusDropdownSimple
task={task} task={task}
@@ -456,21 +546,70 @@ export default function ProjectTasksSection({ projectId }) {
/> />
</td> </td>
<td className="px-4 py-4"> <td className="px-4 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<button <Button
variant="ghost"
size="sm"
onClick={() => toggleNotes(task.id)} onClick={() => toggleNotes(task.id)}
className="text-xs text-blue-600 hover:text-blue-800 font-medium" 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} notes`} title={`${taskNotes[task.id]?.length || 0} ${t("tasks.notes")}`}
> >
Notes ({taskNotes[task.id]?.length || 0}) <svg
</button> 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 <Button
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => handleDeleteTask(task.id)} 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> </Button>
</div> </div>
</td> </td>
@@ -481,7 +620,7 @@ export default function ProjectTasksSection({ projectId }) {
<td colSpan="6" className="px-4 py-3"> <td colSpan="6" className="px-4 py-3">
<div className="text-sm text-gray-700"> <div className="text-sm text-gray-700">
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
Description: {t("tasks.description")}:
</span> </span>
<p className="mt-1">{task.description}</p> <p className="mt-1">{task.description}</p>
</div> </div>
@@ -494,7 +633,7 @@ export default function ProjectTasksSection({ projectId }) {
<td colSpan="6" className="px-4 py-4"> <td colSpan="6" className="px-4 py-4">
<div className="space-y-3"> <div className="space-y-3">
<h5 className="text-sm font-medium text-gray-900"> <h5 className="text-sm font-medium text-gray-900">
Notes ({taskNotes[task.id]?.length || 0}) {t("tasks.comments")} ({taskNotes[task.id]?.length || 0})
</h5> </h5>
{/* Existing Notes */} {/* Existing Notes */}
@@ -512,11 +651,11 @@ export default function ProjectTasksSection({ projectId }) {
> >
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-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"> <span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
System {t("admin.system")}
</span> </span>
)} ) : null}
{note.created_by_name && ( {note.created_by_name && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full font-medium"> <span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full font-medium">
{note.created_by_name} {note.created_by_name}
@@ -530,11 +669,6 @@ export default function ProjectTasksSection({ projectId }) {
{formatDate(note.note_date, { {formatDate(note.note_date, {
includeTime: true, includeTime: true,
})} })}
{note.created_by_name && (
<span className="ml-2">
by {note.created_by_name}
</span>
)}
</p> </p>
</div> </div>
{!note.is_system && ( {!note.is_system && (
@@ -546,7 +680,7 @@ export default function ProjectTasksSection({ projectId }) {
) )
} }
className="ml-2 text-red-500 hover:text-red-700 text-xs font-bold" className="ml-2 text-red-500 hover:text-red-700 text-xs font-bold"
title="Delete note" title={t("tasks.deleteNote")}
> >
× ×
</button> </button>
@@ -560,7 +694,7 @@ export default function ProjectTasksSection({ projectId }) {
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
placeholder="Add a note..." placeholder={t("tasks.addComment")}
value={newNote[task.id] || ""} value={newNote[task.id] || ""}
onChange={(e) => onChange={(e) =>
setNewNote((prev) => ({ setNewNote((prev) => ({
@@ -584,7 +718,7 @@ export default function ProjectTasksSection({ projectId }) {
!newNote[task.id]?.trim() !newNote[task.id]?.trim()
} }
> >
{loadingNotes[task.id] ? "Adding..." : "Add"} {loadingNotes[task.id] ? t("common.saving") : t("common.add")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -599,6 +733,134 @@ export default function ProjectTasksSection({ projectId }) {
)} )}
</CardContent> </CardContent>
</Card> </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="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> </div>
); );
} }

View File

@@ -0,0 +1,371 @@
"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 { useTranslation } from "@/lib/i18n";
export default function TaskCommentsModal({ task, isOpen, onClose }) {
const { t } = useTranslation();
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [newNote, setNewNote] = useState("");
const [loadingAdd, setLoadingAdd] = useState(false);
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 }),
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 rounded-lg w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b">
<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">
{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 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 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 rounded-lg">
{/* Location Information */}
{(task?.city || task?.address) && (
<div className="space-y-1">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide">{t("projects.locationDetails")}</h4>
{task?.city && (
<p className="text-sm text-gray-900">{task.city}</p>
)}
{task?.address && (
<p className="text-xs text-gray-600">{task.address}</p>
)}
</div>
)}
{/* Assignment Information */}
<div className="space-y-1">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide">{t("tasks.assignedTo")}</h4>
{task?.assigned_to_name ? (
<div>
<p className="text-sm text-gray-900">{task.assigned_to_name}</p>
<p className="text-xs text-gray-600">{task.assigned_to_email}</p>
</div>
) : (
<p className="text-sm text-gray-500 italic">{t("projects.unassigned")}</p>
)}
</div>
{/* Task Timing */}
<div className="space-y-1">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide">{t("tasks.dateCreated")}</h4>
{task?.max_wait_days && (
<p className="text-xs text-gray-600">{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 uppercase tracking-wide">{t("tasks.description")}</h4>
<p className="text-sm text-gray-900 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 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-4 bg-gray-200 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">
{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">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 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 border-blue-200 hover:bg-blue-100"
: "bg-white border-gray-200 hover:bg-gray-50 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 text-blue-700 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 text-gray-700 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 font-medium">
{formatDate(note.note_date, {
includeTime: true,
})}
</span>
</div>
<p className="text-sm text-gray-800 leading-relaxed">
{note.note}
</p>
</div>
{!note.is_system && (
<button
onClick={() => handleDeleteNote(note.note_id)}
className="ml-3 p-1 text-red-400 hover:text-red-600 hover:bg-red-50 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 bg-gradient-to-r from-gray-50 to-gray-100">
<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">
{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 rounded-lg 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 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>
);
}

View File

@@ -3,13 +3,16 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
import { useTranslation } from "@/lib/i18n";
export default function TaskStatusDropdown({ export default function TaskStatusDropdown({
task, task,
size = "sm", size = "sm",
showDropdown = true, showDropdown = true,
onStatusChange, onStatusChange,
}) { const [status, setStatus] = useState(task.status); }) {
const { t } = useTranslation();
const [status, setStatus] = useState(task.status);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0, position: 'bottom' }); const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0, position: 'bottom' });
@@ -23,19 +26,19 @@ export default function TaskStatusDropdown({
const statusConfig = { const statusConfig = {
pending: { pending: {
label: "Pending", label: t("taskStatus.pending"),
variant: "warning", variant: "warning",
}, },
in_progress: { in_progress: {
label: "In Progress", label: t("taskStatus.in_progress"),
variant: "primary", variant: "primary",
}, },
completed: { completed: {
label: "Completed", label: t("taskStatus.completed"),
variant: "success", variant: "success",
}, },
cancelled: { cancelled: {
label: "Cancelled", label: t("taskStatus.cancelled"),
variant: "danger", variant: "danger",
}, },
}; };

View File

@@ -9,6 +9,7 @@ const buttonVariants = {
danger: "bg-red-600 hover:bg-red-700 text-white", danger: "bg-red-600 hover:bg-red-700 text-white",
success: "bg-green-600 hover:bg-green-700 text-white", success: "bg-green-600 hover:bg-green-700 text-white",
outline: "border border-blue-600 text-blue-600 hover:bg-blue-50", outline: "border border-blue-600 text-blue-600 hover:bg-blue-50",
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-50",
}; };
const buttonSizes = { const buttonSizes = {

View File

@@ -0,0 +1,42 @@
"use client";
import { useTranslation } from '@/lib/i18n';
const LanguageSwitcher = ({ className = '' }) => {
const { language, changeLanguage, availableLanguages, t } = useTranslation();
const languageNames = {
pl: 'Polski',
en: 'English'
};
return (
<div className={`relative ${className}`}>
<select
value={language}
onChange={(e) => changeLanguage(e.target.value)}
className="appearance-none bg-white border border-gray-300 rounded-md px-3 py-1 pr-8 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
title={t('common.selectLanguage')}
>
{availableLanguages.map((lang) => (
<option key={lang} value={lang}>
{languageNames[lang] || lang.toUpperCase()}
</option>
))}
</select>
{/* Custom dropdown arrow */}
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<svg
className="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
</svg>
</div>
</div>
);
};
export default LanguageSwitcher;

View File

@@ -3,31 +3,34 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
import { useTranslation } from "@/lib/i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { useState } from "react";
const Navigation = () => { const Navigation = () => {
const pathname = usePathname(); const pathname = usePathname();
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const { t } = useTranslation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const isActive = (path) => { const isActive = (path) => {
if (path === "/") return pathname === "/"; if (path === "/") return pathname === "/";
// Exact match for paths
if (pathname === path) return true; if (pathname === path) return true;
// For nested paths, ensure we match the full path segment
if (pathname.startsWith(path + "/")) return true; if (pathname.startsWith(path + "/")) return true;
return false; return false;
}; };
const navItems = [ const navItems = [
{ href: "/", label: "Dashboard" }, { href: "/projects", label: t('navigation.projects') },
{ href: "/projects", label: "Projects" }, { href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
{ href: "/tasks/templates", label: "Task Templates" }, { href: "/tasks/templates", label: t('navigation.taskTemplates') },
{ href: "/project-tasks", label: "Project Tasks" }, { href: "/project-tasks", label: t('navigation.projectTasks') },
{ href: "/contracts", label: "Contracts" }, { href: "/contracts", label: t('navigation.contracts') },
]; ];
// Add admin-only items // Add admin-only items
if (session?.user?.role === 'admin') { if (session?.user?.role === 'admin') {
navItems.push({ href: "/admin/users", label: "User Management" }); navItems.push({ href: "/admin/users", label: t('navigation.userManagement') });
} }
const handleSignOut = async () => { const handleSignOut = async () => {
@@ -40,62 +43,145 @@ const Navigation = () => {
} }
return ( return (
<nav className="bg-white border-b border-gray-200"> <nav className="bg-white border-b border-gray-200 shadow-sm">
<div className="max-w-6xl mx-auto px-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
{/* Logo/Brand */}
<div className="flex items-center"> <div className="flex items-center">
<Link href="/" className="text-xl font-bold text-gray-900"> <Link href="/projects" className="text-xl font-bold text-gray-900 hover:text-blue-600 transition-colors">
Project Panel {t('navigation.projectPanel')}
</Link> </Link>
</div> </div>
<div className="flex items-center space-x-4"> {/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1">
{status === "loading" ? ( {status === "loading" ? (
<div className="text-gray-500">Loading...</div> <div className="text-gray-500">{t('navigation.loading')}</div>
) : session ? ( ) : session ? (
<> <>
<div className="flex space-x-8"> {navItems.map((item) => (
{navItems.map((item) => ( <Link
<Link key={item.href}
key={item.href} href={item.href}
href={item.href} className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ isActive(item.href)
isActive(item.href) ? "bg-blue-50 text-blue-700 border border-blue-200"
? "bg-blue-100 text-blue-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50" }`}
}`}
>
{item.label}
</Link>
))}
</div>
<div className="flex items-center space-x-4 ml-8 pl-8 border-l border-gray-200">
<div className="flex items-center space-x-2">
<div className="text-sm">
<div className="font-medium text-gray-900">{session.user.name}</div>
<div className="text-gray-500 capitalize">{session.user.role?.replace('_', ' ')}</div>
</div>
</div>
<button
onClick={handleSignOut}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm font-medium transition-colors"
> >
Sign Out {item.label}
</button> </Link>
))}
</>
) : null}
</div>
{/* Right side - User/Auth section */}
<div className="flex items-center space-x-4">
{status === "loading" ? (
<div className="text-blue-100">{t('navigation.loading')}</div>
) : session ? (
<>
{/* User Info */}
<div className="hidden md:flex items-center space-x-3">
<div className="text-right">
<div className="text-sm font-medium text-gray-900">{session.user.name}</div>
{/* <div className="text-xs text-gray-500 capitalize">
{t(`userRoles.${session.user.role}`) || session.user.role?.replace('_', ' ')}
</div> */}
</div>
{/* <div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-medium text-sm">
{session.user.name?.charAt(0).toUpperCase()}
</span>
</div> */}
</div> </div>
{/* Sign Out Button */}
<button
onClick={handleSignOut}
className="hidden md:block bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-md text-sm font-medium transition-colors"
>
{t('navigation.signOut')}
</button>
</> </>
) : ( ) : (
<Link <>
href="/auth/signin" <LanguageSwitcher />
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors" <Link
href="/auth/signin"
className="bg-white hover:bg-gray-50 text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md"
>
{t('navigation.signIn')}
</Link>
</>
)}
{/* Mobile menu button */}
{session && (
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
> >
Sign In <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</Link> {isMobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
)}
</svg>
</button>
)} )}
</div> </div>
</div> </div>
{/* Mobile Navigation Menu */}
{session && isMobileMenuOpen && (
<div className="md:hidden mt-4 pb-4 border-t border-gray-200">
<div className="flex flex-col space-y-2 pt-4">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`px-4 py-3 rounded-md text-sm font-medium transition-colors ${
isActive(item.href)
? "bg-blue-50 text-blue-700 border border-blue-200"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
>
{item.label}
</Link>
))}
{/* Mobile User Info */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 mt-4">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-600 font-medium text-sm">
{session.user.name?.charAt(0).toUpperCase()}
</span>
</div>
<div>
<div className="text-sm font-medium text-gray-900">{session.user.name}</div>
{/* <div className="text-xs text-gray-500 capitalize">
{t(`userRoles.${session.user.role}`) || session.user.role?.replace('_', ' ')}
</div> */}
</div>
</div>
<button
onClick={() => {
setIsMobileMenuOpen(false);
handleSignOut();
}}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded text-sm transition-colors"
>
{t('navigation.signOut')}
</button>
</div>
</div>
</div>
)}
</div> </div>
</nav> </nav>
); );

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "./Card";
import Badge from "./Badge";
import Button from "./Button";
import { formatDate } from "@/lib/utils";
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",
fulfilled: "bg-gray-100 text-gray-800",
cancelled: "bg-red-100 text-red-800",
};
const statusTranslations = {
registered: "Zarejestrowany",
approved: "Zatwierdzony",
pending: "Oczekujący",
in_progress: "W trakcie",
fulfilled: "Zakończony",
cancelled: "Wycofany",
};
export default function ProjectCalendarWidget({
projects = [],
compact = false,
showUpcoming = true,
maxUpcoming = 5
}) {
const [currentDate, setCurrentDate] = useState(new Date());
const [viewMode, setViewMode] = useState(compact ? 'upcoming' : 'mini-calendar');
// Filter projects that have finish dates and are not fulfilled
const activeProjects = projects.filter(p =>
p.finish_date && p.project_status !== 'fulfilled'
);
const getProjectsForDate = (date) => {
return activeProjects.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, 4);
return activeProjects
.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;
})
.slice(0, maxUpcoming);
};
const getOverdueProjects = () => {
const today = startOfDay(new Date());
return activeProjects
.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
})
.slice(0, maxUpcoming);
};
const renderMiniCalendar = () => {
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 = ['P', 'W', 'Ś', 'C', 'P', 'S', 'N'];
return (
<div className="space-y-3">
{/* Calendar Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">
{format(currentDate, 'LLLL yyyy', { locale: pl })}
</h3>
<div className="flex space-x-1">
<button
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
className="p-1 hover:bg-gray-100 rounded"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
className="p-1 hover:bg-gray-100 rounded"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 gap-1">
{weekdays.map(weekday => (
<div key={weekday} className="text-xs font-medium text-gray-500 text-center p-1">
{weekday}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((day, index) => {
const dayProjects = getProjectsForDate(day);
const isCurrentMonth = isSameMonth(day, currentDate);
const isToday = isSameDay(day, new Date());
const hasProjects = dayProjects.length > 0;
return (
<div
key={index}
className={`text-xs p-1 text-center relative ${
!isCurrentMonth ? 'text-gray-300' :
isToday ? 'bg-blue-100 text-blue-700 font-semibold rounded' :
'text-gray-700'
} ${hasProjects && isCurrentMonth ? 'font-semibold' : ''}`}
title={hasProjects ? `${dayProjects.length} projekt(ów): ${dayProjects.map(p => p.project_name).join(', ')}` : ''}
>
{format(day, 'd')}
{hasProjects && isCurrentMonth && (
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-1 h-1 bg-red-500 rounded-full"></div>
)}
</div>
);
})}
</div>
</div>
);
};
const renderUpcomingList = () => {
const upcomingProjects = getUpcomingProjects();
const overdueProjects = getOverdueProjects();
return (
<div className="space-y-4">
{/* Overdue Projects */}
{overdueProjects.length > 0 && (
<div>
<h4 className="text-sm font-medium text-red-600 mb-2">
Przeterminowane ({overdueProjects.length})
</h4>
<div className="space-y-2">
{overdueProjects.map(project => (
<Link
key={project.project_id}
href={`/projects/${project.project_id}`}
className="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition-colors"
>
<div className="text-sm font-medium text-gray-900 truncate">
{project.project_name}
</div>
<div className="text-xs text-red-600">
{formatDate(project.finish_date)}
</div>
</Link>
))}
</div>
</div>
)}
{/* Upcoming Projects */}
{upcomingProjects.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-2">
Nadchodzące ({upcomingProjects.length})
</h4>
<div className="space-y-2">
{upcomingProjects.map(project => {
const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24));
return (
<Link
key={project.project_id}
href={`/projects/${project.project_id}`}
className="block p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors"
>
<div className="text-sm font-medium text-gray-900 truncate">
{project.project_name}
</div>
<div className="text-xs text-gray-600">
{formatDate(project.finish_date)} za {daysUntilDeadline} dni
</div>
</Link>
);
})}
</div>
</div>
)}
{upcomingProjects.length === 0 && overdueProjects.length === 0 && (
<div className="text-sm text-gray-500 text-center py-4">
Brak nadchodzących terminów
</div>
)}
</div>
);
};
if (activeProjects.length === 0) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Kalendarz projektów</h3>
<Link href="/calendar">
<Button variant="outline" size="sm">
Zobacz pełny kalendarz
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-gray-500">
Brak aktywnych projektów z terminami
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Kalendarz projektów</h3>
<div className="flex items-center space-x-2">
{!compact && (
<div className="flex rounded-md bg-gray-100 p-1">
<button
onClick={() => setViewMode('mini-calendar')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'mini-calendar'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Kalendarz
</button>
<button
onClick={() => setViewMode('upcoming')}
className={`px-2 py-1 text-xs rounded ${
viewMode === 'upcoming'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Lista
</button>
</div>
)}
<Link href="/calendar">
<Button variant="outline" size="sm">
Pełny kalendarz
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent>
{viewMode === 'mini-calendar' ? renderMiniCalendar() : renderUpcomingList()}
</CardContent>
</Card>
);
}

View File

@@ -32,6 +32,7 @@ export default function ProjectMap({
label: "In Progress (Construction)", label: "In Progress (Construction)",
}, },
fulfilled: { color: "#10B981", label: "Completed" }, fulfilled: { color: "#10B981", label: "Completed" },
cancelled: { color: "#EF4444", label: "Cancelled" },
}; };
useEffect(() => { useEffect(() => {
@@ -51,7 +52,7 @@ export default function ProjectMap({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700"> <h3 className="text-sm font-medium text-gray-700">
Project Location Lokalizacja projektu
</h3> </h3>
<div className="text-xs text-gray-500">No coordinates available</div> <div className="text-xs text-gray-500">No coordinates available</div>
</div> </div>
@@ -85,7 +86,7 @@ export default function ProjectMap({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-gray-700"> <h3 className="text-sm font-medium text-gray-700">
Project Location Lokalizacja projektu
</h3> </h3>
<div <div
className="w-3 h-3 rounded-full border border-white shadow-sm" className="w-3 h-3 rounded-full border border-white shadow-sm"
@@ -95,7 +96,7 @@ export default function ProjectMap({
</div> </div>
{showLayerControl && ( {showLayerControl && (
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
Use the layer control (📚) to switch map views {/* Use the layer control (📚) to switch map views */}
</div> </div>
)} )}
</div> </div>
@@ -158,7 +159,7 @@ export default function ProjectMap({
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Coordinates: {coords.lat.toFixed(6)}, {coords.lng.toFixed(6)} {coords.lat.toFixed(6)}, {coords.lng.toFixed(6)}
</p> </p>
<div className="flex items-center gap-1 text-xs text-gray-500"> <div className="flex items-center gap-1 text-xs text-gray-500">
<div <div

View File

@@ -0,0 +1,95 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
export default function Tooltip({ children, content, className = "" }) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const updatePosition = () => {
if (triggerRef.current && tooltipRef.current) {
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const scrollY = window.scrollY;
const scrollX = window.scrollX;
// Calculate position (above the trigger by default)
let top = triggerRect.top + scrollY - tooltipRect.height - 8;
let left = triggerRect.left + scrollX + (triggerRect.width / 2) - (tooltipRect.width / 2);
// Keep tooltip within viewport
if (left < 10) left = 10;
if (left + tooltipRect.width > window.innerWidth - 10) {
left = window.innerWidth - tooltipRect.width - 10;
}
// If tooltip would go above viewport, show below instead
if (top < scrollY + 10) {
top = triggerRect.bottom + scrollY + 8;
}
setPosition({ top, left });
}
};
const handleMouseEnter = () => {
setIsVisible(true);
};
const handleMouseLeave = () => {
setIsVisible(false);
};
useEffect(() => {
if (isVisible) {
// Small delay to ensure tooltip is rendered before positioning
const timer = setTimeout(() => {
updatePosition();
}, 10);
const handleScroll = () => updatePosition();
const handleResize = () => updatePosition();
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize);
return () => {
clearTimeout(timer);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
};
}
}, [isVisible]);
const tooltip = isVisible && (
<div
ref={tooltipRef}
className={`fixed z-50 px-3 py-2 text-sm bg-gray-900 text-white rounded-lg shadow-lg border max-w-sm ${className}`}
style={{
top: position.top,
left: position.left,
}}
>
{content}
{/* Arrow pointing down */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
</div>
);
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="inline-block"
>
{children}
</span>
{typeof document !== "undefined" && createPortal(tooltip, document.body)}
</>
);
}

View File

@@ -177,7 +177,7 @@ export async function getAuditLogs({
SELECT SELECT
al.*, al.*,
u.name as user_name, u.name as user_name,
u.email as user_email u.username as user_email
FROM audit_logs al FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id LEFT JOIN users u ON al.user_id = u.id
WHERE 1=1 WHERE 1=1
@@ -291,7 +291,7 @@ export async function getAuditLogStats({
// Dynamic import to avoid Edge Runtime issues // Dynamic import to avoid Edge Runtime issues
const { default: db } = await import("./db.js"); const { default: db } = await import("./db.js");
let baseQuery = "FROM audit_logs WHERE 1=1"; let baseQuery = "FROM audit_logs al WHERE 1=1";
const params = []; const params = [];
if (startDate) { if (startDate) {
@@ -310,34 +310,48 @@ export async function getAuditLogStats({
// Actions breakdown // Actions breakdown
const actionsStmt = db.prepare(` const actionsStmt = db.prepare(`
SELECT action, COUNT(*) as count SELECT al.action, COUNT(*) as count
${baseQuery} ${baseQuery}
GROUP BY action GROUP BY al.action
ORDER BY count DESC ORDER BY count DESC
`); `);
const actionsResult = actionsStmt.all(...params); const actionsResult = actionsStmt.all(...params);
// Users breakdown // Users breakdown
const usersStmt = db.prepare(` let usersQuery = `
SELECT SELECT
al.user_id, al.user_id,
u.name as user_name, u.name as user_name,
u.email as user_email, u.username as user_email,
COUNT(*) as count COUNT(*) as count
${baseQuery} FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id LEFT JOIN users u ON al.user_id = u.id
GROUP BY al.user_id, u.name, u.email WHERE 1=1
`;
if (startDate) {
usersQuery += " AND al.timestamp >= ?";
}
if (endDate) {
usersQuery += " AND al.timestamp <= ?";
}
usersQuery += `
GROUP BY al.user_id, u.name, u.username
ORDER BY count DESC ORDER BY count DESC
LIMIT 10 LIMIT 10
`); `;
const usersStmt = db.prepare(usersQuery);
const usersResult = usersStmt.all(...params); const usersResult = usersStmt.all(...params);
// Resource types breakdown // Resource types breakdown
const resourcesStmt = db.prepare(` const resourcesStmt = db.prepare(`
SELECT resource_type, COUNT(*) as count SELECT al.resource_type, COUNT(*) as count
${baseQuery} ${baseQuery}
WHERE resource_type IS NOT NULL WHERE al.resource_type IS NOT NULL
GROUP BY resource_type GROUP BY al.resource_type
ORDER BY count DESC ORDER BY count DESC
`); `);
const resourcesResult = resourcesStmt.all(...params); const resourcesResult = resourcesStmt.all(...params);

View File

@@ -4,7 +4,7 @@ import bcrypt from "bcryptjs";
import { z } from "zod"; import { z } from "zod";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email("Invalid email format"), username: z.string().min(1, "Username is required"),
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(6, "Password must be at least 6 characters"),
}); });
@@ -13,7 +13,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
Credentials({ Credentials({
name: "credentials", name: "credentials",
credentials: { credentials: {
email: { label: "Email", type: "email" }, username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" }, password: { label: "Password", type: "password" },
}, },
async authorize(credentials) { async authorize(credentials) {
@@ -28,13 +28,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const user = db const user = db
.prepare( .prepare(
` `
SELECT id, email, name, password_hash, role, is_active, SELECT id, username, name, password_hash, role, is_active,
failed_login_attempts, locked_until failed_login_attempts, locked_until
FROM users FROM users
WHERE email = ? AND is_active = 1 WHERE username = ? AND is_active = 1
` `
) )
.get(validatedFields.email); .get(validatedFields.username);
if (!user) { if (!user) {
throw new Error("Invalid credentials"); throw new Error("Invalid credentials");
@@ -75,7 +75,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
userId: user.id, userId: user.id,
resourceType: RESOURCE_TYPES.SESSION, resourceType: RESOURCE_TYPES.SESSION,
details: { details: {
email: validatedFields.email, username: validatedFields.username,
reason: "invalid_password", reason: "invalid_password",
failed_attempts: user.failed_login_attempts + 1, failed_attempts: user.failed_login_attempts + 1,
}, },
@@ -107,7 +107,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
userId: user.id, userId: user.id,
resourceType: RESOURCE_TYPES.SESSION, resourceType: RESOURCE_TYPES.SESSION,
details: { details: {
email: user.email, username: user.username,
role: user.role, role: user.role,
}, },
}); });
@@ -117,7 +117,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return { return {
id: user.id, id: user.id,
email: user.email, username: user.username,
name: user.name, name: user.name,
role: user.role, role: user.role,
}; };
@@ -128,30 +128,29 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}, },
}), }),
], ],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user }) {
if (user) { if (user) {
token.role = user.role; token.role = user.role;
token.userId = user.id; token.username = user.username;
} }
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
if (token) { if (token) {
session.user.id = token.userId; session.user.id = token.sub;
session.user.role = token.role; session.user.role = token.role;
session.user.username = token.username;
} }
return session; return session;
}, },
}, },
pages: { pages: {
signIn: "/auth/signin", signIn: "/auth/signin",
signOut: "/auth/signout",
error: "/auth/error",
}, },
debug: process.env.NODE_ENV === "development", session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours
},
secret: process.env.NEXTAUTH_SECRET,
}); });

156
src/lib/auth_backup.js Normal file
View File

@@ -0,0 +1,156 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { z } from "zod";
const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
// Import database here to avoid edge runtime issues
const { default: db } = await import("./db.js");
// Validate input
const validatedFields = loginSchema.parse(credentials);
// Check if user exists and is active
const user = db
.prepare(
`
SELECT id, username, name, password_hash, role, is_active,
failed_login_attempts, locked_until
FROM users
WHERE username = ? AND is_active = 1
`
)
.get(validatedFields.username);
if (!user) {
throw new Error("Invalid credentials");
}
// Check if account is locked
if (user.locked_until && new Date(user.locked_until) > new Date()) {
throw new Error("Account temporarily locked");
}
// Verify password
const isValidPassword = await bcrypt.compare(
validatedFields.password,
user.password_hash
);
if (!isValidPassword) {
// Increment failed attempts
db.prepare(
`
UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts >= 4
THEN datetime('now', '+15 minutes')
ELSE locked_until
END
WHERE id = ?
`
).run(user.id);
// Log failed login attempt (only in Node.js runtime)
try {
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
await import("./auditLogSafe.js");
await logAuditEventSafe({
action: AUDIT_ACTIONS.LOGIN_FAILED,
userId: user.id,
resourceType: RESOURCE_TYPES.SESSION,
details: {
username: validatedFields.username,
reason: "invalid_password",
failed_attempts: user.failed_login_attempts + 1,
},
});
} catch (auditError) {
console.error("Failed to log audit event:", auditError);
}
throw new Error("Invalid credentials");
}
// Reset failed attempts and update last login
db.prepare(
`
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL,
last_login = CURRENT_TIMESTAMP
WHERE id = ?
`
).run(user.id);
// Log successful login (only in Node.js runtime)
try {
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
await import("./auditLogSafe.js");
await logAuditEventSafe({
action: AUDIT_ACTIONS.LOGIN,
userId: user.id,
resourceType: RESOURCE_TYPES.SESSION,
details: {
username: user.username,
role: user.role,
},
});
} catch (auditError) {
console.error("Failed to log audit event:", auditError);
}
return {
id: user.id,
username: user.username,
name: user.name,
role: user.role,
};
} catch (error) {
console.error("Login error:", error);
return null;
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.username = user.username;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.sub;
session.user.role = token.role;
session.user.username = token.username;
}
return session;
},
},
pages: {
signIn: "/auth/signin",
},
session: {
strategy: "jwt",
maxAge: 24 * 60 * 60, // 24 hours
},
secret: process.env.NEXTAUTH_SECRET,
});

954
src/lib/i18n.js Normal file
View File

@@ -0,0 +1,954 @@
"use client";
import { createContext, useContext, useState, useEffect } from 'react';
// Translation context
const TranslationContext = createContext();
// Translation data
const translations = {
pl: {
// Navigation
navigation: {
dashboard: "Panel główny",
projects: "Projekty",
calendar: "Kalendarz",
taskTemplates: "Szablony zadań",
projectTasks: "Zadania projektów",
contracts: "Umowy",
userManagement: "Zarządzanie użytkownikami",
projectPanel: "Panel Projektów",
loading: "Ładowanie...",
signOut: "Wyloguj się",
signIn: "Zaloguj się"
},
// Common UI elements
common: {
save: "Zapisz",
cancel: "Anuluj",
delete: "Usuń",
edit: "Edytuj",
add: "Dodaj",
create: "Utwórz",
update: "Aktualizuj",
search: "Szukaj",
filter: "Filtruj",
loading: "Ładowanie...",
saving: "Zapisywanie...",
creating: "Tworzenie...",
updating: "Aktualizowanie...",
yes: "Tak",
no: "Nie",
confirm: "Potwierdź",
close: "Zamknij",
back: "Wstecz",
next: "Dalej",
previous: "Poprzedni",
page: "Strona",
of: "z",
items: "pozycji",
all: "Wszystkie",
none: "Brak",
required: "wymagane",
optional: "opcjonalne",
selectOption: "Wybierz opcję",
selectLanguage: "Wybierz język",
noResults: "Brak wyników",
error: "Błąd",
success: "Sukces",
warning: "Ostrzeżenie",
info: "Informacja",
deleteConfirm: "Czy na pewno chcesz to usunąć?",
addNote: "Dodaj notatkę",
addNoteSuccess: "Notatka została dodana",
addNoteError: "Nie udało się zapisać notatki",
addNotePlaceholder: "Dodaj nową notatkę...",
status: "Status",
type: "Typ",
actions: "Akcje",
view: "Wyświetl",
clearSearch: "Wyczyść wyszukiwanie",
clearAllFilters: "Wyczyść wszystkie filtry",
sortBy: "Sortuj według"
},
// Dashboard
dashboard: {
title: "Panel główny",
subtitle: "Przegląd twoich projektów i zadań",
totalProjects: "Wszystkie projekty",
activeProjects: "Aktywne projekty",
completedProjects: "Ukończone projekty",
overdueProjects: "Przeterminowane projekty",
totalContracts: "Wszystkie umowy",
activeContracts: "Aktywne umowy",
pendingTasks: "Oczekujące zadania",
inProgressTasks: "Zadania w trakcie",
completedTasks: "Ukończone zadania",
overdueTasks: "Przeterminowane zadania",
recentProjects: "Najnowsze projekty",
recentTasks: "Najnowsze zadania",
upcomingDeadlines: "Nadchodzące terminy",
projectsThisWeek: "Projekty w tym tygodniu",
tasksThisWeek: "Zadania w tym tygodniu",
completionRate: "Wskaźnik ukończenia",
viewAll: "Zobacz wszystkie",
noRecentProjects: "Brak najnowszych projektów",
noRecentTasks: "Brak najnowszych zadań",
noUpcomingDeadlines: "Brak nadchodzących terminów"
},
// Project statuses
projectStatus: {
registered: "Zarejestrowany",
in_progress_design: "W realizacji (projektowanie)",
in_progress_construction: "W realizacji (realizacja)",
fulfilled: "Zakończony",
cancelled: "Wycofany",
unknown: "Nieznany"
},
// Project types
projectType: {
design: "Projektowanie",
construction: "Realizacja",
"design+construction": "Projektowanie + Realizacja"
},
// Task statuses
taskStatus: {
pending: "Oczekujące",
in_progress: "W trakcie",
completed: "Ukończone",
cancelled: "Anulowane",
on_hold: "Wstrzymane"
},
// Projects
projects: {
title: "Projekty",
subtitle: "---",
newProject: "Nowy projekt",
editProject: "Edytuj projekt",
deleteProject: "Usuń projekt",
projectName: "Nazwa projektu",
city: "Miasto",
address: "Adres",
plot: "Działka",
district: "Jednostka ewidencyjna",
unit: "Obręb",
finishDate: "Data zakończenia",
type: "Typ",
contact: "Kontakt",
coordinates: "Współrzędne",
notes: "Notatki",
assignedTo: "Przypisane do",
unassigned: "Nieprzypisane",
createdBy: "Utworzone przez",
lastModified: "Ostatnia modyfikacja",
basicInformation: "Informacje podstawowe",
locationDetails: "Szczegóły lokalizacji",
projectDetails: "Szczegóły projektu",
additionalInfo: "Dodatkowe informacje",
searchPlaceholder: "Szukaj projektów po nazwie, mieście lub adresie...",
noProjects: "Brak projektów",
noProjectsMessage: "Rozpocznij od utworzenia swojego pierwszego projektu.",
notFinished: "Nie zakończone",
projects: "projektów",
mapView: "Widok mapy",
createFirstProject: "Utwórz pierwszy projekt",
noMatchingResults: "Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.",
showingResults: "Wyświetlono {shown} z {total} projektów",
deleteConfirm: "Czy na pewno chcesz usunąć ten projekt?",
deleteSuccess: "Projekt został pomyślnie usunięty",
createSuccess: "Projekt został pomyślnie utworzony",
updateSuccess: "Projekt został pomyślnie zaktualizowany",
saveError: "Nie udało się zapisać projektu",
enterProjectName: "Wprowadź nazwę projektu",
enterCity: "Wprowadź miasto",
enterAddress: "Wprowadź adres",
enterPlotNumber: "Wprowadź numer działki",
enterDistrict: "Wprowadź jednostkę ewidencyjną",
enterUnit: "Wprowadź obręb",
enterContactDetails: "Wprowadź dane kontaktowe",
coordinatesPlaceholder: "np. 49.622958,20.629562",
enterNotes: "Wprowadź notatki...",
createProject: "Utwórz projekt",
updateProject: "Aktualizuj projekt",
creating: "Tworzenie...",
updating: "Aktualizowanie...",
projectDetails: "Szczegóły projektu",
editProjectDetails: "Edytuj szczegóły projektu",
contract: "Umowa",
selectContract: "Wybierz umowę",
investmentNumber: "Numer inwestycji",
enterInvestmentNumber: "Wprowadź numer inwestycji",
enterWP: "Wprowadź WP",
placeholders: {
contact: "Wprowadź dane kontaktowe",
coordinates: "np. 49.622958,20.629562",
notes: "Wprowadź notatki...",
investmentNumber: "Wprowadź numer inwestycji",
wp: "Wprowadź WP"
}
},
// Contracts
contracts: {
title: "Umowy",
subtitle: "---",
newContract: "Nowa umowa",
editContract: "Edytuj umowę",
deleteContract: "Usuń umowę",
contractNumber: "Numer umowy",
contractName: "Nazwa umowy",
customerContractNumber: "Numer umowy klienta",
customer: "Klient",
investor: "Inwestor",
dateSigned: "Data zawarcia",
finishDate: "Data zakończenia",
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
noContracts: "Brak umów",
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
noMatchingContracts: "Brak pasujących umów",
changeSearchCriteria: "Spróbuj zmienić kryteria wyszukiwania lub filtry.",
all: "Wszystkie",
active: "Aktywne",
withoutEndDate: "W trakcie",
expired: "Przeterminowane",
createContract: "Utwórz umowę",
enterContractNumber: "Wprowadź numer umowy",
enterContractName: "Wprowadź nazwę umowy",
enterCustomerContractNumber: "Wprowadź numer umowy klienta",
enterCustomerName: "Wprowadź nazwę klienta",
enterInvestorName: "Wprowadź nazwę inwestora",
signedOn: "Zawarcie:",
finishOn: "Zakończenie:",
customerLabel: "Zleceniodawca:",
investorLabel: "Inwestor:"
},
// Tasks
tasks: {
title: "Zadania",
task: "zadanie",
tasks: "zadania",
subtitle: "Zarządzaj zadaniami projektów",
newTask: "Nowe zadanie",
editTask: "Edytuj zadanie",
deleteTask: "Usuń zadanie",
taskName: "Nazwa zadania",
description: "Opis",
priority: "Priorytet",
assignedTo: "Przypisane do",
dueDate: "Termin wykonania",
status: "Status",
project: "Projekt",
template: "Szablon",
searchPlaceholder: "Szukaj zadań, projektów, miast lub adresów...",
noTasks: "Brak zadań",
noTasksMessage: "Brak zadań przypisanych do tego projektu.",
addTaskMessage: "Dodaj zadanie powyżej, aby rozpocząć.",
filterBy: "Filtruj według",
sortBy: "Sortuj według",
allTasks: "Wszystkie zadania",
myTasks: "Moje zadania",
overdue: "Przeterminowane",
dueSoon: "Niedługo termin",
high: "Wysoki",
normal: "Normalny",
medium: "Średni",
low: "Niski",
dateCreated: "Data utworzenia",
dateModified: "Data modyfikacji",
daysLeft: "dni pozostało",
daysOverdue: "dni przeterminowane",
dueToday: "Termin dzisiaj",
startTask: "Rozpocznij zadanie",
completeTask: "Zakończ zadanie",
comments: "Komentarze",
addComment: "Dodaj komentarz",
noComments: "Brak komentarzy",
maxWait: "Maksymalne oczekiwanie",
dateStarted: "Data rozpoczęcia",
actions: "Działania",
urgent: "Pilne",
updateTask: "Aktualizuj zadanie",
taskType: "Typ zadania",
fromTemplate: "Z szablonu",
customTask: "Zadanie niestandardowe",
selectTemplate: "Wybierz szablon zadania",
chooseTemplate: "Wybierz szablon zadania...",
enterTaskName: "Wprowadź nazwę zadania...",
enterDescription: "Wprowadź opis zadania (opcjonalnie)...",
addTask: "Dodaj zadanie",
adding: "Dodawanie...",
addTaskError: "Nie udało się dodać zadania do projektu",
notes: "notatki",
deleteNote: "Usuń notatkę",
deleteError: "Błąd usuwania zadania",
notStarted: "Nie rozpoczęte",
days: "dni"
},
// Task Templates
taskTemplates: {
title: "Szablony zadań",
subtitle: "Zarządzaj szablonami zadań",
newTemplate: "Nowy szablon",
editTemplate: "Edytuj szablon",
deleteTemplate: "Usuń szablon",
templateName: "Nazwa szablonu",
templateDescription: "Opis szablonu",
defaultPriority: "Domyślny priorytet",
estimatedDuration: "Szacowany czas trwania",
category: "Kategoria",
searchPlaceholder: "Szukaj szablonów...",
noTemplates: "Brak szablonów",
noTemplatesMessage: "Rozpocznij od utworzenia swojego pierwszego szablonu zadania.",
useTemplate: "Użyj szablonu",
duplicateTemplate: "Duplikuj szablon"
},
// Forms
forms: {
validation: {
required: "To pole jest wymagane",
email: "Wprowadź prawidłowy adres email",
minLength: "Minimalna długość: {min} znaków",
maxLength: "Maksymalna długość: {max} znaków",
invalidDate: "Nieprawidłowa data",
invalidCoordinates: "Nieprawidłowe współrzędne"
},
placeholders: {
enterText: "Wprowadź tekst...",
selectDate: "Wybierz datę",
selectOption: "Wybierz opcję",
searchResults: "Szukaj wyników..."
}
},
// Sorting and filtering
sorting: {
sortBy: "Sortuj według",
orderBy: "Kolejność",
ascending: "Rosnąco",
descending: "Malejąco",
name: "Nazwa",
date: "Data",
status: "Status",
priority: "Priorytet",
dateCreated: "Data utworzenia",
dateModified: "Data modyfikacji",
startDate: "Data rozpoczęcia",
finishDate: "Data zakończenia"
},
// Date formats
dates: {
today: "Dzisiaj",
yesterday: "Wczoraj",
tomorrow: "Jutro",
daysAgo: "{count} dni temu",
inDays: "za {count} dni",
invalidDate: "Nieprawidłowa data",
selectDate: "Wybierz datę"
},
// Map layers
mapLayers: {
polishOrthophoto: "🛰️ Polska Ortofotomapa",
polishCadastral: "📋 Polskie Dane Katastralne",
polishSpatialPlanning: "🏗️ Polskie Planowanie Przestrzenne",
lpPortalRoads: "🛣️ LP-Portal Drogi",
lpPortalStreetNames: "🏷️ LP-Portal Nazwy Ulic",
lpPortalParcels: "📐 LP-Portal Działki",
lpPortalSurveyMarkers: "📍 LP-Portal Punkty Pomiarowe",
openStreetMap: "🗺️ OpenStreetMap",
googleSatellite: "🛰️ Google Satelita",
googleRoads: "🛣️ Google Drogi",
layerControl: "Kontrola warstw",
baseLayers: "Warstwy bazowe",
overlayLayers: "Warstwy nakładkowe",
opacity: "Przezroczystość"
},
// Authentication
auth: {
signIn: "Zaloguj się",
signOut: "Wyloguj się",
email: "Email",
password: "Hasło",
rememberMe: "Zapamiętaj mnie",
forgotPassword: "Zapomniałeś hasła?",
signInWith: "Zaloguj się przez",
signUp: "Zarejestruj się",
createAccount: "Utwórz konto",
alreadyHaveAccount: "Masz już konto?",
dontHaveAccount: "Nie masz konta?",
signInError: "Błąd logowania",
signOutError: "Błąd wylogowania",
emailRequired: "Email jest wymagany",
passwordRequired: "Hasło jest wymagane",
invalidCredentials: "Nieprawidłowe dane logowania"
},
// User roles
userRoles: {
admin: "Administrator",
user: "Użytkownik",
manager: "Menedżer",
viewer: "Czytelnik"
},
// Error messages
errors: {
generic: "Wystąpił błąd. Spróbuj ponownie.",
network: "Błąd połączenia sieciowego",
unauthorized: "Brak autoryzacji",
forbidden: "Brak uprawnień",
notFound: "Nie znaleziono",
validation: "Błąd walidacji danych",
server: "Błąd serwera",
timeout: "Przekroczono limit czasu",
unknown: "Nieznany błąd"
},
// Success messages
success: {
saved: "Zapisano pomyślnie",
created: "Utworzono pomyślnie",
updated: "Zaktualizowano pomyślnie",
deleted: "Usunięto pomyślnie",
sent: "Wysłano pomyślnie",
completed: "Ukończono pomyślnie"
},
// Admin section
admin: {
title: "Administracja",
subtitle: "Zarządzaj użytkownikami systemu i uprawnieniami",
userManagement: "Zarządzanie użytkownikami",
auditLogs: "Logi audytu",
systemSettings: "Ustawienia systemu",
users: "Użytkownicy",
newUser: "Nowy użytkownik",
editUser: "Edytuj użytkownika",
deleteUser: "Usuń użytkownika",
userName: "Nazwa użytkownika",
userEmail: "Email użytkownika",
userRole: "Rola użytkownika",
active: "Aktywny",
inactive: "Nieaktywny",
lastLogin: "Ostatnie logowanie",
createdAt: "Data utworzenia",
noUsers: "Brak użytkowników",
system: "System"
}
},
en: {
// English translations (fallback)
navigation: {
dashboard: "Dashboard",
projects: "Projects",
taskTemplates: "Task Templates",
projectTasks: "Project Tasks",
contracts: "Contracts",
userManagement: "User Management",
projectPanel: "Project Panel",
loading: "Loading...",
signOut: "Sign Out",
signIn: "Sign In"
},
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
add: "Add",
create: "Create",
update: "Update",
search: "Search",
filter: "Filter",
loading: "Loading...",
saving: "Saving...",
creating: "Creating...",
updating: "Updating...",
yes: "Yes",
no: "No",
confirm: "Confirm",
close: "Close",
back: "Back",
next: "Next",
previous: "Previous",
page: "Page",
of: "of",
items: "items",
all: "All",
none: "None",
required: "required",
optional: "optional",
selectOption: "Select option",
selectLanguage: "Select language",
noResults: "No results",
error: "Error",
success: "Success",
warning: "Warning",
info: "Info",
deleteConfirm: "Are you sure you want to delete this?",
addNote: "Add Note",
addNoteSuccess: "Note added",
addNoteError: "Failed to save note",
addNotePlaceholder: "Add a new note...",
status: "Status",
type: "Type",
actions: "Actions",
view: "View",
clearSearch: "Clear search",
clearAllFilters: "Clear all filters",
sortBy: "Sort by"
},
dashboard: {
title: "Dashboard",
subtitle: "Overview of your projects and tasks",
totalProjects: "Total Projects",
activeProjects: "Active Projects",
completedProjects: "Completed Projects",
overdueProjects: "Overdue Projects",
totalContracts: "Total Contracts",
activeContracts: "Active Contracts",
pendingTasks: "Pending Tasks",
inProgressTasks: "In Progress Tasks",
completedTasks: "Completed Tasks",
overdueTasks: "Overdue Tasks",
recentProjects: "Recent Projects",
recentTasks: "Recent Tasks",
upcomingDeadlines: "Upcoming Deadlines",
projectsThisWeek: "Projects This Week",
tasksThisWeek: "Tasks This Week",
completionRate: "Completion Rate",
viewAll: "View All",
noRecentProjects: "No recent projects",
noRecentTasks: "No recent tasks",
noUpcomingDeadlines: "No upcoming deadlines"
},
projectStatus: {
registered: "Registered",
in_progress_design: "In Progress (Design)",
in_progress_construction: "In Progress (Construction)",
fulfilled: "Completed",
cancelled: "Cancelled",
unknown: "Unknown"
},
projectType: {
design: "Design",
construction: "Construction",
"design+construction": "Design + Construction"
},
taskStatus: {
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
cancelled: "Cancelled",
on_hold: "On Hold"
},
projects: {
title: "Projects",
subtitle: "Manage your projects",
newProject: "New Project",
editProject: "Edit Project",
deleteProject: "Delete Project",
projectName: "Project Name",
city: "City",
address: "Address",
plot: "Plot",
district: "District",
unit: "Unit",
finishDate: "Finish Date",
type: "Type",
contact: "Contact",
coordinates: "Coordinates",
notes: "Notes",
assignedTo: "Assigned To",
unassigned: "Unassigned",
createdBy: "Created By",
lastModified: "Last Modified",
basicInformation: "Basic Information",
locationDetails: "Location Details",
projectDetails: "Project Details",
additionalInfo: "Additional Information",
searchPlaceholder: "Search projects by name, city or address...",
noProjects: "No projects",
noProjectsMessage: "Get started by creating your first project.",
notFinished: "Not finished",
projects: "projects",
mapView: "Map View",
createFirstProject: "Create first project",
noMatchingResults: "No projects match the search criteria. Try changing your search terms.",
showingResults: "Showing {shown} of {total} projects",
deleteConfirm: "Are you sure you want to delete this project?",
deleteSuccess: "Project deleted successfully",
createSuccess: "Project created successfully",
updateSuccess: "Project updated successfully",
saveError: "Failed to save project",
enterProjectName: "Enter project name",
enterCity: "Enter city",
enterAddress: "Enter address",
enterPlotNumber: "Enter plot number",
enterDistrict: "Enter district",
enterUnit: "Enter unit",
enterContactDetails: "Enter contact details",
coordinatesPlaceholder: "e.g., 49.622958,20.629562",
enterNotes: "Enter notes...",
createProject: "Create Project",
updateProject: "Update Project",
creating: "Creating...",
updating: "Updating...",
projectDetails: "Project Details",
editProjectDetails: "Edit Project Details",
contract: "Contract",
selectContract: "Select Contract",
investmentNumber: "Investment Number",
enterInvestmentNumber: "Enter investment number",
enterWP: "Enter WP",
placeholders: {
contact: "Enter contact details",
coordinates: "e.g., 49.622958,20.629562",
notes: "Enter notes...",
investmentNumber: "Enter investment number",
wp: "Enter WP"
}
},
contracts: {
title: "Contracts",
subtitle: "Manage your contracts and agreements",
newContract: "New Contract",
editContract: "Edit Contract",
deleteContract: "Delete Contract",
contractNumber: "Contract Number",
contractName: "Contract Name",
customerContractNumber: "Customer Contract Number",
customer: "Customer",
investor: "Investor",
dateSigned: "Date Signed",
finishDate: "Finish Date",
searchPlaceholder: "Search contracts by number, name, customer or investor...",
noContracts: "No contracts",
noContractsMessage: "Get started by creating your first contract.",
noMatchingContracts: "No matching contracts",
changeSearchCriteria: "Try changing your search criteria or filters.",
all: "All",
active: "Active",
withoutEndDate: "In Progress",
expired: "Expired",
createContract: "Create Contract",
enterContractNumber: "Enter contract number",
enterContractName: "Enter contract name",
enterCustomerContractNumber: "Enter customer contract number",
enterCustomerName: "Enter customer name",
enterInvestorName: "Enter investor name",
signedOn: "Signed:",
finishOn: "Finish:",
customerLabel: "Customer:",
investorLabel: "Investor:"
},
tasks: {
title: "Tasks",
task: "task",
tasks: "tasks",
subtitle: "Manage project tasks",
newTask: "New Task",
editTask: "Edit Task",
deleteTask: "Delete Task",
taskName: "Task Name",
description: "Description",
priority: "Priority",
assignedTo: "Assigned To",
dueDate: "Due Date",
status: "Status",
project: "Project",
template: "Template",
searchPlaceholder: "Search tasks, projects, city, or address...",
noTasks: "No tasks",
noTasksMessage: "No tasks assigned to this project yet.",
addTaskMessage: "Add a task above to get started.",
filterBy: "Filter by",
sortBy: "Sort by",
allTasks: "All Tasks",
myTasks: "My Tasks",
overdue: "Overdue",
dueSoon: "Due Soon",
high: "High",
normal: "Normal",
medium: "Medium",
low: "Low",
dateCreated: "Date Created",
dateModified: "Date Modified",
daysLeft: "days left",
daysOverdue: "days overdue",
dueToday: "Due Today",
startTask: "Start Task",
completeTask: "Complete Task",
comments: "Comments",
addComment: "Add Comment",
noComments: "No comments",
maxWait: "Max Wait",
dateStarted: "Date Started",
actions: "Actions",
urgent: "Urgent",
updateTask: "Update Task",
taskType: "Task Type",
fromTemplate: "From Template",
customTask: "Custom Task",
selectTemplate: "Select Task Template",
chooseTemplate: "Choose a task template...",
enterTaskName: "Enter custom task name...",
enterDescription: "Enter task description (optional)...",
addTask: "Add Task",
adding: "Adding...",
addTaskError: "Failed to add task to project",
notes: "notes",
deleteNote: "Delete note",
deleteError: "Error deleting task",
notStarted: "Not started",
days: "days"
},
taskTemplates: {
title: "Task Templates",
subtitle: "Manage task templates",
newTemplate: "New Template",
editTemplate: "Edit Template",
deleteTemplate: "Delete Template",
templateName: "Template Name",
templateDescription: "Template Description",
defaultPriority: "Default Priority",
estimatedDuration: "Estimated Duration",
category: "Category",
searchPlaceholder: "Search templates...",
noTemplates: "No templates",
noTemplatesMessage: "Get started by creating your first task template.",
useTemplate: "Use Template",
duplicateTemplate: "Duplicate Template"
},
forms: {
validation: {
required: "This field is required",
email: "Please enter a valid email address",
minLength: "Minimum length: {min} characters",
maxLength: "Maximum length: {max} characters",
invalidDate: "Invalid date",
invalidCoordinates: "Invalid coordinates"
},
placeholders: {
enterText: "Enter text...",
selectDate: "Select date",
selectOption: "Select option",
searchResults: "Search results..."
}
},
sorting: {
sortBy: "Sort by",
orderBy: "Order by",
ascending: "Ascending",
descending: "Descending",
name: "Name",
date: "Date",
status: "Status",
priority: "Priority",
dateCreated: "Date Created",
dateModified: "Date Modified",
startDate: "Start Date",
finishDate: "Finish Date"
},
dates: {
today: "Today",
yesterday: "Yesterday",
tomorrow: "Tomorrow",
daysAgo: "{count} days ago",
inDays: "in {count} days",
invalidDate: "Invalid date",
selectDate: "Select date"
},
mapLayers: {
polishOrthophoto: "🛰️ Polish Orthophoto",
polishCadastral: "📋 Polish Cadastral Data",
polishSpatialPlanning: "🏗️ Polish Spatial Planning",
lpPortalRoads: "🛣️ LP-Portal Roads",
lpPortalStreetNames: "🏷️ LP-Portal Street Names",
lpPortalParcels: "📐 LP-Portal Parcels",
lpPortalSurveyMarkers: "📍 LP-Portal Survey Markers",
openStreetMap: "🗺️ OpenStreetMap",
googleSatellite: "🛰️ Google Satellite",
googleRoads: "🛣️ Google Roads",
layerControl: "Layer Control",
baseLayers: "Base Layers",
overlayLayers: "Overlay Layers",
opacity: "Opacity"
},
auth: {
signIn: "Sign In",
signOut: "Sign Out",
email: "Email",
password: "Password",
rememberMe: "Remember Me",
forgotPassword: "Forgot Password?",
signInWith: "Sign in with",
signUp: "Sign Up",
createAccount: "Create Account",
alreadyHaveAccount: "Already have an account?",
dontHaveAccount: "Don't have an account?",
signInError: "Sign in error",
signOutError: "Sign out error",
emailRequired: "Email is required",
passwordRequired: "Password is required",
invalidCredentials: "Invalid credentials"
},
userRoles: {
admin: "Administrator",
user: "User",
manager: "Manager",
viewer: "Viewer"
},
errors: {
generic: "An error occurred. Please try again.",
network: "Network connection error",
unauthorized: "Unauthorized",
forbidden: "Forbidden",
notFound: "Not found",
validation: "Validation error",
server: "Server error",
timeout: "Request timeout",
unknown: "Unknown error"
},
success: {
saved: "Saved successfully",
created: "Created successfully",
updated: "Updated successfully",
deleted: "Deleted successfully",
sent: "Sent successfully",
completed: "Completed successfully"
},
admin: {
title: "Administration",
userManagement: "User Management",
auditLogs: "Audit Logs",
systemSettings: "System Settings",
users: "Users",
newUser: "New User",
editUser: "Edit User",
deleteUser: "Delete User",
userName: "User Name",
userEmail: "User Email",
userRole: "User Role",
active: "Active",
inactive: "Inactive",
lastLogin: "Last Login",
createdAt: "Created At",
noUsers: "No users",
system: "System"
}
}
};
// Translation provider component
export function TranslationProvider({ children, initialLanguage = 'pl' }) {
const [language, setLanguage] = useState(initialLanguage);
// Load language from localStorage on mount
useEffect(() => {
const savedLanguage = localStorage.getItem('app-language');
if (savedLanguage && translations[savedLanguage]) {
setLanguage(savedLanguage);
}
}, []);
// Save language to localStorage when changed
useEffect(() => {
localStorage.setItem('app-language', language);
}, [language]);
const changeLanguage = (newLanguage) => {
if (translations[newLanguage]) {
setLanguage(newLanguage);
}
};
const t = (key, replacements = {}) => {
const keys = key.split('.');
let value = translations[language];
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to English if key not found in current language
value = translations.en;
for (const fallbackKey of keys) {
if (value && typeof value === 'object' && fallbackKey in value) {
value = value[fallbackKey];
} else {
console.warn(`Translation key not found: ${key} (language: ${language})`);
return key; // Return the key itself as fallback
}
}
break;
}
}
if (typeof value !== 'string') {
console.warn(`Translation value is not a string: ${key} (language: ${language})`);
return key;
}
// Replace placeholders like {count}, {min}, {max}, etc.
let result = value;
Object.keys(replacements).forEach(placeholder => {
result = result.replace(new RegExp(`{${placeholder}}`, 'g'), replacements[placeholder]);
});
return result;
};
return (
<TranslationContext.Provider value={{
t,
language,
changeLanguage,
availableLanguages: Object.keys(translations)
}}>
{children}
</TranslationContext.Provider>
);
}
// Hook to use translations
export function useTranslation() {
const context = useContext(TranslationContext);
if (!context) {
throw new Error('useTranslation must be used within a TranslationProvider');
}
return context;
}
// Export translations for direct access if needed
export { translations };

View File

@@ -31,7 +31,7 @@ export default function initializeDatabase() {
contact TEXT, contact TEXT,
notes TEXT, notes TEXT,
project_type TEXT CHECK(project_type IN ('design', 'construction', 'design+construction')) DEFAULT 'design', project_type TEXT CHECK(project_type IN ('design', 'construction', 'design+construction')) DEFAULT 'design',
project_status TEXT CHECK(project_status IN ('registered', 'in_progress_design', 'in_progress_construction', 'fulfilled')) DEFAULT 'registered', project_status TEXT CHECK(project_status IN ('registered', 'in_progress_design', 'in_progress_construction', 'fulfilled', 'cancelled')) DEFAULT 'registered',
FOREIGN KEY (contract_id) REFERENCES contracts(contract_id) FOREIGN KEY (contract_id) REFERENCES contracts(contract_id)
); );
@@ -113,7 +113,7 @@ export default function initializeDatabase() {
// Migration: Add project_status column to projects table // Migration: Add project_status column to projects table
try { try {
db.exec(` db.exec(`
ALTER TABLE projects ADD COLUMN project_status TEXT CHECK(project_status IN ('registered', 'in_progress_design', 'in_progress_construction', 'fulfilled')) DEFAULT 'registered'; ALTER TABLE projects ADD COLUMN project_status TEXT CHECK(project_status IN ('registered', 'in_progress_design', 'in_progress_construction', 'fulfilled', 'cancelled')) DEFAULT 'registered';
`); `);
} catch (e) { } catch (e) {
// Column already exists, ignore error // Column already exists, ignore error
@@ -273,7 +273,7 @@ export default function initializeDatabase() {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT CHECK(role IN ('admin', 'project_manager', 'user', 'read_only')) DEFAULT 'user', role TEXT CHECK(role IN ('admin', 'project_manager', 'user', 'read_only')) DEFAULT 'user',
created_at TEXT DEFAULT CURRENT_TIMESTAMP, created_at TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -309,9 +309,77 @@ export default function initializeDatabase() {
); );
-- Create indexes for performance -- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(session_token); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp ON audit_logs(user_id, timestamp); CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp ON audit_logs(user_id, timestamp);
`); `);
// Migration: Add username column and migrate from email if needed
try {
// Check if username column exists
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasUsername = columns.some(col => col.name === 'username');
const hasEmail = columns.some(col => col.name === 'email');
if (!hasUsername && hasEmail) {
// Add username column
db.exec(`ALTER TABLE users ADD COLUMN username TEXT;`);
// Migrate existing email data to username (for development/testing)
// In production, you might want to handle this differently
db.exec(`UPDATE users SET username = email WHERE username IS NULL;`);
// Create unique index on username
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_unique ON users(username);`);
console.log("✅ Migrated users table from email to username");
} else if (!hasUsername) {
// If neither username nor email exists, something is wrong
console.warn("⚠️ Users table missing both username and email columns");
}
} catch (e) {
console.warn("Migration warning:", e.message);
}
// Generic file attachments table
db.exec(`
CREATE TABLE IF NOT EXISTS file_attachments (
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL CHECK(entity_type IN ('contract', 'project', 'task')),
entity_id INTEGER NOT NULL,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
description TEXT,
uploaded_by TEXT,
upload_date TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (uploaded_by) REFERENCES users(id)
);
-- Create indexes for file attachments
CREATE INDEX IF NOT EXISTS idx_file_attachments_entity ON file_attachments(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_file_attachments_uploaded_by ON file_attachments(uploaded_by);
-- Generic field change history table
CREATE TABLE IF NOT EXISTS field_change_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
record_id INTEGER NOT NULL,
field_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by INTEGER,
changed_at TEXT DEFAULT CURRENT_TIMESTAMP,
change_reason TEXT,
FOREIGN KEY (changed_by) REFERENCES users(id)
);
-- Create indexes for field change history
CREATE INDEX IF NOT EXISTS idx_field_history_table_record ON field_change_history(table_name, record_id);
CREATE INDEX IF NOT EXISTS idx_field_history_field ON field_change_history(table_name, record_id, field_name);
CREATE INDEX IF NOT EXISTS idx_field_history_changed_by ON field_change_history(changed_by);
`);
} }

View File

@@ -0,0 +1,94 @@
import db from "../db.js";
/**
* Log a field change to the history table
*/
export function logFieldChange(tableName, recordId, fieldName, oldValue, newValue, changedBy = null, reason = null) {
// Don't log if values are the same
if (oldValue === newValue) return null;
const stmt = db.prepare(`
INSERT INTO field_change_history
(table_name, record_id, field_name, old_value, new_value, changed_by, change_reason)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
return stmt.run(
tableName,
recordId,
fieldName,
oldValue || null,
newValue || null,
changedBy,
reason
);
}
/**
* Get field change history for a specific field
*/
export function getFieldHistory(tableName, recordId, fieldName) {
const stmt = db.prepare(`
SELECT
fch.*,
u.name as changed_by_name,
u.username as changed_by_username
FROM field_change_history fch
LEFT JOIN users u ON fch.changed_by = u.id
WHERE fch.table_name = ? AND fch.record_id = ? AND fch.field_name = ?
ORDER BY fch.changed_at DESC
`);
return stmt.all(tableName, recordId, fieldName);
}
/**
* Get all field changes for a specific record
*/
export function getAllFieldHistory(tableName, recordId) {
const stmt = db.prepare(`
SELECT
fch.*,
u.name as changed_by_name,
u.username as changed_by_username
FROM field_change_history fch
LEFT JOIN users u ON fch.changed_by = u.id
WHERE fch.table_name = ? AND fch.record_id = ?
ORDER BY fch.changed_at DESC, fch.field_name ASC
`);
return stmt.all(tableName, recordId);
}
/**
* Check if a field has any change history
*/
export function hasFieldHistory(tableName, recordId, fieldName) {
const stmt = db.prepare(`
SELECT COUNT(*) as count
FROM field_change_history
WHERE table_name = ? AND record_id = ? AND field_name = ?
`);
const result = stmt.get(tableName, recordId, fieldName);
return result.count > 0;
}
/**
* Get the most recent change for a field
*/
export function getLatestFieldChange(tableName, recordId, fieldName) {
const stmt = db.prepare(`
SELECT
fch.*,
u.name as changed_by_name,
u.username as changed_by_username
FROM field_change_history fch
LEFT JOIN users u ON fch.changed_by = u.id
WHERE fch.table_name = ? AND fch.record_id = ? AND fch.field_name = ?
ORDER BY fch.changed_at DESC
LIMIT 1
`);
return stmt.get(tableName, recordId, fieldName);
}

View File

@@ -6,7 +6,7 @@ export function getNotesByProjectId(project_id) {
` `
SELECT n.*, SELECT n.*,
u.name as created_by_name, u.name as created_by_name,
u.email as created_by_email u.username as created_by_username
FROM notes n FROM notes n
LEFT JOIN users u ON n.created_by = u.id LEFT JOIN users u ON n.created_by = u.id
WHERE n.project_id = ? WHERE n.project_id = ?
@@ -16,13 +16,13 @@ export function getNotesByProjectId(project_id) {
.all(project_id); .all(project_id);
} }
export function addNoteToProject(project_id, note, created_by = null) { export function addNoteToProject(project_id, note, created_by = null, is_system = false) {
db.prepare( db.prepare(
` `
INSERT INTO notes (project_id, note, created_by, note_date) INSERT INTO notes (project_id, note, created_by, is_system, note_date)
VALUES (?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
` `
).run(project_id, note, created_by); ).run(project_id, note, created_by, is_system ? 1 : 0);
} }
export function getNotesByTaskId(task_id) { export function getNotesByTaskId(task_id) {
@@ -31,7 +31,7 @@ export function getNotesByTaskId(task_id) {
` `
SELECT n.*, SELECT n.*,
u.name as created_by_name, u.name as created_by_name,
u.email as created_by_email u.username as created_by_username
FROM notes n FROM notes n
LEFT JOIN users u ON n.created_by = u.id LEFT JOIN users u ON n.created_by = u.id
WHERE n.task_id = ? WHERE n.task_id = ?
@@ -64,7 +64,7 @@ export function getAllNotesWithUsers() {
` `
SELECT n.*, SELECT n.*,
u.name as created_by_name, u.name as created_by_name,
u.email as created_by_email, u.username as created_by_username,
p.project_name, p.project_name,
COALESCE(pt.custom_task_name, t.name) as task_name COALESCE(pt.custom_task_name, t.name) as task_name
FROM notes n FROM notes n
@@ -85,7 +85,7 @@ export function getNotesByCreator(userId) {
` `
SELECT n.*, SELECT n.*,
u.name as created_by_name, u.name as created_by_name,
u.email as created_by_email, u.username as created_by_username,
p.project_name, p.project_name,
COALESCE(pt.custom_task_name, t.name) as task_name COALESCE(pt.custom_task_name, t.name) as task_name
FROM notes n FROM notes n

View File

@@ -4,11 +4,16 @@ export function getAllProjects(contractId = null) {
const baseQuery = ` const baseQuery = `
SELECT SELECT
p.*, p.*,
c.contract_number,
c.contract_name,
c.customer,
c.investor,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM projects p FROM projects p
LEFT JOIN contracts c ON p.contract_id = c.contract_id
LEFT JOIN users creator ON p.created_by = creator.id LEFT JOIN users creator ON p.created_by = creator.id
LEFT JOIN users assignee ON p.assigned_to = assignee.id LEFT JOIN users assignee ON p.assigned_to = assignee.id
`; `;
@@ -30,9 +35,9 @@ export function getProjectById(id) {
SELECT SELECT
p.*, p.*,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM projects p FROM projects p
LEFT JOIN users creator ON p.created_by = creator.id LEFT JOIN users creator ON p.created_by = creator.id
LEFT JOIN users assignee ON p.assigned_to = assignee.id LEFT JOIN users assignee ON p.assigned_to = assignee.id
@@ -105,7 +110,7 @@ export function updateProject(id, data, userId = null) {
coordinates = ?, assigned_to = ?, updated_at = CURRENT_TIMESTAMP coordinates = ?, assigned_to = ?, updated_at = CURRENT_TIMESTAMP
WHERE project_id = ? WHERE project_id = ?
`); `);
stmt.run( const result = stmt.run(
data.contract_id, data.contract_id,
data.project_name, data.project_name,
data.project_number, data.project_number,
@@ -125,6 +130,9 @@ export function updateProject(id, data, userId = null) {
data.assigned_to || null, data.assigned_to || null,
id id
); );
console.log('Update result:', result);
return result;
} }
export function deleteProject(id) { export function deleteProject(id) {
@@ -136,7 +144,7 @@ export function getAllUsersForAssignment() {
return db return db
.prepare( .prepare(
` `
SELECT id, name, email, role SELECT id, name, username, role
FROM users FROM users
WHERE is_active = 1 WHERE is_active = 1
ORDER BY name ORDER BY name
@@ -153,9 +161,9 @@ export function getProjectsByAssignedUser(userId) {
SELECT SELECT
p.*, p.*,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM projects p FROM projects p
LEFT JOIN users creator ON p.created_by = creator.id LEFT JOIN users creator ON p.created_by = creator.id
LEFT JOIN users assignee ON p.assigned_to = assignee.id LEFT JOIN users assignee ON p.assigned_to = assignee.id
@@ -174,9 +182,9 @@ export function getProjectsByCreator(userId) {
SELECT SELECT
p.*, p.*,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM projects p FROM projects p
LEFT JOIN users creator ON p.created_by = creator.id LEFT JOIN users creator ON p.created_by = creator.id
LEFT JOIN users assignee ON p.assigned_to = assignee.id LEFT JOIN users assignee ON p.assigned_to = assignee.id
@@ -224,7 +232,7 @@ export function getNotesForProject(projectId) {
` `
SELECT n.*, SELECT n.*,
u.name as created_by_name, u.name as created_by_name,
u.email as created_by_email u.username as created_by_username
FROM notes n FROM notes n
LEFT JOIN users u ON n.created_by = u.id LEFT JOIN users u ON n.created_by = u.id
WHERE n.project_id = ? WHERE n.project_id = ?
@@ -233,3 +241,39 @@ export function getNotesForProject(projectId) {
) )
.all(projectId); .all(projectId);
} }
// Get finish date update history for a project
export function getFinishDateUpdates(projectId) {
return db
.prepare(
`
SELECT fdu.*,
u.name as updated_by_name,
u.username as updated_by_username
FROM project_finish_date_updates fdu
LEFT JOIN users u ON fdu.updated_by = u.id
WHERE fdu.project_id = ?
ORDER BY fdu.updated_at DESC
`
)
.all(projectId);
}
// Log a finish date update
export function logFinishDateUpdate(projectId, oldDate, newDate, userId, reason = null) {
const stmt = db.prepare(`
INSERT INTO project_finish_date_updates (
project_id, old_finish_date, new_finish_date, updated_by, reason, updated_at
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
return stmt.run(projectId, oldDate, newDate, userId, reason);
}
// Check if project has finish date updates
export function hasFinishDateUpdates(projectId) {
const result = db
.prepare('SELECT COUNT(*) as count FROM project_finish_date_updates WHERE project_id = ?')
.get(projectId);
return result.count > 0;
}

View File

@@ -1,5 +1,6 @@
import db from "../db.js"; import db from "../db.js";
import { addNoteToTask } from "./notes.js"; import { addNoteToTask } from "./notes.js";
import { getUserLanguage, serverT, translateStatus, translatePriority } from "../serverTranslations.js";
// Get all task templates (for dropdown selection) // Get all task templates (for dropdown selection)
export function getAllTaskTemplates() { export function getAllTaskTemplates() {
@@ -29,9 +30,9 @@ export function getAllProjectTasks() {
p.address, p.address,
p.finish_date, p.finish_date,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM project_tasks pt FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id LEFT JOIN projects p ON pt.project_id = p.project_id
@@ -58,9 +59,9 @@ export function getProjectTasks(projectId) {
ELSE 'custom' ELSE 'custom'
END as task_type, END as task_type,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM project_tasks pt FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN users creator ON pt.created_by = creator.id LEFT JOIN users creator ON pt.created_by = creator.id
@@ -182,8 +183,10 @@ export function updateProjectTaskStatus(taskId, status, userId = null) {
// Add system note for status change (only if status actually changed) // Add system note for status change (only if status actually changed)
if (result.changes > 0 && oldStatus !== status) { if (result.changes > 0 && oldStatus !== status) {
const taskName = currentTask.task_name || "Unknown task"; const language = getUserLanguage(); // Default to Polish for now
const logMessage = `Status changed from "${oldStatus}" to "${status}"`; const fromStatus = translateStatus(oldStatus, language);
const toStatus = translateStatus(status, language);
const logMessage = `${serverT("Status changed from", language)} "${fromStatus}" ${serverT("to", language)} "${toStatus}"`;
addNoteToTask(taskId, logMessage, true, userId); addNoteToTask(taskId, logMessage, true, userId);
} }
@@ -192,8 +195,13 @@ export function updateProjectTaskStatus(taskId, status, userId = null) {
// Delete a project task // Delete a project task
export function deleteProjectTask(taskId) { export function deleteProjectTask(taskId) {
const stmt = db.prepare("DELETE FROM project_tasks WHERE id = ?"); // First delete all related task notes
return stmt.run(taskId); const deleteNotesStmt = db.prepare("DELETE FROM notes WHERE task_id = ?");
deleteNotesStmt.run(taskId);
// Then delete the task itself
const deleteTaskStmt = db.prepare("DELETE FROM project_tasks WHERE id = ?");
return deleteTaskStmt.run(taskId);
} }
// Get project tasks assigned to a specific user // Get project tasks assigned to a specific user
@@ -217,9 +225,9 @@ export function getProjectTasksByAssignedUser(userId) {
p.address, p.address,
p.finish_date, p.finish_date,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM project_tasks pt FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id LEFT JOIN projects p ON pt.project_id = p.project_id
@@ -253,9 +261,9 @@ export function getProjectTasksByCreator(userId) {
p.address, p.address,
p.finish_date, p.finish_date,
creator.name as created_by_name, creator.name as created_by_name,
creator.email as created_by_email, creator.username as created_by_username,
assignee.name as assigned_to_name, assignee.name as assigned_to_name,
assignee.email as assigned_to_email assignee.username as assigned_to_username
FROM project_tasks pt FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id LEFT JOIN projects p ON pt.project_id = p.project_id
@@ -283,7 +291,7 @@ export function getAllUsersForTaskAssignment() {
return db return db
.prepare( .prepare(
` `
SELECT id, name, email, role SELECT id, name, username, role
FROM users FROM users
WHERE is_active = 1 WHERE is_active = 1
ORDER BY name ASC ORDER BY name ASC
@@ -291,3 +299,114 @@ export function getAllUsersForTaskAssignment() {
) )
.all(); .all();
} }
// Update project task (general update for edit modal)
export function updateProjectTask(taskId, updates, userId = null) {
// Get current task for logging
const getCurrentTask = db.prepare(`
SELECT
pt.*,
COALESCE(pt.custom_task_name, t.name) as task_name
FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
WHERE pt.id = ?
`);
const currentTask = getCurrentTask.get(taskId);
if (!currentTask) {
throw new Error(`Task with ID ${taskId} not found`);
}
// Build dynamic update query
const fields = [];
const values = [];
if (updates.priority !== undefined) {
fields.push("priority = ?");
values.push(updates.priority);
}
if (updates.status !== undefined) {
fields.push("status = ?");
values.push(updates.status);
// Handle status-specific timestamp updates
if (currentTask.status === "pending" && updates.status === "in_progress") {
fields.push("date_started = CURRENT_TIMESTAMP");
} else if (updates.status === "completed") {
fields.push("date_completed = CURRENT_TIMESTAMP");
}
}
if (updates.assigned_to !== undefined) {
fields.push("assigned_to = ?");
values.push(updates.assigned_to || null);
}
if (updates.date_started !== undefined) {
fields.push("date_started = ?");
values.push(updates.date_started || null);
}
// Always update the updated_at timestamp
fields.push("updated_at = CURRENT_TIMESTAMP");
values.push(taskId);
const stmt = db.prepare(`
UPDATE project_tasks
SET ${fields.join(", ")}
WHERE id = ?
`);
const result = stmt.run(...values);
// Log the update
if (userId) {
const language = getUserLanguage(); // Default to Polish for now
const changes = [];
if (
updates.priority !== undefined &&
updates.priority !== currentTask.priority
) {
const oldPriority = translatePriority(currentTask.priority, language) || serverT("None", language);
const newPriority = translatePriority(updates.priority, language) || serverT("None", language);
changes.push(
`${serverT("Priority", language)}: ${oldPriority}${newPriority}`
);
}
if (updates.status !== undefined && updates.status !== currentTask.status) {
const oldStatus = translateStatus(currentTask.status, language) || serverT("None", language);
const newStatus = translateStatus(updates.status, language) || serverT("None", language);
changes.push(
`${serverT("Status", language)}: ${oldStatus}${newStatus}`
);
}
if (
updates.assigned_to !== undefined &&
updates.assigned_to !== currentTask.assigned_to
) {
changes.push(serverT("Assignment updated", language));
}
if (
updates.date_started !== undefined &&
updates.date_started !== currentTask.date_started
) {
const oldDate = currentTask.date_started || serverT("None", language);
const newDate = updates.date_started || serverT("None", language);
changes.push(
`${serverT("Date started", language)}: ${oldDate}${newDate}`
);
}
if (changes.length > 0) {
const logMessage = `${serverT("Task updated", language)}: ${changes.join(", ")}`;
addNoteToTask(taskId, logMessage, true, userId);
}
}
return result;
}

View File

View File

@@ -0,0 +1,79 @@
// Server-side translations for system messages
// This is separate from the client-side translation system
const serverTranslations = {
pl: {
"Status changed from": "Status zmieniony z",
"to": "na",
"Priority": "Priorytet",
"Status": "Status",
"Assignment updated": "Przypisanie zaktualizowane",
"Date started": "Data rozpoczęcia",
"None": "Brak",
"Task updated": "Zadanie zaktualizowane",
"pending": "oczekujące",
"in_progress": "w trakcie",
"completed": "ukończone",
"cancelled": "anulowane",
"on_hold": "wstrzymane",
"low": "niski",
"normal": "normalny",
"medium": "średni",
"high": "wysoki",
"urgent": "pilny"
},
en: {
"Status changed from": "Status changed from",
"to": "to",
"Priority": "Priority",
"Status": "Status",
"Assignment updated": "Assignment updated",
"Date started": "Date started",
"None": "None",
"Task updated": "Task updated",
"pending": "pending",
"in_progress": "in_progress",
"completed": "completed",
"cancelled": "cancelled",
"on_hold": "on_hold",
"low": "low",
"normal": "normal",
"medium": "medium",
"high": "high",
"urgent": "urgent"
}
};
// Get user's preferred language from request headers or default to Polish
export function getUserLanguage(req = null) {
// For now, default to Polish. In the future, this could be determined from:
// - Request headers (Accept-Language)
// - User profile settings
// - Session data
return 'pl';
}
// Translate a key for server-side use
export function serverT(key, language = 'pl') {
if (serverTranslations[language] && serverTranslations[language][key]) {
return serverTranslations[language][key];
}
// Fallback to English if Polish not found
if (language !== 'en' && serverTranslations.en[key]) {
return serverTranslations.en[key];
}
// Return the key itself if no translation found
return key;
}
// Helper function to translate status values
export function translateStatus(status, language = 'pl') {
return serverT(status, language);
}
// Helper function to translate priority values
export function translatePriority(priority, language = 'pl') {
return serverT(priority, language);
}

View File

@@ -3,22 +3,22 @@ import bcrypt from "bcryptjs"
import { randomBytes } from "crypto" import { randomBytes } from "crypto"
// Create a new user // Create a new user
export async function createUser({ name, email, password, role = 'user', is_active = true }) { export async function createUser({ name, username, password, role = 'user', is_active = true }) {
const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email) const existingUser = db.prepare("SELECT id FROM users WHERE username = ?").get(username)
if (existingUser) { if (existingUser) {
throw new Error("User with this email already exists") throw new Error("User with this username already exists")
} }
const passwordHash = await bcrypt.hash(password, 12) const passwordHash = await bcrypt.hash(password, 12)
const userId = randomBytes(16).toString('hex') const userId = randomBytes(16).toString('hex')
const result = db.prepare(` const result = db.prepare(`
INSERT INTO users (id, name, email, password_hash, role, is_active) INSERT INTO users (id, name, username, password_hash, role, is_active)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run(userId, name, email, passwordHash, role, is_active ? 1 : 0) `).run(userId, name, username, passwordHash, role, is_active ? 1 : 0)
return db.prepare(` return db.prepare(`
SELECT id, name, email, role, created_at, updated_at, last_login, SELECT id, name, username, role, created_at, updated_at, last_login,
is_active, failed_login_attempts, locked_until is_active, failed_login_attempts, locked_until
FROM users WHERE id = ? FROM users WHERE id = ?
`).get(userId) `).get(userId)
@@ -27,24 +27,24 @@ export async function createUser({ name, email, password, role = 'user', is_acti
// Get user by ID // Get user by ID
export function getUserById(id) { export function getUserById(id) {
return db.prepare(` return db.prepare(`
SELECT id, name, email, password_hash, role, created_at, updated_at, last_login, SELECT id, name, username, password_hash, role, created_at, updated_at, last_login,
is_active, failed_login_attempts, locked_until is_active, failed_login_attempts, locked_until
FROM users WHERE id = ? FROM users WHERE id = ?
`).get(id) `).get(id)
} }
// Get user by email // Get user by username
export function getUserByEmail(email) { export function getUserByUsername(username) {
return db.prepare(` return db.prepare(`
SELECT id, name, email, role, created_at, last_login, is_active SELECT id, name, username, role, created_at, last_login, is_active
FROM users WHERE email = ? FROM users WHERE username = ?
`).get(email) `).get(username)
} }
// Get all users (for admin) // Get all users (for admin)
export function getAllUsers() { export function getAllUsers() {
return db.prepare(` return db.prepare(`
SELECT id, name, email, password_hash, role, created_at, updated_at, last_login, is_active, SELECT id, name, username, password_hash, role, created_at, updated_at, last_login, is_active,
failed_login_attempts, locked_until failed_login_attempts, locked_until
FROM users FROM users
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -136,11 +136,11 @@ export async function updateUser(userId, updates) {
return null; return null;
} }
// Check if email is being changed and if it already exists // Check if username is being changed and if it already exists
if (updates.email && updates.email !== user.email) { if (updates.username && updates.username !== user.username) {
const existingUser = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(updates.email, userId); const existingUser = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(updates.username, userId);
if (existingUser) { if (existingUser) {
throw new Error("User with this email already exists"); throw new Error("User with this username already exists");
} }
} }
@@ -153,9 +153,9 @@ export async function updateUser(userId, updates) {
updateValues.push(updates.name); updateValues.push(updates.name);
} }
if (updates.email !== undefined) { if (updates.username !== undefined) {
updateFields.push("email = ?"); updateFields.push("username = ?");
updateValues.push(updates.email); updateValues.push(updates.username);
} }
if (updates.role !== undefined) { if (updates.role !== undefined) {
@@ -198,7 +198,7 @@ export async function updateUser(userId, updates) {
if (result.changes > 0) { if (result.changes > 0) {
return db.prepare(` return db.prepare(`
SELECT id, name, email, role, created_at, updated_at, last_login, SELECT id, name, username, role, created_at, updated_at, last_login,
is_active, failed_login_attempts, locked_until is_active, failed_login_attempts, locked_until
FROM users WHERE id = ? FROM users WHERE id = ?
`).get(userId); `).get(userId);

View File

@@ -15,6 +15,8 @@ export const formatProjectStatus = (status) => {
return "W realizacji (realizacja)"; return "W realizacji (realizacja)";
case "fulfilled": case "fulfilled":
return "Zakończony"; return "Zakończony";
case "cancelled":
return "Wycofany";
default: default:
return "-"; return "-";
} }
@@ -34,9 +36,9 @@ export const formatProjectType = (type) => {
}; };
export const getDeadlineText = (daysRemaining) => { export const getDeadlineText = (daysRemaining) => {
if (daysRemaining === 0) return "Due Today"; if (daysRemaining === 0) return "Termin dzisiaj";
if (daysRemaining > 0) return `${daysRemaining} days left`; if (daysRemaining > 0) return `${daysRemaining} dni pozostało`;
return `${Math.abs(daysRemaining)} days overdue`; return `${Math.abs(daysRemaining)} dni przeterminowane`;
}; };
export const formatDate = (date, options = {}) => { export const formatDate = (date, options = {}) => {
@@ -46,7 +48,7 @@ export const formatDate = (date, options = {}) => {
const dateObj = typeof date === "string" ? new Date(date) : date; const dateObj = typeof date === "string" ? new Date(date) : date;
if (isNaN(dateObj.getTime())) { if (isNaN(dateObj.getTime())) {
return "Invalid date"; return "Nieprawidłowa data";
} }
// Default to DD.MM.YYYY format // Default to DD.MM.YYYY format
@@ -63,7 +65,7 @@ export const formatDate = (date, options = {}) => {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} catch (error) { } catch (error) {
console.error("Error formatting date:", error); console.error("Error formatting date:", error);
return "Invalid date"; return "Nieprawidłowa data";
} }
}; };
@@ -88,3 +90,42 @@ export const formatDateForInput = (date) => {
return ""; return "";
} }
}; };
export const formatCoordinates = (coordinatesString) => {
if (!coordinatesString) return "";
try {
const [latStr, lngStr] = coordinatesString.split(",");
const lat = parseFloat(latStr.trim());
const lng = parseFloat(lngStr.trim());
if (isNaN(lat) || isNaN(lng)) {
return coordinatesString; // Return original if parsing fails
}
const formatDMS = (decimal, isLatitude) => {
const direction = isLatitude
? decimal >= 0
? "N"
: "S"
: decimal >= 0
? "E"
: "W";
const absolute = Math.abs(decimal);
const degrees = Math.floor(absolute);
const minutes = Math.floor((absolute - degrees) * 60);
const seconds = Math.round(((absolute - degrees) * 60 - minutes) * 60);
return `${direction}: ${degrees}°${minutes}'${seconds}"`;
};
const latDMS = formatDMS(lat, true);
const lngDMS = formatDMS(lng, false);
return `${latDMS}, ${lngDMS}`;
} catch (error) {
console.error("Error formatting coordinates:", error);
return coordinatesString;
}
};

28
update-admin-username.js Normal file
View File

@@ -0,0 +1,28 @@
import Database from "better-sqlite3";
const db = new Database("./data/database.sqlite");
console.log("🔄 Updating admin username...");
try {
// Update admin username from email to simple "admin"
const result = db.prepare('UPDATE users SET username = ? WHERE username = ?').run('admin', 'admin@localhost.com');
if (result.changes > 0) {
console.log('✅ Admin username updated to "admin"');
} else {
console.log(' No admin user found with email "admin@localhost.com"');
}
// Show current users
const users = db.prepare("SELECT name, username, role FROM users").all();
console.log("\nCurrent users:");
users.forEach(user => {
console.log(` - ${user.name} (${user.role}): username="${user.username}"`);
});
} catch (error) {
console.error("❌ Error:", error.message);
} finally {
db.close();
}

20
update-queries.ps1 Normal file
View File

@@ -0,0 +1,20 @@
$files = @(
"d:\panel\src\lib\queries\tasks.js",
"d:\panel\src\lib\userManagement.js"
)
foreach ($file in $files) {
if (Test-Path $file) {
Write-Host "Updating $file..."
$content = Get-Content $file -Raw
$content = $content -replace "creator\.email as created_by_email", "creator.username as created_by_username"
$content = $content -replace "assignee\.email as assigned_to_email", "assignee.username as assigned_to_username"
$content = $content -replace "u\.email as created_by_email", "u.username as created_by_username"
$content = $content -replace "SELECT id, name, email, role", "SELECT id, name, username, role"
$content = $content -replace "name, email, role", "name, username, role"
Set-Content $file $content -NoNewline
Write-Host "Updated $file"
}
}
Write-Host "All files updated!"