Compare commits
106 Commits
main
...
6ac5ac9dda
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac5ac9dda | |||
| fae7615818 | |||
| acb7117c7d | |||
| 1d8ee8b0ab | |||
| d3fa4df621 | |||
| a1f1b33e44 | |||
| 7f63dc1df6 | |||
| ac77a9d259 | |||
| 38b9401b04 | |||
| 9b1f42c4ec | |||
| 6b205f36bb | |||
| be1bab103f | |||
| c2dbc9d777 | |||
| 3f87ea16f2 | |||
| 056198ff16 | |||
| 5b1a284fc3 | |||
| 23b3c0e9e8 | |||
| eec0c0a281 | |||
| cc242d4e10 | |||
| b6ceac6e38 | |||
| 42668862fd | |||
| af28be8112 | |||
| 27247477c9 | |||
| bd0345df1a | |||
| a1b9c05673 | |||
| d9e559982a | |||
| 0e237a9549 | |||
| f1e7c2d7aa | |||
| 7ec4bdf620 | |||
| ec5b60d478 | |||
| ac5fedb61a | |||
| ce3c53b4a8 | |||
| cdfc37c273 | |||
| 1288fe1cf8 | |||
| 33c5466d77 | |||
| a6ef325813 | |||
| 952caf10d1 | |||
| e19172d2bb | |||
| 80a53d5d15 | |||
| 5011f80fc4 | |||
| 9357c2e0b9 | |||
| 119b03a7ba | |||
| f4b30c0faf | |||
| 79238dd643 | |||
| 31736ccc78 | |||
| 50760ab099 | |||
| a59dc83678 | |||
| 769fc73898 | |||
| 6ab87c7396 | |||
| a4e607bfe1 | |||
| e589d6667f | |||
| fc5f0fd39a | |||
| e68b185aeb | |||
| 5aac63dfde | |||
| 8a0baa02c3 | |||
| fd87b66b06 | |||
| 96333ecced | |||
| 0f451555d3 | |||
| 5193442e10 | |||
| 94b46be15b | |||
| c39746f4f6 | |||
| 671a4490d7 | |||
| e091e29a80 | |||
| 142b6490cc | |||
| abfd174f85 | |||
| 8964a9b29b | |||
| 1a49919000 | |||
| 0bb0b07429 | |||
| e4a4261a0e | |||
| 029b091b10 | |||
| cf8ff874da | |||
| c75982818c | |||
| e5e72b597a | |||
| 06599c844a | |||
| e5955a31fd | |||
| 43622f8e65 | |||
| 7a2611f031 | |||
| 249b1e21c3 | |||
| 551a0ea71a | |||
| adc348b61b | |||
| 49f97a9939 | |||
| 99f3d657ab | |||
| cc6d217476 | |||
| 47d730f192 | |||
| c1d49689da | |||
| 95ef139843 | |||
| 2735d46552 | |||
| 0dd988730f | |||
| 50adc50a24 | |||
| 639a7b7eab | |||
| 07b4af5f24 | |||
| 6fc2e6703b | |||
| 764f6d1100 | |||
| 225d16c1c9 | |||
| aada481c0a | |||
| c767e65819 | |||
| 8e35821344 | |||
|
|
747a68832e | ||
|
|
e828aa660b | ||
|
|
9b6307eabe | ||
|
|
490994d323 | ||
|
|
b5120657a9 | ||
|
|
5228ed3fc0 | ||
|
|
51d37fc65a | ||
|
|
92f458e59b | ||
|
|
33ea8de17e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,3 +45,6 @@ next-env.d.ts
|
||||
|
||||
# kosz
|
||||
/kosz
|
||||
|
||||
# uploads
|
||||
/public/uploads
|
||||
129
DEPLOYMENT_TIMEZONE_FIX.md
Normal file
129
DEPLOYMENT_TIMEZONE_FIX.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Quick Deployment Guide - Timezone Fix
|
||||
|
||||
## For Production Server
|
||||
|
||||
1. **SSH into your server** where Docker is running
|
||||
|
||||
2. **Navigate to project directory**
|
||||
```bash
|
||||
cd /path/to/panel
|
||||
```
|
||||
|
||||
3. **Pull latest code** (includes timezone fixes)
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
4. **Stop running containers**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
5. **Rebuild Docker images** (this is critical - it bakes in the timezone configuration)
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
```
|
||||
|
||||
6. **Start containers**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
7. **Verify timezone is correct**
|
||||
```bash
|
||||
# Check container timezone
|
||||
docker-compose -f docker-compose.prod.yml exec app date
|
||||
# Should show Polish time with CEST/CET timezone
|
||||
|
||||
# Example output:
|
||||
# Sat Oct 4 19:45:00 CEST 2025
|
||||
```
|
||||
|
||||
8. **Test the fix**
|
||||
- Post a new note at a known time (e.g., 19:45)
|
||||
- Verify it displays the same time (19:45)
|
||||
- Test both project notes and task notes
|
||||
|
||||
## What Changed
|
||||
|
||||
### Code Changes
|
||||
- ✅ Fixed `datetime('now', 'localtime')` in all database queries
|
||||
- ✅ Updated display formatters to use Europe/Warsaw timezone
|
||||
- ✅ Fixed note display in components
|
||||
|
||||
### Docker Changes (Critical!)
|
||||
- ✅ Set `ENV TZ=Europe/Warsaw` in Dockerfile
|
||||
- ✅ Configured system timezone in containers
|
||||
- ✅ Added TZ environment variable to docker-compose files
|
||||
|
||||
## Why Rebuild is Necessary
|
||||
|
||||
The timezone configuration is **baked into the Docker image** during build time:
|
||||
- `ENV TZ=Europe/Warsaw` - Set during image build
|
||||
- `RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime` - Executed during image build
|
||||
|
||||
Just restarting containers (`docker-compose restart`) will **NOT** apply these changes!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### If times are still wrong after deployment:
|
||||
|
||||
1. **Verify you rebuilt the images**
|
||||
```bash
|
||||
docker images | grep panel
|
||||
# Check the "CREATED" timestamp - should be recent
|
||||
```
|
||||
|
||||
2. **Check if container has correct timezone**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec app date
|
||||
```
|
||||
Should show Polish time, not UTC!
|
||||
|
||||
3. **Check SQLite is using correct time**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec app node -e "const db = require('better-sqlite3')('./data/database.sqlite'); console.log(db.prepare(\"SELECT datetime('now', 'localtime') as time\").get());"
|
||||
```
|
||||
Should show current Polish time
|
||||
|
||||
4. **Force rebuild if needed**
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker system prune -f
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Expected Behavior After Fix
|
||||
|
||||
### Before Fix (Docker in UTC):
|
||||
```
|
||||
User posts note at 10:30 Poland time
|
||||
→ Docker sees 08:30 UTC as "local time"
|
||||
→ SQLite stores: 08:30
|
||||
→ Display shows: 08:30 ❌ (2 hours off!)
|
||||
```
|
||||
|
||||
### After Fix (Docker in Europe/Warsaw):
|
||||
```
|
||||
User posts note at 10:30 Poland time
|
||||
→ Docker sees 10:30 Poland time as "local time"
|
||||
→ SQLite stores: 10:30
|
||||
→ Display shows: 10:30 ✅ (correct!)
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Old notes**: Notes created before this fix may still show incorrect times (they were stored in UTC)
|
||||
2. **New notes**: All new notes after deployment will show correct times
|
||||
3. **Audit logs**: Continue to work correctly (they always used ISO format)
|
||||
4. **Zero downtime**: Can't achieve - need to stop/rebuild/start containers
|
||||
|
||||
## Quick Check Command
|
||||
|
||||
After deployment, run this one-liner to verify everything:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml exec app sh -c 'date && node -e "console.log(new Date().toLocaleString(\"pl-PL\"))"'
|
||||
```
|
||||
|
||||
Both outputs should show the same Polish time!
|
||||
204
DOCKER_GIT_DEPLOYMENT.md
Normal file
204
DOCKER_GIT_DEPLOYMENT.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Docker Git Deployment Guide
|
||||
|
||||
This project now supports deploying directly from a Git repository using Docker. This is useful for automated deployments and CI/CD pipelines.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `Dockerfile` - Production dockerfile that supports Git deployment
|
||||
- `Dockerfile.dev` - Development dockerfile
|
||||
- `docker-compose.yml` - Development environment
|
||||
- `docker-compose.prod.yml` - Production environment with Git support
|
||||
- `deploy.sh` / `deploy.bat` - Deployment scripts
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### 1. Deploy from Local Files (Default)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
docker-compose up
|
||||
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yml up --build
|
||||
```
|
||||
|
||||
**Note**: Both development and production Docker builds automatically:
|
||||
- Create the default admin account
|
||||
- Run any pending database migrations
|
||||
- Initialize/update the database schema
|
||||
|
||||
### 2. Deploy from Git Repository
|
||||
|
||||
#### Using Environment Variables
|
||||
|
||||
Create a `.env` file with:
|
||||
```env
|
||||
GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git
|
||||
GIT_BRANCH=ui-fix
|
||||
GIT_COMMIT=abc123 # Optional: specific commit hash
|
||||
```
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up --build
|
||||
```
|
||||
|
||||
#### Using Build Arguments
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git \
|
||||
--build-arg GIT_BRANCH=ui-fix \
|
||||
--build-arg GIT_COMMIT=abc123 \
|
||||
-t your-app .
|
||||
```
|
||||
|
||||
#### Using Deployment Scripts
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
./deploy.sh https://git.wastpol.pl/Admin/panel.git ui-fix abc123
|
||||
|
||||
# Windows
|
||||
deploy.bat https://git.wastpol.pl/Admin/panel.git ui-fix abc123
|
||||
```
|
||||
|
||||
## Private Repositories
|
||||
|
||||
For private repositories, you have several options:
|
||||
|
||||
### 1. SSH Keys (Recommended for development)
|
||||
```bash
|
||||
# Build with SSH URL
|
||||
docker build --build-arg GIT_REPO_URL=git@git.wastpol.pl:Admin/panel.git .
|
||||
```
|
||||
|
||||
### 2. Personal Access Token
|
||||
```bash
|
||||
# Build with token in URL
|
||||
docker build --build-arg GIT_REPO_URL=https://username:token@git.wastpol.pl/Admin/panel.git .
|
||||
```
|
||||
|
||||
### 3. Docker Secrets (Recommended for production)
|
||||
```yaml
|
||||
# In docker-compose.prod.yml
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git
|
||||
secrets:
|
||||
- git_token
|
||||
secrets:
|
||||
git_token:
|
||||
file: ./git_token.txt
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Example
|
||||
|
||||
```yaml
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg GIT_REPO_URL=${{ github.repository }} \
|
||||
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||
-t my-app .
|
||||
docker run -d -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
### Docker Compose in CI/CD
|
||||
|
||||
```bash
|
||||
# Set environment variables in your CI/CD system
|
||||
export GIT_REPO_URL="https://git.wastpol.pl/Admin/panel.git"
|
||||
export GIT_BRANCH="ui-fix"
|
||||
export GIT_COMMIT="$CI_COMMIT_SHA"
|
||||
|
||||
# Deploy
|
||||
docker-compose -f docker-compose.prod.yml up --build -d
|
||||
```
|
||||
|
||||
## Build Process
|
||||
|
||||
When `GIT_REPO_URL` is provided:
|
||||
1. Git repository is cloned into the container
|
||||
2. If `GIT_COMMIT` is specified, checkout that specific commit
|
||||
3. Install dependencies from the repository's package.json
|
||||
4. Build the application
|
||||
5. **Admin account is created when container starts (not during build)**
|
||||
6. Start the production server
|
||||
|
||||
When `GIT_REPO_URL` is not provided:
|
||||
1. Copy local files into the container
|
||||
2. Install dependencies
|
||||
3. Build the application
|
||||
4. **Admin account is created when container starts (not during build)**
|
||||
5. Start the production server
|
||||
|
||||
## Default Admin Account
|
||||
|
||||
Both development and production Docker containers automatically create a default admin account **when the container starts** (not during build). This ensures the database is properly persisted in mounted volumes.
|
||||
|
||||
- **Email**: `admin@localhost.com`
|
||||
- **Password**: `admin123456`
|
||||
- **Role**: `admin`
|
||||
|
||||
⚠️ **Important Security Note**: Please change the default password immediately after your first login!
|
||||
|
||||
### Manual Admin Account Creation
|
||||
|
||||
If you need to create the admin account manually (for development or testing):
|
||||
|
||||
```bash
|
||||
# Using npm script
|
||||
npm run create-admin
|
||||
|
||||
# Or directly
|
||||
node scripts/create-admin.js
|
||||
```
|
||||
|
||||
The script will skip creation if an admin account already exists.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `GIT_REPO_URL` - Git repository URL (HTTPS or SSH)
|
||||
- `GIT_BRANCH` - Git branch to checkout (default: main)
|
||||
- `GIT_COMMIT` - Specific commit hash to checkout (optional)
|
||||
- `NODE_ENV` - Node.js environment (development/production)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Git Authentication Issues
|
||||
- Ensure your Git credentials are properly configured
|
||||
- For HTTPS, use personal access tokens instead of passwords
|
||||
- For SSH, ensure SSH keys are properly mounted or available
|
||||
|
||||
### Build Failures
|
||||
- Check if the repository URL is accessible
|
||||
- Verify the branch name exists
|
||||
- Ensure the commit hash is valid
|
||||
- Check Docker build logs for specific errors
|
||||
|
||||
### Permission Issues
|
||||
- Ensure the Docker daemon has network access
|
||||
- For private repositories, verify authentication tokens/keys
|
||||
|
||||
### Admin Account Issues
|
||||
- If admin creation fails during startup, check database initialization
|
||||
- Ensure the `./data` directory is writable on the host
|
||||
- Database files are persisted in the mounted `./data` volume
|
||||
- Admin account is created on container startup, not during build
|
||||
- If admin already exists, the script will skip creation (this is normal)
|
||||
- To recreate admin account, delete the database file in `./data/` and restart the container
|
||||
156
DOCKER_TIMEZONE_FIX.md
Normal file
156
DOCKER_TIMEZONE_FIX.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Docker Timezone Configuration Fix
|
||||
|
||||
## Problem
|
||||
Even after fixing the SQLite `datetime('now', 'localtime')` calls, notes posted at 10:00 still showed as 08:00 when running in Docker.
|
||||
|
||||
## Root Cause
|
||||
**Docker containers run in UTC timezone by default!**
|
||||
|
||||
When using `datetime('now', 'localtime')` in SQLite:
|
||||
- On local Windows machine: Uses Windows timezone (Europe/Warsaw) → ✅ Correct
|
||||
- In Docker container: Uses container timezone (UTC) → ❌ Wrong by 2 hours
|
||||
|
||||
Example:
|
||||
```
|
||||
User posts at 10:00 Poland time (UTC+2)
|
||||
↓
|
||||
Docker container thinks local time is 08:00 UTC
|
||||
↓
|
||||
SQLite datetime('now', 'localtime') stores: 08:00
|
||||
↓
|
||||
Display shows: 08:00 (wrong!)
|
||||
```
|
||||
|
||||
## Solution
|
||||
Set the Docker container timezone to Europe/Warsaw
|
||||
|
||||
### 1. Updated Dockerfile (Production)
|
||||
|
||||
```dockerfile
|
||||
# Use Node.js 22.11.0 as the base image
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone to Europe/Warsaw (Polish timezone)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# ... rest of Dockerfile
|
||||
```
|
||||
|
||||
### 2. Updated Dockerfile.dev (Development)
|
||||
|
||||
```dockerfile
|
||||
# Use Node.js 22.11.0 as the base image
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone to Europe/Warsaw (Polish timezone)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# ... rest of Dockerfile
|
||||
```
|
||||
|
||||
### 3. Updated docker-compose.yml (Development)
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Europe/Warsaw
|
||||
```
|
||||
|
||||
### 4. Updated docker-compose.prod.yml (Production)
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=Europe/Warsaw
|
||||
- NEXTAUTH_SECRET=...
|
||||
- NEXTAUTH_URL=...
|
||||
```
|
||||
|
||||
## How to Apply
|
||||
|
||||
### Option 1: Rebuild Docker Images
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
|
||||
# Rebuild images
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Option 2: For Production Deployment
|
||||
```bash
|
||||
# Pull latest code with fixes
|
||||
git pull
|
||||
|
||||
# Rebuild production image
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
# Restart
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After rebuilding and restarting, verify timezone inside container:
|
||||
|
||||
```bash
|
||||
# Check timezone
|
||||
docker exec -it <container_name> date
|
||||
# Should show: Sat Oct 4 19:00:00 CEST 2025
|
||||
|
||||
# Check Node.js sees correct timezone
|
||||
docker exec -it <container_name> node -e "console.log(new Date().toLocaleString('pl-PL', {timeZone: 'Europe/Warsaw'}))"
|
||||
# Should show current Polish time
|
||||
|
||||
# Check SQLite sees correct timezone
|
||||
docker exec -it <container_name> node -e "const db = require('better-sqlite3')('./data/database.sqlite'); console.log(db.prepare(\"SELECT datetime('now', 'localtime')\").get());"
|
||||
# Should show current Polish time
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
|
||||
1. **TZ Environment Variable**: Tells all processes (including Node.js and SQLite) what timezone to use
|
||||
2. **Symlink /etc/localtime**: Updates system timezone for the entire container
|
||||
3. **echo TZ > /etc/timezone**: Ensures the timezone persists
|
||||
|
||||
Now when SQLite uses `datetime('now', 'localtime')`:
|
||||
- Container local time is 10:00 Poland time
|
||||
- SQLite stores: 10:00
|
||||
- Display shows: 10:00 ✅
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Must rebuild images**: Just restarting containers is not enough - the timezone configuration is baked into the image
|
||||
2. **All existing data**: Old notes will still show incorrect times (they were stored in UTC)
|
||||
3. **New notes**: Will now display correctly
|
||||
4. **DST handling**: Europe/Warsaw automatically handles Daylight Saving Time transitions
|
||||
|
||||
## Alternative Approach (Not Recommended)
|
||||
|
||||
Instead of changing container timezone, you could:
|
||||
1. Store everything in UTC (like audit logs do with ISO format)
|
||||
2. Always convert on display
|
||||
|
||||
But this requires more code changes and the current approach is simpler and more maintainable.
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `Dockerfile` - Added TZ configuration
|
||||
2. `Dockerfile.dev` - Added TZ configuration
|
||||
3. `docker-compose.yml` - Added TZ environment variable
|
||||
4. `docker-compose.prod.yml` - Added TZ environment variable
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After deployment:
|
||||
- [ ] Container shows correct date/time with `docker exec <container> date`
|
||||
- [ ] Post a new note at known time (e.g., 10:30)
|
||||
- [ ] Verify note displays the same time (10:30)
|
||||
- [ ] Check both project notes and task notes
|
||||
- [ ] Verify audit logs still work correctly
|
||||
- [ ] Check task timestamps (date_started, date_completed)
|
||||
35
Dockerfile
35
Dockerfile
@@ -1,20 +1,47 @@
|
||||
# Use Node.js 22.11.0 as the base image
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone to Europe/Warsaw (Polish timezone)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# Install git
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (if any)
|
||||
# If building from a git repository, clone it
|
||||
# This will be used when the build context doesn't include source files
|
||||
ARG GIT_REPO_URL
|
||||
ARG GIT_BRANCH=main
|
||||
ARG GIT_COMMIT
|
||||
|
||||
# If GIT_REPO_URL is provided, clone the repo; otherwise copy local files
|
||||
RUN if [ -n "$GIT_REPO_URL" ]; then \
|
||||
git clone --branch ${GIT_BRANCH} ${GIT_REPO_URL} . && \
|
||||
if [ -n "$GIT_COMMIT" ]; then git checkout ${GIT_COMMIT}; fi; \
|
||||
fi
|
||||
|
||||
# Copy package.json and package-lock.json (if not cloned from git)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the app
|
||||
# Copy the rest of the app (if not cloned from git)
|
||||
RUN if [ -z "$GIT_REPO_URL" ]; then echo "Copying local files..."; fi
|
||||
COPY . .
|
||||
|
||||
# Build the application for production
|
||||
RUN npm run build
|
||||
|
||||
# Copy the entrypoint script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Expose the default Next.js port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the dev server
|
||||
CMD ["npm", "run", "dev"]
|
||||
# Use the entrypoint script
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
31
Dockerfile.dev
Normal file
31
Dockerfile.dev
Normal file
@@ -0,0 +1,31 @@
|
||||
# Use Node.js 22.11.0 as the base image
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone to Europe/Warsaw (Polish timezone)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# Install git for development
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (if any)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . .
|
||||
|
||||
# Copy the development entrypoint script
|
||||
COPY docker-entrypoint-dev.sh /docker-entrypoint-dev.sh
|
||||
RUN chmod +x /docker-entrypoint-dev.sh
|
||||
|
||||
# Expose the default Next.js port
|
||||
EXPOSE 3000
|
||||
|
||||
# Use the development entrypoint script
|
||||
ENTRYPOINT ["/docker-entrypoint-dev.sh"]
|
||||
62
README.md
62
README.md
@@ -32,13 +32,43 @@ A comprehensive project management system built with Next.js for managing constr
|
||||
- Task completion tracking
|
||||
- Quick access to pending items
|
||||
|
||||
### 🔐 Authentication & Authorization
|
||||
|
||||
- Complete user authentication system with NextAuth.js
|
||||
- Role-based access control (Admin, User, Guest roles)
|
||||
- Secure session management
|
||||
- Password-based authentication
|
||||
- User registration and management
|
||||
|
||||
### 👥 User Management
|
||||
|
||||
- Admin interface for user administration
|
||||
- Create, edit, and delete user accounts
|
||||
- Role assignment and permission management
|
||||
- User activity monitoring
|
||||
|
||||
### 📋 Audit Logging
|
||||
|
||||
- Comprehensive logging of all user actions
|
||||
- Security event tracking
|
||||
- System activity monitoring
|
||||
- Audit trail for compliance and debugging
|
||||
|
||||
### 📊 Data Export
|
||||
|
||||
- Export projects to Excel format grouped by status
|
||||
- Includes project name, address, plot, WP, and finish date
|
||||
- Separate sheets for each project status (registered, in progress, fulfilled, etc.)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 15.1.8
|
||||
- **Database**: SQLite with better-sqlite3
|
||||
- **Authentication**: NextAuth.js
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Date Handling**: date-fns
|
||||
- **Frontend**: React 19
|
||||
- **Mapping**: Leaflet with React-Leaflet
|
||||
- **Container**: Docker & Docker Compose
|
||||
|
||||
## Getting Started
|
||||
@@ -95,6 +125,16 @@ docker-compose up
|
||||
|
||||
The application uses SQLite database which will be automatically initialized on first run. The database file is located at `data/database.sqlite`.
|
||||
|
||||
### Admin Setup
|
||||
|
||||
To create an initial admin user:
|
||||
|
||||
```bash
|
||||
npm run create-admin
|
||||
```
|
||||
|
||||
This will create an admin user account. Access the admin panel at `/admin/users` to manage users and roles.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -144,6 +184,7 @@ src/
|
||||
- `npm run build` - Build for production
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint
|
||||
- `npm run export-projects` - Export all projects to Excel file grouped by status
|
||||
|
||||
## Docker Commands
|
||||
|
||||
@@ -219,12 +260,25 @@ The application uses the following main tables:
|
||||
|
||||
## Advanced Map Features
|
||||
|
||||
This project includes a powerful map system for project locations, supporting multiple dynamic base layers:
|
||||
This project includes a powerful map system for project locations, supporting multiple dynamic base layers and transparent overlays:
|
||||
|
||||
### Base Layers (8 total)
|
||||
- **OpenStreetMap** (default street map)
|
||||
- **Polish Geoportal Orthophoto** (aerial imagery via WMTS)
|
||||
- **Polish Land Records** (WMS cadastral data)
|
||||
- **Satellite (Esri)** and **Topographic** layers
|
||||
- **🇵🇱 Polish Orthophoto (Standard Resolution)** - WMTS aerial imagery
|
||||
- **🇵🇱 Polish Orthophoto (High Resolution)** - WMTS high-res aerial imagery
|
||||
- **🌍 Google Satellite** - Global satellite imagery
|
||||
- **🌍 Google Hybrid** - Satellite imagery with road overlays
|
||||
- **🌍 Google Roads** - Google Maps road view
|
||||
- **Satellite (Esri)** - Alternative satellite imagery
|
||||
- **Topographic** - CartoDB topographic maps
|
||||
|
||||
### Overlay Layers (6 total with transparency)
|
||||
- **📋 Polish Cadastral Data** (WMS, property boundaries - 80% opacity)
|
||||
- **🏗️ Polish Spatial Planning** (WMS, zoning data - 70% opacity)
|
||||
- **🛣️ LP-Portal Roads** (WMS, road network - 90% opacity)
|
||||
- **🏷️ LP-Portal Street Names** (WMS, street labels - 100% opacity)
|
||||
- **📐 LP-Portal Parcels** (WMS, property parcels - 60% opacity)
|
||||
- **📍 LP-Portal Survey Markers** (WMS, survey points - 80% opacity)
|
||||
|
||||
Users can switch layers using the map control (📚 icon). WMTS/WMS layers are configured dynamically using OGC GetCapabilities, making it easy to add new sources. See [`docs/MAP_LAYERS.md`](docs/MAP_LAYERS.md) for details on adding and configuring map layers.
|
||||
|
||||
|
||||
235
ROADMAP.md
235
ROADMAP.md
@@ -16,59 +16,43 @@ This is a solid Next.js-based project management system for construction/enginee
|
||||
- **API Structure**: RESTful API endpoints for all entities
|
||||
- **Docker Support**: Containerized development and deployment
|
||||
- **Testing Setup**: Jest, Playwright, Testing Library configured
|
||||
- **Authentication & Authorization**: NextAuth.js with role-based access control, user management UI, session management
|
||||
- **Security Features**: Input validation with Zod, password hashing with bcryptjs, audit logging
|
||||
- **Reporting Libraries**: Recharts for charts, jsPDF/jspdf-autotable for PDF, exceljs/xlsx for Excel export
|
||||
- **Search & Filtering**: Basic search functionality implemented
|
||||
|
||||
---
|
||||
|
||||
## Critical Missing Features for App
|
||||
|
||||
### 🔐 **1. Authentication & Authorization (HIGH PRIORITY)**
|
||||
### <EFBFBD> **1. Security & Data Protection (HIGH PRIORITY)**
|
||||
|
||||
**Current State**: No authentication system
|
||||
**Current State**: Partial security measures implemented (Zod validation, bcrypt hashing, audit logging)
|
||||
**Required**:
|
||||
|
||||
- User login/logout system
|
||||
- Role-based access control (Admin, Project Manager, User, Read-only)
|
||||
- Session management
|
||||
- Password reset functionality
|
||||
- User management interface
|
||||
- API route protection
|
||||
|
||||
**Implementation Options**:
|
||||
|
||||
- NextAuth.js with database sessions
|
||||
- Auth0 integration
|
||||
- Custom JWT implementation
|
||||
|
||||
### 🔒 **2. Security & Data Protection (HIGH PRIORITY)**
|
||||
|
||||
**Current State**: No security measures
|
||||
**Required**:
|
||||
|
||||
- Input validation and sanitization
|
||||
- SQL injection protection (prepared statements are good start)
|
||||
- XSS protection
|
||||
- CSRF protection
|
||||
- Rate limiting
|
||||
- Environment variable security
|
||||
- Data encryption for sensitive fields
|
||||
- Audit logging
|
||||
- XSS protection (additional measures)
|
||||
- Security headers middleware
|
||||
- Comprehensive error handling
|
||||
|
||||
### 📊 **3. Advanced Reporting & Analytics (MEDIUM PRIORITY)**
|
||||
### 📊 **2. Advanced Reporting & Analytics (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic dashboard statistics
|
||||
**Current State**: Libraries installed (Recharts, jsPDF, exceljs), basic dashboard statistics, API endpoints for reports
|
||||
**Required**:
|
||||
|
||||
- Project timeline reports
|
||||
- Full UI for project timeline reports
|
||||
- Budget tracking and financial reports
|
||||
- Task completion analytics
|
||||
- Project performance metrics
|
||||
- Export to PDF/Excel
|
||||
- Custom report builder
|
||||
- Charts and graphs (Chart.js, D3.js)
|
||||
- Charts and graphs integration in UI
|
||||
|
||||
### 💾 **4. Backup & Data Management (HIGH PRIORITY)**
|
||||
### 💾 **3. Backup & Data Management (HIGH PRIORITY)**
|
||||
|
||||
**Current State**: Single SQLite file
|
||||
**Current State**: Single SQLite file, manual export scripts
|
||||
**Required**:
|
||||
|
||||
- Automated database backups
|
||||
@@ -77,6 +61,122 @@ This is a solid Next.js-based project management system for construction/enginee
|
||||
- Data archiving for old projects
|
||||
- Recovery procedures
|
||||
|
||||
### 📱 **4. Mobile Responsiveness & PWA (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic responsive design
|
||||
**Required**:
|
||||
|
||||
- Progressive Web App capabilities
|
||||
- Offline functionality
|
||||
- Mobile-optimized interface
|
||||
- Push notifications
|
||||
- App manifest and service workers
|
||||
|
||||
### 🔗 **5. API & Integration (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Internal REST API only
|
||||
**Required**:
|
||||
|
||||
- External API integrations (accounting software, CRM)
|
||||
- Webhook support
|
||||
- API documentation (Swagger/OpenAPI)
|
||||
- API versioning
|
||||
- Third-party service integrations
|
||||
|
||||
### <20> **6. Communication & Notifications (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: No notification system
|
||||
**Required**:
|
||||
|
||||
- Email notifications for deadlines, status changes
|
||||
- In-app notifications
|
||||
- SMS notifications (optional)
|
||||
- Email templates
|
||||
- Notification preferences per user
|
||||
|
||||
### 📋 **7. Enhanced Project Management (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic project tracking
|
||||
**Required**:
|
||||
|
||||
- Gantt charts for project timelines
|
||||
- Resource allocation and management
|
||||
- Budget tracking per project
|
||||
- Document attachment system
|
||||
- Project templates
|
||||
- Milestone tracking
|
||||
- Dependencies between tasks
|
||||
|
||||
### 🔍 **8. Search & Filtering (LOW PRIORITY)**
|
||||
|
||||
**Current State**: Basic search implemented
|
||||
**Required**:
|
||||
|
||||
- Advanced search with filters
|
||||
- Full-text search
|
||||
- Saved search queries
|
||||
- Search autocomplete
|
||||
- Global search across all entities
|
||||
|
||||
### ⚡ **9. Performance & Scalability (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Good for small-medium datasets
|
||||
**Required**:
|
||||
|
||||
- Database optimization and indexing
|
||||
- Caching layer (Redis)
|
||||
- Image optimization
|
||||
- Lazy loading
|
||||
- Pagination for large datasets
|
||||
- Background job processing
|
||||
|
||||
### 📝 **10. Documentation & Help System (LOW PRIORITY)**
|
||||
|
||||
**Current State**: README.md only
|
||||
**Required**:
|
||||
|
||||
- User manual/documentation
|
||||
- In-app help system
|
||||
- API documentation
|
||||
- Video tutorials
|
||||
- FAQ section
|
||||
|
||||
### 🧪 **11. Testing & Quality Assurance (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Testing frameworks set up but minimal actual tests
|
||||
**Required**:
|
||||
|
||||
- Unit tests for all components
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
- Performance testing
|
||||
- Accessibility testing
|
||||
- Code coverage reports
|
||||
|
||||
### <20> **12. DevOps & Deployment (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Docker setup exists
|
||||
**Required**:
|
||||
|
||||
- CI/CD pipeline
|
||||
- Production deployment strategy
|
||||
- Environment management (dev, staging, prod)
|
||||
- Monitoring and logging
|
||||
- Error tracking (Sentry)
|
||||
- Health checks
|
||||
|
||||
### 🎨 **13. UI/UX Improvements (LOW PRIORITY)**
|
||||
|
||||
**Current State**: Clean, functional interface
|
||||
**Required**:
|
||||
|
||||
- Dark mode support
|
||||
- Customizable themes
|
||||
- Accessibility improvements (WCAG compliance)
|
||||
- Keyboard navigation
|
||||
- Better loading states
|
||||
- Drag and drop functionality
|
||||
|
||||
### 📱 **5. Mobile Responsiveness & PWA (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic responsive design
|
||||
@@ -197,18 +297,18 @@ This is a solid Next.js-based project management system for construction/enginee
|
||||
|
||||
## Implementation Priority Levels
|
||||
|
||||
### Phase 1: Security & Stability (Weeks 1-4)
|
||||
### Phase 1: Security Completion & Backup (Weeks 1-4)
|
||||
|
||||
1. Authentication system
|
||||
2. Authorization and role management
|
||||
3. Input validation and security
|
||||
4. Backup system
|
||||
1. Complete security measures (CSRF protection, rate limiting, security headers)
|
||||
2. Backup system implementation
|
||||
3. Password reset functionality
|
||||
4. Enhanced error handling
|
||||
5. Basic testing coverage
|
||||
|
||||
### Phase 2: Core Features (Weeks 5-8)
|
||||
|
||||
1. Advanced reporting
|
||||
2. Mobile optimization
|
||||
1. Advanced reporting UI
|
||||
2. Mobile optimization & PWA
|
||||
3. Notification system
|
||||
4. Enhanced project management features
|
||||
|
||||
@@ -230,34 +330,36 @@ This is a solid Next.js-based project management system for construction/enginee
|
||||
|
||||
## Immediate Next Steps (Recommended Order)
|
||||
|
||||
1. **Set up Authentication**
|
||||
1. **Complete Security Measures**
|
||||
|
||||
- Install NextAuth.js or implement custom auth
|
||||
- Create user management system
|
||||
- Add login/logout functionality
|
||||
- Implement CSRF protection
|
||||
- Add rate limiting
|
||||
- Set up security headers middleware
|
||||
- Enhance error handling
|
||||
|
||||
2. **Implement Input Validation**
|
||||
|
||||
- Add Zod or Joi for schema validation
|
||||
- Protect all API endpoints
|
||||
- Add error handling
|
||||
|
||||
3. **Create Backup System**
|
||||
2. **Create Backup System**
|
||||
|
||||
- Implement database backup scripts
|
||||
- Set up automated backups
|
||||
- Create recovery procedures
|
||||
|
||||
3. **Implement Password Reset**
|
||||
|
||||
- Add password reset functionality
|
||||
- Email templates and sending
|
||||
- Secure token generation
|
||||
|
||||
4. **Add Basic Tests**
|
||||
|
||||
- Write unit tests for critical functions
|
||||
- Add integration tests for API routes
|
||||
- Set up test automation
|
||||
|
||||
5. **Implement Reporting**
|
||||
- Add Chart.js for visualizations
|
||||
- Create project timeline reports
|
||||
- Add export functionality
|
||||
5. **Build Advanced Reporting UI**
|
||||
|
||||
- Create project timeline reports page
|
||||
- Integrate charts with Recharts
|
||||
- Add PDF/Excel export UI
|
||||
|
||||
---
|
||||
|
||||
@@ -265,25 +367,25 @@ This is a solid Next.js-based project management system for construction/enginee
|
||||
|
||||
### Authentication
|
||||
|
||||
- **NextAuth.js** - For easy authentication setup
|
||||
- **NextAuth.js** - ✅ Implemented with role-based access and user management
|
||||
- **Prisma** - For better database management (optional upgrade from better-sqlite3)
|
||||
|
||||
### Security
|
||||
|
||||
- **Zod** - Runtime type checking and validation
|
||||
- **bcryptjs** - Password hashing
|
||||
- **rate-limiter-flexible** - Rate limiting
|
||||
- **Zod** - ✅ Implemented for validation
|
||||
- **bcryptjs** - ✅ Implemented for password hashing
|
||||
- **rate-limiter-flexible** - Rate limiting (to implement)
|
||||
|
||||
### Reporting
|
||||
|
||||
- **Chart.js** or **Recharts** - Data visualization
|
||||
- **jsPDF** - PDF generation
|
||||
- **xlsx** - Excel export
|
||||
- **Recharts** - ✅ Installed for data visualization
|
||||
- **jsPDF/jspdf-autotable** - ✅ Installed for PDF generation
|
||||
- **exceljs/xlsx** - ✅ Installed for Excel export
|
||||
|
||||
### Notifications
|
||||
|
||||
- **Nodemailer** - Email sending
|
||||
- **Socket.io** - Real-time notifications
|
||||
- **Nodemailer** - Email sending (to implement)
|
||||
- **Socket.io** - Real-time notifications (to implement)
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -302,13 +404,16 @@ This is a solid Next.js-based project management system for construction/enginee
|
||||
5. **Docker support** for easy deployment
|
||||
6. **Map integration** with multiple layers
|
||||
7. **Modular components** that are reusable
|
||||
8. **Authentication & Authorization** fully implemented with NextAuth.js
|
||||
9. **Security foundations** (validation, hashing, audit logging)
|
||||
10. **Reporting capabilities** with installed libraries for charts and exports
|
||||
|
||||
---
|
||||
|
||||
## Estimated Development Time
|
||||
|
||||
- **Minimum Viable Professional App**: 8-12 weeks
|
||||
- **Full-featured Professional App**: 16-20 weeks
|
||||
- **Enterprise-grade Application**: 24-30 weeks
|
||||
- **Minimum Viable Professional App**: 6-10 weeks
|
||||
- **Full-featured Professional App**: 14-18 weeks
|
||||
- **Enterprise-grade Application**: 22-28 weeks
|
||||
|
||||
This assessment is based on a single developer working full-time. Team development could reduce these timelines significantly.
|
||||
|
||||
18
add-assignable-column.mjs
Normal file
18
add-assignable-column.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
console.log("Adding can_be_assigned column to users table...");
|
||||
|
||||
// Add the new column
|
||||
db.prepare(`
|
||||
ALTER TABLE users
|
||||
ADD COLUMN can_be_assigned INTEGER DEFAULT 1
|
||||
`).run();
|
||||
|
||||
// Set admin users to not be assignable by default
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET can_be_assigned = 0
|
||||
WHERE role = 'admin'
|
||||
`).run();
|
||||
|
||||
console.log("Migration completed. Admin users are now not assignable by default.");
|
||||
57
deploy.bat
Normal file
57
deploy.bat
Normal 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
52
deploy.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Production deployment script
|
||||
# Usage: ./deploy.sh [git_repo_url] [branch] [commit_hash]
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
GIT_REPO_URL=${1:-""}
|
||||
GIT_BRANCH=${2:-"ui-fix"}
|
||||
GIT_COMMIT=${3:-""}
|
||||
|
||||
# Check if .env.production exists and source it
|
||||
if [ -f .env.production ]; then
|
||||
echo "Loading production environment variables..."
|
||||
export $(grep -v '^#' .env.production | xargs)
|
||||
else
|
||||
echo "Warning: .env.production not found. Make sure environment variables are set!"
|
||||
fi
|
||||
|
||||
# Validate critical environment variables
|
||||
# if [ -z "$NEXTAUTH_SECRET" ] || [ "$NEXTAUTH_SECRET" = "YOUR_SUPER_SECURE_SECRET_KEY_HERE_AT_LEAST_32_CHARACTERS_LONG" ]; then
|
||||
# echo "ERROR: NEXTAUTH_SECRET must be set to a secure random string!"
|
||||
# echo "Generate one with: openssl rand -base64 32"
|
||||
# exit 1
|
||||
# fi
|
||||
|
||||
if [ -z "$NEXTAUTH_URL" ]; then
|
||||
echo "ERROR: NEXTAUTH_URL must be set to your production URL!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$GIT_REPO_URL" ]; then
|
||||
echo "Building from local files..."
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
else
|
||||
echo "Building from git repository: $GIT_REPO_URL"
|
||||
echo "Branch: $GIT_BRANCH"
|
||||
if [ -n "$GIT_COMMIT" ]; then
|
||||
echo "Commit: $GIT_COMMIT"
|
||||
fi
|
||||
|
||||
GIT_REPO_URL=$GIT_REPO_URL GIT_BRANCH=$GIT_BRANCH GIT_COMMIT=$GIT_COMMIT \
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
fi
|
||||
|
||||
echo "Starting production deployment..."
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
echo "Deployment completed successfully!"
|
||||
echo "Application is running at http://localhost:3001"
|
||||
23
docker-compose.prod.yml
Normal file
23
docker-compose.prod.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- GIT_REPO_URL=${GIT_REPO_URL}
|
||||
- GIT_BRANCH=${GIT_BRANCH:-main}
|
||||
- GIT_COMMIT=${GIT_COMMIT}
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/public/uploads
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=Europe/Warsaw
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-your-secret-key-generate-a-strong-random-string-at-least-32-characters}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-https://panel2.wastpol.pl}
|
||||
- AUTH_TRUST_HOST=true
|
||||
restart: unless-stopped
|
||||
@@ -2,12 +2,15 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Europe/Warsaw
|
||||
|
||||
25
docker-entrypoint-dev.sh
Normal file
25
docker-entrypoint-dev.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Development container startup script
|
||||
# This runs when the development container starts
|
||||
|
||||
echo "🚀 Starting development environment..."
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
# Ensure uploads directory structure exists
|
||||
mkdir -p /app/public/uploads/contracts
|
||||
mkdir -p /app/public/uploads/projects
|
||||
mkdir -p /app/public/uploads/tasks
|
||||
|
||||
# Set proper permissions for uploads directory
|
||||
chmod -R 755 /app/public/uploads
|
||||
|
||||
# Create admin account if it doesn't exist
|
||||
echo "🔧 Setting up admin account..."
|
||||
node scripts/create-admin.js
|
||||
|
||||
# Start the development server
|
||||
echo "✅ Starting development server..."
|
||||
exec npm run dev
|
||||
29
docker-entrypoint.sh
Normal file
29
docker-entrypoint.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Container startup script
|
||||
# This runs when the container starts, not during build
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
# Ensure uploads directory structure exists
|
||||
mkdir -p /app/public/uploads/contracts
|
||||
mkdir -p /app/public/uploads/projects
|
||||
mkdir -p /app/public/uploads/tasks
|
||||
|
||||
# Set proper permissions for uploads directory
|
||||
chmod -R 755 /app/public/uploads
|
||||
|
||||
# Create admin account if it doesn't exist
|
||||
echo "🔧 Setting up admin account..."
|
||||
node scripts/create-admin.js
|
||||
|
||||
# Run any pending database migrations
|
||||
echo "🔄 Running database migrations..."
|
||||
./run-migrations.sh
|
||||
|
||||
# Start the application
|
||||
echo "✅ Starting production server..."
|
||||
exec npm start
|
||||
58
export-projects-to-excel.mjs
Normal file
58
export-projects-to-excel.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getAllProjects } from './src/lib/queries/projects.js';
|
||||
|
||||
function exportProjectsToExcel() {
|
||||
try {
|
||||
// Get all projects
|
||||
const projects = getAllProjects();
|
||||
|
||||
// Group projects by status
|
||||
const groupedProjects = projects.reduce((acc, project) => {
|
||||
const status = project.project_status || 'unknown';
|
||||
if (!acc[status]) {
|
||||
acc[status] = [];
|
||||
}
|
||||
acc[status].push({
|
||||
'Nazwa projektu': project.project_name,
|
||||
'Adres': project.address || '',
|
||||
'Działka': project.plot || '',
|
||||
'WP': project.wp || '',
|
||||
'Data zakończenia': project.finish_date || ''
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Polish status translations for sheet names
|
||||
const statusTranslations = {
|
||||
'registered': 'Zarejestrowany',
|
||||
'in_progress_design': 'W realizacji (projektowanie)',
|
||||
'in_progress_construction': 'W realizacji (budowa)',
|
||||
'fulfilled': 'Zakończony',
|
||||
'cancelled': 'Wycofany',
|
||||
'unknown': 'Nieznany'
|
||||
};
|
||||
|
||||
// Create workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Create a sheet for each status
|
||||
Object.keys(groupedProjects).forEach(status => {
|
||||
const sheetName = statusTranslations[status] || status;
|
||||
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
});
|
||||
|
||||
// Write to file
|
||||
const filename = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
|
||||
console.log(`Excel file created: ${filename}`);
|
||||
console.log(`Sheets created for statuses: ${Object.keys(groupedProjects).join(', ')}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting projects to Excel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the export
|
||||
exportProjectsToExcel();
|
||||
92
files-to-delete.md
Normal file
92
files-to-delete.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Files to Delete from Codebase
|
||||
|
||||
Based on analysis of the workspace, the following files and folders appear to be temporary, debug-related, test-specific, or one-off scripts that should not remain in the production codebase. Review and delete as appropriate.
|
||||
|
||||
## Debug/Test Folders (entirely removable)
|
||||
- `debug-disabled/` (and all subfolders: comprehensive-polish-map/, debug-polish-orthophoto/, test-improved-wmts/, test-polish-map/, test-polish-orthophoto/)
|
||||
- `data/` (contains database.sqlite, likely a development database)
|
||||
- `uploads/` (user-uploaded files that should be gitignored or stored elsewhere)
|
||||
- `scripts/` (test data creation scripts: create-additional-test-data.js, create-admin.js, create-diverse-test-data.js, create-sample-projects.js, create-test-data.js)
|
||||
|
||||
## Test Files (one-off test scripts)
|
||||
- test-audit-fix-direct.mjs
|
||||
- test-audit-logging.mjs
|
||||
- test-auth-api.mjs
|
||||
- test-auth-detailed.mjs
|
||||
- test-auth-pages.mjs
|
||||
- test-auth-session.mjs
|
||||
- test-auth.mjs
|
||||
- test-complete-auth.mjs
|
||||
- test-create-function.mjs
|
||||
- test-current-audit-logs.mjs
|
||||
- test-date-formatting.js
|
||||
- test-dropdown-comprehensive.html
|
||||
- test-dropdown.html
|
||||
- test-edge-compatibility.mjs
|
||||
- test-logged-in-flow.mjs
|
||||
- test-logging.mjs
|
||||
- test-mobile.html
|
||||
- test-nextauth.mjs
|
||||
- test-project-api.mjs
|
||||
- test-project-creation.mjs
|
||||
- test-safe-audit-logging.mjs
|
||||
- test-task-api.mjs
|
||||
- test-task-sets.mjs
|
||||
- test-user-tracking.mjs
|
||||
|
||||
## Debug Files
|
||||
- debug-dropdown.js
|
||||
- debug-task-insert.mjs
|
||||
|
||||
## Check/Verification Scripts (one-off)
|
||||
- check-audit-db.mjs
|
||||
- check-columns.mjs
|
||||
- check-projects-table.mjs
|
||||
- check-projects.mjs
|
||||
- check-schema.mjs
|
||||
- check-task-schema.mjs
|
||||
|
||||
## Migration Scripts (likely already executed)
|
||||
- migrate-add-completion-date.mjs
|
||||
- migrate-add-edited-at-to-notes.mjs
|
||||
- migrate-add-initial-column.mjs
|
||||
- migrate-add-team-lead-role.mjs
|
||||
- migrate-add-wartosc-zlecenia.mjs
|
||||
- migrate-to-username.js
|
||||
- run-migrations.sh
|
||||
|
||||
## Other One-Off Scripts
|
||||
- add-assignable-column.mjs
|
||||
- export-projects-to-excel.mjs
|
||||
- fix-notes-columns.mjs
|
||||
- fix-task-columns.mjs
|
||||
- init-db-temp.mjs
|
||||
- update-admin-username.js
|
||||
- update-queries.ps1
|
||||
- verify-audit-fix.mjs
|
||||
- verify-project.mjs
|
||||
|
||||
## Implementation/Status Documentation (temporary notes)
|
||||
- AUDIT_LOGGING_IMPLEMENTATION.md
|
||||
- AUTHORIZATION_IMPLEMENTATION.md
|
||||
- DEPLOYMENT_TIMEZONE_FIX.md
|
||||
- DOCKER_GIT_DEPLOYMENT.md
|
||||
- DOCKER_TIMEZONE_FIX.md
|
||||
- DROPDOWN_COMPLETION_STATUS.md
|
||||
- DROPDOWN_IMPLEMENTATION_SUMMARY.md
|
||||
- EDGE_RUNTIME_FIX_FINAL.md
|
||||
- EDGE_RUNTIME_FIX.md
|
||||
- INTEGRATION_COMPLETE.md
|
||||
- INTEGRATION_SUMMARY.md
|
||||
- MERGE_COMPLETE.md
|
||||
- MERGE_PREPARATION_SUMMARY.md
|
||||
- POLISH_LAYERS_IMPLEMENTATION.md
|
||||
|
||||
## Development-Only Files
|
||||
- start-dev.bat
|
||||
|
||||
## Potentially Keep (but review)
|
||||
- deploy.bat / deploy.sh (if used for production deployment)
|
||||
- geoportal-capabilities.xml (if it's configuration data)
|
||||
|
||||
This list focuses on files that seem to be development artifacts, temporary fixes, or test utilities. Before deletion, verify if any are still referenced in the codebase or needed for specific workflows. The core application code in `src/`, configuration files, and essential docs like `README.md` should remain.
|
||||
5
init-db-temp.mjs
Normal file
5
init-db-temp.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import initializeDatabase from './src/lib/init-db.js';
|
||||
|
||||
console.log('Initializing database...');
|
||||
initializeDatabase();
|
||||
console.log('Database initialized successfully!');
|
||||
29
migrate-add-completion-date.mjs
Normal file
29
migrate-add-completion-date.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
export default function migrateAddCompletionDate() {
|
||||
try {
|
||||
// First, check if actual_completion_date exists and rename it to completion_date
|
||||
const columns = db.prepare("PRAGMA table_info(projects)").all();
|
||||
const hasActualCompletionDate = columns.some(col => col.name === 'actual_completion_date');
|
||||
const hasCompletionDate = columns.some(col => col.name === 'completion_date');
|
||||
|
||||
if (hasActualCompletionDate && !hasCompletionDate) {
|
||||
// Rename the column
|
||||
db.exec(`
|
||||
ALTER TABLE projects RENAME COLUMN actual_completion_date TO completion_date;
|
||||
`);
|
||||
console.log("Migration completed: Renamed actual_completion_date to completion_date");
|
||||
} else if (!hasActualCompletionDate && !hasCompletionDate) {
|
||||
// Add the column if it doesn't exist
|
||||
db.exec(`
|
||||
ALTER TABLE projects ADD COLUMN completion_date TEXT;
|
||||
`);
|
||||
console.log("Migration completed: Added completion_date column to projects table");
|
||||
} else if (hasCompletionDate) {
|
||||
console.log("Migration skipped: completion_date column already exists");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
27
migrate-add-edited-at-to-notes.mjs
Normal file
27
migrate-add-edited-at-to-notes.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
export default function migrateAddEditedAtToNotes() {
|
||||
try {
|
||||
// Check if edited_at column already exists
|
||||
const columns = db.prepare("PRAGMA table_info(notes)").all();
|
||||
const hasEditedAt = columns.some(col => col.name === 'edited_at');
|
||||
|
||||
if (!hasEditedAt) {
|
||||
// Add the edited_at column
|
||||
db.exec(`
|
||||
ALTER TABLE notes ADD COLUMN edited_at TEXT;
|
||||
`);
|
||||
console.log("Migration completed: Added edited_at column to notes table");
|
||||
} else {
|
||||
console.log("Migration skipped: edited_at column already exists");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the migration if this file is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
migrateAddEditedAtToNotes();
|
||||
}
|
||||
43
migrate-add-initial-column.mjs
Normal file
43
migrate-add-initial-column.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
// Migration script to add 'initial' column to users table
|
||||
// Run this on your live server to apply the database changes
|
||||
|
||||
const dbPath = process.argv[2] || "./data/database.sqlite"; // Allow custom path via command line
|
||||
|
||||
console.log(`Applying migration to database: ${dbPath}`);
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Check if initial column already exists
|
||||
const schema = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasInitialColumn = schema.some(column => column.name === 'initial');
|
||||
|
||||
if (hasInitialColumn) {
|
||||
console.log("✅ Initial column already exists in users table");
|
||||
} else {
|
||||
// Add the initial column
|
||||
db.prepare("ALTER TABLE users ADD COLUMN initial TEXT").run();
|
||||
console.log("✅ Added 'initial' column to users table");
|
||||
}
|
||||
|
||||
// Verify the column was added
|
||||
const updatedSchema = db.prepare("PRAGMA table_info(users)").all();
|
||||
const initialColumn = updatedSchema.find(column => column.name === 'initial');
|
||||
|
||||
if (initialColumn) {
|
||||
console.log("✅ Migration completed successfully");
|
||||
console.log(`Column details: ${JSON.stringify(initialColumn, null, 2)}`);
|
||||
} else {
|
||||
console.error("❌ Migration failed - initial column not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.close();
|
||||
console.log("Database connection closed");
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
75
migrate-add-team-lead-role.mjs
Normal file
75
migrate-add-team-lead-role.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
import db from './src/lib/db.js';
|
||||
|
||||
console.log('Starting migration to add team_lead role to users table constraint...');
|
||||
|
||||
try {
|
||||
// Disable foreign key constraints temporarily
|
||||
db.pragma('foreign_keys = OFF');
|
||||
console.log('Disabled foreign key constraints');
|
||||
|
||||
// Since SQLite doesn't support modifying CHECK constraints directly,
|
||||
// we need to recreate the table with the new constraint
|
||||
|
||||
// First, create a backup table with current data
|
||||
db.exec('CREATE TABLE users_backup AS SELECT * FROM users');
|
||||
console.log('Created backup table');
|
||||
|
||||
// Drop the original table
|
||||
db.exec('DROP TABLE users');
|
||||
console.log('Dropped original table');
|
||||
|
||||
// Recreate the table with the updated constraint
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
name TEXT NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT CHECK(role IN ('admin', 'team_lead', 'project_manager', 'user', 'read_only')) DEFAULT 'user',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
last_login TEXT,
|
||||
failed_login_attempts INTEGER DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
can_be_assigned INTEGER DEFAULT 1,
|
||||
initial TEXT
|
||||
)
|
||||
`);
|
||||
console.log('Created new table with updated constraint');
|
||||
|
||||
// Copy data back from backup
|
||||
db.exec(`
|
||||
INSERT INTO users (
|
||||
id, name, username, password_hash, role, created_at, updated_at,
|
||||
is_active, last_login, failed_login_attempts, locked_until,
|
||||
can_be_assigned, initial
|
||||
)
|
||||
SELECT
|
||||
id, name, username, password_hash, role, created_at, updated_at,
|
||||
is_active, last_login, failed_login_attempts, locked_until,
|
||||
can_be_assigned, initial
|
||||
FROM users_backup
|
||||
`);
|
||||
console.log('Copied data back from backup');
|
||||
|
||||
// Drop the backup table
|
||||
db.exec('DROP TABLE users_backup');
|
||||
console.log('Dropped backup table');
|
||||
|
||||
// Re-enable foreign key constraints
|
||||
db.pragma('foreign_keys = ON');
|
||||
console.log('Re-enabled foreign key constraints');
|
||||
|
||||
// Verify the migration
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
console.log(`✅ Migration completed successfully! Users table now has ${userCount.count} records`);
|
||||
|
||||
// Verify the constraint allows the new role
|
||||
console.log('✅ CHECK constraint now includes: admin, team_lead, project_manager, user, read_only');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
console.error('You may need to restore from backup manually');
|
||||
process.exit(1);
|
||||
}
|
||||
36
migrate-add-wartosc-zlecenia.mjs
Normal file
36
migrate-add-wartosc-zlecenia.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import db from './src/lib/db.js';
|
||||
|
||||
console.log('Starting migration to add wartosc_zlecenia field to projects table...');
|
||||
|
||||
try {
|
||||
// Check if wartosc_zlecenia column already exists
|
||||
const schema = db.prepare("PRAGMA table_info(projects)").all();
|
||||
const hasWartoscZleceniaColumn = schema.some(column => column.name === 'wartosc_zlecenia');
|
||||
|
||||
if (hasWartoscZleceniaColumn) {
|
||||
console.log("✅ wartosc_zlecenia column already exists in projects table");
|
||||
} else {
|
||||
// Add the wartosc_zlecenia column
|
||||
db.prepare("ALTER TABLE projects ADD COLUMN wartosc_zlecenia REAL").run();
|
||||
console.log("✅ Added 'wartosc_zlecenia' column to projects table");
|
||||
}
|
||||
|
||||
// Verify the column was added
|
||||
const updatedSchema = db.prepare("PRAGMA table_info(projects)").all();
|
||||
const wartoscZleceniaColumn = updatedSchema.find(column => column.name === 'wartosc_zlecenia');
|
||||
|
||||
if (wartoscZleceniaColumn) {
|
||||
console.log("✅ Migration completed successfully");
|
||||
console.log(`Column details: ${JSON.stringify(wartoscZleceniaColumn, null, 2)}`);
|
||||
} else {
|
||||
console.error("❌ Migration failed - wartosc_zlecenia column not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.close();
|
||||
console.log("Database connection closed");
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
60
migrate-to-username.js
Normal file
60
migrate-to-username.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const db = new Database("./data/database.sqlite");
|
||||
|
||||
console.log("🔄 Migrating database to username-based authentication...\n");
|
||||
|
||||
try {
|
||||
// Check current table structure
|
||||
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
|
||||
console.log("Current users table columns:");
|
||||
tableInfo.forEach(col => console.log(` - ${col.name}: ${col.type}`));
|
||||
|
||||
const hasUsername = tableInfo.some(col => col.name === 'username');
|
||||
const hasEmail = tableInfo.some(col => col.name === 'email');
|
||||
|
||||
if (hasUsername) {
|
||||
console.log("✅ Username column already exists!");
|
||||
} else if (hasEmail) {
|
||||
console.log("\n📝 Adding username column...");
|
||||
|
||||
// Add username column
|
||||
db.exec(`ALTER TABLE users ADD COLUMN username TEXT;`);
|
||||
console.log("✅ Username column added");
|
||||
|
||||
// Copy email data to username for existing users
|
||||
console.log("📋 Migrating existing email data to username...");
|
||||
const result = db.exec(`UPDATE users SET username = email WHERE username IS NULL;`);
|
||||
console.log("✅ Data migrated");
|
||||
|
||||
// Create unique index on username
|
||||
console.log("🔍 Creating unique index on username...");
|
||||
try {
|
||||
db.exec(`CREATE UNIQUE INDEX idx_users_username_unique ON users(username);`);
|
||||
console.log("✅ Unique index created");
|
||||
} catch (e) {
|
||||
console.log("ℹ️ Index already exists or couldn't be created:", e.message);
|
||||
}
|
||||
|
||||
// Verify migration
|
||||
console.log("\n🔍 Verifying migration...");
|
||||
const users = db.prepare("SELECT id, name, username, email FROM users LIMIT 3").all();
|
||||
console.log("Sample users after migration:");
|
||||
users.forEach(user => {
|
||||
console.log(` - ${user.name}: username="${user.username}", email="${user.email || 'NULL'}"`);
|
||||
});
|
||||
|
||||
console.log("\n✅ Migration completed successfully!");
|
||||
console.log("ℹ️ You can now log in using usernames instead of emails");
|
||||
|
||||
} else {
|
||||
console.log("❌ Neither username nor email column found. Database may be corrupted.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Migration failed:", error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
2781
package-lock.json
generated
2781
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"create-admin": "node scripts/create-admin.js",
|
||||
"export-projects": "node export-projects-to-excel.mjs",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
@@ -15,9 +17,14 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/polyline": "^1.2.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "15.1.8",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
@@ -28,6 +35,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,6 +46,7 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/leaflet": "^1.9.18",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.8",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
308
route_planning_readme.md
Normal file
308
route_planning_readme.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Route Planning Feature with Optimization
|
||||
|
||||
This feature allows you to plan routes between multiple project locations using OpenRouteService, with automatic optimization to find the fastest route regardless of point addition order.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Get an API Key**:
|
||||
- Visit [OpenRouteService](https://openrouteservice.org/)
|
||||
- Sign up for a free account
|
||||
- Generate an API key
|
||||
|
||||
2. **Configure Environment**:
|
||||
- Copy `.env.example` to `.env.local`
|
||||
- Add your API key: `NEXT_PUBLIC_ORS_API_KEY=your_actual_api_key`
|
||||
|
||||
3. **Install Dependencies**:
|
||||
```bash
|
||||
npm install @mapbox/polyline
|
||||
```
|
||||
|
||||
4. **Restart Development Server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
### Basic Routing (2 Points)
|
||||
1. **Select Route Tool**: Click the route icon in the tool panel (looks like a path)
|
||||
2. **Add Projects**: Click on project markers to add them to your route
|
||||
3. **Calculate Route**: Click "Calculate Route" to get directions
|
||||
4. **View Results**: See distance, duration, and route path on the map
|
||||
|
||||
### Optimized Routing (3+ Points)
|
||||
1. **Select Route Tool**: Click the route icon in the tool panel
|
||||
2. **Add Projects**: Click on project markers (order doesn't matter)
|
||||
3. **Find Optimal Route**: Click "Find Optimal Route" - system automatically finds fastest path
|
||||
4. **View Optimization Results**: See which route order was selected and performance stats
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- **Multi-point routing**: Plan routes through multiple project locations
|
||||
- **Visual route display**: Blue dashed line shows the calculated route
|
||||
- **Route markers**: Green start marker, red end marker
|
||||
- **Route information**: Distance and estimated travel time
|
||||
- **Interactive management**: Add/remove projects from route
|
||||
- **Map auto-fit**: Automatically adjusts map view to show entire route
|
||||
|
||||
### Optimization Features ✨
|
||||
- **Hybrid Optimization**: Uses ORS Optimization API first, falls back to permutation testing
|
||||
- **Smart Fallback**: Automatically switches to proven permutation method if ORS fails
|
||||
- **Order Detection**: Clearly shows when route order was actually optimized vs unchanged
|
||||
- **Large Point Support**: Can handle up to 50+ points with ORS API
|
||||
- **Performance Monitoring**: Detailed logging of optimization approach and results
|
||||
- **Real-time Progress**: Shows "Finding Optimal Route..." during calculation
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Core Functions
|
||||
|
||||
#### `calculateRoute()`
|
||||
Main function that handles both basic and optimized routing with hybrid approach:
|
||||
|
||||
```javascript
|
||||
const calculateRoute = async () => {
|
||||
// For 2 points: direct calculation
|
||||
if (coordinates.length === 2) {
|
||||
const routeData = await calculateRouteForCoordinates(coordinates);
|
||||
setRouteData({...routeData, optimized: false});
|
||||
return;
|
||||
}
|
||||
|
||||
// For 3+ points: try ORS Optimization API first
|
||||
let optimizationRequest = {
|
||||
jobs: coordinates.map((coord, index) => ({
|
||||
id: index,
|
||||
location: coord,
|
||||
service: 0
|
||||
})),
|
||||
vehicles: [{
|
||||
id: 0,
|
||||
profile: 'driving-car',
|
||||
// No fixed start/end for true optimization
|
||||
capacity: [coordinates.length]
|
||||
}],
|
||||
options: { g: true }
|
||||
};
|
||||
|
||||
try {
|
||||
const optimizationResponse = await fetch('https://api.openrouteservice.org/optimization', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(optimizationRequest)
|
||||
});
|
||||
const optimizationData = await optimizationResponse.json();
|
||||
|
||||
// Extract optimized order from ORS response
|
||||
const optimizedCoordinates = extractOptimizedOrder(optimizationData, coordinates);
|
||||
|
||||
// Check if order actually changed
|
||||
const orderChanged = detectOrderChange(coordinates, optimizedCoordinates);
|
||||
|
||||
if (orderChanged) {
|
||||
// Use optimized order
|
||||
const routeData = await calculateRouteForCoordinates(optimizedCoordinates);
|
||||
setRouteData({...routeData, optimized: true, optimizationStats: {
|
||||
method: 'ORS_Optimization_API',
|
||||
totalJobs: coordinates.length,
|
||||
duration: optimizationData.routes[0].duration,
|
||||
distance: optimizationData.routes[0].distance
|
||||
}});
|
||||
} else {
|
||||
// Fallback to permutation testing
|
||||
console.log('ORS optimization did not change order, trying permutations...');
|
||||
const bestRoute = await findOptimalRouteByPermutations(coordinates);
|
||||
const routeData = await calculateRouteForCoordinates(bestRoute);
|
||||
setRouteData({...routeData, optimized: true, optimizationStats: {
|
||||
method: 'Permutation_Testing',
|
||||
totalJobs: coordinates.length,
|
||||
duration: routeData.summary.total_duration,
|
||||
distance: routeData.summary.total_distance
|
||||
}});
|
||||
}
|
||||
} catch (error) {
|
||||
// Complete fallback to permutations
|
||||
console.log('ORS optimization failed, using permutation fallback...');
|
||||
const bestRoute = await findOptimalRouteByPermutations(coordinates);
|
||||
const routeData = await calculateRouteForCoordinates(bestRoute);
|
||||
setRouteData({...routeData, optimized: true, optimizationStats: {
|
||||
method: 'Permutation_Testing',
|
||||
totalJobs: coordinates.length,
|
||||
duration: routeData.summary.total_duration,
|
||||
distance: routeData.summary.total_distance
|
||||
}});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### `calculateRouteForCoordinates(coordinates)`
|
||||
Handles individual OpenRouteService Directions API calls:
|
||||
|
||||
```javascript
|
||||
const calculateRouteForCoordinates = async (coordinates) => {
|
||||
const requestBody = {
|
||||
coordinates: coordinates,
|
||||
format: 'geojson',
|
||||
instructions: true,
|
||||
geometry_simplify: false,
|
||||
continue_straight: false,
|
||||
roundabout_exits: true,
|
||||
attributes: ['avgspeed', 'percentage']
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.openrouteservice.org/v2/directions/driving-car', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
```
|
||||
|
||||
### UI Components
|
||||
|
||||
#### Dynamic Button Text
|
||||
```javascript
|
||||
{routeProjects.length > 2 ? 'Find Optimal Route' : 'Calculate Route'}
|
||||
```
|
||||
|
||||
#### Optimization Status Display
|
||||
```javascript
|
||||
{routeData.optimized && (
|
||||
<div className="mb-2 p-2 bg-green-50 border border-green-200 rounded">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
✅ Route Optimized
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
Tested {routeData.optimizationStats.totalPermutations} routes
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimization Limits
|
||||
- **Maximum Points**: Limited to 50 points (ORS can handle 100+ in some cases)
|
||||
- **Algorithm**: Advanced TSP solver instead of brute-force permutations
|
||||
- **API Calls**: Only 2 API calls (1 optimization + 1 detailed route)
|
||||
- **Processing Time**: ~1-2 seconds for 50 points (much faster than permutation testing)
|
||||
|
||||
### Memory Usage
|
||||
- Each route response contains detailed geometry data
|
||||
- Large numbers of points can consume significant memory
|
||||
- Automatic cleanup of unused route data
|
||||
|
||||
## API Integration
|
||||
|
||||
### OpenRouteService Optimization API
|
||||
```javascript
|
||||
{
|
||||
jobs: [
|
||||
{ id: 0, location: [lng, lat], service: 0 },
|
||||
{ id: 1, location: [lng, lat], service: 0 }
|
||||
],
|
||||
vehicles: [{
|
||||
id: 0,
|
||||
profile: 'driving-car',
|
||||
start: [lng, lat],
|
||||
end: [lng, lat],
|
||||
capacity: [point_count]
|
||||
}],
|
||||
options: { g: true }
|
||||
}
|
||||
```
|
||||
|
||||
### Directions API Parameters
|
||||
```javascript
|
||||
{
|
||||
coordinates: [[lng, lat], [lng, lat], ...],
|
||||
format: 'geojson',
|
||||
instructions: true,
|
||||
geometry_simplify: false,
|
||||
continue_straight: false,
|
||||
roundabout_exits: true,
|
||||
attributes: ['avgspeed', 'percentage']
|
||||
}
|
||||
```
|
||||
|
||||
### Response Handling
|
||||
- **Optimization API**: `data.routes[0].steps[]` for optimized order
|
||||
- **Directions API**: `data.routes[0].summary` for route details
|
||||
- **Fallback Path**: `data.features[0].properties.segments[0]`
|
||||
- **Geometry**: Supports both encoded polylines and direct coordinates
|
||||
- **Error Handling**: Graceful fallback for failed calculations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "Failed to calculate route"
|
||||
- **Cause**: Invalid API key or network issues
|
||||
- **Solution**: Verify `NEXT_PUBLIC_ORS_API_KEY` in `.env.local`
|
||||
|
||||
#### "Too many points for optimization"
|
||||
- **Cause**: Selected more than 50 points
|
||||
- **Solution**: Reduce to 50 or fewer points, or use manual routing
|
||||
|
||||
#### Optimization taking too long
|
||||
- **Cause**: Large number of points or slow API responses
|
||||
- **Solution**: Reduce points or wait for completion (much faster than before)
|
||||
|
||||
#### Optimization API unavailable
|
||||
- **Cause**: ORS Optimization API temporarily unavailable
|
||||
- **Solution**: Falls back to direct routing without optimization
|
||||
|
||||
#### Route order not optimized
|
||||
- **Cause**: ORS Optimization API returned same order or failed
|
||||
- **Solution**: System automatically falls back to permutation testing for guaranteed optimization
|
||||
|
||||
#### Optimization shows "Order unchanged"
|
||||
- **Cause**: Points may already be in optimal order, or API returned original sequence
|
||||
- **Solution**: Check browser console for detailed optimization logs
|
||||
|
||||
#### Permutation fallback activated
|
||||
- **Cause**: ORS API unavailable or returned suboptimal results
|
||||
- **Solution**: This is normal behavior - permutation testing ensures optimization
|
||||
|
||||
### Debug Information
|
||||
Check browser console for detailed logs:
|
||||
- Coordinate parsing details
|
||||
- API request/response structures
|
||||
- **Optimization approach used** (ORS API vs permutation fallback)
|
||||
- **Order change detection** (whether optimization actually improved the route)
|
||||
- Performance timing information
|
||||
- **Original vs optimized coordinate sequences**
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/app/projects/map/page.js # Main map page with routing logic
|
||||
src/components/ui/LeafletMap.js # Map component with route rendering
|
||||
src/components/ui/mapLayers.js # Map layer configurations
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@mapbox/polyline`: For decoding route geometry
|
||||
- `leaflet`: Map rendering library
|
||||
- `react-leaflet`: React integration for Leaflet
|
||||
- OpenRouteService API key (free tier available)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Advanced Vehicle Constraints**: Multiple vehicles, capacity limits, time windows
|
||||
- **Route Preferences**: Allow users to prioritize distance vs time vs fuel efficiency
|
||||
- **Real-time Traffic**: Integration with live traffic data
|
||||
- **Route History**: Save and compare previously optimized routes
|
||||
- **Mobile Optimization**: Optimize routes considering current location
|
||||
- **Multi-stop Services**: Add service times at each location
|
||||
31
run-migrations.sh
Normal file
31
run-migrations.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Database migration runner for deployment
|
||||
# This script runs all pending migrations in order
|
||||
|
||||
echo "🔄 Running database migrations..."
|
||||
|
||||
# List of migration scripts to run (in order)
|
||||
MIGRATIONS=(
|
||||
"migrate-add-team-lead-role.mjs"
|
||||
"migrate-add-wartosc-zlecenia.mjs"
|
||||
)
|
||||
|
||||
for migration in "${MIGRATIONS[@]}"; do
|
||||
if [ -f "$migration" ]; then
|
||||
echo "Running migration: $migration"
|
||||
if node "$migration"; then
|
||||
echo "✅ Migration $migration completed successfully"
|
||||
# Optionally move completed migration to a completed folder
|
||||
# mkdir -p migrations/completed
|
||||
# mv "$migration" "migrations/completed/"
|
||||
else
|
||||
echo "❌ Migration $migration failed"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Migration file $migration not found, skipping..."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ All migrations completed"
|
||||
@@ -10,13 +10,13 @@ async function createInitialAdmin() {
|
||||
|
||||
const adminUser = await createUser({
|
||||
name: "Administrator",
|
||||
email: "admin@localhost.com",
|
||||
username: "admin",
|
||||
password: "admin123456", // Change this in production!
|
||||
role: "admin"
|
||||
})
|
||||
|
||||
console.log("✅ Initial admin user created successfully!")
|
||||
console.log("📧 Email: admin@localhost.com")
|
||||
console.log("<EFBFBD> Username: admin")
|
||||
console.log("🔑 Password: admin123456")
|
||||
console.log("⚠️ Please change the password after first login!")
|
||||
console.log("👤 User ID:", adminUser.id)
|
||||
|
||||
206
scripts/create-sample-projects.js
Normal file
206
scripts/create-sample-projects.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import db from '../src/lib/db.js';
|
||||
import initializeDatabase from '../src/lib/init-db.js';
|
||||
|
||||
// Initialize the database
|
||||
initializeDatabase();
|
||||
|
||||
// Sample projects data
|
||||
const sampleProjects = [
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Residential Complex Alpha',
|
||||
address: 'ul. Główna 123',
|
||||
plot: 'Plot 45/6',
|
||||
district: 'Śródmieście',
|
||||
unit: 'Unit A',
|
||||
city: 'Warszawa',
|
||||
investment_number: 'INV-2025-001',
|
||||
finish_date: '2025-06-30',
|
||||
wp: 'WP-001',
|
||||
contact: 'Jan Kowalski, tel. 123-456-789',
|
||||
notes: 'Modern residential building with 50 apartments',
|
||||
coordinates: '52.2297,21.0122',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'registered'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Office Building Beta',
|
||||
address: 'al. Jerozolimskie 50',
|
||||
plot: 'Plot 12/8',
|
||||
district: 'Mokotów',
|
||||
unit: 'Unit B',
|
||||
city: 'Warszawa',
|
||||
investment_number: 'INV-2025-002',
|
||||
finish_date: '2025-09-15',
|
||||
wp: 'WP-002',
|
||||
contact: 'Anna Nowak, tel. 987-654-321',
|
||||
notes: 'Commercial office space, 10 floors',
|
||||
coordinates: '52.2215,21.0071',
|
||||
project_type: 'construction',
|
||||
project_status: 'in_progress_design'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'Shopping Mall Gamma',
|
||||
address: 'pl. Centralny 1',
|
||||
plot: 'Plot 78/3',
|
||||
district: 'Centrum',
|
||||
unit: 'Unit C',
|
||||
city: 'Kraków',
|
||||
investment_number: 'INV-2025-003',
|
||||
finish_date: '2025-12-20',
|
||||
wp: 'WP-003',
|
||||
contact: 'Piotr Wiśniewski, tel. 555-123-456',
|
||||
notes: 'Large shopping center with parking',
|
||||
coordinates: '50.0647,19.9450',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'in_progress_construction'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'Industrial Warehouse Delta',
|
||||
address: 'ul. Przemysłowa 100',
|
||||
plot: 'Plot 200/15',
|
||||
district: 'Przemysłowa',
|
||||
unit: 'Unit D',
|
||||
city: 'Łódź',
|
||||
investment_number: 'INV-2025-004',
|
||||
finish_date: '2025-08-10',
|
||||
wp: 'WP-004',
|
||||
contact: 'Maria Lewandowska, tel. 444-789-012',
|
||||
notes: 'Logistics warehouse facility',
|
||||
coordinates: '51.7592,19.4600',
|
||||
project_type: 'design',
|
||||
project_status: 'fulfilled'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Hotel Complex Epsilon',
|
||||
address: 'ul. Morska 25',
|
||||
plot: 'Plot 5/2',
|
||||
district: 'Nadmorze',
|
||||
unit: 'Unit E',
|
||||
city: 'Gdańsk',
|
||||
investment_number: 'INV-2025-005',
|
||||
finish_date: '2025-11-05',
|
||||
wp: 'WP-005',
|
||||
contact: 'Tomasz Malinowski, tel. 333-456-789',
|
||||
notes: 'Luxury hotel with conference facilities',
|
||||
coordinates: '54.3520,18.6466',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'registered'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'School Complex Zeta',
|
||||
address: 'ul. Edukacyjna 15',
|
||||
plot: 'Plot 30/4',
|
||||
district: 'Edukacyjny',
|
||||
unit: 'Unit F',
|
||||
city: 'Poznań',
|
||||
investment_number: 'INV-2025-006',
|
||||
finish_date: '2025-07-20',
|
||||
wp: 'WP-006',
|
||||
contact: 'Ewa Dombrowska, tel. 222-333-444',
|
||||
notes: 'Modern educational facility with sports complex',
|
||||
coordinates: '52.4064,16.9252',
|
||||
project_type: 'design',
|
||||
project_status: 'in_progress_design'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Medical Center Eta',
|
||||
address: 'al. Zdrowia 8',
|
||||
plot: 'Plot 67/9',
|
||||
district: 'Medyczny',
|
||||
unit: 'Unit G',
|
||||
city: 'Wrocław',
|
||||
investment_number: 'INV-2025-007',
|
||||
finish_date: '2025-10-30',
|
||||
wp: 'WP-007',
|
||||
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
|
||||
notes: 'Specialized medical center with emergency department',
|
||||
coordinates: '51.1079,17.0385',
|
||||
project_type: 'construction',
|
||||
project_status: 'in_progress_construction'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'Sports Stadium Theta',
|
||||
address: 'ul. Sportowa 50',
|
||||
plot: 'Plot 150/20',
|
||||
district: 'Sportowy',
|
||||
unit: 'Unit H',
|
||||
city: 'Szczecin',
|
||||
investment_number: 'INV-2025-008',
|
||||
finish_date: '2025-05-15',
|
||||
wp: 'WP-008',
|
||||
contact: 'Katarzyna Wojcik, tel. 999-888-777',
|
||||
notes: 'Multi-purpose sports stadium with seating for 20,000',
|
||||
coordinates: '53.4289,14.5530',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'fulfilled'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Library Complex Iota',
|
||||
address: 'pl. Wiedzy 3',
|
||||
plot: 'Plot 25/7',
|
||||
district: 'Kulturalny',
|
||||
unit: 'Unit I',
|
||||
city: 'Lublin',
|
||||
investment_number: 'INV-2025-009',
|
||||
finish_date: '2025-08-25',
|
||||
wp: 'WP-009',
|
||||
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
|
||||
notes: 'Modern library with digital archives and community spaces',
|
||||
coordinates: '51.2465,22.5684',
|
||||
project_type: 'design',
|
||||
project_status: 'registered'
|
||||
}
|
||||
];
|
||||
|
||||
console.log('Creating sample test projects...\n');
|
||||
|
||||
sampleProjects.forEach((projectData, index) => {
|
||||
try {
|
||||
// Generate project number based on contract
|
||||
const contractInfo = db.prepare('SELECT contract_number FROM contracts WHERE contract_id = ?').get(projectData.contract_id);
|
||||
const existingProjects = db.prepare('SELECT COUNT(*) as count FROM projects WHERE contract_id = ?').get(projectData.contract_id);
|
||||
const sequentialNumber = existingProjects.count + 1;
|
||||
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO projects (
|
||||
contract_id, project_name, project_number, address, plot, district, unit, city,
|
||||
investment_number, finish_date, wp, contact, notes, coordinates,
|
||||
project_type, project_status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`).run(
|
||||
projectData.contract_id,
|
||||
projectData.project_name,
|
||||
projectNumber,
|
||||
projectData.address,
|
||||
projectData.plot,
|
||||
projectData.district,
|
||||
projectData.unit,
|
||||
projectData.city,
|
||||
projectData.investment_number,
|
||||
projectData.finish_date,
|
||||
projectData.wp,
|
||||
projectData.contact,
|
||||
projectData.notes,
|
||||
projectData.coordinates,
|
||||
projectData.project_type,
|
||||
projectData.project_status
|
||||
);
|
||||
|
||||
console.log(`✓ Created project: ${projectData.project_name} (ID: ${result.lastInsertRowid}, Number: ${projectNumber})`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`✗ Error creating project ${projectData.project_name}:`, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nSample test projects created successfully!');
|
||||
@@ -15,9 +15,10 @@ export default function EditUserPage() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
username: "",
|
||||
role: "user",
|
||||
is_active: true,
|
||||
initial: "",
|
||||
password: ""
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -62,9 +63,10 @@ export default function EditUserPage() {
|
||||
setUser(userData);
|
||||
setFormData({
|
||||
name: userData.name,
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
role: userData.role,
|
||||
is_active: userData.is_active,
|
||||
initial: userData.initial || "",
|
||||
password: "" // Never populate password field
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -84,9 +86,10 @@ export default function EditUserPage() {
|
||||
// Prepare update data (exclude empty password)
|
||||
const updateData = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
username: formData.username,
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
is_active: formData.is_active,
|
||||
initial: formData.initial.trim() || null
|
||||
};
|
||||
|
||||
// Only include password if it's provided
|
||||
@@ -209,12 +212,12 @@ export default function EditUserPage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
Username *
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -232,6 +235,7 @@ export default function EditUserPage() {
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="user">User</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="team_lead">Team Lead</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -253,6 +257,23 @@ export default function EditUserPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initial
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.initial}
|
||||
onChange={(e) => setFormData({ ...formData, initial: e.target.value })}
|
||||
placeholder="1-2 letter identifier"
|
||||
maxLength={2}
|
||||
className="w-full md:w-1/2"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Optional 1-2 letter identifier for the user
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -12,8 +12,10 @@ import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
@@ -54,7 +56,7 @@ export default function UserManagementPage() {
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||
if (!confirm(t('admin.deleteUser') + "?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
@@ -95,10 +97,36 @@ export default function UserManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAssignable = async (userId, canBeAssigned) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ can_be_assigned: !canBeAssigned }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update user");
|
||||
}
|
||||
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId
|
||||
? { ...user, can_be_assigned: !canBeAssigned }
|
||||
: user
|
||||
));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "red";
|
||||
case "team_lead":
|
||||
return "purple";
|
||||
case "project_manager":
|
||||
return "blue";
|
||||
case "user":
|
||||
@@ -114,6 +142,8 @@ export default function UserManagementPage() {
|
||||
switch (role) {
|
||||
case "project_manager":
|
||||
return "Project Manager";
|
||||
case "team_lead":
|
||||
return "Team Lead";
|
||||
case "read_only":
|
||||
return "Read Only";
|
||||
default:
|
||||
@@ -141,7 +171,7 @@ export default function UserManagementPage() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="User Management" description="Manage system users and permissions">
|
||||
<PageHeader title={t('admin.userManagement')} description={t('admin.subtitle')}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
@@ -149,7 +179,7 @@ export default function UserManagementPage() {
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add User
|
||||
{t('admin.newUser')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -192,7 +222,10 @@ export default function UserManagementPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-sm text-gray-500">{user.username}</p>
|
||||
{user.initial && (
|
||||
<p className="text-xs text-blue-600 font-medium mt-1">Initial: {user.initial}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -202,6 +235,9 @@ export default function UserManagementPage() {
|
||||
<Badge color={user.is_active ? "green" : "red"}>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
<Badge color={user.can_be_assigned ? "blue" : "gray"}>
|
||||
{user.can_be_assigned ? "Assignable" : "Not Assignable"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -232,6 +268,20 @@ export default function UserManagementPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`assignable-${user.id}`}
|
||||
checked={user.can_be_assigned || false}
|
||||
onChange={() => handleToggleAssignable(user.id, user.can_be_assigned)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor={`assignable-${user.id}`} className="text-sm text-gray-700">
|
||||
Can be assigned to projects/tasks
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -282,10 +332,11 @@ export default function UserManagementPage() {
|
||||
function CreateUserModal({ onClose, onUserCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
is_active: true
|
||||
is_active: true,
|
||||
can_be_assigned: true
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -351,12 +402,12 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -386,6 +437,7 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="user">User</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="team_lead">Team Lead</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -403,6 +455,19 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="can_be_assigned"
|
||||
checked={formData.can_be_assigned}
|
||||
onChange={(e) => setFormData({ ...formData, can_be_assigned: e.target.checked })}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="can_be_assigned" className="ml-2 block text-sm text-gray-900">
|
||||
Can be assigned to projects/tasks
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Creating..." : "Create User"}
|
||||
|
||||
@@ -4,8 +4,9 @@ import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get user by ID (admin only)
|
||||
async function getUserHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const user = getUserById(params.id);
|
||||
const user = getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
@@ -29,9 +30,10 @@ async function getUserHandler(req, { params }) {
|
||||
|
||||
// PUT: Update user (admin only)
|
||||
async function updateUserHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const data = await req.json();
|
||||
const userId = params.id;
|
||||
const userId = id;
|
||||
|
||||
// Prevent admin from deactivating themselves
|
||||
if (data.is_active === false && userId === req.user.id) {
|
||||
@@ -43,7 +45,7 @@ async function updateUserHandler(req, { params }) {
|
||||
|
||||
// Validate role if provided
|
||||
if (data.role) {
|
||||
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||
const validRoles = ["read_only", "user", "project_manager", "team_lead", "admin"];
|
||||
if (!validRoles.includes(data.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role specified" },
|
||||
@@ -78,7 +80,7 @@ async function updateUserHandler(req, { params }) {
|
||||
|
||||
if (error.message.includes("already exists")) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user with this email already exists" },
|
||||
{ error: "A user with this username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
@@ -92,8 +94,9 @@ async function updateUserHandler(req, { params }) {
|
||||
|
||||
// DELETE: Delete user (admin only)
|
||||
async function deleteUserHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const userId = params.id;
|
||||
const userId = id;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (userId === req.user.id) {
|
||||
|
||||
@@ -27,9 +27,9 @@ async function createUserHandler(req) {
|
||||
const data = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name || !data.email || !data.password) {
|
||||
if (!data.name || !data.username || !data.password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name, email, and password are required" },
|
||||
{ error: "Name, username, and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function createUserHandler(req) {
|
||||
}
|
||||
|
||||
// Validate role
|
||||
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||
const validRoles = ["read_only", "user", "project_manager", "team_lead", "admin"];
|
||||
if (data.role && !validRoles.includes(data.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role specified" },
|
||||
@@ -53,7 +53,7 @@ async function createUserHandler(req) {
|
||||
|
||||
const newUser = await createUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
role: data.role || "user",
|
||||
is_active: data.is_active !== undefined ? data.is_active : true
|
||||
@@ -68,7 +68,7 @@ async function createUserHandler(req) {
|
||||
|
||||
if (error.message.includes("already exists")) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user with this email already exists" },
|
||||
{ error: "A user with this username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
80
src/app/api/auth/change-password/route.js
Normal file
80
src/app/api/auth/change-password/route.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { currentPassword, newPassword } = changePasswordSchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Get current user password hash
|
||||
const user = db
|
||||
.prepare("SELECT password_hash FROM users WHERE id = ?")
|
||||
.get(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Current password is incorrect" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.run(hashedNewPassword, session.user.id);
|
||||
|
||||
// Log audit event
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.USER_UPDATE,
|
||||
userId: session.user.id,
|
||||
resourceType: RESOURCE_TYPES.USER,
|
||||
details: { field: "password", username: session.user.username },
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Password changed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Change password error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/api/auth/password-reset/request/route.js
Normal file
70
src/app/api/auth/password-reset/request/route.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
const requestSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username } = requestSchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db
|
||||
.prepare("SELECT id, username, name FROM users WHERE username = ? AND is_active = 1")
|
||||
.get(username);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if user exists or not for security
|
||||
return NextResponse.json(
|
||||
{ message: "If the username exists, a password reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
|
||||
// Delete any existing tokens for this user
|
||||
db.prepare("DELETE FROM password_reset_tokens WHERE user_id = ?").run(user.id);
|
||||
|
||||
// Insert new token
|
||||
db.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
|
||||
).run(user.id, token, expiresAt);
|
||||
|
||||
// TODO: Send email with reset link
|
||||
// For now, return the token for testing purposes
|
||||
console.log(`Password reset token for ${username}: ${token}`);
|
||||
|
||||
// Log audit event
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.PASSWORD_RESET_REQUEST,
|
||||
userId: user.id,
|
||||
resourceType: RESOURCE_TYPES.USER,
|
||||
details: { username: user.username },
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "If the username exists, a password reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Password reset request error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/auth/password-reset/reset/route.js
Normal file
71
src/app/api/auth/password-reset/reset/route.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const resetSchema = z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token, password } = resetSchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Check if token exists and is valid
|
||||
const resetToken = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT prt.*, u.username, u.name
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON prt.user_id = u.id
|
||||
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
|
||||
`
|
||||
)
|
||||
.get(token);
|
||||
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Update user password
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.run(hashedPassword, resetToken.user_id);
|
||||
|
||||
// Mark token as used
|
||||
db.prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?")
|
||||
.run(resetToken.id);
|
||||
|
||||
// Log audit event
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.PASSWORD_RESET,
|
||||
userId: resetToken.user_id,
|
||||
resourceType: RESOURCE_TYPES.USER,
|
||||
details: { username: resetToken.username },
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Password has been reset successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/auth/password-reset/verify/route.js
Normal file
47
src/app/api/auth/password-reset/verify/route.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const verifySchema = z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token } = verifySchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Check if token exists and is valid
|
||||
const resetToken = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT prt.*, u.username, u.name
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON prt.user_id = u.id
|
||||
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
|
||||
`
|
||||
)
|
||||
.get(token);
|
||||
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
username: resetToken.username,
|
||||
name: resetToken.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Token verification error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
262
src/app/api/dashboard/route.js
Normal file
262
src/app/api/dashboard/route.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAllProjects } from "@/lib/queries/projects";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only team leads can access dashboard data
|
||||
if (session.user.role !== 'team_lead') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const selectedYear = searchParams.get('year') ? parseInt(searchParams.get('year')) : null;
|
||||
|
||||
// Get all projects
|
||||
const projects = getAllProjects();
|
||||
|
||||
// Calculate realised and unrealised values by project type
|
||||
const projectTypes = ['design', 'design+construction', 'construction'];
|
||||
const typeSummary = {};
|
||||
|
||||
projectTypes.forEach(type => {
|
||||
typeSummary[type] = {
|
||||
realisedValue: 0,
|
||||
unrealisedValue: 0
|
||||
};
|
||||
});
|
||||
|
||||
projects.forEach(project => {
|
||||
const value = parseFloat(project.wartosc_zlecenia) || 0;
|
||||
const type = project.project_type;
|
||||
|
||||
if (!type || !projectTypes.includes(type)) return;
|
||||
|
||||
if (project.project_status === 'fulfilled' && project.completion_date && project.wartosc_zlecenia) {
|
||||
typeSummary[type].realisedValue += value;
|
||||
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
|
||||
typeSummary[type].unrealisedValue += value;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate overall totals
|
||||
let realisedValue = 0;
|
||||
let unrealisedValue = 0;
|
||||
|
||||
Object.values(typeSummary).forEach(summary => {
|
||||
realisedValue += summary.realisedValue;
|
||||
unrealisedValue += summary.unrealisedValue;
|
||||
});
|
||||
|
||||
// Filter completed projects (those with completion_date and fulfilled status)
|
||||
const completedProjects = projects.filter(project =>
|
||||
project.completion_date &&
|
||||
project.wartosc_zlecenia &&
|
||||
project.project_status === 'fulfilled'
|
||||
);
|
||||
|
||||
// If no data, return sample data for demonstration
|
||||
let chartData;
|
||||
let summary;
|
||||
if (completedProjects.length === 0) {
|
||||
// Generate continuous sample data based on selected year or default range
|
||||
const currentDate = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
if (selectedYear) {
|
||||
startDate = new Date(selectedYear, 0, 1); // Jan 1st of selected year
|
||||
endDate = new Date(selectedYear, 11, 31); // Dec 31st of selected year
|
||||
if (endDate > currentDate) endDate = currentDate;
|
||||
} else {
|
||||
startDate = new Date(2024, 0, 1); // Jan 2024
|
||||
endDate = currentDate;
|
||||
}
|
||||
|
||||
chartData = [];
|
||||
let cumulative = 0;
|
||||
|
||||
let tempDate = new Date(startDate);
|
||||
while (tempDate <= endDate) {
|
||||
const monthName = tempDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
let monthlyValue = 0;
|
||||
|
||||
// Add some sample values for certain months (only if they match the selected year or no year selected)
|
||||
const shouldAddData = !selectedYear || tempDate.getFullYear() === selectedYear;
|
||||
|
||||
if (shouldAddData) {
|
||||
if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2024) monthlyValue = 50000; // Jan 2024
|
||||
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2024) monthlyValue = 75000; // Feb 2024
|
||||
else if (tempDate.getMonth() === 2 && tempDate.getFullYear() === 2024) monthlyValue = 60000; // Mar 2024
|
||||
else if (tempDate.getMonth() === 7 && tempDate.getFullYear() === 2024) monthlyValue = 10841; // Aug 2024 (real data)
|
||||
else if (tempDate.getMonth() === 8 && tempDate.getFullYear() === 2024) monthlyValue = 18942; // Sep 2024
|
||||
else if (tempDate.getMonth() === 9 && tempDate.getFullYear() === 2024) monthlyValue = 13945; // Oct 2024
|
||||
else if (tempDate.getMonth() === 10 && tempDate.getFullYear() === 2024) monthlyValue = 12542; // Nov 2024
|
||||
else if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2025) monthlyValue = 25000; // Jan 2025
|
||||
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2025) monthlyValue = 35000; // Feb 2025
|
||||
}
|
||||
|
||||
cumulative += monthlyValue;
|
||||
chartData.push({
|
||||
month: monthName,
|
||||
value: monthlyValue,
|
||||
cumulative: cumulative
|
||||
});
|
||||
|
||||
tempDate.setMonth(tempDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
summary = {
|
||||
total: {
|
||||
realisedValue: 958000,
|
||||
unrealisedValue: 1242000
|
||||
},
|
||||
byType: {
|
||||
design: {
|
||||
realisedValue: 320000,
|
||||
unrealisedValue: 480000
|
||||
},
|
||||
'design+construction': {
|
||||
realisedValue: 480000,
|
||||
unrealisedValue: 520000
|
||||
},
|
||||
construction: {
|
||||
realisedValue: 158000,
|
||||
unrealisedValue: 242000
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Group by month and calculate monthly totals first
|
||||
const monthlyData = {};
|
||||
|
||||
// Sort projects by completion date
|
||||
completedProjects.sort((a, b) => new Date(a.completion_date) - new Date(b.completion_date));
|
||||
|
||||
// First pass: calculate monthly totals
|
||||
completedProjects.forEach(project => {
|
||||
const date = new Date(project.completion_date);
|
||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const monthName = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
|
||||
if (!monthlyData[monthKey]) {
|
||||
monthlyData[monthKey] = {
|
||||
month: monthName,
|
||||
value: 0
|
||||
};
|
||||
}
|
||||
|
||||
const projectValue = parseFloat(project.wartosc_zlecenia) || 0;
|
||||
monthlyData[monthKey].value += projectValue;
|
||||
});
|
||||
|
||||
// Generate continuous timeline from earliest completion to current date
|
||||
let startDate = new Date();
|
||||
let endDate = new Date();
|
||||
|
||||
if (completedProjects.length > 0) {
|
||||
// Find earliest completion date
|
||||
const earliestCompletion = completedProjects.reduce((earliest, project) => {
|
||||
const projectDate = new Date(project.completion_date);
|
||||
return projectDate < earliest ? projectDate : earliest;
|
||||
}, new Date());
|
||||
|
||||
startDate = new Date(earliestCompletion.getFullYear(), earliestCompletion.getMonth(), 1);
|
||||
} else {
|
||||
// If no completed projects, start from 6 months ago
|
||||
startDate = new Date();
|
||||
startDate.setMonth(startDate.getMonth() - 6);
|
||||
startDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
|
||||
}
|
||||
|
||||
// If a specific year is selected, adjust the date range
|
||||
if (selectedYear) {
|
||||
startDate = new Date(selectedYear, 0, 1); // January 1st of selected year
|
||||
endDate = new Date(selectedYear, 11, 31); // December 31st of selected year
|
||||
|
||||
// Don't go beyond current date
|
||||
if (endDate > new Date()) {
|
||||
endDate = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all months from start to current
|
||||
const allMonths = {};
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
const monthKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const monthName = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
|
||||
allMonths[monthKey] = {
|
||||
month: monthName,
|
||||
value: monthlyData[monthKey]?.value || 0
|
||||
};
|
||||
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
// Calculate cumulative values
|
||||
let cumulativeValue = 0;
|
||||
const sortedMonths = Object.keys(allMonths).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
sortedMonths.forEach(monthKey => {
|
||||
cumulativeValue += allMonths[monthKey].value;
|
||||
allMonths[monthKey].cumulative = cumulativeValue;
|
||||
});
|
||||
|
||||
// Convert to array
|
||||
chartData = sortedMonths.map(monthKey => ({
|
||||
month: allMonths[monthKey].month,
|
||||
value: Math.round(allMonths[monthKey].value),
|
||||
cumulative: Math.round(allMonths[monthKey].cumulative)
|
||||
}));
|
||||
summary = {
|
||||
total: {
|
||||
realisedValue: Math.round(realisedValue),
|
||||
unrealisedValue: Math.round(unrealisedValue)
|
||||
},
|
||||
byType: Object.fromEntries(
|
||||
Object.entries(typeSummary).map(([type, data]) => [
|
||||
type,
|
||||
{
|
||||
realisedValue: Math.round(data.realisedValue),
|
||||
unrealisedValue: Math.round(data.unrealisedValue)
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
chartData,
|
||||
summary: {
|
||||
total: {
|
||||
realisedValue: Math.round(realisedValue),
|
||||
unrealisedValue: Math.round(unrealisedValue)
|
||||
},
|
||||
byType: Object.fromEntries(
|
||||
Object.entries(typeSummary).map(([type, data]) => [
|
||||
type,
|
||||
{
|
||||
realisedValue: Math.round(data.realisedValue),
|
||||
unrealisedValue: Math.round(data.unrealisedValue)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard API error:', error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/field-history/route.js
Normal file
46
src/app/api/field-history/route.js
Normal 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);
|
||||
186
src/app/api/files/[fileId]/route.js
Normal file
186
src/app/api/files/[fileId]/route.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import { unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
const { fileId } = await params;
|
||||
|
||||
try {
|
||||
// Get file info from database
|
||||
const file = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the full file path
|
||||
const fullPath = path.join(process.cwd(), "public", file.file_path);
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(fullPath)) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found on disk" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileBuffer = await readFile(fullPath);
|
||||
|
||||
// Return the file with appropriate headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": file.mime_type || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(file.original_filename)}"`,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error downloading file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to download file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request, { params }) {
|
||||
const { fileId } = await params;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { description, original_filename } = body;
|
||||
|
||||
// Validate input
|
||||
if (description !== undefined && typeof description !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: "Description must be a string" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (original_filename !== undefined && typeof original_filename !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: "Original filename must be a string" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const existingFile = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!existingFile) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update query
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
values.push(description);
|
||||
}
|
||||
|
||||
if (original_filename !== undefined) {
|
||||
updates.push('original_filename = ?');
|
||||
values.push(original_filename);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid fields to update" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
values.push(parseInt(fileId));
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE file_attachments
|
||||
SET ${updates.join(', ')}
|
||||
WHERE file_id = ?
|
||||
`).run(...values);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated file
|
||||
const updatedFile = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
return NextResponse.json(updatedFile);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error updating file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
const { fileId } = await params;
|
||||
try {
|
||||
// Get file info from database
|
||||
const file = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete physical file
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), "public", file.file_path);
|
||||
await unlink(fullPath);
|
||||
} catch (fileError) {
|
||||
console.warn("Could not delete physical file:", fileError.message);
|
||||
// Continue with database deletion even if file doesn't exist
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
const result = db.prepare(`
|
||||
DELETE FROM file_attachments WHERE file_id = ?
|
||||
`).run(parseInt(fileId));
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
162
src/app/api/files/route.js
Normal file
162
src/app/api/files/route.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
137
src/app/api/notes/[id]/route.js
Normal file
137
src/app/api/notes/[id]/route.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Note ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: "Note not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this note
|
||||
// Users can delete their own notes, or admins can delete any note
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (userRole !== 'admin' && note.created_by !== userId) {
|
||||
return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete the note
|
||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||
|
||||
// Log note deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_DELETE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
id,
|
||||
req.auth,
|
||||
{
|
||||
deletedNote: {
|
||||
project_id: note?.project_id,
|
||||
task_id: note?.task_id,
|
||||
note_length: note?.note?.length || 0,
|
||||
created_by: note?.created_by,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting note:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete note", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNoteHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const noteId = id;
|
||||
const { note: noteText } = await req.json();
|
||||
|
||||
if (!noteText || !noteId) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get original note for audit log and permission check
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
if (!originalNote) {
|
||||
return NextResponse.json({ error: "Note not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has permission to update this note
|
||||
// Users can update their own notes, or admins can update any note
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (userRole !== 'admin' && originalNote.created_by !== userId) {
|
||||
return NextResponse.json({ error: "Unauthorized to update this note" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Update the note
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ?, edited_at = datetime('now', 'localtime') WHERE note_id = ?
|
||||
`
|
||||
).run(noteText, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.auth,
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: noteText.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating note:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update note", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require user authentication
|
||||
export const DELETE = withUserAuth(deleteNoteHandler);
|
||||
export const PUT = withUserAuth(updateNoteHandler);
|
||||
@@ -3,13 +3,59 @@ export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
async function getNotesHandler(req) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const projectId = searchParams.get("project_id");
|
||||
const taskId = searchParams.get("task_id");
|
||||
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (projectId) {
|
||||
query = `
|
||||
SELECT n.*,
|
||||
u.name as created_by_name,
|
||||
u.username as created_by_username
|
||||
FROM notes n
|
||||
LEFT JOIN users u ON n.created_by = u.id
|
||||
WHERE n.project_id = ?
|
||||
ORDER BY n.note_date DESC
|
||||
`;
|
||||
params = [projectId];
|
||||
} else if (taskId) {
|
||||
query = `
|
||||
SELECT n.*,
|
||||
u.name as created_by_name,
|
||||
u.username as created_by_username
|
||||
FROM notes n
|
||||
LEFT JOIN users u ON n.created_by = u.id
|
||||
WHERE n.task_id = ?
|
||||
ORDER BY n.note_date DESC
|
||||
`;
|
||||
params = [taskId];
|
||||
} else {
|
||||
return NextResponse.json({ error: "project_id or task_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const notes = db.prepare(query).all(...params);
|
||||
return NextResponse.json(notes);
|
||||
} catch (error) {
|
||||
console.error("Error fetching notes:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch notes" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteHandler(req) {
|
||||
const { project_id, task_id, note } = await req.json();
|
||||
|
||||
@@ -22,11 +68,25 @@ async function createNoteHandler(req) {
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
|
||||
`
|
||||
)
|
||||
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
|
||||
// Get the created note with user info
|
||||
const createdNote = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT n.*,
|
||||
u.name as created_by_name,
|
||||
u.username as created_by_username
|
||||
FROM notes n
|
||||
LEFT JOIN users u ON n.created_by = u.id
|
||||
WHERE n.note_id = ?
|
||||
`
|
||||
)
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
// Log note creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
@@ -39,7 +99,7 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json(createdNote);
|
||||
} catch (error) {
|
||||
console.error("Error creating note:", error);
|
||||
return NextResponse.json(
|
||||
@@ -50,7 +110,7 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
@@ -76,48 +136,7 @@ async function deleteNoteHandler(req, { params }) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
async function updateNoteHandler(req, { params }) {
|
||||
const noteId = params.id;
|
||||
const { note } = await req.json();
|
||||
|
||||
if (!note || !noteId) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get original note for audit log
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ? WHERE note_id = ?
|
||||
`
|
||||
).run(note, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: note.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getNotesHandler);
|
||||
export const POST = withUserAuth(createNoteHandler);
|
||||
export const DELETE = withUserAuth(deleteNoteHandler);
|
||||
export const PUT = withUserAuth(updateNoteHandler);
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import {
|
||||
updateProjectTaskStatus,
|
||||
deleteProjectTask,
|
||||
updateProjectTask,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// PATCH: Update project task status
|
||||
// PUT: Update project task (general update)
|
||||
async function updateProjectTaskHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const updates = await req.json();
|
||||
|
||||
// Validate that we have at least one field to update
|
||||
const allowedFields = ["priority", "status", "assigned_to", "date_started"];
|
||||
const hasValidFields = Object.keys(updates).some((key) =>
|
||||
allowedFields.includes(key)
|
||||
);
|
||||
|
||||
if (!hasValidFields) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid fields provided for update" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTask(id, updates, req.user?.id || null);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update project task", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Update project task status
|
||||
async function updateProjectTaskStatusHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { status } = await req.json();
|
||||
|
||||
if (!status) {
|
||||
@@ -17,7 +49,15 @@ async function updateProjectTaskHandler(req, { params }) {
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTaskStatus(params.id, status, req.user?.id || null);
|
||||
const allowedStatuses = ['not_started', 'pending', 'in_progress', 'completed', 'cancelled'];
|
||||
if (!allowedStatuses.includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid status. Must be one of: " + allowedStatuses.join(', ') },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTaskStatus(id, status, req.user?.id || null);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task status:", error);
|
||||
@@ -31,16 +71,19 @@ async function updateProjectTaskHandler(req, { params }) {
|
||||
// DELETE: Delete a project task
|
||||
async function deleteProjectTaskHandler(req, { params }) {
|
||||
try {
|
||||
deleteProjectTask(params.id);
|
||||
const { id } = await params;
|
||||
const result = deleteProjectTask(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in deleteProjectTaskHandler:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete project task" },
|
||||
{ error: "Failed to delete project task", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const PATCH = withUserAuth(updateProjectTaskHandler);
|
||||
export const PUT = withUserAuth(updateProjectTaskHandler);
|
||||
export const PATCH = withUserAuth(updateProjectTaskStatusHandler);
|
||||
export const DELETE = withUserAuth(deleteProjectTaskHandler);
|
||||
|
||||
@@ -47,10 +47,19 @@ async function createProjectTaskHandler(req) {
|
||||
const taskData = {
|
||||
...data,
|
||||
created_by: req.user?.id || null,
|
||||
// If no assigned_to is specified, default to the creator
|
||||
assigned_to: data.assigned_to || req.user?.id || null,
|
||||
};
|
||||
|
||||
// Set assigned_to: if specified, use it; otherwise default to creator only if they're not admin
|
||||
if (data.assigned_to) {
|
||||
taskData.assigned_to = data.assigned_to;
|
||||
} else if (req.user?.id) {
|
||||
// Check if the creator is an admin - if so, don't assign to them
|
||||
const userRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
||||
taskData.assigned_to = userRole?.role === 'admin' ? null : req.user.id;
|
||||
} else {
|
||||
taskData.assigned_to = null;
|
||||
}
|
||||
|
||||
const result = createProjectTask(taskData);
|
||||
return NextResponse.json({ success: true, id: result.lastInsertRowid });
|
||||
} catch (error) {
|
||||
|
||||
24
src/app/api/projects/[id]/finish-date-updates/route.js
Normal file
24
src/app/api/projects/[id]/finish-date-updates/route.js
Normal 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);
|
||||
@@ -3,9 +3,12 @@ export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getProjectById,
|
||||
getProjectWithContract,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from "@/lib/queries/projects";
|
||||
import { logFieldChange } from "@/lib/queries/fieldHistory";
|
||||
import { addNoteToProject } from "@/lib/queries/notes";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
@@ -14,13 +17,14 @@ import {
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
import { getUserLanguage, serverT } from "@/lib/serverTranslations";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function getProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const project = getProjectById(parseInt(id));
|
||||
const project = getProjectWithContract(parseInt(id));
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
@@ -40,35 +44,86 @@ async function getProjectHandler(req, { params }) {
|
||||
}
|
||||
|
||||
async function updateProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const data = await req.json();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await req.json();
|
||||
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Get original project data for audit log
|
||||
const originalProject = getProjectById(parseInt(id));
|
||||
// Get original project data for audit log and field tracking
|
||||
const originalProject = getProjectById(parseInt(id));
|
||||
|
||||
updateProject(parseInt(id), data, userId);
|
||||
|
||||
// Get updated project
|
||||
const updatedProject = getProjectById(parseInt(id));
|
||||
|
||||
// Log project update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
originalData: originalProject,
|
||||
updatedData: data,
|
||||
changedFields: Object.keys(data),
|
||||
if (!originalProject) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(updatedProject);
|
||||
// Track field changes for specific fields we want to monitor
|
||||
const fieldsToTrack = ['finish_date', 'project_status', 'assigned_to', 'contract_id', 'wartosc_zlecenia'];
|
||||
|
||||
for (const fieldName of fieldsToTrack) {
|
||||
if (data.hasOwnProperty(fieldName)) {
|
||||
const oldValue = originalProject[fieldName];
|
||||
const newValue = data[fieldName];
|
||||
|
||||
if (oldValue !== newValue) {
|
||||
try {
|
||||
logFieldChange('projects', parseInt(id), fieldName, oldValue, newValue, userId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to log field change for ${fieldName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for project cancellation
|
||||
if (data.project_status === 'cancelled' && originalProject.project_status !== 'cancelled') {
|
||||
const now = new Date();
|
||||
const cancellationDate = now.toLocaleDateString('pl-PL', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const language = getUserLanguage();
|
||||
const cancellationNote = `${serverT("Project cancelled on", language)} ${cancellationDate}`;
|
||||
|
||||
try {
|
||||
addNoteToProject(parseInt(id), cancellationNote, userId, true); // true for is_system
|
||||
} catch (error) {
|
||||
console.error('Failed to log project cancellation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateProject(parseInt(id), data, userId);
|
||||
|
||||
// Get updated project
|
||||
const updatedProject = getProjectById(parseInt(id));
|
||||
|
||||
// Log project update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
id,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
originalData: originalProject,
|
||||
updatedData: data,
|
||||
changedFields: Object.keys(data),
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Error in updateProjectHandler:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProjectHandler(req, { params }) {
|
||||
|
||||
97
src/app/api/projects/export/route.js
Normal file
97
src/app/api/projects/export/route.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Force this API route to use Node.js runtime for database access and file operations
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getAllProjects } from "@/lib/queries/projects";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function exportProjectsHandler(req) {
|
||||
try {
|
||||
// Get all projects
|
||||
const projects = getAllProjects();
|
||||
|
||||
// Group projects by status
|
||||
const groupedProjects = projects.reduce((acc, project) => {
|
||||
const status = project.project_status || 'unknown';
|
||||
if (!acc[status]) {
|
||||
acc[status] = [];
|
||||
}
|
||||
acc[status].push({
|
||||
'Nazwa projektu': project.project_name,
|
||||
'Adres': project.address || '',
|
||||
'Działka': project.plot || '',
|
||||
'WP': project.wp || '',
|
||||
'Data zakończenia': project.finish_date || '',
|
||||
'Przypisany do': project.assigned_to_initial || ''
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Polish status translations for sheet names
|
||||
const statusTranslations = {
|
||||
'registered': 'Zarejestrowany',
|
||||
'in_progress_design': 'W realizacji (projektowanie)',
|
||||
'in_progress_construction': 'W realizacji (budowa)',
|
||||
'fulfilled': 'Zakończony',
|
||||
'cancelled': 'Wycofany',
|
||||
'unknown': 'Nieznany'
|
||||
};
|
||||
|
||||
// Create workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Create a sheet for each status
|
||||
Object.keys(groupedProjects).forEach(status => {
|
||||
const sheetName = statusTranslations[status] || status;
|
||||
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
});
|
||||
|
||||
// Generate buffer
|
||||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
// Generate filename with current date
|
||||
const filename = `eksport_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
// Log the export action
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.DATA_EXPORT,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
null,
|
||||
req.auth,
|
||||
{
|
||||
exportType: 'excel',
|
||||
totalProjects: projects.length,
|
||||
statuses: Object.keys(groupedProjects)
|
||||
}
|
||||
);
|
||||
|
||||
// Return the Excel file
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting projects to Excel:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to export projects' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withReadAuth(exportProjectsHandler);
|
||||
192
src/app/api/reports/upcoming-projects/route.js
Normal file
192
src/app/api/reports/upcoming-projects/route.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { getAllProjects } from '@/lib/queries/projects';
|
||||
import { parseISO, isAfter, isBefore, startOfDay, addWeeks, differenceInDays } from 'date-fns';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const today = startOfDay(new Date());
|
||||
const nextMonth = addWeeks(today, 5); // Next 5 weeks
|
||||
|
||||
// Get all projects
|
||||
const allProjects = getAllProjects();
|
||||
|
||||
// Filter for upcoming projects (not fulfilled, not cancelled, have finish dates)
|
||||
const upcomingProjects = allProjects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
|
||||
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateA - dateB;
|
||||
});
|
||||
|
||||
// Filter for overdue projects
|
||||
const overdueProjects = allProjects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
|
||||
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isBefore(projectDate, today);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateB - dateA; // Most recently overdue first
|
||||
});
|
||||
|
||||
// Create workbook
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Panel Zarządzania Projektami';
|
||||
workbook.created = new Date();
|
||||
|
||||
// Status translations
|
||||
const statusTranslations = {
|
||||
registered: 'Zarejestrowany',
|
||||
approved: 'Zatwierdzony',
|
||||
pending: 'Oczekujący',
|
||||
in_progress: 'W trakcie',
|
||||
in_progress_design: 'W realizacji (projektowanie)',
|
||||
in_progress_construction: 'W realizacji (realizacja)',
|
||||
fulfilled: 'Zakończony',
|
||||
cancelled: 'Wycofany',
|
||||
};
|
||||
|
||||
// Create Upcoming Projects sheet
|
||||
const upcomingSheet = workbook.addWorksheet('Nadchodzące terminy');
|
||||
|
||||
upcomingSheet.columns = [
|
||||
{ header: 'Nazwa projektu', key: 'name', width: 35 },
|
||||
{ header: 'Klient', key: 'customer', width: 25 },
|
||||
{ header: 'Adres', key: 'address', width: 30 },
|
||||
{ header: 'Działka', key: 'plot', width: 15 },
|
||||
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
|
||||
{ header: 'Dni do terminu', key: 'days_until', width: 15 },
|
||||
{ header: 'Status', key: 'status', width: 25 },
|
||||
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
|
||||
];
|
||||
|
||||
// Style header row
|
||||
upcomingSheet.getRow(1).font = { bold: true };
|
||||
upcomingSheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF4472C4' }
|
||||
};
|
||||
upcomingSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
// Add upcoming projects data
|
||||
upcomingProjects.forEach(project => {
|
||||
const daysUntil = differenceInDays(parseISO(project.finish_date), today);
|
||||
const row = upcomingSheet.addRow({
|
||||
name: project.project_name,
|
||||
customer: project.customer || '',
|
||||
address: project.address || '',
|
||||
plot: project.plot || '',
|
||||
finish_date: project.finish_date,
|
||||
days_until: daysUntil,
|
||||
status: statusTranslations[project.project_status] || project.project_status,
|
||||
assigned_to: project.assigned_to || ''
|
||||
});
|
||||
|
||||
// Color code based on urgency
|
||||
if (daysUntil <= 7) {
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFE0E0' } // Light red
|
||||
};
|
||||
} else if (daysUntil <= 14) {
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFF4E0' } // Light orange
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Create Overdue Projects sheet
|
||||
if (overdueProjects.length > 0) {
|
||||
const overdueSheet = workbook.addWorksheet('Przeterminowane');
|
||||
|
||||
overdueSheet.columns = [
|
||||
{ header: 'Nazwa projektu', key: 'name', width: 35 },
|
||||
{ header: 'Klient', key: 'customer', width: 25 },
|
||||
{ header: 'Adres', key: 'address', width: 30 },
|
||||
{ header: 'Działka', key: 'plot', width: 15 },
|
||||
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
|
||||
{ header: 'Dni po terminie', key: 'days_overdue', width: 15 },
|
||||
{ header: 'Status', key: 'status', width: 25 },
|
||||
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
|
||||
];
|
||||
|
||||
// Style header row
|
||||
overdueSheet.getRow(1).font = { bold: true };
|
||||
overdueSheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE74C3C' }
|
||||
};
|
||||
overdueSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
// Add overdue projects data
|
||||
overdueProjects.forEach(project => {
|
||||
const daysOverdue = Math.abs(differenceInDays(parseISO(project.finish_date), today));
|
||||
const row = overdueSheet.addRow({
|
||||
name: project.project_name,
|
||||
customer: project.customer || '',
|
||||
address: project.address || '',
|
||||
plot: project.plot || '',
|
||||
finish_date: project.finish_date,
|
||||
days_overdue: daysOverdue,
|
||||
status: statusTranslations[project.project_status] || project.project_status,
|
||||
assigned_to: project.assigned_to || ''
|
||||
});
|
||||
|
||||
// Color code based on severity
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFE0E0' } // Light red
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Generate buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
|
||||
// Generate filename with current date
|
||||
const filename = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
// Return response with Excel file
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating upcoming projects report:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate report', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/task-notes/[id]/route.js
Normal file
48
src/app/api/task-notes/[id]/route.js
Normal 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);
|
||||
35
src/app/api/task-sets/[id]/apply/route.js
Normal file
35
src/app/api/task-sets/[id]/apply/route.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { applyTaskSetToProject } from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// POST: Apply a task set to a project (bulk create project tasks)
|
||||
async function applyTaskSetHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { project_id } = await req.json();
|
||||
|
||||
if (!project_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "project_id is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const createdTaskIds = applyTaskSetToProject(id, project_id, req.user?.id || null);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Task set applied successfully. Created ${createdTaskIds.length} tasks.`,
|
||||
createdTaskIds
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error applying task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to apply task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require authentication
|
||||
export const POST = withUserAuth(applyTaskSetHandler);
|
||||
130
src/app/api/task-sets/[id]/route.js
Normal file
130
src/app/api/task-sets/[id]/route.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
getTaskSetById,
|
||||
updateTaskSet,
|
||||
deleteTaskSet,
|
||||
addTaskTemplateToSet,
|
||||
removeTaskTemplateFromSet,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// GET: Get a specific task set with its templates
|
||||
async function getTaskSetHandler(req, { params }) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const taskSet = getTaskSetById(id);
|
||||
|
||||
if (!taskSet) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task set not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(taskSet);
|
||||
} catch (error) {
|
||||
console.error("Error fetching task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch task set" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update a task set
|
||||
async function updateTaskSetHandler(req, { params }) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const updates = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (updates.name !== undefined && !updates.name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name cannot be empty" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (updates.task_category !== undefined) {
|
||||
const validTypes = ["design", "construction"];
|
||||
if (!validTypes.includes(updates.task_category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid task_category. Must be one of: design, construction" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template updates
|
||||
if (updates.templates !== undefined) {
|
||||
// Clear existing templates
|
||||
// Note: This is a simple implementation. In a real app, you might want to handle this more efficiently
|
||||
const currentSet = getTaskSetById(id);
|
||||
if (currentSet) {
|
||||
for (const template of currentSet.templates) {
|
||||
removeTaskTemplateFromSet(id, template.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new templates
|
||||
if (Array.isArray(updates.templates)) {
|
||||
for (let i = 0; i < updates.templates.length; i++) {
|
||||
const template = updates.templates[i];
|
||||
addTaskTemplateToSet(id, template.task_id, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove templates from updates object so it doesn't interfere with task set update
|
||||
delete updates.templates;
|
||||
}
|
||||
|
||||
const result = updateTaskSet(id, updates);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task set not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete a task set
|
||||
async function deleteTaskSetHandler(req, { params }) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = deleteTaskSet(id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task set not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskSetHandler);
|
||||
export const PUT = withUserAuth(updateTaskSetHandler);
|
||||
export const DELETE = withUserAuth(deleteTaskSetHandler);
|
||||
60
src/app/api/task-sets/route.js
Normal file
60
src/app/api/task-sets/route.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
getAllTaskSets,
|
||||
getTaskSetsByProjectType,
|
||||
createTaskSet,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// GET: Get all task sets or filter by task category
|
||||
async function getTaskSetsHandler(req) {
|
||||
initializeDatabase();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const taskCategory = searchParams.get("task_category");
|
||||
|
||||
if (taskCategory) {
|
||||
const taskSets = getTaskSetsByTaskCategory(taskCategory);
|
||||
return NextResponse.json(taskSets);
|
||||
} else {
|
||||
const taskSets = getAllTaskSets();
|
||||
return NextResponse.json(taskSets);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create a new task set
|
||||
async function createTaskSetHandler(req) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
if (!data.name || !data.task_category) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name and task_category are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate task_category
|
||||
const validTypes = ["design", "construction"];
|
||||
if (!validTypes.includes(data.task_category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid task_category. Must be one of: design, construction" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const setId = createTaskSet(data);
|
||||
return NextResponse.json({ success: true, id: setId });
|
||||
} catch (error) {
|
||||
console.error("Error creating task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskSetsHandler);
|
||||
export const POST = withUserAuth(createTaskSetHandler);
|
||||
@@ -4,10 +4,11 @@ import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get a specific task template
|
||||
async function getTaskHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const template = db
|
||||
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||
.get(params.id);
|
||||
.get(id);
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
@@ -27,20 +28,25 @@ async function getTaskHandler(req, { params }) {
|
||||
|
||||
// PUT: Update a task template
|
||||
async function updateTaskHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name, max_wait_days, description } = await req.json();
|
||||
const { name, max_wait_days, description, task_category } = await req.json();
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (task_category && !['design', 'construction'].includes(task_category)) {
|
||||
return NextResponse.json({ error: "Invalid task_category (must be design or construction)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET name = ?, max_wait_days = ?, description = ?
|
||||
SET name = ?, max_wait_days = ?, description = ?, task_category = ?
|
||||
WHERE task_id = ? AND is_standard = 1`
|
||||
)
|
||||
.run(name, max_wait_days || 0, description || null, params.id);
|
||||
.run(name, max_wait_days || 0, description || null, task_category, id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
@@ -60,10 +66,11 @@ async function updateTaskHandler(req, { params }) {
|
||||
|
||||
// DELETE: Delete a task template
|
||||
async function deleteTaskHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = db
|
||||
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||
.run(params.id);
|
||||
.run(id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -5,18 +5,22 @@ import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
||||
|
||||
// POST: create new template
|
||||
async function createTaskHandler(req) {
|
||||
const { name, max_wait_days, description } = await req.json();
|
||||
const { name, max_wait_days, description, task_category } = await req.json();
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!task_category || !['design', 'construction'].includes(task_category)) {
|
||||
return NextResponse.json({ error: "Valid task_category is required (design or construction)" }, { status: 400 });
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO tasks (name, max_wait_days, description, is_standard)
|
||||
VALUES (?, ?, ?, 1)
|
||||
INSERT INTO tasks (name, max_wait_days, description, is_standard, task_category)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
`
|
||||
).run(name, max_wait_days || 0, description || null);
|
||||
).run(name, max_wait_days || 0, description || null, task_category);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
function SignInContent() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -21,13 +21,13 @@ function SignInContent() {
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password")
|
||||
setError("Invalid username or password")
|
||||
} else {
|
||||
// Successful login
|
||||
router.push(callbackUrl)
|
||||
@@ -45,10 +45,10 @@ function SignInContent() {
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
Zaloguj się do swojego konta
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Access the Project Management Panel
|
||||
Dostęp do panelu
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
@@ -60,24 +60,24 @@ function SignInContent() {
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Nazwa użytkownika
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Nazwa użytkownika"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
Hasło
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -105,7 +105,7 @@ function SignInContent() {
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
Zaloguj...
|
||||
</span>
|
||||
) : (
|
||||
"Sign in"
|
||||
@@ -113,13 +113,13 @@ function SignInContent() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{/* <div className="text-center">
|
||||
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||
<p className="font-medium">Default Admin Account:</p>
|
||||
<p>Email: admin@localhost</p>
|
||||
<p>Password: admin123456</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
434
src/app/calendar/page.js
Normal file
434
src/app/calendar/page.js
Normal file
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
parseISO,
|
||||
isAfter,
|
||||
isBefore,
|
||||
startOfDay,
|
||||
addWeeks
|
||||
} from "date-fns";
|
||||
import { pl } from "date-fns/locale";
|
||||
|
||||
const statusColors = {
|
||||
registered: "bg-blue-100 text-blue-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
in_progress: "bg-orange-100 text-orange-800",
|
||||
in_progress_design: "bg-purple-100 text-purple-800",
|
||||
in_progress_construction: "bg-indigo-100 text-indigo-800",
|
||||
fulfilled: "bg-gray-100 text-gray-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
const getStatusTranslation = (status) => {
|
||||
const translations = {
|
||||
registered: "Zarejestrowany",
|
||||
approved: "Zatwierdzony",
|
||||
pending: "Oczekujący",
|
||||
in_progress: "W trakcie",
|
||||
in_progress_design: "W realizacji (projektowanie)",
|
||||
in_progress_construction: "W realizacji (realizacja)",
|
||||
fulfilled: "Zakończony",
|
||||
cancelled: "Wycofany",
|
||||
};
|
||||
return translations[status] || status;
|
||||
};
|
||||
|
||||
export default function ProjectCalendarPage() {
|
||||
const { t } = useTranslation();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState('month'); // 'month' or 'upcoming'
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/projects")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// Filter projects that have finish dates and are not fulfilled
|
||||
const projectsWithDates = data.filter(p =>
|
||||
p.finish_date && p.project_status !== 'fulfilled'
|
||||
);
|
||||
setProjects(projectsWithDates);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching projects:", error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getProjectsForDate = (date) => {
|
||||
return projects.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isSameDay(projectDate, date);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getUpcomingProjects = () => {
|
||||
const today = startOfDay(new Date());
|
||||
const nextMonth = addWeeks(today, 5); // Next 5 weeks
|
||||
|
||||
return projects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateA - dateB;
|
||||
});
|
||||
};
|
||||
|
||||
const getOverdueProjects = () => {
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
return projects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isBefore(projectDate, today);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateB - dateA; // Most recently overdue first
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadReport = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await fetch('/api/reports/upcoming-projects');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download report');
|
||||
}
|
||||
|
||||
// Get the blob from the response
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading report:', error);
|
||||
alert('Błąd podczas pobierania raportu');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCalendarGrid = () => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days = [];
|
||||
let day = calendarStart;
|
||||
|
||||
while (day <= calendarEnd) {
|
||||
days.push(day);
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
|
||||
const weekdays = ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nie'];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Calendar Header */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{format(currentDate, 'LLLL yyyy', { locale: pl })}
|
||||
</h2>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
>
|
||||
Dziś
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday Headers */}
|
||||
<div className="grid grid-cols-7 border-b border-gray-200">
|
||||
{weekdays.map(weekday => (
|
||||
<div key={weekday} className="p-2 text-sm font-medium text-gray-500 text-center">
|
||||
{weekday}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((day, index) => {
|
||||
const dayProjects = getProjectsForDate(day);
|
||||
const isCurrentMonth = isSameMonth(day, currentDate);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`min-h-[120px] p-2 border-r border-b border-gray-100 ${
|
||||
!isCurrentMonth ? 'bg-gray-50' : 'bg-white'
|
||||
} ${isToday ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-2 ${
|
||||
!isCurrentMonth ? 'text-gray-400' : isToday ? 'text-blue-600' : 'text-gray-900'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
|
||||
{dayProjects.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{dayProjects.slice(0, 3).map(project => (
|
||||
<Link
|
||||
key={project.project_id}
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`text-xs p-1 rounded truncate ${
|
||||
statusColors[project.project_status] || statusColors.registered
|
||||
} hover:opacity-80 transition-opacity`}>
|
||||
{project.project_name}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{dayProjects.length > 3 && (
|
||||
<div className="relative group">
|
||||
<div className="text-xs text-gray-500 p-1 cursor-pointer">
|
||||
+{dayProjects.length - 3} więcej
|
||||
</div>
|
||||
<div className="absolute left-0 top-full mt-1 bg-white border border-gray-200 rounded shadow-lg p-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{dayProjects.slice(3).map(project => (
|
||||
<Link
|
||||
key={project.project_id}
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`text-xs p-1 rounded truncate ${
|
||||
statusColors[project.project_status] || statusColors.registered
|
||||
} hover:opacity-80 transition-opacity`}>
|
||||
{project.project_name}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpcomingView = () => {
|
||||
const upcomingProjects = getUpcomingProjects().filter(project => project.project_status !== 'cancelled');
|
||||
const overdueProjects = getOverdueProjects().filter(project => project.project_status !== 'cancelled');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming Projects */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Nadchodzące terminy ({upcomingProjects.length})
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{upcomingProjects.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingProjects.map(project => {
|
||||
const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div key={project.project_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<Link
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="font-medium text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{project.project_name}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{project.customer && `${project.customer} • `}
|
||||
{project.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatDate(project.finish_date)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
za {daysUntilDeadline} dni
|
||||
</div>
|
||||
<Badge className={statusColors[project.project_status] || statusColors.registered}>
|
||||
{getStatusTranslation(project.project_status) || project.project_status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Brak nadchodzących projektów w następnych 4 tygodniach
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overdue Projects */}
|
||||
{overdueProjects.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-red-600">
|
||||
Projekty przeterminowane ({overdueProjects.length})
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{overdueProjects.map(project => (
|
||||
<div key={project.project_id} className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<div className="flex-1">
|
||||
<Link
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="font-medium text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{project.project_name}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{project.customer && `${project.customer} • `}
|
||||
{project.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-red-600">
|
||||
{formatDate(project.finish_date)}
|
||||
</div>
|
||||
<Badge className={statusColors[project.project_status] || statusColors.registered}>
|
||||
{getStatusTranslation(project.project_status) || project.project_status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Kalendarz projektów"
|
||||
subtitle={`${projects.length} aktywnych projektów z terminami`}
|
||||
>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={viewMode === 'month' ? 'primary' : 'outline'}
|
||||
onClick={() => setViewMode('month')}
|
||||
>
|
||||
Kalendarz
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'upcoming' ? 'primary' : 'outline'}
|
||||
onClick={() => setViewMode('upcoming')}
|
||||
>
|
||||
Lista terminów
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleDownloadReport}
|
||||
disabled={downloading}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
|
||||
title={downloading ? 'Pobieranie...' : 'Eksportuj raport nadchodzących projektów do Excel'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import FileUploadModal from "@/components/FileUploadModal";
|
||||
import FileAttachmentsList from "@/components/FileAttachmentsList";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ContractDetailsPage() {
|
||||
const params = useParams();
|
||||
@@ -17,6 +20,9 @@ export default function ContractDetailsPage() {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [attachments, setAttachments] = useState([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchContractDetails() {
|
||||
@@ -52,10 +58,18 @@ export default function ContractDetailsPage() {
|
||||
fetchContractDetails();
|
||||
}
|
||||
}, [contractId]);
|
||||
const handleFileUploaded = (newFile) => {
|
||||
setAttachments(prev => [newFile, ...prev]);
|
||||
};
|
||||
|
||||
const handleFilesChange = (files) => {
|
||||
setAttachments(files);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading contract details..." />
|
||||
<LoadingState message={t('contracts.loadingContractDetails')} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -65,9 +79,9 @@ export default function ContractDetailsPage() {
|
||||
<PageContainer>
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<p className="text-red-600 text-lg mb-4">Contract not found.</p>
|
||||
<p className="text-red-600 text-lg mb-4">{t('contracts.contractNotFound')}</p>
|
||||
<Link href="/contracts">
|
||||
<Button variant="primary">Back to Contracts</Button>
|
||||
<Button variant="primary">{t('contracts.backToContracts')}</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -77,8 +91,8 @@ export default function ContractDetailsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={`Contract ${contract.contract_number}`}
|
||||
description={contract.contract_name || "Contract Details"}
|
||||
title={`${t('contracts.contract')} ${contract.contract_number}`}
|
||||
description={contract.contract_name || t('contracts.contractInformation')}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/contracts">
|
||||
@@ -96,7 +110,7 @@ export default function ContractDetailsPage() {
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Contracts
|
||||
{t('contracts.backToContracts')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||
@@ -114,7 +128,7 @@ export default function ContractDetailsPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Project
|
||||
{t('contracts.addProject')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -127,14 +141,14 @@ export default function ContractDetailsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Contract Information
|
||||
{t('contracts.contractInformation')}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contract Number
|
||||
{t('contracts.contractNumber')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.contract_number}
|
||||
@@ -143,7 +157,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.contract_name && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contract Name
|
||||
{t('contracts.contractName')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.contract_name}
|
||||
@@ -153,7 +167,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.customer_contract_number && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Customer Contract Number
|
||||
{t('contracts.customerContractNumber')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.customer_contract_number}
|
||||
@@ -163,7 +177,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.customer && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Customer
|
||||
{t('contracts.customer')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.customer}
|
||||
@@ -173,7 +187,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.investor && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Investor
|
||||
{t('contracts.investor')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.investor}
|
||||
@@ -183,7 +197,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.date_signed && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Date Signed
|
||||
{t('contracts.dateSigned')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{formatDate(contract.date_signed)}
|
||||
@@ -193,7 +207,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.finish_date && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Finish Date
|
||||
{t('contracts.finishDate')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{formatDate(contract.finish_date)}
|
||||
@@ -209,22 +223,22 @@ export default function ContractDetailsPage() {
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Summary</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t('contracts.summary')}</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Projects Count
|
||||
{t('contracts.projectsCount')}
|
||||
</span>
|
||||
<Badge variant="primary" size="lg">
|
||||
{projects.length} Projects
|
||||
{projects.length} {t('contracts.projects')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.finish_date && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Contract Status
|
||||
{t('contracts.contractStatus')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -235,8 +249,8 @@ export default function ContractDetailsPage() {
|
||||
size="md"
|
||||
>
|
||||
{new Date(contract.finish_date) > new Date()
|
||||
? "Active"
|
||||
: "Expired"}
|
||||
? t('contracts.active')
|
||||
: t('contracts.expired')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -245,12 +259,50 @@ export default function ContractDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract Documents */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{t('contracts.contractDocuments')} ({attachments.length})
|
||||
</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{t('contracts.uploadDocument')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileAttachmentsList
|
||||
entityType="contract"
|
||||
entityId={contractId}
|
||||
onFilesChange={handleFilesChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Associated Projects */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Associated Projects ({projects.length})
|
||||
{t('contracts.associatedProjects')} ({projects.length})
|
||||
</h2>
|
||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -267,7 +319,7 @@ export default function ContractDetailsPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Project
|
||||
{t('contracts.addProject')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -289,13 +341,13 @@ export default function ContractDetailsPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No projects yet
|
||||
{t('contracts.noProjectsYet')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Get started by creating your first project for this contract
|
||||
{t('contracts.getStartedMessage')}
|
||||
</p>
|
||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||
<Button variant="primary">Create First Project</Button>
|
||||
<Button variant="primary">{t('contracts.createFirstProject')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -361,22 +413,22 @@ export default function ContractDetailsPage() {
|
||||
size="sm"
|
||||
>
|
||||
{project.project_status === "registered"
|
||||
? "Registered"
|
||||
? t('projectStatus.registered')
|
||||
: project.project_status === "in_progress_design"
|
||||
? "In Progress (Design)"
|
||||
? t('projectStatus.in_progress_design')
|
||||
: project.project_status ===
|
||||
"in_progress_construction"
|
||||
? "In Progress (Construction)"
|
||||
? t('projectStatus.in_progress_construction')
|
||||
: project.project_status === "fulfilled"
|
||||
? "Completed"
|
||||
: "Unknown"}
|
||||
? t('projectStatus.fulfilled')
|
||||
: t('projectStatus.unknown')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/projects/${project.project_id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
View Details
|
||||
{t('contracts.viewDetails')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -386,6 +438,15 @@ export default function ContractDetailsPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
entityType="contract"
|
||||
entityId={contractId}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import ContractForm from "@/components/ContractForm";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function NewContractPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Create New Contract"
|
||||
description="Add a new contract to your portfolio"
|
||||
title={t('contracts.createNewContract')}
|
||||
description={t('contracts.addNewContractDescription')}
|
||||
action={
|
||||
<Link href="/contracts">
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -26,7 +30,7 @@ export default function NewContractPage() {
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Contracts
|
||||
{t('contracts.backToContracts')}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import SearchBar from "@/components/ui/SearchBar";
|
||||
import FilterBar from "@/components/ui/FilterBar";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ContractsMainPage() {
|
||||
const { t } = useTranslation();
|
||||
const [contracts, setContracts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -133,13 +135,13 @@ export default function ContractsMainPage() {
|
||||
const getStatusBadge = (status) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge variant="success">Aktywna</Badge>;
|
||||
return <Badge variant="success">{t('contracts.active')}</Badge>;
|
||||
case "completed":
|
||||
return <Badge variant="secondary">Zakończona</Badge>;
|
||||
return <Badge variant="secondary">{t('common.completed')}</Badge>;
|
||||
case "ongoing":
|
||||
return <Badge variant="primary">W trakcie</Badge>;
|
||||
return <Badge variant="primary">{t('contracts.withoutEndDate')}</Badge>;
|
||||
default:
|
||||
return <Badge>Nieznany</Badge>;
|
||||
return <Badge>{t('common.unknown')}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,17 +172,29 @@ export default function ContractsMainPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Umowy"
|
||||
description="Zarządzaj swoimi umowami i kontraktami"
|
||||
title={t('contracts.title')}
|
||||
description={t('contracts.subtitle')}
|
||||
>
|
||||
<Link href="/contracts/new">
|
||||
<Button variant="primary" size="lg">
|
||||
<span className="mr-2">➕</span>
|
||||
Nowa umowa
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{t('contracts.newContract')}
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<LoadingState message="Ładowanie umów..." />
|
||||
<LoadingState message={t('navigation.loading')} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -225,13 +239,25 @@ export default function ContractsMainPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Umowy"
|
||||
description="Zarządzaj swoimi umowami i kontraktami"
|
||||
title={t('contracts.title')}
|
||||
description={t('contracts.subtitle')}
|
||||
>
|
||||
<Link href="/contracts/new">
|
||||
<Button variant="primary" size="lg">
|
||||
<span className="mr-2">➕</span>
|
||||
Nowa umowa
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{t('contracts.newContract')}
|
||||
</Button>
|
||||
</Link>{" "}
|
||||
</PageHeader>
|
||||
|
||||
289
src/app/dashboard/page.js
Normal file
289
src/app/dashboard/page.js
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, ComposedChart, PieChart, Pie, Cell } from 'recharts';
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function TeamLeadsDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const [chartData, setChartData] = useState([]);
|
||||
const [summaryData, setSummaryData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedYear, setSelectedYear] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [selectedYear]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedYear) {
|
||||
params.append('year', selectedYear.toString());
|
||||
}
|
||||
|
||||
const url = `/api/dashboard${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch dashboard data');
|
||||
}
|
||||
const data = await response.json();
|
||||
setChartData(data.chartData || []);
|
||||
setSummaryData(data.summary || null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = [];
|
||||
for (let year = currentYear; year >= 2023; year--) {
|
||||
availableYears.push(year);
|
||||
}
|
||||
|
||||
const handleYearChange = (year) => {
|
||||
setSelectedYear(year === 'all' ? null : parseInt(year));
|
||||
};
|
||||
|
||||
const formatCurrency = (value) => {
|
||||
return new Intl.NumberFormat('pl-PL', {
|
||||
style: 'currency',
|
||||
currency: 'PLN',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
// Find the monthly and cumulative values
|
||||
const monthlyData = payload.find(p => p.dataKey === 'value');
|
||||
const cumulativeData = payload.find(p => p.dataKey === 'cumulative');
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{`${t('teamDashboard.monthLabel')} ${label}`}</p>
|
||||
<p className="text-blue-600 dark:text-blue-400 font-semibold">
|
||||
{`${t('teamDashboard.monthlyValue')} ${monthlyData ? formatCurrency(monthlyData.value) : t('teamDashboard.na')}`}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-sm">
|
||||
{`${t('teamDashboard.cumulative')} ${cumulativeData ? formatCurrency(cumulativeData.value) : t('teamDashboard.na')}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('teamDashboard.title')}
|
||||
</h1>
|
||||
|
||||
{/* Year Filter */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="year-select" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('teamDashboard.yearLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="year-select"
|
||||
value={selectedYear || 'all'}
|
||||
onChange={(e) => handleYearChange(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">{t('teamDashboard.allYears')}</option>
|
||||
{availableYears.map(year => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('teamDashboard.projectCompletionTitle')}
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.loadingChart')}</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500 dark:text-red-400">{t('teamDashboard.errorPrefix')} {error}</div>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.noData')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-96">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill="#3b82f6"
|
||||
name={t('teamDashboard.monthlyValue').replace(':', '')}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cumulative"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
name={t('teamDashboard.cumulative').replace(':', '')}
|
||||
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6, stroke: '#10b981', strokeWidth: 2 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Total Chart */}
|
||||
<div className="lg:col-span-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('teamDashboard.totalPortfolio')}
|
||||
</h2>
|
||||
|
||||
{summaryData?.total ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
name: t('teamDashboard.realised'),
|
||||
value: summaryData.total.realisedValue,
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
name: t('teamDashboard.unrealised'),
|
||||
value: summaryData.total.unrealisedValue,
|
||||
color: '#f59e0b'
|
||||
}
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
<Cell fill="#10b981" />
|
||||
<Cell fill="#f59e0b" />
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => formatCurrency(value)} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.noSummaryData')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summaryData?.total && (
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg">
|
||||
<div className="text-sm text-green-600 dark:text-green-400 font-medium">{t('teamDashboard.realisedValue')}</div>
|
||||
<div className="text-xl font-bold text-green-700 dark:text-green-300">
|
||||
{formatCurrency(summaryData.total.realisedValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg">
|
||||
<div className="text-sm text-amber-600 dark:text-amber-400 font-medium">{t('teamDashboard.unrealisedValue')}</div>
|
||||
<div className="text-xl font-bold text-amber-700 dark:text-amber-300">
|
||||
{formatCurrency(summaryData.total.unrealisedValue)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Type Charts */}
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('teamDashboard.byProjectType')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{summaryData?.byType && Object.entries(summaryData.byType).map(([type, data]) => (
|
||||
<div key={type} className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4 capitalize text-center">
|
||||
{type.replace('+', ' + ')}
|
||||
</h3>
|
||||
|
||||
{/* Mini pie chart */}
|
||||
<div className="h-32 mb-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: t('teamDashboard.realised'), value: data.realisedValue, color: '#10b981' },
|
||||
{ name: t('teamDashboard.unrealised'), value: data.unrealisedValue, color: '#f59e0b' }
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={45}
|
||||
dataKey="value"
|
||||
label={({ percent }) => percent > 0.1 ? `${(percent * 100).toFixed(0)}%` : ''}
|
||||
>
|
||||
<Cell fill="#10b981" />
|
||||
<Cell fill="#f59e0b" />
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => formatCurrency(value)} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-green-600 dark:text-green-400">{t('teamDashboard.realised')}</span>
|
||||
<span className="text-sm font-semibold text-green-700 dark:text-green-300">
|
||||
{formatCurrency(data.realisedValue)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-amber-600 dark:text-amber-400">{t('teamDashboard.unrealised')}</span>
|
||||
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">
|
||||
{formatCurrency(data.unrealisedValue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,91 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
|
||||
/* Surface colors */
|
||||
--surface-primary: #ffffff;
|
||||
--surface-secondary: #f9fafb;
|
||||
--surface-tertiary: #f3f4f6;
|
||||
--surface-modal: #ffffff;
|
||||
--surface-card: #ffffff;
|
||||
--surface-hover: #f9fafb;
|
||||
--surface-active: #f3f4f6;
|
||||
|
||||
/* Text colors */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--text-tertiary: #9ca3af;
|
||||
--text-inverse: #ffffff;
|
||||
--text-muted: #6b7280;
|
||||
|
||||
/* Border colors */
|
||||
--border-default: #d1d5db;
|
||||
--border-hover: #9ca3af;
|
||||
--border-focus: #3b82f6;
|
||||
--border-divider: #e5e7eb;
|
||||
|
||||
/* Interactive colors */
|
||||
--interactive-primary: #3b82f6;
|
||||
--interactive-primary-hover: #2563eb;
|
||||
--interactive-secondary: #6b7280;
|
||||
--interactive-secondary-hover: #4b5563;
|
||||
--interactive-danger: #ef4444;
|
||||
--interactive-danger-hover: #dc2626;
|
||||
|
||||
/* Status colors */
|
||||
--status-success: #10b981;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-info: #3b82f6;
|
||||
|
||||
/* Shadow colors */
|
||||
--shadow-default: rgba(0, 0, 0, 0.1);
|
||||
--shadow-hover: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
|
||||
/* Surface colors */
|
||||
--surface-primary: #1f2937;
|
||||
--surface-secondary: #374151;
|
||||
--surface-tertiary: #4b5563;
|
||||
--surface-modal: #1f2937;
|
||||
--surface-card: #374151;
|
||||
--surface-hover: #4b5563;
|
||||
--surface-active: #6b7280;
|
||||
|
||||
/* Text colors */
|
||||
--text-primary: #f9fafb;
|
||||
--text-secondary: #d1d5db;
|
||||
--text-tertiary: #9ca3af;
|
||||
--text-inverse: #111827;
|
||||
--text-muted: #9ca3af;
|
||||
|
||||
/* Border colors */
|
||||
--border-default: #4b5563;
|
||||
--border-hover: #6b7280;
|
||||
--border-focus: #60a5fa;
|
||||
--border-divider: #374151;
|
||||
|
||||
/* Interactive colors */
|
||||
--interactive-primary: #3b82f6;
|
||||
--interactive-primary-hover: #60a5fa;
|
||||
--interactive-secondary: #6b7280;
|
||||
--interactive-secondary-hover: #9ca3af;
|
||||
--interactive-danger: #ef4444;
|
||||
--interactive-danger-hover: #f87171;
|
||||
|
||||
/* Status colors */
|
||||
--status-success: #10b981;
|
||||
--status-warning: #f59e0b;
|
||||
--status-error: #ef4444;
|
||||
--status-info: #3b82f6;
|
||||
|
||||
/* Shadow colors */
|
||||
--shadow-default: rgba(0, 0, 0, 0.3);
|
||||
--shadow-hover: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
@@ -29,6 +114,18 @@ body {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:window-inactive {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track:window-inactive {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
@@ -38,6 +135,14 @@ body {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
|
||||
@@ -92,5 +197,66 @@ body {
|
||||
|
||||
/* Map controls positioning */
|
||||
.leaflet-control-container .leaflet-top.leaflet-right {
|
||||
top: 80px !important; /* Account for floating header */
|
||||
top: 10px !important; /* Position closer to top for project page */
|
||||
right: 10px !important;
|
||||
}
|
||||
|
||||
/* Style the layer control to make it prettier */
|
||||
.leaflet-control-layers {
|
||||
border-radius: 6px !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
background: rgba(255, 255, 255, 0.95) !important;
|
||||
backdrop-filter: blur(8px) !important;
|
||||
-webkit-backdrop-filter: blur(8px) !important;
|
||||
font-family: inherit !important;
|
||||
font-size: 13px !important;
|
||||
min-width: 180px !important;
|
||||
max-width: 220px !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-toggle {
|
||||
background-color: #3b82f6 !important;
|
||||
color: white !important;
|
||||
border-radius: 4px !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-toggle:hover {
|
||||
background-color: #2563eb !important;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
|
||||
transform: scale(1.05) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-list {
|
||||
border-radius: 4px !important;
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-base label,
|
||||
.leaflet-control-layers-overlays label {
|
||||
padding: 4px 8px !important;
|
||||
border-radius: 3px !important;
|
||||
margin: 1px 0 !important;
|
||||
transition: background-color 0.2s ease !important;
|
||||
cursor: pointer !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.3 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-base label:hover,
|
||||
.leaflet-control-layers-overlays label:hover {
|
||||
background-color: rgba(59, 130, 246, 0.08) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers input[type="radio"],
|
||||
.leaflet-control-layers input[type="checkbox"] {
|
||||
margin-right: 6px !important;
|
||||
transform: scale(0.9) !important;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navigation from "@/components/ui/Navigation";
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||
import { TranslationProvider } from "@/lib/i18n";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -14,20 +16,24 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Project Management Panel",
|
||||
description: "Professional project management dashboard",
|
||||
title: "eProjektant Wastpol",
|
||||
description: "Panel Wastpol",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="pl">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||
>
|
||||
<AuthProvider>
|
||||
<Navigation />
|
||||
<main>{children}</main>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<TranslationProvider initialLanguage="pl">
|
||||
<AuthProvider>
|
||||
<Navigation />
|
||||
<main>{children}</main>
|
||||
</AuthProvider>
|
||||
</TranslationProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
1083
src/app/page.js
1083
src/app/page.js
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,53 @@
|
||||
import ProjectTasksList from "@/components/ProjectTasksList";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ProjectTasksPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Project Tasks"
|
||||
description="View and manage tasks across all projects in a structured list format"
|
||||
title="Zadania"
|
||||
description="Zarządzaj zadaniami projektów"
|
||||
actions={[
|
||||
<Link href="/tasks/templates" key="templates">
|
||||
<Button variant="secondary" size="md">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 7v10c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V7M4 7c0-2.21 1.79-4 4-4h8c2.21 0 4 1.79 4 4M4 7h16M9 11v4m6-4v4"
|
||||
/>
|
||||
</svg>
|
||||
Szablony zadań
|
||||
</Button>
|
||||
</Link>,
|
||||
<Link href="/task-sets" key="task-sets">
|
||||
<Button variant="secondary" size="md">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
Zestawy zadań
|
||||
</Button>
|
||||
</Link>
|
||||
]}
|
||||
/>
|
||||
<ProjectTasksList />
|
||||
</PageContainer>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import ProjectForm from "@/components/ProjectForm";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
@@ -8,6 +8,7 @@ import PageHeader from "@/components/ui/PageHeader";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Link from "next/link";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function EditProjectPage() {
|
||||
const params = useParams();
|
||||
@@ -15,6 +16,8 @@ export default function EditProjectPage() {
|
||||
const [project, setProject] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProject = async () => {
|
||||
@@ -62,10 +65,30 @@ export default function EditProjectPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Edit Project"
|
||||
description={`Editing: ${project.project_name || "Untitled Project"}`}
|
||||
title={t('projects.editProject')}
|
||||
description={`${t('projects.editing')}: ${project.project_name || "Untitled Project"}`}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => formRef.current?.saveProject()}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Link href={`/projects/${id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
<svg
|
||||
@@ -81,7 +104,7 @@ export default function EditProjectPage() {
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/projects">
|
||||
@@ -99,14 +122,14 @@ export default function EditProjectPage() {
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Projects
|
||||
{t('projects.backToProjects')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="max-w-2xl">
|
||||
<ProjectForm initialData={project} />
|
||||
<ProjectForm ref={formRef} initialData={project} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,209 @@
|
||||
import {
|
||||
getProjectWithContract,
|
||||
getNotesForProject,
|
||||
} from "@/lib/queries/projects";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import NoteForm from "@/components/NoteForm";
|
||||
import ProjectTasksSection from "@/components/ProjectTasksSection";
|
||||
import FieldWithHistory from "@/components/FieldWithHistory";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import Link from "next/link";
|
||||
import { differenceInCalendarDays, parseISO } from "date-fns";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDate, formatCoordinates } from "@/lib/utils";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
|
||||
import ProjectAssigneeDropdown from "@/components/ProjectAssigneeDropdown";
|
||||
import ClientProjectMap from "@/components/ui/ClientProjectMap";
|
||||
import FileUploadBox from "@/components/FileUploadBox";
|
||||
import FileItem from "@/components/FileItem";
|
||||
import proj4 from "proj4";
|
||||
|
||||
export default async function ProjectViewPage({ params }) {
|
||||
const { id } = await params;
|
||||
const project = await getProjectWithContract(id);
|
||||
const notes = await getNotesForProject(id);
|
||||
export default function ProjectViewPage() {
|
||||
const params = useParams();
|
||||
const { data: session } = useSession();
|
||||
const [project, setProject] = useState(null);
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
const [editingNoteId, setEditingNoteId] = useState(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
// Helper function to parse note text with links
|
||||
const parseNoteText = (text) => {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = linkRegex.exec(text)) !== null) {
|
||||
// Add text before the link
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
// Add the link
|
||||
parts.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[2]}
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
|
||||
// Helper function to add a new note to the list
|
||||
const addNote = (newNote) => {
|
||||
setNotes(prevNotes => [newNote, ...prevNotes]);
|
||||
};
|
||||
|
||||
// Helper function to check if user can modify a note (edit or delete)
|
||||
const canModifyNote = (note) => {
|
||||
if (!session?.user) return false;
|
||||
|
||||
// Admins can modify any note
|
||||
if (session.user.role === 'admin') return true;
|
||||
|
||||
// Users can modify their own notes
|
||||
return note.created_by === session.user.id;
|
||||
};
|
||||
|
||||
// Helper function to handle file upload
|
||||
const handleFileUploaded = (newFile) => {
|
||||
setUploadedFiles(prevFiles => [newFile, ...prevFiles]);
|
||||
};
|
||||
|
||||
// Helper function to handle file deletion
|
||||
const handleFileDelete = async (fileId) => {
|
||||
if (confirm('Czy na pewno chcesz usunąć ten plik?')) {
|
||||
try {
|
||||
const res = await fetch(`/api/files/${fileId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
setUploadedFiles(prevFiles => prevFiles.filter(file => file.file_id !== fileId));
|
||||
} else {
|
||||
alert('Błąd podczas usuwania pliku');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
alert('Błąd podczas usuwania pliku');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to handle file update (edit)
|
||||
const handleFileUpdate = async (updatedFile) => {
|
||||
setUploadedFiles(prevFiles =>
|
||||
prevFiles.map(file =>
|
||||
file.file_id === updatedFile.file_id ? updatedFile : file
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to save edited note
|
||||
const handleSaveNote = async (noteId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ note: editText }),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Update the note in local state
|
||||
setNotes(prevNotes =>
|
||||
prevNotes.map(note =>
|
||||
note.note_id === noteId ? { ...note, note: editText, edited_at: new Date().toISOString() } : note
|
||||
)
|
||||
);
|
||||
setEditingNoteId(null);
|
||||
setEditText('');
|
||||
} else {
|
||||
alert('Błąd podczas aktualizacji notatki');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error);
|
||||
alert('Błąd podczas aktualizacji notatki');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!params.id) return;
|
||||
|
||||
try {
|
||||
// Fetch project data
|
||||
const projectRes = await fetch(`/api/projects/${params.id}`);
|
||||
if (!projectRes.ok) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
const projectData = await projectRes.json();
|
||||
|
||||
// Fetch notes data
|
||||
const notesRes = await fetch(`/api/notes?project_id=${params.id}`);
|
||||
const notesData = notesRes.ok ? await notesRes.json() : [];
|
||||
|
||||
// Fetch files data
|
||||
const filesRes = await fetch(`/api/files?entityType=project&entityId=${params.id}`);
|
||||
const filesData = filesRes.ok ? await filesRes.json() : [];
|
||||
|
||||
setProject(projectData);
|
||||
setNotes(notesData);
|
||||
setUploadedFiles(filesData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
setProject(null);
|
||||
setNotes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project?.project_name) {
|
||||
document.title = `${project.project_name} - Panel`;
|
||||
} else {
|
||||
document.title = 'Panel';
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-red-600 text-lg">Project not found.</p>
|
||||
<p className="text-red-600 text-lg">Projekt nie został znaleziony.</p>
|
||||
<Link href="/projects" className="mt-4 inline-block">
|
||||
<Button variant="primary">Back to Projects</Button>
|
||||
<Button variant="primary">Powrót do projektów</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -44,62 +220,140 @@ export default async function ProjectViewPage({ params }) {
|
||||
if (days <= 7) return "warning";
|
||||
return "success";
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={project.project_name}
|
||||
description={`${project.city} • ${project.address}`}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<ProjectStatusDropdown project={project} size="sm" />
|
||||
{daysRemaining !== null && (
|
||||
<Badge variant={getDeadlineVariant(daysRemaining)} size="md">
|
||||
{daysRemaining === 0
|
||||
? "Due Today"
|
||||
: daysRemaining > 0
|
||||
? `${daysRemaining} days left`
|
||||
: `${Math.abs(daysRemaining)} days overdue`}
|
||||
</Badge>
|
||||
)}
|
||||
<Link href="/projects">
|
||||
<Button variant="outline" size="sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Projects
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/projects/${id}/edit`}>
|
||||
<Button variant="primary">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Edit Project
|
||||
</Button>
|
||||
</Link>
|
||||
{/* Mobile: Full-width title, Desktop: Standard PageHeader */}
|
||||
<div className="block sm:hidden mb-6">
|
||||
{/* Mobile Layout */}
|
||||
<div className="space-y-4">
|
||||
{/* Full-width title */}
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-bold text-gray-900 break-words">
|
||||
{project.project_name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{project.city} • {project.address} • {project.project_number}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>{" "}
|
||||
|
||||
{/* Mobile action bar */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
{/* Status and deadline badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ProjectStatusDropdown project={project} size="sm" />
|
||||
{daysRemaining !== null && (
|
||||
<Badge variant={getDeadlineVariant(daysRemaining)} size="sm" className="text-xs">
|
||||
{daysRemaining === 0
|
||||
? "Termin dzisiaj"
|
||||
: daysRemaining > 0
|
||||
? `${daysRemaining} dni pozostało`
|
||||
: `${Math.abs(daysRemaining)} dni po terminie`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons - full width */}
|
||||
<div className="flex gap-2 w-full">
|
||||
<Link href="/projects" className="flex-1">
|
||||
<Button variant="outline" size="sm" className="w-full text-xs">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Powrót
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/projects/${params.id}/edit`} className="flex-1">
|
||||
<Button variant="primary" size="sm" className="w-full text-xs">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Edytuj
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Standard PageHeader */}
|
||||
<div className="hidden sm:block">
|
||||
<PageHeader
|
||||
title={project.project_name}
|
||||
description={`${project.city} • ${project.address} • ${project.project_number}`}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<ProjectStatusDropdown project={project} size="sm" />
|
||||
{daysRemaining !== null && (
|
||||
<Badge variant={getDeadlineVariant(daysRemaining)} size="md">
|
||||
{daysRemaining === 0
|
||||
? "Termin dzisiaj"
|
||||
: daysRemaining > 0
|
||||
? `${daysRemaining} dni pozostało`
|
||||
: `${Math.abs(daysRemaining)} dni po terminie`}
|
||||
</Badge>
|
||||
)}
|
||||
<Link href="/projects">
|
||||
<Button variant="outline" size="sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Powrót do projektów
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/projects/${params.id}/edit`}>
|
||||
<Button variant="primary">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Edytuj projekt
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* Main Project Information */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -108,7 +362,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
{" "}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Project Information
|
||||
Informacje o projekcie
|
||||
</h2>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -123,12 +377,12 @@ export default async function ProjectViewPage({ params }) {
|
||||
size="sm"
|
||||
>
|
||||
{project.project_type === "design"
|
||||
? "Design (P)"
|
||||
? "Projektowanie (P)"
|
||||
: project.project_type === "construction"
|
||||
? "Construction (R)"
|
||||
? "Budowa (B)"
|
||||
: project.project_type === "design+construction"
|
||||
? "Design + Construction (P+R)"
|
||||
: "Unknown"}
|
||||
? "Projektowanie + Budowa (P+B)"
|
||||
: "Nieznany"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -136,7 +390,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Location
|
||||
Lokalizacja
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.city || "N/A"}
|
||||
@@ -144,7 +398,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Address
|
||||
Adres
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.address || "N/A"}
|
||||
@@ -152,7 +406,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Plot
|
||||
Działka
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.plot || "N/A"}
|
||||
@@ -160,7 +414,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
District
|
||||
Jednostka ewidencyjna
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.district || "N/A"}
|
||||
@@ -168,22 +422,29 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Unit
|
||||
Obręb
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.unit || "N/A"}
|
||||
</p>
|
||||
</div>{" "}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Deadline
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.finish_date
|
||||
? formatDate(project.finish_date)
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<FieldWithHistory
|
||||
tableName="projects"
|
||||
recordId={project.project_id}
|
||||
fieldName="finish_date"
|
||||
currentValue={project.finish_date}
|
||||
label="Termin zakończenia"
|
||||
/>
|
||||
{project.completion_date && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Data zakończenia projektu
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{formatDate(project.completion_date)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
WP
|
||||
@@ -194,35 +455,110 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Investment Number
|
||||
Numer inwestycji
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.investment_number || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
{session?.user?.role === 'team_lead' && project.wartosc_zlecenia && (
|
||||
<FieldWithHistory
|
||||
tableName="projects"
|
||||
recordId={project.project_id}
|
||||
fieldName="wartosc_zlecenia"
|
||||
currentValue={project.wartosc_zlecenia}
|
||||
displayValue={parseFloat(project.wartosc_zlecenia).toLocaleString('pl-PL', {
|
||||
style: 'currency',
|
||||
currency: 'PLN'
|
||||
})}
|
||||
label="Wartość zlecenia"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{project.contact && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contact
|
||||
Kontakt
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">{project.contact}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.coordinates && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Coordinates
|
||||
</span>
|
||||
{project.coordinates && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Współrzędne
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-gray-900 font-medium font-mono text-sm">
|
||||
{project.coordinates}
|
||||
{formatCoordinates(project.coordinates)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={`https://www.google.com/maps/place/${project.coordinates}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
title="Otwórz w Google Maps"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={(() => {
|
||||
// Define EPSG:2180 projection (Poland CS92)
|
||||
proj4.defs("EPSG:2180", "+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
|
||||
|
||||
{project.notes && (
|
||||
const [lat, lng] = project.coordinates.split(',').map(c => parseFloat(c.trim()));
|
||||
|
||||
// Convert WGS84 to EPSG:2180
|
||||
const [x, y] = proj4('EPSG:4326', 'EPSG:2180', [lng, lat]);
|
||||
|
||||
// Create bbox with ~100m offset in each direction
|
||||
const offset = 100;
|
||||
const bbox = `${x - offset},${y - offset},${x + offset},${y + offset}`;
|
||||
|
||||
return `https://mapy.geoportal.gov.pl/imap/Imgp_2.html?gpmap=gp0&bbox=${bbox}&variant=KATASTER`;
|
||||
})()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:text-green-800 transition-colors"
|
||||
title="Otwórz w Geoportal"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)} {project.notes && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Notes
|
||||
@@ -237,14 +573,14 @@ export default async function ProjectViewPage({ params }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Contract Details
|
||||
Szczegóły umowy
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contract Number
|
||||
Numer umowy
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.contract_number || "N/A"}
|
||||
@@ -252,7 +588,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contract Name
|
||||
Nazwa umowy
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.contract_name || "N/A"}
|
||||
@@ -260,7 +596,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Customer
|
||||
Klient
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.customer || "N/A"}
|
||||
@@ -268,7 +604,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Investor
|
||||
Inwestor
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{project.investor || "N/A"}
|
||||
@@ -284,21 +620,27 @@ export default async function ProjectViewPage({ params }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Project Status
|
||||
Status projektu
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{" "}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Current Status
|
||||
Aktualny status
|
||||
</span>
|
||||
<ProjectStatusDropdown project={project} size="md" />
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Przypisany do
|
||||
</span>
|
||||
<ProjectAssigneeDropdown project={project} size="md" />
|
||||
</div>
|
||||
{daysRemaining !== null && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Timeline
|
||||
Harmonogram
|
||||
</span>
|
||||
<div className="text-center">
|
||||
<Badge
|
||||
@@ -306,10 +648,10 @@ export default async function ProjectViewPage({ params }) {
|
||||
size="lg"
|
||||
>
|
||||
{daysRemaining === 0
|
||||
? "Due Today"
|
||||
? "Termin dzisiaj"
|
||||
: daysRemaining > 0
|
||||
? `${daysRemaining} days remaining`
|
||||
: `${Math.abs(daysRemaining)} days overdue`}
|
||||
? `${daysRemaining} dni pozostało`
|
||||
: `${Math.abs(daysRemaining)} dni po terminie`}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,11 +663,11 @@ export default async function ProjectViewPage({ params }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Quick Actions
|
||||
Szybkie akcje
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Link href={`/projects/${id}/edit`} className="block">
|
||||
<Link href={`/projects/${params.id}/edit`} className="block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -344,13 +686,13 @@ export default async function ProjectViewPage({ params }) {
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Edit Project
|
||||
Edytuj projekt
|
||||
</Button>
|
||||
</Link>{" "}
|
||||
<Link href="/projects" className="block">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<svg
|
||||
@@ -366,7 +708,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Projects
|
||||
Powrót do projektów
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/projects/map" className="block">
|
||||
@@ -388,13 +730,38 @@ export default async function ProjectViewPage({ params }) {
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
View All on Map
|
||||
Zobacz wszystkie na mapie
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Załączniki
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FileUploadBox projectId={params.id} onFileUploaded={handleFileUploaded} />
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-700">Przesłane pliki:</h3>
|
||||
{uploadedFiles.map((file) => (
|
||||
<FileItem
|
||||
key={file.file_id}
|
||||
file={file}
|
||||
onDelete={handleFileDelete}
|
||||
onUpdate={handleFileUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
{/* Project Location Map */}
|
||||
{project.coordinates && (
|
||||
<div className="mb-8">
|
||||
@@ -404,7 +771,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
{" "}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Project Location
|
||||
Lokalizacja projektu
|
||||
</h2>
|
||||
{project.coordinates && (
|
||||
<Link
|
||||
@@ -428,7 +795,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
View on Full Map
|
||||
Zobacz na pełnej mapie
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -442,6 +809,7 @@ export default async function ProjectViewPage({ params }) {
|
||||
showLayerControl={true}
|
||||
mapHeight="h-80"
|
||||
defaultLayer="Polish Geoportal Orthophoto"
|
||||
showOverlays={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -449,36 +817,39 @@ export default async function ProjectViewPage({ params }) {
|
||||
)}
|
||||
{/* Project Tasks Section */}
|
||||
<div className="mb-8">
|
||||
<ProjectTasksSection projectId={id} />
|
||||
<ProjectTasksSection projectId={params.id} />
|
||||
</div>
|
||||
{/* Notes Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Notes</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Notatki</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-6">
|
||||
<NoteForm projectId={id} />
|
||||
<NoteForm projectId={params.id} onNoteAdded={addNote} />
|
||||
</div>
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 110 2h-3a1 1 0 01-1-1v-1H8v1a1 1 0 01-1 1H4a1 1 0 110-2V4zm3 1h2v4a1 1 0 001 1h1a1 1 0 100-2v-1a2 2 0 00-2-2H7a1 1 0 000 2z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No notes yet
|
||||
Brak notatek
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Add your first note using the form above.
|
||||
Dodaj swoją pierwszą notatkę używając formularza powyżej.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -486,21 +857,121 @@ export default async function ProjectViewPage({ params }) {
|
||||
{notes.map((n) => (
|
||||
<div
|
||||
key={n.note_id}
|
||||
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-500">
|
||||
{n.note_date}
|
||||
{formatDate(n.note_date, { includeTime: true })}
|
||||
</span>
|
||||
{n.created_by_name && (
|
||||
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||
{n.created_by_name}
|
||||
</span>
|
||||
)}
|
||||
{n.edited_at && (
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
• edytowane
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canModifyNote(n) && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingNoteId(n.note_id);
|
||||
setEditText(n.note);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"
|
||||
title="Edytuj notatkę"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Czy na pewno chcesz usunąć tę notatkę?')) {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${n.note_id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) {
|
||||
// Remove the note from local state instead of full page reload
|
||||
setNotes(prevNotes => prevNotes.filter(note => note.note_id !== n.note_id));
|
||||
} else {
|
||||
alert('Błąd podczas usuwania notatki');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
alert('Błąd podczas usuwania notatki');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded"
|
||||
title="Usuń notatkę"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-900 leading-relaxed">{n.note}</p>
|
||||
{editingNoteId === n.note_id ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder="Wpisz notatkę..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleSaveNote(n.note_id)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Zapisz
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingNoteId(null);
|
||||
setEditText('');
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-900 leading-relaxed">
|
||||
{parseNoteText(n.note)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import ProjectForm from "@/components/ProjectForm";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
@@ -5,11 +8,13 @@ import Button from "@/components/ui/Button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Create New Project"
|
||||
description="Add a new project to your portfolio"
|
||||
title={t("projects.newProject")}
|
||||
// description={t("projects.noProjectsMessage")}
|
||||
action={
|
||||
<Link href="/projects">
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -26,7 +31,7 @@ export default function NewProjectPage() {
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Projects
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
|
||||
@@ -11,41 +11,125 @@ import PageHeader from "@/components/ui/PageHeader";
|
||||
import SearchBar from "@/components/ui/SearchBar";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function ProjectListPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filteredProjects, setFilteredProjects] = useState([]);
|
||||
const [filters, setFilters] = useState({
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
customer: 'all',
|
||||
mine: false,
|
||||
phoneOnly: false
|
||||
});
|
||||
|
||||
const [customers, setCustomers] = useState([]);
|
||||
|
||||
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
|
||||
useEffect(() => {
|
||||
const savedPhoneOnly = localStorage.getItem('projectsPhoneOnlyFilter') === 'true';
|
||||
if (savedPhoneOnly) {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
phoneOnly: savedPhoneOnly
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist
|
||||
const [searchMatchType, setSearchMatchType] = useState(null); // Track what type of match was found
|
||||
|
||||
// Helper function to normalize strings by removing spaces
|
||||
const normalizeString = (str) => {
|
||||
return str?.replace(/\s+/g, '') || '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/projects")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setProjects(data);
|
||||
setFilteredProjects(data);
|
||||
|
||||
// Extract unique customers for filter
|
||||
const uniqueCustomers = [...new Set(data.map(p => p.customer).filter(Boolean))];
|
||||
setCustomers(uniqueCustomers);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Filter projects based on search term
|
||||
// Filter projects based on search term and filters
|
||||
useEffect(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
setFilteredProjects(projects);
|
||||
} else {
|
||||
const filtered = projects.filter((project) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
let filtered = projects;
|
||||
|
||||
// Apply status filter
|
||||
if (filters.status !== 'all') {
|
||||
if (filters.status === 'not_finished') {
|
||||
filtered = filtered.filter(project => project.project_status !== 'fulfilled');
|
||||
} else {
|
||||
filtered = filtered.filter(project => project.project_status === filters.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if (filters.type !== 'all') {
|
||||
filtered = filtered.filter(project => project.project_type === filters.type);
|
||||
}
|
||||
|
||||
// Apply customer filter
|
||||
if (filters.customer !== 'all') {
|
||||
filtered = filtered.filter(project => project.customer === filters.customer);
|
||||
}
|
||||
|
||||
// Apply mine filter
|
||||
if (filters.mine && session?.user?.id) {
|
||||
filtered = filtered.filter(project => project.assigned_to === session.user.id);
|
||||
}
|
||||
|
||||
// Apply search term
|
||||
if (searchTerm.trim()) {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const searchNormalized = normalizeString(searchLower);
|
||||
let hasContactMatch = false;
|
||||
|
||||
filtered = filtered.map((project) => {
|
||||
const isContactMatch = normalizeString(project.contact?.toLowerCase()).includes(searchNormalized);
|
||||
if (isContactMatch) hasContactMatch = true;
|
||||
|
||||
// Add a flag to mark projects that matched on contact
|
||||
return {
|
||||
...project,
|
||||
_matchedOnContact: isContactMatch
|
||||
};
|
||||
}).filter((project) => {
|
||||
const baseMatches =
|
||||
project.project_name?.toLowerCase().includes(searchLower) ||
|
||||
project.wp?.toLowerCase().includes(searchLower) ||
|
||||
project.plot?.toLowerCase().includes(searchLower) ||
|
||||
project.investment_number?.toLowerCase().includes(searchLower) ||
|
||||
project.address?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
project.address?.toLowerCase().includes(searchLower) ||
|
||||
project.customer?.toLowerCase().includes(searchLower) ||
|
||||
project.investor?.toLowerCase().includes(searchLower);
|
||||
|
||||
// Include contact matches only if phoneOnly is enabled
|
||||
const contactMatch = filters.phoneOnly ? project._matchedOnContact : false;
|
||||
|
||||
return baseMatches || contactMatch;
|
||||
});
|
||||
setFilteredProjects(filtered);
|
||||
|
||||
setSearchMatchType(hasContactMatch ? 'contact' : null);
|
||||
} else {
|
||||
setSearchMatchType(null);
|
||||
}
|
||||
}, [searchTerm, projects]);
|
||||
|
||||
setFilteredProjects(filtered);
|
||||
}, [searchTerm, projects, filters, session]);
|
||||
|
||||
async function handleDelete(id) {
|
||||
const confirmed = confirm("Are you sure you want to delete this project?");
|
||||
const confirmed = confirm(t('projects.deleteConfirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
const res = await fetch(`/api/projects/${id}`, {
|
||||
@@ -59,32 +143,100 @@ export default function ProjectListPage() {
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterType, value) => {
|
||||
setFilters(prev => {
|
||||
const newFilters = {
|
||||
...prev,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
// Save phoneOnly filter to localStorage
|
||||
if (filterType === 'phoneOnly') {
|
||||
localStorage.setItem('projectsPhoneOnlyFilter', value.toString());
|
||||
}
|
||||
|
||||
return newFilters;
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setFilters(prev => ({
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
customer: 'all',
|
||||
mine: false,
|
||||
phoneOnly: prev.phoneOnly // Preserve phone toggle state
|
||||
}));
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects/export');
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
alert('Failed to export projects. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
alert('An error occurred during export. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFilters = () => {
|
||||
setFiltersExpanded(!filtersExpanded);
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm.trim() !== '';
|
||||
|
||||
const getActiveFilterCount = () => {
|
||||
let count = 0;
|
||||
if (filters.status !== 'all') count++;
|
||||
if (filters.type !== 'all') count++;
|
||||
if (filters.customer !== 'all') count++;
|
||||
if (filters.mine) count++;
|
||||
if (searchTerm.trim()) count++;
|
||||
return count;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
switch(status) {
|
||||
case "registered": return t('projectStatus.registered');
|
||||
case "in_progress_design": return t('projectStatus.in_progress_design');
|
||||
case "in_progress_construction": return t('projectStatus.in_progress_construction');
|
||||
case "fulfilled": return t('projectStatus.fulfilled');
|
||||
case "cancelled": return t('projectStatus.cancelled');
|
||||
default: return "-";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
switch(type) {
|
||||
case "design": return t('projectType.design');
|
||||
case "construction": return t('projectType.construction');
|
||||
case "design+construction": return t('projectType.design+construction');
|
||||
default: return "-";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Projects" description="Manage and track your projects">
|
||||
<div className="flex gap-2">
|
||||
<Link href="/projects/map">
|
||||
<Button variant="outline" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
Map View
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/projects/new">
|
||||
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Primary Action - New Project */}
|
||||
<Link href="/projects/new" className="flex-shrink-0">
|
||||
<Button variant="primary" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
className="w-5 h-5 sm:mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -96,19 +248,378 @@ export default function ProjectListPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Project
|
||||
<span className="hidden sm:inline">{t('projects.newProject')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1"></div>
|
||||
|
||||
{/* Utility Actions - Icon Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/projects/map" title={t('projects.mapView') || 'Widok mapy'}>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={handleExportExcel}
|
||||
title={t('projects.exportExcel') || 'Export to Excel'}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Search by project name, WP, plot, or investment number..."
|
||||
placeholder={t('projects.searchPlaceholder')}
|
||||
resultsCount={filteredProjects.length}
|
||||
resultsText="projects"
|
||||
resultsText={t('projects.projects') || 'projektów'}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
{/* Mobile collapsible header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 transition-colors md:hidden"
|
||||
onClick={toggleFilters}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform duration-200 ${filtersExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
{t('common.filters') || 'Filtry'}
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{getActiveFilterCount()}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearAllFilters();
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
{t('common.clearAll') || 'Wyczyść'}
|
||||
</Button>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile collapsible content */}
|
||||
<div className={`overflow-hidden transition-all duration-300 ease-in-out md:hidden ${filtersExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className="px-4 pb-4 border-t border-gray-100">
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:flex-wrap md:gap-4 md:space-y-0 md:items-center pt-4">
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
|
||||
{t('common.status') || 'Status'}:
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
|
||||
>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
|
||||
<option value="registered">{t('projectStatus.registered')}</option>
|
||||
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
|
||||
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
|
||||
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
|
||||
{t('common.type') || 'Typ'}:
|
||||
</label>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => handleFilterChange('type', e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
|
||||
>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="design">{t('projectType.design')}</option>
|
||||
<option value="construction">{t('projectType.construction')}</option>
|
||||
<option value="design+construction">{t('projectType.design+construction')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
|
||||
{t('contracts.customer') || 'Klient'}:
|
||||
</label>
|
||||
<select
|
||||
value={filters.customer}
|
||||
onChange={(e) => handleFilterChange('customer', e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
|
||||
>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer} value={customer}>
|
||||
{customer}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
|
||||
className={`
|
||||
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
|
||||
${filters.phoneOnly
|
||||
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{session?.user && (
|
||||
<button
|
||||
onClick={() => handleFilterChange('mine', !filters.mine)}
|
||||
className={`
|
||||
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
|
||||
${filters.mine
|
||||
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('projects.mine') || 'Tylko moje'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop always visible content */}
|
||||
<div className="hidden md:block">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-3">
|
||||
{/* Filters row */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{t('common.status') || 'Status'}:
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
|
||||
<option value="registered">{t('projectStatus.registered')}</option>
|
||||
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
|
||||
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
|
||||
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{t('common.type') || 'Typ'}:
|
||||
</label>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => handleFilterChange('type', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
<option value="design">{t('projectType.design')}</option>
|
||||
<option value="construction">{t('projectType.construction')}</option>
|
||||
<option value="design+construction">{t('projectType.design+construction')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{t('contracts.customer') || 'Klient'}:
|
||||
</label>
|
||||
<select
|
||||
value={filters.customer}
|
||||
onChange={(e) => handleFilterChange('customer', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">{t('common.all')}</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer} value={customer}>
|
||||
{customer}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
|
||||
className={`
|
||||
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
|
||||
${filters.phoneOnly
|
||||
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{session?.user && (
|
||||
<button
|
||||
onClick={() => handleFilterChange('mine', !filters.mine)}
|
||||
className={`
|
||||
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
|
||||
${filters.mine
|
||||
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('projects.mine') || 'Tylko moje'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results and clear button row */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
|
||||
</div>
|
||||
|
||||
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
className="text-xs"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.clearAllFilters') || 'Wyczyść wszystkie filtry'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{filteredProjects.length === 0 && searchTerm ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
@@ -126,14 +637,13 @@ export default function ProjectListPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No projects found
|
||||
{t('common.noResults')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
No projects match your search criteria. Try adjusting your search
|
||||
terms.
|
||||
{t('projects.noMatchingResults') || 'Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.'}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setSearchTerm("")}>
|
||||
Clear Search
|
||||
{t('common.clearSearch') || 'Wyczyść wyszukiwanie'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -154,160 +664,170 @@ export default function ProjectListPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No projects yet
|
||||
{t('projects.noProjects')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Get started by creating your first project
|
||||
{t('projects.noProjectsMessage')}
|
||||
</p>
|
||||
<Link href="/projects/new">
|
||||
<Button variant="primary">Create First Project</Button>
|
||||
<Button variant="primary">{t('projects.createFirstProject') || 'Utwórz pierwszy projekt'}</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b">
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-32">
|
||||
No.
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700">
|
||||
Project Name
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40">
|
||||
WP
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
|
||||
City
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40">
|
||||
Address
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
|
||||
Plot
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Finish
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-12">
|
||||
Type
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Created By
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||
Assigned To
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
{/* Mobile scroll container */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[600px] table-fixed">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-700 border-b dark:border-gray-600">
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24">
|
||||
Nr.
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-[200px] md:w-[250px]">
|
||||
{t('projects.projectName')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24 hidden lg:table-cell">
|
||||
{t('projects.address')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-16 md:w-20 hidden sm:table-cell">
|
||||
WP
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden md:table-cell">
|
||||
{t('projects.city')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden sm:table-cell">
|
||||
{t('projects.plot')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-18 md:w-20 hidden md:table-cell">
|
||||
{t('projects.finishDate')}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
|
||||
{t('common.type') || 'Typ'}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
|
||||
{t('common.status') || 'Status'}
|
||||
</th>
|
||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16">
|
||||
{t('projects.assigned') || 'Przypisany'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredProjects.map((project, index) => (
|
||||
<tr
|
||||
key={project.project_id}
|
||||
className={`border-b hover:bg-gray-50 transition-colors ${
|
||||
index % 2 === 0 ? "bg-white" : "bg-gray-25"
|
||||
}`}
|
||||
className={`border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
|
||||
index % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-25 dark:bg-gray-750"
|
||||
} ${project._matchedOnContact && filters.phoneOnly ? 'border-l-4 border-l-blue-500' : ''}`}
|
||||
>
|
||||
<td className="px-2 py-3">
|
||||
<Badge variant="primary" size="sm" className="text-xs">
|
||||
<td className="px-1 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="secondary" size="sm" className="text-xs">
|
||||
{project.project_number}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
{project._matchedOnContact && filters.phoneOnly && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full"
|
||||
title={t('projects.contactNumberMatch') || 'Znaleziono numer kontaktowy'}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3 w-[200px] md:w-[250px]">
|
||||
<Link
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block"
|
||||
title={project.project_name}
|
||||
>
|
||||
{project.project_name}
|
||||
<span className="block sm:hidden">
|
||||
{project.project_name.length > 20
|
||||
? `${project.project_name.substring(0, 20)}...`
|
||||
: project.project_name}
|
||||
</span>
|
||||
<span className="hidden sm:block">
|
||||
{project.project_name}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
title={project.wp}
|
||||
>
|
||||
{project.wp || "N/A"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
title={project.city}
|
||||
>
|
||||
{project.city || "N/A"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden lg:table-cell"
|
||||
title={project.address}
|
||||
>
|
||||
{project.address || "N/A"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
|
||||
title={project.wp}
|
||||
>
|
||||
{project.wp || "N/A"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
|
||||
title={project.city}
|
||||
>
|
||||
{project.city || "N/A"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
|
||||
title={project.plot}
|
||||
>
|
||||
{project.plot || "N/A"}
|
||||
</td>{" "}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
|
||||
title={project.finish_date}
|
||||
>
|
||||
{project.finish_date
|
||||
? formatDate(project.finish_date)
|
||||
: "N/A"}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-xs text-gray-600 text-center font-semibold">
|
||||
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 text-center font-semibold">
|
||||
{project.project_type === "design"
|
||||
? "P"
|
||||
: project.project_type === "construction"
|
||||
? "R"
|
||||
? "B"
|
||||
: project.project_type === "design+construction"
|
||||
? "P+R"
|
||||
? "P+B"
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-2 py-3 text-xs text-gray-600 truncate">
|
||||
{project.project_status === "registered"
|
||||
? "Zarejestr."
|
||||
: project.project_status === "in_progress_design"
|
||||
? "W real. (P)"
|
||||
: project.project_status === "in_progress_construction"
|
||||
? "W real. (R)"
|
||||
: project.project_status === "fulfilled"
|
||||
? "Zakończony"
|
||||
: "-"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
title={project.created_by_name || "Unknown"}
|
||||
>
|
||||
{project.created_by_name || "Unknown"}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||
title={project.assigned_to_name || "Unassigned"}
|
||||
>
|
||||
{project.assigned_to_name || "Unassigned"}
|
||||
<td className="px-2 py-3 text-xs text-gray-600">
|
||||
<div className="flex justify-center items-center h-full">
|
||||
{project.project_status === 'registered' ? (
|
||||
<span className="text-red-500 font-bold text-sm" title={t('projectStatus.registered')}>N</span>
|
||||
) : project.project_status === 'in_progress_design' ? (
|
||||
<span className="inline-block w-3 h-3 bg-blue-500 rounded-full" title={t('projectStatus.in_progress_design')}></span>
|
||||
) : project.project_status === 'in_progress_construction' ? (
|
||||
<span className="inline-block w-3 h-3 bg-yellow-400 rounded-full" title={t('projectStatus.in_progress_construction')}></span>
|
||||
) : project.project_status === 'fulfilled' ? (
|
||||
<span className="inline-block w-3 h-3 bg-green-500 rounded-full" title={t('projectStatus.fulfilled')}></span>
|
||||
) : project.project_status === 'cancelled' ? (
|
||||
<span className="text-red-500 font-bold text-lg" title={t('projectStatus.cancelled')}>×</span>
|
||||
) : (
|
||||
<span title="Unknown status">-</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3">
|
||||
<Link href={`/projects/${project.project_id}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs px-2 py-1"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
{project.assigned_to_initial ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs font-semibold">
|
||||
{project.assigned_to_initial}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
77
src/app/settings/page.js
Normal file
77
src/app/settings/page.js
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import ThemeToggle from "@/components/ui/ThemeToggle";
|
||||
import LanguageSwitcher from "@/components/ui/LanguageSwitcher";
|
||||
import PasswordReset from "@/components/settings/PasswordReset";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title={t('settings.title') || 'Settings'} />
|
||||
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Appearance Settings */}
|
||||
<Card className="hidden">
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.appearance') || 'Appearance'}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('settings.theme') || 'Theme'}
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.themeDescription') || 'Choose your preferred theme'}
|
||||
</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Language Settings */}
|
||||
<Card className="hidden">
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.language') || 'Language'}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('settings.language') || 'Language'}
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('settings.languageDescription') || 'Select your preferred language'}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Password Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.password') || 'Password'}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordReset />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
245
src/app/task-sets/[id]/apply/page.js
Normal file
245
src/app/task-sets/[id]/apply/page.js
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ApplyTaskSetPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const setId = params.id;
|
||||
|
||||
const [taskSet, setTaskSet] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch task set
|
||||
const setResponse = await fetch(`/api/task-sets/${setId}`);
|
||||
if (setResponse.ok) {
|
||||
const setData = await setResponse.json();
|
||||
setTaskSet(setData);
|
||||
} else {
|
||||
console.error('Failed to fetch task set');
|
||||
router.push('/task-sets');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch projects
|
||||
const projectsResponse = await fetch('/api/projects');
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
setProjects(projectsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (setId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [setId, router]);
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!selectedProject) {
|
||||
alert("Wybierz projekt");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplying(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/task-sets/${setId}/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: parseInt(selectedProject) })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
alert(`Zestaw zadań został pomyślnie zastosowany. Utworzono ${result.createdTaskIds.length} zadań.`);
|
||||
router.push(`/projects/${selectedProject}`);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Błąd: ${error.details || 'Nie udało się zastosować zestawu zadań'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying task set:', error);
|
||||
alert('Wystąpił błąd podczas stosowania zestawu zadań');
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Zastosuj zestaw zadań" />
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Ładowanie...</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskSet) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Zastosuj zestaw zadań" />
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Zastosuj zestaw zadań"
|
||||
description={`Zastosuj zestaw "${taskSet.name}" do projektu`}
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl">
|
||||
{/* Task set info */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Informacje o zestawie</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{taskSet.name}</div>
|
||||
{taskSet.description && (
|
||||
<div className="text-sm text-gray-600 mt-1">{taskSet.description}</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500 mt-2">
|
||||
Typ projektu: <span className="capitalize">{taskSet.project_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
Zawarte szablony zadań ({taskSet.templates?.length || 0}):
|
||||
</div>
|
||||
{taskSet.templates && taskSet.templates.length > 0 ? (
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{taskSet.templates.map((template, index) => (
|
||||
<li key={template.task_id} className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">{index + 1}.</span>
|
||||
{template.name}
|
||||
{template.max_wait_days > 0 && (
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
({template.max_wait_days} dni)
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Brak szablonów w zestawie</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project selection */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Wybierz projekt</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wybierz projekt, do którego chcesz zastosować ten zestaw zadań
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Projekt *
|
||||
</label>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Wybierz projekt...</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.project_id} value={project.project_id}>
|
||||
{project.project_name} - {project.city} ({project.project_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<p className="text-gray-500 text-sm">
|
||||
Brak dostępnych projektów dla tego typu zestawu zadań.
|
||||
{taskSet.project_type !== 'design+construction' &&
|
||||
" Spróbuj utworzyć projekt typu 'Projekt + Budowa' lub zmienić typ zestawu."
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Warning */}
|
||||
<Card className="mb-6 bg-yellow-50 border-yellow-200">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Informacja
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
Zastosowanie tego zestawu utworzy {taskSet.templates?.length || 0} nowych zadań w wybranym projekcie.
|
||||
Zadania będą miały status "Oczekujące" i zostaną przypisane zgodnie z domyślnymi regułami.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.back()}
|
||||
disabled={isApplying}
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleApply}
|
||||
disabled={isApplying || !selectedProject}
|
||||
>
|
||||
{isApplying ? "Stosowanie..." : "Zastosuj zestaw"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
354
src/app/task-sets/[id]/page.js
Normal file
354
src/app/task-sets/[id]/page.js
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function EditTaskSetPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const setId = params.id;
|
||||
|
||||
const [taskTemplates, setTaskTemplates] = useState([]);
|
||||
const [taskSet, setTaskSet] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
project_type: "design",
|
||||
selectedTemplates: []
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch task set
|
||||
const setResponse = await fetch(`/api/task-sets/${setId}`);
|
||||
if (setResponse.ok) {
|
||||
const setData = await setResponse.json();
|
||||
setTaskSet(setData);
|
||||
setFormData({
|
||||
name: setData.name,
|
||||
description: setData.description || "",
|
||||
project_type: setData.project_type,
|
||||
selectedTemplates: setData.templates?.map(t => t.task_id) || []
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to fetch task set');
|
||||
router.push('/task-sets');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch available task templates
|
||||
const templatesResponse = await fetch('/api/tasks/templates');
|
||||
if (templatesResponse.ok) {
|
||||
const templatesData = await templatesResponse.json();
|
||||
setTaskTemplates(templatesData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (setId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [setId, router]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert("Nazwa zestawu jest wymagana");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.selectedTemplates.length === 0) {
|
||||
alert("Wybierz przynajmniej jeden szablon zadania");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Update the task set
|
||||
const updateData = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
task_category: formData.task_category,
|
||||
templates: formData.selectedTemplates.map((templateId, index) => ({
|
||||
task_id: templateId,
|
||||
sort_order: index
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/task-sets/${setId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update task set');
|
||||
}
|
||||
|
||||
router.push('/task-sets');
|
||||
} catch (error) {
|
||||
console.error('Error updating task set:', error);
|
||||
alert('Wystąpił błąd podczas aktualizacji zestawu zadań');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Czy na pewno chcesz usunąć ten zestaw zadań?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/task-sets/${setId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/task-sets');
|
||||
} else {
|
||||
alert('Wystąpił błąd podczas usuwania zestawu zadań');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting task set:', error);
|
||||
alert('Wystąpił błąd podczas usuwania zestawu zadań');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTemplate = (templateId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedTemplates: prev.selectedTemplates.includes(templateId)
|
||||
? prev.selectedTemplates.filter(id => id !== templateId)
|
||||
: [...prev.selectedTemplates, templateId]
|
||||
}));
|
||||
};
|
||||
|
||||
const moveTemplate = (fromIndex, toIndex) => {
|
||||
const newSelected = [...formData.selectedTemplates];
|
||||
const [moved] = newSelected.splice(fromIndex, 1);
|
||||
newSelected.splice(toIndex, 0, moved);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedTemplates: newSelected
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Edycja zestawu zadań" />
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Ładowanie...</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskSet) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Edycja zestawu zadań" />
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Edycja zestawu zadań"
|
||||
description={`Edytuj zestaw: ${taskSet.name}`}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nazwa zestawu *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="np. Standardowe zadania projektowe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Opis
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Opcjonalny opis zestawu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kategoria zadań *
|
||||
</label>
|
||||
<select
|
||||
value={formData.task_category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, task_category: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="design">Zadania projektowe</option>
|
||||
<option value="construction">Zadania budowlane</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Template selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wybrano: {formData.selectedTemplates.length} szablonów
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formData.selectedTemplates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{formData.selectedTemplates.map((templateId, index) => {
|
||||
const template = taskTemplates.find(t => t.task_id === templateId);
|
||||
return (
|
||||
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
|
||||
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => index > 0 && moveTemplate(index, index - 1)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={index === formData.selectedTemplates.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTemplate(templateId)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Available templates */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wybierz szablony, które chcesz dodać do zestawu
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{taskTemplates.map((template) => (
|
||||
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selectedTemplates.includes(template.task_id)}
|
||||
onChange={() => toggleTemplate(template.task_id)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{template.name}</div>
|
||||
{template.description && (
|
||||
<div className="text-xs text-gray-600">{template.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{taskTemplates.length === 0 && (
|
||||
<p className="text-gray-500 text-sm text-center py-4">
|
||||
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Usuń zestaw
|
||||
</Button>
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Zapisywanie..." : "Zapisz zmiany"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
289
src/app/task-sets/new/page.js
Normal file
289
src/app/task-sets/new/page.js
Normal file
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function NewTaskSetPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [taskTemplates, setTaskTemplates] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
task_category: "design",
|
||||
selectedTemplates: []
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch available task templates
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks/templates');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTaskTemplates(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching templates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert("Nazwa zestawu jest wymagana");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.selectedTemplates.length === 0) {
|
||||
alert("Wybierz przynajmniej jeden szablon zadania");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Create the task set
|
||||
const createResponse = await fetch('/api/task-sets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
task_category: formData.task_category
|
||||
})
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error('Failed to create task set');
|
||||
}
|
||||
|
||||
const { id: setId } = await createResponse.json();
|
||||
|
||||
// Add templates to the set
|
||||
const templatesData = {
|
||||
templates: formData.selectedTemplates.map((templateId, index) => ({
|
||||
task_id: templateId,
|
||||
sort_order: index
|
||||
}))
|
||||
};
|
||||
|
||||
const updateResponse = await fetch(`/api/task-sets/${setId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(templatesData)
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error('Failed to add templates to task set');
|
||||
}
|
||||
|
||||
router.push('/task-sets');
|
||||
} catch (error) {
|
||||
console.error('Error creating task set:', error);
|
||||
alert('Wystąpił błąd podczas tworzenia zestawu zadań');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTemplate = (templateId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedTemplates: prev.selectedTemplates.includes(templateId)
|
||||
? prev.selectedTemplates.filter(id => id !== templateId)
|
||||
: [...prev.selectedTemplates, templateId]
|
||||
}));
|
||||
};
|
||||
|
||||
const moveTemplate = (fromIndex, toIndex) => {
|
||||
const newSelected = [...formData.selectedTemplates];
|
||||
const [moved] = newSelected.splice(fromIndex, 1);
|
||||
newSelected.splice(toIndex, 0, moved);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedTemplates: newSelected
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Nowy zestaw zadań"
|
||||
description="Utwórz nowy zestaw zadań z szablonów"
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nazwa zestawu *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="np. Standardowe zadania projektowe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Opis
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Opcjonalny opis zestawu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kategoria zadań *
|
||||
</label>
|
||||
<select
|
||||
value={formData.task_category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, task_category: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="design">Zadania projektowe</option>
|
||||
<option value="construction">Zadania budowlane</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Template selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wybrano: {formData.selectedTemplates.length} szablonów
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formData.selectedTemplates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{formData.selectedTemplates.map((templateId, index) => {
|
||||
const template = taskTemplates.find(t => t.task_id === templateId);
|
||||
return (
|
||||
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
|
||||
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => index > 0 && moveTemplate(index, index - 1)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
disabled={index === formData.selectedTemplates.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTemplate(templateId)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Available templates */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wybierz szablony, które chcesz dodać do zestawu
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{taskTemplates.map((template) => (
|
||||
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selectedTemplates.includes(template.task_id)}
|
||||
onChange={() => toggleTemplate(template.task_id)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{template.name}</div>
|
||||
{template.description && (
|
||||
<div className="text-xs text-gray-600">{template.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{taskTemplates.length === 0 && (
|
||||
<p className="text-gray-500 text-sm text-center py-4">
|
||||
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-4 mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Tworzenie..." : "Utwórz zestaw"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
189
src/app/task-sets/page.js
Normal file
189
src/app/task-sets/page.js
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function TaskSetsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [taskSets, setTaskSets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTaskSets = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/task-sets');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTaskSets(data);
|
||||
} else {
|
||||
console.error('Failed to fetch task sets');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task sets:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTaskSets();
|
||||
}, []);
|
||||
|
||||
const filteredTaskSets = taskSets.filter(taskSet => {
|
||||
if (filter === "all") return true;
|
||||
return taskSet.task_category === filter;
|
||||
});
|
||||
|
||||
const getTaskCategoryBadge = (taskCategory) => {
|
||||
const colors = {
|
||||
design: "bg-blue-100 text-blue-800",
|
||||
construction: "bg-green-100 text-green-800"
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={colors[taskCategory] || "bg-gray-100 text-gray-800"}>
|
||||
{taskCategory === "design" ? "Zadania projektowe" : taskCategory === "construction" ? "Zadania budowlane" : taskCategory}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Zestawy zadań"
|
||||
description="Zarządzaj zestawami zadań"
|
||||
/>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Ładowanie...</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Zestawy zadań"
|
||||
description="Zarządzaj zestawami zadań"
|
||||
action={
|
||||
<Link href="/task-sets/new">
|
||||
<Button variant="primary" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Nowy zestaw
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<div className="mb-6">
|
||||
<div className="flex space-x-2">
|
||||
{["all", "design", "construction"].map(type => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filter === type ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setFilter(type)}
|
||||
>
|
||||
{type === "all" ? "Wszystkie" :
|
||||
type === "design" ? "Projektowanie" :
|
||||
"Budowa"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task sets grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTaskSets.map((taskSet) => (
|
||||
<Card key={taskSet.set_id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{taskSet.name}
|
||||
</h3>
|
||||
{taskSet.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{taskSet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{getTaskCategoryBadge(taskSet.task_category)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Szablony zadań:</span>{" "}
|
||||
{taskSet.templates?.length || 0}
|
||||
</div>
|
||||
|
||||
{taskSet.templates && taskSet.templates.length > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{taskSet.templates.slice(0, 3).map((template) => (
|
||||
<li key={template.task_id}>{template.name}</li>
|
||||
))}
|
||||
{taskSet.templates.length > 3 && (
|
||||
<li>...i {taskSet.templates.length - 3} więcej</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2 pt-2">
|
||||
<Link href={`/task-sets/${taskSet.set_id}`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
Edytuj
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/task-sets/${taskSet.set_id}/apply`}>
|
||||
<Button variant="primary" size="sm">
|
||||
Zastosuj
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredTaskSets.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500 mb-4">
|
||||
{filter === "all"
|
||||
? "Brak zestawów zadań"
|
||||
: `Brak zestawów zadań dla typu "${filter}"`
|
||||
}
|
||||
</div>
|
||||
<Link href="/task-sets/new">
|
||||
<Button variant="primary">
|
||||
Utwórz pierwszy zestaw
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,20 @@ import Badge from "@/components/ui/Badge";
|
||||
import TaskStatusDropdownSimple from "@/components/TaskStatusDropdownSimple";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { pl, enUS } from "date-fns/locale";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import SearchBar from "@/components/ui/SearchBar";
|
||||
import FilterBar from "@/components/ui/FilterBar";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectTasksPage() {
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
// Get locale for date-fns
|
||||
const locale = language === 'pl' ? pl : enUS;
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [filteredTasks, setFilteredTasks] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -148,25 +154,25 @@ export default function ProjectTasksPage() {
|
||||
|
||||
const filterOptions = [
|
||||
{
|
||||
label: "Status",
|
||||
label: t('tasks.status'),
|
||||
value: statusFilter,
|
||||
onChange: (e) => setStatusFilter(e.target.value),
|
||||
options: [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "all", label: t('common.all') },
|
||||
{ value: "pending", label: t('taskStatus.pending') },
|
||||
{ value: "in_progress", label: t('taskStatus.in_progress') },
|
||||
{ value: "completed", label: t('taskStatus.completed') },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Priority",
|
||||
label: t('tasks.priority'),
|
||||
value: priorityFilter,
|
||||
onChange: (e) => setPriorityFilter(e.target.value),
|
||||
options: [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "all", label: t('common.all') },
|
||||
{ value: "high", label: t('tasks.high') },
|
||||
{ value: "normal", label: t('tasks.medium') },
|
||||
{ value: "low", label: t('tasks.low') },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -174,8 +180,8 @@ export default function ProjectTasksPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Project Tasks"
|
||||
description="Monitor and manage tasks across all projects"
|
||||
title={t('tasks.title')}
|
||||
description={t('tasks.subtitle')}
|
||||
/>
|
||||
<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
@@ -206,7 +212,7 @@ export default function ProjectTasksPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Tasks</p>
|
||||
<p className="text-sm font-medium text-gray-600">{t('dashboard.totalTasks')}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{statusCounts.all}
|
||||
</p>
|
||||
@@ -233,7 +239,7 @@ export default function ProjectTasksPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p className="text-sm font-medium text-gray-600">{t('taskStatus.pending')}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{statusCounts.pending}
|
||||
</p>
|
||||
@@ -260,7 +266,7 @@ export default function ProjectTasksPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">In Progress</p>
|
||||
<p className="text-sm font-medium text-gray-600">{t('taskStatus.in_progress')}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{statusCounts.in_progress}
|
||||
</p>
|
||||
@@ -287,7 +293,7 @@ export default function ProjectTasksPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Completed</p>
|
||||
<p className="text-sm font-medium text-gray-600">{t('taskStatus.completed')}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{statusCounts.completed}
|
||||
</p>
|
||||
@@ -379,6 +385,7 @@ export default function ProjectTasksPage() {
|
||||
Added{" "}
|
||||
{formatDistanceToNow(parseISO(task.date_added), {
|
||||
addSuffix: true,
|
||||
locale: locale
|
||||
})}
|
||||
</span>
|
||||
{task.max_wait_days > 0 && (
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import db from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TaskTemplateForm from "@/components/TaskTemplateForm";
|
||||
import EditTaskTemplateClient from "@/components/EditTaskTemplateClient";
|
||||
|
||||
export default async function EditTaskTemplatePage({ params }) {
|
||||
const { id } = await params;
|
||||
@@ -16,52 +13,5 @@ export default async function EditTaskTemplatePage({ params }) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/tasks/templates">
|
||||
<Button variant="outline" size="sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Templates
|
||||
</Button>
|
||||
</Link>{" "}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Edit Task Template
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Update the details for “{template.name}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Template Details
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Modify the template information below
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskTemplateForm templateId={params.id} initialData={template} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <EditTaskTemplateClient template={template} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import TaskTemplateForm from "@/components/TaskTemplateForm";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function NewTaskTemplatePage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-4 max-w-xl mx-auto">
|
||||
<h1 className="text-xl font-bold mb-4">New Task Template</h1>
|
||||
<h1 className="text-xl font-bold mb-4">{t('taskTemplates.newTemplate')}</h1>
|
||||
<TaskTemplateForm />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,111 @@
|
||||
import db from "@/lib/db";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function TaskTemplatesPage() {
|
||||
const templates = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM tasks WHERE is_standard = 1 ORDER BY name ASC
|
||||
`
|
||||
)
|
||||
.all();
|
||||
const { t } = useTranslation();
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks/templates');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTemplates(data);
|
||||
} else {
|
||||
console.error('Failed to fetch templates');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching templates:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const getTaskCategoryBadge = (taskCategory) => {
|
||||
const colors = {
|
||||
design: "bg-blue-100 text-blue-800",
|
||||
construction: "bg-green-100 text-green-800"
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${colors[taskCategory] || "bg-gray-100 text-gray-800"}`}>
|
||||
{taskCategory === "design" ? "Projektowe" : taskCategory === "construction" ? "Budowlane" : taskCategory}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={t('taskTemplates.title')}
|
||||
description={t('taskTemplates.subtitle')}
|
||||
actions={[
|
||||
<Link href="/tasks/templates/new" key="new-template">
|
||||
<Button variant="primary" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{t('taskTemplates.newTemplate')}
|
||||
</Button>
|
||||
</Link>,
|
||||
<Link href="/task-sets" key="task-sets">
|
||||
<Button variant="secondary" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
Zestawy zadań
|
||||
</Button>
|
||||
</Link>
|
||||
]}
|
||||
/>
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">{t('common.loading')}</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Task Templates"
|
||||
description="Manage reusable task templates"
|
||||
action={
|
||||
<Link href="/tasks/templates/new">
|
||||
title={t('taskTemplates.title')}
|
||||
description={t('taskTemplates.subtitle')}
|
||||
actions={[
|
||||
<Link href="/tasks/templates/new" key="new-template">
|
||||
<Button variant="primary" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
@@ -35,10 +120,28 @@ export default function TaskTemplatesPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Template
|
||||
{t('taskTemplates.newTemplate')}
|
||||
</Button>
|
||||
</Link>,
|
||||
<Link href="/task-sets" key="task-sets">
|
||||
<Button variant="secondary" size="lg">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
Zestawy zadań
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
@@ -58,13 +161,13 @@ export default function TaskTemplatesPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No task templates yet
|
||||
{t('taskTemplates.noTemplates')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Create reusable task templates to streamline your workflow
|
||||
{t('taskTemplates.noTemplatesMessage')}
|
||||
</p>
|
||||
<Link href="/tasks/templates/new">
|
||||
<Button variant="primary">Create First Template</Button>
|
||||
<Button variant="primary">{t('taskTemplates.newTemplate')}</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -80,29 +183,24 @@ export default function TaskTemplatesPage() {
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate pr-2">
|
||||
{template.name}
|
||||
</h3>
|
||||
<Badge variant="primary" size="sm">
|
||||
{template.max_wait_days} days
|
||||
</Badge>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="primary" size="sm">
|
||||
{template.max_wait_days} {t('common.days')}
|
||||
</Badge>
|
||||
{getTaskCategoryBadge(template.task_category)}
|
||||
</div>
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}{" "}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
Template ID: {template.task_id}
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<Link href={`/tasks/templates/${template.task_id}/edit`}>
|
||||
<Button variant="outline" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="secondary" size="sm">
|
||||
Duplicate
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/tasks/templates/${template.task_id}/edit`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{t('taskTemplates.editTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -132,7 +132,17 @@ export default function AuditLogViewer() {
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
try {
|
||||
return format(new Date(timestamp), "yyyy-MM-dd HH:mm:ss");
|
||||
const date = new Date(timestamp);
|
||||
// Format in Polish timezone
|
||||
return date.toLocaleString("pl-PL", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
@@ -280,7 +290,7 @@ export default function AuditLogViewer() {
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
{stats && stats.total > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Total Events</h3>
|
||||
@@ -289,22 +299,22 @@ export default function AuditLogViewer() {
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Top Action</h3>
|
||||
<p className="text-sm font-medium">
|
||||
{stats.actionBreakdown[0]?.action || "N/A"}
|
||||
{stats.actionBreakdown && stats.actionBreakdown[0]?.action || "N/A"}
|
||||
</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{stats.actionBreakdown[0]?.count || 0}
|
||||
{stats.actionBreakdown && stats.actionBreakdown[0]?.count || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Active Users</h3>
|
||||
<p className="text-2xl font-bold text-purple-600">
|
||||
{stats.userBreakdown.length}
|
||||
{stats.userBreakdown ? stats.userBreakdown.length : 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-semibold">Resource Types</h3>
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{stats.resourceBreakdown.length}
|
||||
{stats.resourceBreakdown ? stats.resourceBreakdown.length : 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,43 +328,43 @@ export default function AuditLogViewer() {
|
||||
)}
|
||||
|
||||
{/* Audit Logs Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{log.user_name || "Anonymous"}
|
||||
</div>
|
||||
<div className="text-gray-500">{log.user_email}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">{log.user_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
@@ -364,26 +374,26 @@ export default function AuditLogViewer() {
|
||||
{log.action.replace(/_/g, " ").toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{log.resource_type || "N/A"}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
ID: {log.resource_id || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.ip_address || "Unknown"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.details && (
|
||||
<details className="cursor-pointer">
|
||||
<summary className="text-blue-600 hover:text-blue-800">
|
||||
<summary className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
View Details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-gray-100 p-2 rounded overflow-auto max-w-md">
|
||||
<pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded overflow-auto max-w-md">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
@@ -396,7 +406,7 @@ export default function AuditLogViewer() {
|
||||
</div>
|
||||
|
||||
{logs.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No audit logs found matching your criteria.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { formatDateForInput } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ContractForm() {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({
|
||||
contract_number: "",
|
||||
contract_name: "",
|
||||
@@ -42,13 +44,11 @@ export default function ContractForm() {
|
||||
const contract = await res.json();
|
||||
router.push(`/contracts/${contract.contract_id}`);
|
||||
} else {
|
||||
alert(
|
||||
"Failed to create contract. Please check the data and try again."
|
||||
);
|
||||
alert(t('contracts.failedToCreateContract'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating contract:", error);
|
||||
alert("Failed to create contract. Please try again.");
|
||||
alert(t('contracts.failedToCreateContract'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export default function ContractForm() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Contract Details
|
||||
{t('contracts.contractDetails')}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -67,73 +67,73 @@ export default function ContractForm() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contract Number <span className="text-red-500">*</span>
|
||||
{t('contracts.contractNumber')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="contract_number"
|
||||
value={form.contract_number || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contract number"
|
||||
placeholder={t('contracts.enterContractNumber')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contract Name
|
||||
{t('contracts.contractName')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="contract_name"
|
||||
value={form.contract_name || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contract name"
|
||||
placeholder={t('contracts.enterContractName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Customer Contract Number
|
||||
{t('contracts.customerContractNumber')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="customer_contract_number"
|
||||
value={form.customer_contract_number || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter customer contract number"
|
||||
placeholder={t('contracts.enterCustomerContractNumber')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Customer
|
||||
{t('contracts.customer')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="customer"
|
||||
value={form.customer || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter customer name"
|
||||
placeholder={t('contracts.enterCustomerName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Investor
|
||||
{t('contracts.investor')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="investor"
|
||||
value={form.investor || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter investor name"
|
||||
placeholder={t('contracts.enterInvestorName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date Signed
|
||||
{t('contracts.dateSigned')}
|
||||
</label>{" "}
|
||||
<Input
|
||||
type="date"
|
||||
@@ -145,7 +145,7 @@ export default function ContractForm() {
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Finish Date
|
||||
{t('contracts.finishDate')}
|
||||
</label>{" "}
|
||||
<Input
|
||||
type="date"
|
||||
@@ -164,7 +164,7 @@ export default function ContractForm() {
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={loading}>
|
||||
{loading ? (
|
||||
@@ -189,7 +189,7 @@ export default function ContractForm() {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Creating...
|
||||
{t('common.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -206,7 +206,7 @@ export default function ContractForm() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Create Contract
|
||||
{t('contracts.createContract')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
60
src/components/EditTaskTemplateClient.js
Normal file
60
src/components/EditTaskTemplateClient.js
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import TaskTemplateForm from "@/components/TaskTemplateForm";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function EditTaskTemplateClient({ template }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/tasks/templates">
|
||||
<Button variant="outline" size="sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t('taskTemplates.title')}
|
||||
</Button>
|
||||
</Link>{" "}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{t('taskTemplates.editTemplate')}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{t('taskTemplates.subtitle')} “{template.name}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{t('taskTemplates.templateName')}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{t('taskTemplates.subtitle')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskTemplateForm templateId={template.task_id} initialData={template} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/components/FieldWithHistory.js
Normal file
205
src/components/FieldWithHistory.js
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Tooltip from "@/components/ui/Tooltip";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { pl, enUS } from "date-fns/locale";
|
||||
|
||||
export default function FieldWithHistory({
|
||||
tableName,
|
||||
recordId,
|
||||
fieldName,
|
||||
currentValue,
|
||||
displayValue = null,
|
||||
label = null,
|
||||
className = "",
|
||||
}) {
|
||||
const { t, language } = useTranslation();
|
||||
const [hasHistory, setHasHistory] = useState(false);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Get locale for date-fns
|
||||
const locale = language === 'pl' ? pl : enUS;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/field-history?table_name=${tableName}&record_id=${recordId}&field_name=${fieldName}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const historyData = await response.json();
|
||||
setHistory(historyData);
|
||||
setHasHistory(historyData.length > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch field history:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (tableName && recordId && fieldName) {
|
||||
fetchHistory();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tableName, recordId, fieldName]);
|
||||
|
||||
// Format value for display
|
||||
const getDisplayValue = (value) => {
|
||||
if (displayValue !== null) return displayValue;
|
||||
if (value && fieldName.includes("date")) {
|
||||
try {
|
||||
return formatDate(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value || t("common.na");
|
||||
};
|
||||
|
||||
// Create tooltip content
|
||||
const tooltipContent = history.length > 0 && (
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<div className="flex items-center gap-2 font-semibold text-white mb-3 pb-2 border-b border-gray-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{t("common.changeHistory")} ({history.length})
|
||||
</div>
|
||||
{history.map((change, index) => (
|
||||
<div
|
||||
key={change.id}
|
||||
className="text-xs border-b border-gray-700 pb-2 last:border-b-0 hover:bg-gray-800/30 p-2 rounded transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3 mb-2">
|
||||
<div className="flex-1">
|
||||
{/* New Value */}
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<svg className="w-3.5 h-3.5 text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="text-green-400 font-medium text-[10px] uppercase tracking-wide block mb-0.5">
|
||||
{t("common.changedTo")}
|
||||
</span>
|
||||
<span className="text-white font-semibold">
|
||||
{getDisplayValue(change.new_value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Old Value */}
|
||||
{change.old_value && (
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<svg className="w-3.5 h-3.5 text-red-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="text-red-400 font-medium text-[10px] uppercase tracking-wide block mb-0.5">
|
||||
{t("common.from")}
|
||||
</span>
|
||||
<span className="text-gray-300 line-through">
|
||||
{getDisplayValue(change.old_value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changed By */}
|
||||
{change.changed_by_name && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-gray-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="text-xs">
|
||||
{t("common.by")} <span className="text-gray-200 font-medium">{change.changed_by_name}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-gray-400 text-[10px] whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(change.changed_at), {
|
||||
addSuffix: true,
|
||||
locale: locale
|
||||
})}
|
||||
</div>
|
||||
<div className="text-gray-500 text-[9px] mt-0.5">
|
||||
{formatDate(change.changed_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Reason */}
|
||||
{change.change_reason && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-700">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<svg className="w-3 h-3 text-amber-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span className="text-amber-400 font-medium text-[10px] uppercase tracking-wide block mb-0.5">
|
||||
{t("common.reason")}
|
||||
</span>
|
||||
<span className="text-gray-300 text-xs italic">
|
||||
{change.change_reason}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <span className="text-sm font-medium text-gray-500 block mb-1">{label}</span>}
|
||||
<p className="text-gray-900 font-medium">{getDisplayValue(currentValue)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && <span className="text-sm font-medium text-gray-500 block mb-1">{label}</span>}
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-gray-900 font-medium">{getDisplayValue(currentValue)}</p>
|
||||
{hasHistory && (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<div className="relative group cursor-help">
|
||||
{/* History Icon with Badge */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded-full bg-blue-50 hover:bg-blue-100 transition-colors border border-blue-200">
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-blue-600 group-hover:text-blue-700 transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-[10px] font-semibold text-blue-600 group-hover:text-blue-700 transition-colors">
|
||||
{history.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
src/components/FileAttachmentsList.js
Normal file
179
src/components/FileAttachmentsList.js
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function FileAttachmentsList({ entityType, entityId, onFilesChange }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/files?entityType=${entityType}&entityId=${entityId}`);
|
||||
if (response.ok) {
|
||||
const filesData = await response.json();
|
||||
setFiles(filesData);
|
||||
if (onFilesChange) {
|
||||
onFilesChange(filesData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching files:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, [entityType, entityId]);
|
||||
|
||||
const handleDelete = async (fileId) => {
|
||||
if (!confirm(t('contracts.confirmDeleteFile'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/${fileId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setFiles(files.filter(file => file.file_id !== fileId));
|
||||
if (onFilesChange) {
|
||||
onFilesChange(files.filter(file => file.file_id !== fileId));
|
||||
}
|
||||
} else {
|
||||
alert(t('contracts.failedToDeleteFile'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
alert(t('contracts.failedToDeleteFile'));
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType) => {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType === 'application/pdf') {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType.includes('word') || mimeType.includes('document')) {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType.includes('excel') || mimeType.includes('sheet')) {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="animate-spin h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span className="ml-2 text-gray-500">{t('contracts.loadingFiles')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-gray-500">{t('contracts.noDocumentsUploaded')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.file_id}
|
||||
className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
{getFileIcon(file.mime_type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{file.original_filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>{formatFileSize(file.file_size)}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(file.upload_date, { includeTime: true })}</span>
|
||||
</div>
|
||||
{file.description && (
|
||||
<div className="text-xs text-gray-600 mt-1 truncate">
|
||||
{file.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
<a
|
||||
href={`/api/files/${file.file_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{t('contracts.download')}
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(file.file_id)}
|
||||
className="text-red-600 hover:text-red-800 hover:border-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/FileItem.js
Normal file
181
src/components/FileItem.js
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
export default function FileItem({ file, onDelete, onUpdate }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editFilename, setEditFilename] = useState(file.original_filename);
|
||||
const [editDescription, setEditDescription] = useState(file.description || "");
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/files/${file.file_id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
original_filename: editFilename,
|
||||
description: editDescription,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updatedFile = await res.json();
|
||||
onUpdate(updatedFile);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
alert('Błąd podczas aktualizacji pliku');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating file:', error);
|
||||
alert('Błąd podczas aktualizacji pliku');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditFilename(file.original_filename);
|
||||
setEditDescription(file.description || "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '';
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getFileIcon = (filename) => {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (ext === 'pdf') {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else if (['xls', 'xlsx'].includes(ext)) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Nazwa pliku
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editFilename}
|
||||
onChange={(e) => setEditFilename(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Opis (opcjonalny)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Dodaj opis pliku..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
Zapisz
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCancel}>
|
||||
Anuluj
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors group">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
{getFileIcon(file.original_filename)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{file.original_filename}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>{formatFileSize(file.file_size)}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(file.upload_date).toLocaleDateString('pl-PL')}</span>
|
||||
</div>
|
||||
{file.description && (
|
||||
<div className="text-xs text-gray-600 mt-1 truncate">
|
||||
{file.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
<a
|
||||
href={`/api/files/${file.file_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 p-1 rounded"
|
||||
title="Pobierz"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-gray-600 hover:text-gray-800 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Edytuj"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(file.file_id)}
|
||||
className="text-red-600 hover:text-red-800 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Usuń"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/components/FileUploadBox.js
Normal file
137
src/components/FileUploadBox.js
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
export default function FileUploadBox({ projectId, onFileUploaded }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const acceptedTypes = ".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt,.dwg,.zip";
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
const validFiles = Array.from(files).filter(file => {
|
||||
const isValidType = acceptedTypes.split(',').some(type =>
|
||||
file.name.toLowerCase().endsWith(type.replace('*', ''))
|
||||
);
|
||||
const isValidSize = file.size <= 10 * 1024 * 1024; // 10MB limit
|
||||
return isValidType && isValidSize;
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
alert('No valid files selected (invalid type or size > 10MB)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (validFiles.length !== files.length) {
|
||||
alert('Some files were skipped due to invalid type or size (max 10MB)');
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
const uploadPromises = validFiles.map(async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("entityType", "project");
|
||||
formData.append("entityId", projectId.toString());
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/files", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const uploadedFile = await response.json();
|
||||
onFileUploaded?.(uploadedFile);
|
||||
return { success: true };
|
||||
} else {
|
||||
const error = await response.json();
|
||||
return { success: false, error: error.error || "Upload failed" };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: "Network error" };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(uploadPromises);
|
||||
const failed = results.filter(r => !r.success);
|
||||
|
||||
if (failed.length > 0) {
|
||||
alert(`Failed to upload ${failed.length} file(s)`);
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
uploadFiles(e.target.files);
|
||||
}
|
||||
e.target.value = ''; // Reset input
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors cursor-pointer">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
accept={acceptedTypes}
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-600 mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span className="text-xs text-gray-600">Przesyłanie plików...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex flex-col items-center ${dragOver ? 'scale-105' : ''} transition-transform`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<svg className="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-900 mb-1">
|
||||
{dragOver ? 'Upuść pliki tutaj' : 'Przeciągnij pliki lub kliknij'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
PDF, DOC, XLS, obrazki, DWG, ZIP
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
src/components/FileUploadModal.js
Normal file
188
src/components/FileUploadModal.js
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function FileUploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
entityType,
|
||||
entityId,
|
||||
onFileUploaded
|
||||
}) {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const fileInputRef = useRef(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
e.preventDefault();
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
handleFiles(e.target.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("entityType", entityType);
|
||||
formData.append("entityId", entityId.toString());
|
||||
formData.append("description", description);
|
||||
|
||||
const response = await fetch("/api/files", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const uploadedFile = await response.json();
|
||||
onFileUploaded(uploadedFile);
|
||||
setDescription("");
|
||||
onClose();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || t('contracts.failedToUploadFile'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
alert(t('contracts.failedToUploadFile'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-surface-modal rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{t('contracts.uploadDocumentTitle')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-tertiary hover:text-text-secondary"
|
||||
disabled={uploading}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Description Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('contracts.descriptionOptional')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('contracts.descriptionPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-border-default rounded-md shadow-sm bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-interactive-primary focus:border-border-focus"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Drop Zone */}
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
dragActive
|
||||
? "border-interactive-primary bg-surface-hover"
|
||||
: "border-border-default hover:border-border-hover"
|
||||
} ${uploading ? "opacity-50 pointer-events-none" : ""}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.txt"
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-600 mb-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span className="text-sm text-text-secondary">{t('contracts.uploading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="w-12 h-12 text-text-tertiary mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-text-primary mb-2">
|
||||
{t('contracts.dropFilesHere')}
|
||||
</span>
|
||||
<span className="text-xs text-text-secondary mb-4">
|
||||
{t('contracts.supportedFiles')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onButtonClick}
|
||||
disabled={uploading}
|
||||
>
|
||||
{t('contracts.chooseFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={uploading}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/FinishDateWithHistory.js
Normal file
119
src/components/FinishDateWithHistory.js
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Tooltip from "@/components/ui/Tooltip";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export default function FinishDateWithHistory({ projectId, finishDate }) {
|
||||
const [hasUpdates, setHasUpdates] = useState(false);
|
||||
const [updates, setUpdates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUpdates = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/finish-date-updates`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUpdates(data);
|
||||
setHasUpdates(data.length > 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch finish date updates:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
fetchUpdates();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("pl-PL", {
|
||||
timeZone: "Europe/Warsaw",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const tooltipContent = (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-xs text-gray-300 mb-2">
|
||||
Historia zmian terminu:
|
||||
</div>
|
||||
{updates.map((update, index) => (
|
||||
<div key={update.id} className="text-xs">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{update.new_finish_date
|
||||
? formatDate(update.new_finish_date)
|
||||
: "Usunięto termin"}
|
||||
</div>
|
||||
{update.old_finish_date && (
|
||||
<div className="text-gray-400 text-xs">
|
||||
poprzednio: {formatDate(update.old_finish_date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs mt-1">
|
||||
{update.updated_by_name && (
|
||||
<span>przez {update.updated_by_name} • </span>
|
||||
)}
|
||||
<span>{formatDateTime(update.updated_at)}</span>
|
||||
</div>
|
||||
{update.reason && (
|
||||
<div className="text-gray-300 text-xs mt-1 italic">
|
||||
"{update.reason}"
|
||||
</div>
|
||||
)}
|
||||
{index < updates.length - 1 && (
|
||||
<div className="border-b border-gray-600 my-2"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="text-gray-900 font-medium">
|
||||
{finishDate ? formatDate(finishDate) : "N/A"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-gray-900 font-medium">
|
||||
{finishDate ? formatDate(finishDate) : "N/A"}
|
||||
</p>
|
||||
{hasUpdates && (
|
||||
<Tooltip content={tooltipContent} position="top">
|
||||
<svg
|
||||
className="w-4 h-4 text-blue-500 cursor-help hover:text-blue-600 transition-colors"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function NoteForm({ projectId }) {
|
||||
export default function NoteForm({ projectId, onNoteAdded }) {
|
||||
const { t } = useTranslation();
|
||||
const [note, setNote] = useState("");
|
||||
const [status, setStatus] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [filteredProjects, setFilteredProjects] = useState([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [triggerChar, setTriggerChar] = useState(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const res = await fetch("/api/projects");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setProjects(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
}
|
||||
}
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (note && cursorPosition > 0) {
|
||||
const beforeCursor = note.slice(0, cursorPosition);
|
||||
const match = beforeCursor.match(/([@#])([^@#]*)$/);
|
||||
if (match) {
|
||||
const char = match[1];
|
||||
const query = match[2].toLowerCase();
|
||||
setTriggerChar(char);
|
||||
const filtered = projects.filter(project =>
|
||||
project.project_name.toLowerCase().includes(query)
|
||||
);
|
||||
setFilteredProjects(filtered);
|
||||
setShowDropdown(filtered.length > 0);
|
||||
setSelectedIndex(0);
|
||||
} else {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
} else {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [note, cursorPosition, projects]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -16,29 +62,107 @@ export default function NoteForm({ projectId }) {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const newNote = await res.json();
|
||||
setNote("");
|
||||
setStatus("Note added");
|
||||
window.location.reload();
|
||||
setStatus(t("common.addNoteSuccess"));
|
||||
// Call the callback to add the new note to the parent component's state
|
||||
if (onNoteAdded) {
|
||||
onNoteAdded(newNote);
|
||||
}
|
||||
// Clear status message after 3 seconds
|
||||
setTimeout(() => setStatus(null), 3000);
|
||||
} else {
|
||||
setStatus("Failed to save note");
|
||||
setStatus(t("common.addNoteError"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
if (showDropdown) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredProjects.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredProjects.length) % filteredProjects.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filteredProjects.length > 0) {
|
||||
selectProject(filteredProjects[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange(e) {
|
||||
setNote(e.target.value);
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
// Delay hiding dropdown to allow for clicks on dropdown items
|
||||
setTimeout(() => setShowDropdown(false), 150);
|
||||
}
|
||||
|
||||
function selectProject(project) {
|
||||
const beforeCursor = note.slice(0, cursorPosition);
|
||||
const afterCursor = note.slice(cursorPosition);
|
||||
const match = beforeCursor.match(/([@#])([^@#]*)$/);
|
||||
if (match) {
|
||||
const start = match.index;
|
||||
const end = cursorPosition;
|
||||
const link = `[${triggerChar}${project.project_name}](/projects/${project.project_id})`;
|
||||
const newNote = note.slice(0, start) + link + afterCursor;
|
||||
setNote(newNote);
|
||||
setShowDropdown(false);
|
||||
// Set cursor after the inserted link
|
||||
setTimeout(() => {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(start + link.length, start + link.length);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-2 mb-4">
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a new note..."
|
||||
className="border p-2 w-full"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="space-y-2 mb-4 relative">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={note}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => setCursorPosition(e.target.selectionStart)}
|
||||
onKeyUp={(e) => setCursorPosition(e.target.selectionStart)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t("common.addNotePlaceholder")}
|
||||
className="border p-2 w-full"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white border border-gray-300 rounded shadow-lg z-10 max-h-40 overflow-y-auto">
|
||||
{filteredProjects.map((project, index) => (
|
||||
<div
|
||||
key={project.project_id}
|
||||
className={`p-2 cursor-pointer ${index === selectedIndex ? 'bg-blue-100' : 'hover:bg-gray-100'}`}
|
||||
onClick={() => selectProject(project)}
|
||||
>
|
||||
{triggerChar}{project.project_name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Add Note
|
||||
{t("common.addNote")}
|
||||
</button>
|
||||
{status && <p className="text-sm text-gray-600">{status}</p>}
|
||||
</form>
|
||||
|
||||
219
src/components/ProjectAssigneeDropdown.js
Normal file
219
src/components/ProjectAssigneeDropdown.js
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectAssigneeDropdown({
|
||||
project,
|
||||
size = "md",
|
||||
showDropdown = true,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [assignee, setAssignee] = useState({
|
||||
id: project.assigned_to,
|
||||
name: project.assigned_to_name,
|
||||
username: project.assigned_to_username,
|
||||
initial: project.assigned_to_initial,
|
||||
});
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const buttonRef = useRef(null);
|
||||
|
||||
// Update assignee state when project prop changes
|
||||
useEffect(() => {
|
||||
setAssignee({
|
||||
id: project.assigned_to,
|
||||
name: project.assigned_to_name,
|
||||
username: project.assigned_to_username,
|
||||
initial: project.assigned_to_initial,
|
||||
});
|
||||
}, [project.assigned_to, project.assigned_to_name, project.assigned_to_username, project.assigned_to_initial]);
|
||||
|
||||
// Fetch users for assignment
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/projects/users");
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
setUsers(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen && users.length === 0) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [isOpen, users.length]);
|
||||
|
||||
const handleChange = async (newAssigneeId) => {
|
||||
if (newAssigneeId === assignee.id) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignee = users.find(u => u.id === newAssigneeId) || null;
|
||||
setAssignee(newAssignee);
|
||||
setLoading(true);
|
||||
setIsOpen(false);
|
||||
|
||||
try {
|
||||
const updateData = { ...project, assigned_to: newAssigneeId };
|
||||
|
||||
const response = await fetch(`/api/projects/${project.project_id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Update failed:', errorData);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to update assignee:", error);
|
||||
setAssignee({
|
||||
id: project.assigned_to,
|
||||
name: project.assigned_to_name,
|
||||
username: project.assigned_to_username,
|
||||
initial: project.assigned_to_initial,
|
||||
}); // Revert on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDropdownPosition = () => {
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const handleResize = () => updateDropdownPosition();
|
||||
const handleScroll = () => updateDropdownPosition();
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
|
||||
updateDropdownPosition();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const displayText = loading
|
||||
? "Updating..."
|
||||
: assignee?.name
|
||||
? assignee.name
|
||||
: isOpen && users.length === 0
|
||||
? "Loading..."
|
||||
: t("projects.unassigned");
|
||||
|
||||
if (!showDropdown) {
|
||||
return (
|
||||
<Badge variant="default" size={size}>
|
||||
{displayText}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
disabled={loading || (isOpen && users.length === 0)}
|
||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 rounded-md"
|
||||
>
|
||||
<Badge
|
||||
variant="default"
|
||||
size={size}
|
||||
className={`cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
loading || (isOpen && users.length === 0) ? "opacity-50 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
{loading ? "Updating..." : displayText}
|
||||
<svg
|
||||
className={`w-3 h-3 ml-1 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Badge>
|
||||
</button>{" "}
|
||||
{/* Assignee Options Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-[9999] min-w-[200px] max-h-60 overflow-y-auto">
|
||||
{/* Unassigned option */}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleChange(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span className="text-gray-500 italic">{t("projects.unassigned")}</span>
|
||||
</button>
|
||||
|
||||
{/* User options */}
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => {
|
||||
handleChange(user.id);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span>{user.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}{" "}
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { formatDateForInput } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectForm({ initialData = null }) {
|
||||
const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
const [form, setForm] = useState({
|
||||
contract_id: "",
|
||||
project_name: "",
|
||||
@@ -18,10 +22,12 @@ export default function ProjectForm({ initialData = null }) {
|
||||
city: "",
|
||||
investment_number: "",
|
||||
finish_date: "",
|
||||
completion_date: "",
|
||||
wp: "",
|
||||
contact: "",
|
||||
notes: "",
|
||||
coordinates: "",
|
||||
wartosc_zlecenia: "",
|
||||
project_type: "design",
|
||||
assigned_to: "",
|
||||
});
|
||||
@@ -57,20 +63,26 @@ export default function ProjectForm({ initialData = null }) {
|
||||
city: "",
|
||||
investment_number: "",
|
||||
finish_date: "",
|
||||
completion_date: "",
|
||||
wp: "",
|
||||
contact: "",
|
||||
notes: "",
|
||||
coordinates: "",
|
||||
wartosc_zlecenia: "",
|
||||
project_type: "design",
|
||||
assigned_to: "",
|
||||
...initialData,
|
||||
// Ensure these defaults are preserved if not in initialData
|
||||
project_type: initialData.project_type || "design",
|
||||
assigned_to: initialData.assigned_to || "",
|
||||
// Format finish_date for input if it exists
|
||||
wartosc_zlecenia: initialData.wartosc_zlecenia || "",
|
||||
// Format dates for input if they exist
|
||||
finish_date: initialData.finish_date
|
||||
? formatDateForInput(initialData.finish_date)
|
||||
: "",
|
||||
completion_date: initialData.completion_date
|
||||
? formatDateForInput(initialData.completion_date)
|
||||
: "",
|
||||
});
|
||||
}
|
||||
}, [initialData]);
|
||||
@@ -79,8 +91,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
async function saveProject() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -101,20 +112,30 @@ export default function ProjectForm({ initialData = null }) {
|
||||
router.push("/projects");
|
||||
}
|
||||
} else {
|
||||
alert("Failed to save project.");
|
||||
alert(t('projects.saveError'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving project:", error);
|
||||
alert("Failed to save project.");
|
||||
alert(t('projects.saveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
await saveProject();
|
||||
}
|
||||
|
||||
// Expose save function to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
saveProject
|
||||
}));
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEdit ? "Edit Project Details" : "Project Details"}
|
||||
{isEdit ? t('projects.editProjectDetails') : t('projects.projectDetails')}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -123,7 +144,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contract <span className="text-red-500">*</span>
|
||||
{t('projects.contract')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="contract_id"
|
||||
@@ -132,7 +153,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select Contract</option>
|
||||
<option value="">{t('projects.selectContract')}</option>
|
||||
{contracts.map((contract) => (
|
||||
<option
|
||||
key={contract.contract_id}
|
||||
@@ -146,7 +167,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Project Type <span className="text-red-500">*</span>
|
||||
{t('projects.type')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="project_type"
|
||||
@@ -155,17 +176,17 @@ export default function ProjectForm({ initialData = null }) {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="design">Design (Projektowanie)</option>
|
||||
<option value="construction">Construction (Realizacja)</option>
|
||||
<option value="design">{t('projectType.design')}</option>
|
||||
<option value="construction">{t('projectType.construction')}</option>
|
||||
<option value="design+construction">
|
||||
Design + Construction (Projektowanie + Realizacja)
|
||||
{t('projectType.design+construction')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assigned To
|
||||
{t('projects.assignedTo')}
|
||||
</label>
|
||||
<select
|
||||
name="assigned_to"
|
||||
@@ -173,10 +194,10 @@ export default function ProjectForm({ initialData = null }) {
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t('projects.unassigned')}</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
{user.name} ({user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -186,92 +207,92 @@ export default function ProjectForm({ initialData = null }) {
|
||||
{/* Basic Information Section */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Basic Information
|
||||
{t('projects.basicInformation')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Project Name <span className="text-red-500">*</span>
|
||||
{t('projects.projectName')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="project_name"
|
||||
value={form.project_name || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter project name"
|
||||
placeholder={t('projects.enterProjectName')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
City
|
||||
{t('projects.city')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="city"
|
||||
value={form.city || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter city"
|
||||
placeholder={t('projects.enterCity')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Address
|
||||
{t('projects.address')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="address"
|
||||
value={form.address || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter address"
|
||||
placeholder={t('projects.enterAddress')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plot
|
||||
{t('projects.plot')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="plot"
|
||||
value={form.plot || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter plot number"
|
||||
placeholder={t('projects.enterPlotNumber')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
District
|
||||
{t('projects.district')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="district"
|
||||
value={form.district || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter district"
|
||||
placeholder={t('projects.enterDistrict')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Unit
|
||||
{t('projects.unit')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="unit"
|
||||
value={form.unit || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter unit"
|
||||
placeholder={t('projects.enterUnit')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Finish Date
|
||||
</label>{" "}
|
||||
{t('projects.finishDate')}
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
name="finish_date"
|
||||
@@ -279,25 +300,37 @@ export default function ProjectForm({ initialData = null }) {
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Data zakończenia projektu
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
name="completion_date"
|
||||
value={formatDateForInput(form.completion_date)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Information Section */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Additional Information
|
||||
{t('projects.additionalInfo')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Investment Number
|
||||
{t('projects.investmentNumber')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="investment_number"
|
||||
value={form.investment_number || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter investment number"
|
||||
placeholder={t('projects.placeholders.investmentNumber')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -310,39 +343,56 @@ export default function ProjectForm({ initialData = null }) {
|
||||
name="wp"
|
||||
value={form.wp || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter WP"
|
||||
placeholder={t('projects.placeholders.wp')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{session?.user?.role === 'team_lead' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Wartość zlecenia
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="wartosc_zlecenia"
|
||||
value={form.wartosc_zlecenia || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="0.00"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contact Information
|
||||
{t('projects.contact')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="contact"
|
||||
value={form.contact || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter contact details"
|
||||
placeholder={t('projects.placeholders.contact')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Coordinates
|
||||
{t('projects.coordinates')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="coordinates"
|
||||
value={form.coordinates || ""}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., 49.622958,20.629562"
|
||||
placeholder={t('projects.placeholders.coordinates')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notes
|
||||
{t('projects.notes')}
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
@@ -350,7 +400,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter any additional notes"
|
||||
placeholder={t('projects.placeholders.notes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +414,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={loading}>
|
||||
{loading ? (
|
||||
@@ -389,7 +439,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{isEdit ? "Updating..." : "Creating..."}
|
||||
{isEdit ? t('projects.updating') : t('projects.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -408,7 +458,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Update Project
|
||||
{t('projects.updateProject')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -425,7 +475,7 @@ export default function ProjectForm({ initialData = null }) {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Create Project
|
||||
{t('projects.createProject')}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -436,4 +486,6 @@ export default function ProjectForm({ initialData = null }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ProjectForm;
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectStatusDropdown({
|
||||
project,
|
||||
size = "md",
|
||||
showDropdown = true,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState(project.project_status);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -21,21 +23,25 @@ export default function ProjectStatusDropdown({
|
||||
|
||||
const statusConfig = {
|
||||
registered: {
|
||||
label: "Registered",
|
||||
label: t("projectStatus.registered"),
|
||||
variant: "secondary",
|
||||
},
|
||||
in_progress_design: {
|
||||
label: "In Progress (Design)",
|
||||
label: t("projectStatus.in_progress_design"),
|
||||
variant: "primary",
|
||||
},
|
||||
in_progress_construction: {
|
||||
label: "In Progress (Construction)",
|
||||
label: t("projectStatus.in_progress_construction"),
|
||||
variant: "primary",
|
||||
},
|
||||
fulfilled: {
|
||||
label: "Completed",
|
||||
label: t("projectStatus.fulfilled"),
|
||||
variant: "success",
|
||||
},
|
||||
cancelled: {
|
||||
label: t("projectStatus.cancelled"),
|
||||
variant: "danger",
|
||||
},
|
||||
};
|
||||
const handleChange = async (newStatus) => {
|
||||
if (newStatus === status) {
|
||||
@@ -48,11 +54,19 @@ export default function ProjectStatusDropdown({
|
||||
setIsOpen(false);
|
||||
|
||||
try {
|
||||
await fetch(`/api/projects/${project.project_id}`, {
|
||||
const updateData = { ...project, project_status: newStatus };
|
||||
|
||||
const response = await fetch(`/api/projects/${project.project_id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...project, project_status: newStatus }),
|
||||
body: JSON.stringify(updateData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Update failed:', errorData);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to update status:", error);
|
||||
@@ -73,9 +87,6 @@ export default function ProjectStatusDropdown({
|
||||
}
|
||||
};
|
||||
const handleOpen = () => {
|
||||
console.log(
|
||||
"ProjectStatusDropdown handleOpen called, setting isOpen to true"
|
||||
);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
@@ -111,10 +122,6 @@ export default function ProjectStatusDropdown({
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
console.log(
|
||||
"ProjectStatusDropdown button clicked, current isOpen:",
|
||||
isOpen
|
||||
);
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
disabled={loading}
|
||||
@@ -145,20 +152,16 @@ export default function ProjectStatusDropdown({
|
||||
</svg>
|
||||
</Badge>
|
||||
</button>{" "}
|
||||
{/* Simple dropdown for debugging */}
|
||||
{/* Status Options Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
|
||||
<div className="bg-yellow-100 p-2 text-xs text-center border-b">
|
||||
DEBUG: ProjectStatus Dropdown is visible
|
||||
</div>
|
||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-[9999] min-w-[140px]">
|
||||
{Object.entries(statusConfig).map(([statusKey, config]) => (
|
||||
<button
|
||||
key={statusKey}
|
||||
onClick={() => {
|
||||
console.log("ProjectStatus Option clicked:", statusKey);
|
||||
handleChange(statusKey);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<Badge variant={config.variant} size="sm">
|
||||
{config.label}
|
||||
@@ -170,9 +173,8 @@ export default function ProjectStatusDropdown({
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9998] bg-black bg-opacity-10"
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => {
|
||||
console.log("ProjectStatus Backdrop clicked");
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,10 @@ export default function ProjectStatusDropdownDebug({
|
||||
label: "Completed",
|
||||
variant: "success",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
variant: "danger",
|
||||
},
|
||||
};
|
||||
|
||||
const handleChange = async (newStatus) => {
|
||||
@@ -107,8 +111,8 @@ export default function ProjectStatusDropdownDebug({
|
||||
|
||||
{/* Simple visible dropdown for debugging */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
|
||||
<div className="bg-yellow-100 p-2 text-xs text-center border-b">
|
||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
|
||||
<div className="bg-yellow-100 dark:bg-yellow-900 p-2 text-xs text-center border-b dark:border-gray-600">
|
||||
DEBUG: Project Status Dropdown is visible
|
||||
</div>
|
||||
{Object.entries(statusConfig).map(([statusKey, config]) => (
|
||||
@@ -118,7 +122,7 @@ export default function ProjectStatusDropdownDebug({
|
||||
console.log("Project Status Option clicked:", statusKey);
|
||||
handleChange(statusKey);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<Badge variant={config.variant} size="sm">
|
||||
{config.label}
|
||||
|
||||
@@ -29,6 +29,10 @@ export default function ProjectStatusDropdownSimple({
|
||||
label: "Completed",
|
||||
variant: "success",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Cancelled",
|
||||
variant: "danger",
|
||||
},
|
||||
};
|
||||
|
||||
const handleChange = async (newStatus) => {
|
||||
@@ -110,8 +114,8 @@ export default function ProjectStatusDropdownSimple({
|
||||
|
||||
{/* Simple dropdown for debugging */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
|
||||
<div className="bg-yellow-100 p-2 text-xs text-center border-b">
|
||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 border-2 border-red-500 rounded-md shadow-lg z-[9999] min-w-[140px]">
|
||||
<div className="bg-yellow-100 dark:bg-yellow-900 p-2 text-xs text-center border-b dark:border-gray-600">
|
||||
DEBUG: ProjectStatus Dropdown is visible
|
||||
</div>
|
||||
{Object.entries(statusConfig).map(([statusKey, config]) => (
|
||||
@@ -121,7 +125,7 @@ export default function ProjectStatusDropdownSimple({
|
||||
console.log("ProjectStatus Option clicked:", statusKey);
|
||||
handleChange(statusKey);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 transition-colors first:rounded-t-md last-rounded-b-md"
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors first:rounded-t-md last-rounded-b-md"
|
||||
>
|
||||
<Badge variant={config.variant} size="sm">
|
||||
{config.label}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
const { t } = useTranslation();
|
||||
const [taskTemplates, setTaskTemplates] = useState([]);
|
||||
const [taskSets, setTaskSets] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
|
||||
const [project, setProject] = useState(null);
|
||||
const [taskType, setTaskType] = useState("template"); // "template", "custom", or "task_set"
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||
const [selectedTaskSet, setSelectedTaskSet] = useState("");
|
||||
const [customTaskName, setCustomTaskName] = useState("");
|
||||
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
|
||||
const [customDescription, setCustomDescription] = useState("");
|
||||
@@ -17,6 +22,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch project details
|
||||
fetch(`/api/projects/${projectId}`)
|
||||
.then((res) => res.json())
|
||||
.then(setProject);
|
||||
|
||||
// Fetch available task templates
|
||||
fetch("/api/tasks/templates")
|
||||
.then((res) => res.json())
|
||||
@@ -26,7 +36,23 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
fetch("/api/project-tasks/users")
|
||||
.then((res) => res.json())
|
||||
.then(setUsers);
|
||||
}, []);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch task sets when project type is available
|
||||
if (project?.project_type) {
|
||||
let apiUrl = '/api/task-sets';
|
||||
if (project.project_type === 'design+construction') {
|
||||
// For design+construction projects, don't filter - show all task sets
|
||||
// User can choose which type to apply
|
||||
} else {
|
||||
apiUrl += `?project_type=${project.project_type}`;
|
||||
}
|
||||
fetch(apiUrl)
|
||||
.then((res) => res.json())
|
||||
.then(setTaskSets);
|
||||
}
|
||||
}, [project?.project_type]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -34,43 +60,64 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
// Validate based on task type
|
||||
if (taskType === "template" && !selectedTemplate) return;
|
||||
if (taskType === "custom" && !customTaskName.trim()) return;
|
||||
if (taskType === "task_set" && !selectedTaskSet) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const requestData = {
|
||||
project_id: parseInt(projectId),
|
||||
priority,
|
||||
assigned_to: assignedTo || null,
|
||||
};
|
||||
if (taskType === "task_set") {
|
||||
// Apply task set
|
||||
const response = await fetch(`/api/task-sets/${selectedTaskSet}/apply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ project_id: parseInt(projectId) }),
|
||||
});
|
||||
|
||||
if (taskType === "template") {
|
||||
requestData.task_template_id = parseInt(selectedTemplate);
|
||||
if (response.ok) {
|
||||
// Reset form
|
||||
setSelectedTaskSet("");
|
||||
setPriority("normal");
|
||||
setAssignedTo("");
|
||||
if (onTaskAdded) onTaskAdded();
|
||||
} else {
|
||||
alert(t("tasks.addTaskError"));
|
||||
}
|
||||
} else {
|
||||
requestData.custom_task_name = customTaskName.trim();
|
||||
requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0;
|
||||
requestData.custom_description = customDescription.trim();
|
||||
}
|
||||
// Create single task
|
||||
const requestData = {
|
||||
project_id: parseInt(projectId),
|
||||
priority,
|
||||
assigned_to: assignedTo || null,
|
||||
};
|
||||
|
||||
const res = await fetch("/api/project-tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Reset form
|
||||
setSelectedTemplate("");
|
||||
setCustomTaskName("");
|
||||
setCustomMaxWaitDays("");
|
||||
setCustomDescription("");
|
||||
setPriority("normal");
|
||||
setAssignedTo("");
|
||||
if (onTaskAdded) onTaskAdded();
|
||||
} else {
|
||||
alert("Failed to add task to project.");
|
||||
if (taskType === "template") {
|
||||
requestData.task_template_id = parseInt(selectedTemplate);
|
||||
} else {
|
||||
requestData.custom_task_name = customTaskName.trim();
|
||||
requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0;
|
||||
requestData.custom_description = customDescription.trim();
|
||||
}
|
||||
|
||||
const res = await fetch("/api/project-tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Reset form
|
||||
setSelectedTemplate("");
|
||||
setCustomTaskName("");
|
||||
setCustomMaxWaitDays("");
|
||||
setCustomDescription("");
|
||||
setPriority("normal");
|
||||
setAssignedTo("");
|
||||
if (onTaskAdded) onTaskAdded();
|
||||
} else {
|
||||
alert(t("tasks.addTaskError"));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error adding task to project.");
|
||||
alert(t("tasks.addTaskError"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -79,9 +126,9 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Task Type
|
||||
{t("tasks.taskType")}
|
||||
</label>
|
||||
<div className="flex space-x-6">
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -90,7 +137,17 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900">From Template</span>
|
||||
<span className="ml-2 text-sm text-gray-900">{t("tasks.fromTemplate")}</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="task_set"
|
||||
checked={taskType === "task_set"}
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900">Z zestawu zadań</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
@@ -100,7 +157,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
onChange={(e) => setTaskType(e.target.value)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900">Custom Task</span>
|
||||
<span className="ml-2 text-sm text-gray-900">{t("tasks.customTask")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +165,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
{taskType === "template" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Task Template
|
||||
{t("tasks.selectTemplate")}
|
||||
</label>{" "}
|
||||
<select
|
||||
value={selectedTemplate}
|
||||
@@ -116,32 +173,67 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Choose a task template...</option>
|
||||
<option value="">{t("tasks.chooseTemplate")}</option>
|
||||
{taskTemplates.map((template) => (
|
||||
<option key={template.task_id} value={template.task_id}>
|
||||
{template.name} ({template.max_wait_days} days)
|
||||
{template.name} ({template.max_wait_days} {t("tasks.days")})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : taskType === "task_set" ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Wybierz zestaw zadań
|
||||
</label>
|
||||
<select
|
||||
value={selectedTaskSet}
|
||||
onChange={(e) => setSelectedTaskSet(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">Wybierz zestaw...</option>
|
||||
{taskSets.map((taskSet) => (
|
||||
<option key={taskSet.set_id} value={taskSet.set_id}>
|
||||
{taskSet.name} ({taskSet.task_category === 'design' ? 'Zadania projektowe' : 'Zadania budowlane'}) - {taskSet.templates?.length || 0} zadań
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTaskSet && (
|
||||
<div className="mt-2 p-3 bg-blue-50 rounded-md">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Podgląd zestawu:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-blue-700 mt-1 list-disc list-inside">
|
||||
{taskSets
|
||||
.find(ts => ts.set_id === parseInt(selectedTaskSet))
|
||||
?.templates?.map((template, index) => (
|
||||
<li key={template.task_id}>
|
||||
{template.name} ({template.max_wait_days} dni)
|
||||
</li>
|
||||
)) || []}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Task Name
|
||||
{t("tasks.taskName")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customTaskName}
|
||||
onChange={(e) => setCustomTaskName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter custom task name..."
|
||||
placeholder={t("tasks.enterTaskName")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Wait Days
|
||||
{t("tasks.maxWait")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -154,52 +246,56 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description
|
||||
{t("tasks.description")}
|
||||
</label>
|
||||
<textarea
|
||||
value={customDescription}
|
||||
onChange={(e) => setCustomDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter task description (optional)..."
|
||||
placeholder={t("tasks.enterDescription")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign To <span className="text-gray-500 text-xs">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={assignedTo}
|
||||
onChange={(e) => setAssignedTo(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{taskType !== "task_set" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
|
||||
</label>
|
||||
<select
|
||||
value={assignedTo}
|
||||
onChange={(e) => setAssignedTo(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">{t("projects.unassigned")}</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t("tasks.priority")}
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="low">{t("tasks.low")}</option>
|
||||
<option value="normal">{t("tasks.normal")}</option>
|
||||
<option value="high">{t("tasks.high")}</option>
|
||||
<option value="urgent">{t("tasks.urgent")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
@@ -208,10 +304,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(taskType === "template" && !selectedTemplate) ||
|
||||
(taskType === "custom" && !customTaskName.trim())
|
||||
(taskType === "custom" && !customTaskName.trim()) ||
|
||||
(taskType === "task_set" && !selectedTaskSet)
|
||||
}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add Task"}
|
||||
{isSubmitting ? t("tasks.adding") : taskType === "task_set" ? "Zastosuj zestaw" : t("tasks.addTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -13,11 +13,17 @@ import {
|
||||
parseISO,
|
||||
formatDistanceToNow,
|
||||
} from "date-fns";
|
||||
import { pl, enUS } from "date-fns/locale";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectTasksDashboard() {
|
||||
const { language } = useTranslation();
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Get locale for date-fns
|
||||
const locale = language === 'pl' ? pl : enUS;
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
@@ -283,7 +289,7 @@ export default function ProjectTasksDashboard() {
|
||||
const addedDate = task.date_added.includes("T")
|
||||
? parseISO(task.date_added)
|
||||
: new Date(task.date_added + "T00:00:00");
|
||||
return formatDistanceToNow(addedDate, { addSuffix: true });
|
||||
return formatDistanceToNow(addedDate, { addSuffix: true, locale: locale });
|
||||
} catch (error) {
|
||||
return task.date_added;
|
||||
}
|
||||
@@ -331,7 +337,7 @@ export default function ProjectTasksDashboard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm">No tasks in this category</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardHeader, CardContent } from "./ui/Card";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
import TaskStatusDropdownSimple from "./TaskStatusDropdownSimple";
|
||||
import TaskCommentsModal from "./TaskCommentsModal";
|
||||
import SearchBar from "./ui/SearchBar";
|
||||
import { Select } from "./ui/Input";
|
||||
import Link from "next/link";
|
||||
@@ -13,13 +14,24 @@ import {
|
||||
parseISO,
|
||||
formatDistanceToNow,
|
||||
} from "date-fns";
|
||||
import { pl, enUS } from "date-fns/locale";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function ProjectTasksList() {
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
// Get locale for date-fns
|
||||
const locale = language === 'pl' ? pl : enUS;
|
||||
const { data: session } = useSession();
|
||||
const [allTasks, setAllTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [groupBy, setGroupBy] = useState("none");
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [showCommentsModal, setShowCommentsModal] = useState(false);
|
||||
const [mine, setMine] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllTasks = async () => {
|
||||
@@ -35,7 +47,19 @@ export default function ProjectTasksList() {
|
||||
};
|
||||
|
||||
fetchAllTasks();
|
||||
}, []); // Calculate task status based on date_added and max_wait_days
|
||||
}, []);
|
||||
|
||||
// Handle escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && showCommentsModal) {
|
||||
handleCloseComments();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showCommentsModal]); // Calculate task status based on date_added and max_wait_days
|
||||
const getTaskStatus = (task) => {
|
||||
if (task.status === "completed" || task.status === "cancelled") {
|
||||
return { type: "completed", days: 0 };
|
||||
@@ -118,9 +142,10 @@ export default function ProjectTasksList() {
|
||||
groups.completed.push(taskWithStatus);
|
||||
} else if (task.status === "in_progress") {
|
||||
groups.in_progress.push(taskWithStatus);
|
||||
} else {
|
||||
} else if (task.status === "pending") {
|
||||
groups.pending.push(taskWithStatus);
|
||||
}
|
||||
// not_started tasks are not displayed in the UI
|
||||
});
|
||||
|
||||
// Sort pending tasks by date_added (newest first)
|
||||
@@ -183,19 +208,30 @@ export default function ProjectTasksList() {
|
||||
const taskGroups = groupTasksByStatus();
|
||||
// Filter tasks based on search term
|
||||
const filterTasks = (tasks) => {
|
||||
if (!searchTerm) return tasks;
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.city?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.address?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
let filtered = tasks;
|
||||
|
||||
// Apply mine filter
|
||||
if (mine && session?.user?.id) {
|
||||
filtered = filtered.filter(task => task.assigned_to === session.user.id);
|
||||
}
|
||||
|
||||
// Apply search term
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(task) =>
|
||||
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.project_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.city?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.address?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Group tasks by task name when groupBy is set to "task_name"
|
||||
const groupTasksByName = (tasks) => {
|
||||
if (groupBy !== "task_name") return { "All Tasks": tasks };
|
||||
if (groupBy !== "task_name") return { [t("tasks.allTasks")]: tasks };
|
||||
|
||||
const groups = {};
|
||||
tasks.forEach((task) => {
|
||||
@@ -223,13 +259,23 @@ export default function ProjectTasksList() {
|
||||
const tasks = await res2.json();
|
||||
setAllTasks(tasks);
|
||||
} else {
|
||||
alert("Failed to update task status");
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error updating task status");
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowComments = (task) => {
|
||||
setSelectedTask(task);
|
||||
setShowCommentsModal(true);
|
||||
};
|
||||
|
||||
const handleCloseComments = () => {
|
||||
setShowCommentsModal(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
@@ -250,13 +296,13 @@ export default function ProjectTasksList() {
|
||||
if (days > 3) return "warning";
|
||||
return "high";
|
||||
};
|
||||
const TaskRow = ({ task, showTimeLeft = false }) => (
|
||||
<tr className="hover:bg-gray-50 border-b border-gray-200">
|
||||
const TaskRow = ({ task, showTimeLeft = false, showMaxWait = true }) => (
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{task.task_name}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{task.task_name}</span>
|
||||
<Badge variant={getPriorityVariant(task.priority)} size="sm">
|
||||
{task.priority}
|
||||
{t(`tasks.${task.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</td>
|
||||
@@ -264,35 +310,25 @@ export default function ProjectTasksList() {
|
||||
{" "}
|
||||
<Link
|
||||
href={`/projects/${task.project_id}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
{task.project_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{task.city || "N/A"}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{task.city || "N/A"}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{task.address || "N/A"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{task.created_by_name ? (
|
||||
<div>
|
||||
<div className="font-medium">{task.created_by_name}</div>
|
||||
<div className="text-xs text-gray-500">{task.created_by_email}</div>
|
||||
</div>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{task.assigned_to_name ? (
|
||||
<div>
|
||||
<div className="font-medium">{task.assigned_to_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{task.assigned_to_name}</div>
|
||||
{/* <div className="text-xs text-gray-500">
|
||||
{task.assigned_to_email}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Unassigned</span>
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">{t("projects.unassigned")}</span>
|
||||
)}
|
||||
</td>
|
||||
{showTimeLeft && (
|
||||
@@ -307,9 +343,9 @@ export default function ProjectTasksList() {
|
||||
>
|
||||
{!isNaN(task.statusInfo.daysRemaining)
|
||||
? task.statusInfo.daysRemaining > 0
|
||||
? `${task.statusInfo.daysRemaining}d left`
|
||||
: `${Math.abs(task.statusInfo.daysRemaining)}d overdue`
|
||||
: "Calculating..."}
|
||||
? `${task.statusInfo.daysRemaining}${t("tasks.daysLeft")}`
|
||||
: `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
|
||||
: t("common.loading")}
|
||||
</Badge>
|
||||
)}
|
||||
{task.statusInfo &&
|
||||
@@ -317,23 +353,24 @@ export default function ProjectTasksList() {
|
||||
task.status === "in_progress" && (
|
||||
<Badge variant="danger" size="sm">
|
||||
{!isNaN(task.statusInfo.daysRemaining)
|
||||
? `${Math.abs(task.statusInfo.daysRemaining)}d overdue`
|
||||
: "Overdue"}
|
||||
? `${Math.abs(task.statusInfo.daysRemaining)}${t("tasks.daysOverdue")}`
|
||||
: t("tasks.overdue")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.status === "completed" && task.date_completed ? (
|
||||
<div>
|
||||
<div>
|
||||
Completed:{" "}
|
||||
{t("taskStatus.completed")}:{" "}
|
||||
{(() => {
|
||||
try {
|
||||
const completedDate = new Date(task.date_completed);
|
||||
return formatDistanceToNow(completedDate, {
|
||||
addSuffix: true,
|
||||
locale: locale
|
||||
});
|
||||
} catch (error) {
|
||||
return task.date_completed;
|
||||
@@ -344,11 +381,11 @@ export default function ProjectTasksList() {
|
||||
) : task.status === "in_progress" && task.date_started ? (
|
||||
<div>
|
||||
<div>
|
||||
Started:{" "}
|
||||
{t("tasks.dateStarted")}:{" "}
|
||||
{(() => {
|
||||
try {
|
||||
const startedDate = new Date(task.date_started);
|
||||
return formatDistanceToNow(startedDate, { addSuffix: true });
|
||||
return formatDistanceToNow(startedDate, { addSuffix: true, locale: locale });
|
||||
} catch (error) {
|
||||
return task.date_started;
|
||||
}
|
||||
@@ -361,79 +398,90 @@ export default function ProjectTasksList() {
|
||||
const addedDate = task.date_added.includes("T")
|
||||
? parseISO(task.date_added)
|
||||
: new Date(task.date_added);
|
||||
return formatDistanceToNow(addedDate, { addSuffix: true });
|
||||
return formatDistanceToNow(addedDate, { addSuffix: true, locale: locale });
|
||||
} catch (error) {
|
||||
return task.date_added;
|
||||
}
|
||||
})()
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{task.max_wait_days} days
|
||||
</td>
|
||||
{showMaxWait && (
|
||||
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.max_wait_days} {t("tasks.days")}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<TaskStatusDropdownSimple
|
||||
task={task}
|
||||
size="sm"
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<TaskStatusDropdownSimple
|
||||
task={task}
|
||||
size="sm"
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleShowComments(task)}
|
||||
title={t("tasks.comments")}
|
||||
>
|
||||
💬
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => {
|
||||
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false, showMaxWait = true }) => {
|
||||
const filteredTasks = filterTasks(tasks);
|
||||
const groupedTasks = groupTasksByName(filteredTasks);
|
||||
const colSpan = showTimeLeft ? "10" : "9";
|
||||
const colSpan = showTimeLeft && showMaxWait ? "9" : showTimeLeft || showMaxWait ? "8" : "7";
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full bg-white rounded-lg shadow-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Task Name
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.taskName")}
|
||||
</th>{" "}
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Project
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.project")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
City
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("projects.city")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Address
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("projects.address")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Created By
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Assigned To
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.assignedTo")}
|
||||
</th>
|
||||
{showTimeLeft && (
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Time Left
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.daysLeft")}
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Date Info
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.dateCreated")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Max Wait
|
||||
</th>{" "}
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||
Actions
|
||||
{showMaxWait && (
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.maxWait")}
|
||||
</th>
|
||||
)}{" "}
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupedTasks).map(([groupName, groupTasks]) => (
|
||||
<Fragment key={`group-fragment-${groupName}`}>
|
||||
{showGrouped && groupName !== "All Tasks" && (
|
||||
{showGrouped && groupName !== t("tasks.allTasks") && (
|
||||
<tr key={`group-${groupName}`}>
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className="px-4 py-2 bg-gray-100 font-medium text-gray-800 text-sm"
|
||||
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 font-medium text-gray-800 dark:text-gray-200 text-sm"
|
||||
>
|
||||
{groupName} ({groupTasks.length} tasks)
|
||||
{groupName} ({groupTasks.length} {t("tasks.tasks")})
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -442,6 +490,7 @@ export default function ProjectTasksList() {
|
||||
key={task.id}
|
||||
task={task}
|
||||
showTimeLeft={showTimeLeft}
|
||||
showMaxWait={showMaxWait}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
@@ -449,8 +498,8 @@ export default function ProjectTasksList() {
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredTasks.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No tasks found</p>
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<p>{t("tasks.noTasks")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -476,7 +525,7 @@ export default function ProjectTasksList() {
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{taskGroups.pending.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Pending</div>
|
||||
<div className="text-sm text-gray-600">{t("taskStatus.pending")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -484,7 +533,7 @@ export default function ProjectTasksList() {
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{taskGroups.in_progress.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">In Progress</div>
|
||||
<div className="text-sm text-gray-600">{t("taskStatus.in_progress")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -492,7 +541,7 @@ export default function ProjectTasksList() {
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{taskGroups.completed.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Completed</div>
|
||||
<div className="text-sm text-gray-600">{t("taskStatus.completed")}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>{" "}
|
||||
@@ -500,28 +549,55 @@ export default function ProjectTasksList() {
|
||||
<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search tasks, projects, city, or address..."
|
||||
placeholder={t("tasks.searchPlaceholder")}
|
||||
resultsCount={
|
||||
filterTasks(taskGroups.pending).length +
|
||||
filterTasks(taskGroups.in_progress).length +
|
||||
filterTasks(taskGroups.completed).length
|
||||
}
|
||||
resultsText="tasks"
|
||||
resultsText={t("tasks.tasks")}
|
||||
filters={
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Group by:
|
||||
{t("tasks.sortBy")}:
|
||||
</label>
|
||||
<Select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value)}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="task_name">Task Name</option>
|
||||
<option value="none">{t("common.none")}</option>
|
||||
<option value="task_name">{t("tasks.taskName")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
{session?.user && (
|
||||
<button
|
||||
onClick={() => setMine(!mine)}
|
||||
className={`
|
||||
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
|
||||
${mine
|
||||
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{t('projects.mine') || 'Tylko moje'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>{" "}
|
||||
@@ -531,19 +607,20 @@ export default function ProjectTasksList() {
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
Pending Tasks
|
||||
{t("taskStatus.pending")} {t("tasks.tasks")}
|
||||
<Badge variant="primary" size="md">
|
||||
{taskGroups.pending.length}
|
||||
</Badge>
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Tasks waiting to be started
|
||||
{t("tasks.noTasksMessage")}
|
||||
</p>
|
||||
</div>
|
||||
<TaskTable
|
||||
tasks={taskGroups.pending}
|
||||
showGrouped={groupBy === "task_name"}
|
||||
showTimeLeft={false}
|
||||
showMaxWait={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -551,19 +628,20 @@ export default function ProjectTasksList() {
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
In Progress Tasks
|
||||
{t("taskStatus.in_progress")} {t("tasks.tasks")}
|
||||
<Badge variant="secondary" size="md">
|
||||
{taskGroups.in_progress.length}
|
||||
</Badge>
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Tasks currently being worked on - showing time left for completion
|
||||
Zadania aktualnie w trakcie realizacji - pokazujący pozostały czas do ukończenia
|
||||
</p>
|
||||
</div>
|
||||
<TaskTable
|
||||
tasks={taskGroups.in_progress}
|
||||
showGrouped={groupBy === "task_name"}
|
||||
showTimeLeft={true}
|
||||
showMaxWait={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -571,22 +649,28 @@ export default function ProjectTasksList() {
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
Completed Tasks
|
||||
{t("taskStatus.completed")} {t("tasks.tasks")}
|
||||
<Badge variant="success" size="md">
|
||||
{taskGroups.completed.length}
|
||||
</Badge>
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Recently completed and cancelled tasks
|
||||
Ostatnio ukończone i anulowane zadania
|
||||
</p>
|
||||
</div>
|
||||
<TaskTable
|
||||
tasks={taskGroups.completed}
|
||||
showGrouped={groupBy === "task_name"}
|
||||
showTimeLeft={false}
|
||||
showMaxWait={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div> {/* Comments Modal */}
|
||||
<TaskCommentsModal
|
||||
task={selectedTask}
|
||||
isOpen={showCommentsModal}
|
||||
onClose={handleCloseComments}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Card, CardHeader, CardContent } from "./ui/Card";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ProjectTasksSection({ projectId }) {
|
||||
const { t } = useTranslation();
|
||||
const [projectTasks, setProjectTasks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskNotes, setTaskNotes] = useState({});
|
||||
@@ -17,6 +19,15 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
const [showAddTaskModal, setShowAddTaskModal] = useState(false);
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState({});
|
||||
const [expandedNotes, setExpandedNotes] = useState({});
|
||||
const [editingTask, setEditingTask] = useState(null);
|
||||
const [showEditTaskModal, setShowEditTaskModal] = useState(false);
|
||||
const [editTaskForm, setEditTaskForm] = useState({
|
||||
priority: "",
|
||||
date_started: "",
|
||||
status: "",
|
||||
assigned_to: "",
|
||||
});
|
||||
const [users, setUsers] = useState([]);
|
||||
useEffect(() => {
|
||||
const fetchProjectTasks = async () => {
|
||||
try {
|
||||
@@ -49,22 +60,38 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch users for assignment dropdown
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/project-tasks/users");
|
||||
const usersData = await res.json();
|
||||
setUsers(usersData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjectTasks();
|
||||
fetchUsers();
|
||||
}, [projectId]);
|
||||
// Handle escape key to close modal
|
||||
// Handle escape key to close modals
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && showAddTaskModal) {
|
||||
setShowAddTaskModal(false);
|
||||
if (e.key === "Escape") {
|
||||
if (showEditTaskModal) {
|
||||
handleCloseEditModal();
|
||||
} else if (showAddTaskModal) {
|
||||
setShowAddTaskModal(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [showAddTaskModal]);
|
||||
}, [showAddTaskModal, showEditTaskModal]);
|
||||
// Prevent body scroll when modal is open and handle map z-index
|
||||
useEffect(() => {
|
||||
if (showAddTaskModal) {
|
||||
if (showAddTaskModal || showEditTaskModal) {
|
||||
// Prevent body scroll
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
@@ -111,7 +138,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
nav.style.zIndex = "";
|
||||
});
|
||||
};
|
||||
}, [showAddTaskModal]);
|
||||
}, [showAddTaskModal, showEditTaskModal]);
|
||||
const refetchTasks = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks?project_id=${projectId}`);
|
||||
@@ -155,14 +182,14 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
if (res.ok) {
|
||||
refetchTasks(); // Refresh the list
|
||||
} else {
|
||||
alert("Failed to update task status");
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error updating task status");
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
};
|
||||
const handleDeleteTask = async (taskId) => {
|
||||
if (!confirm("Are you sure you want to delete this task?")) return;
|
||||
if (!confirm(t("common.deleteConfirm"))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks/${taskId}`, {
|
||||
method: "DELETE",
|
||||
@@ -171,10 +198,11 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
if (res.ok) {
|
||||
refetchTasks(); // Refresh the list
|
||||
} else {
|
||||
alert("Failed to delete task");
|
||||
const errorData = await res.json();
|
||||
alert(t("errors.generic") + ": " + (errorData.error || t("errors.unknown")));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error deleting task");
|
||||
alert(t("tasks.deleteError") + ": " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,20 +229,20 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
|
||||
setNewNote((prev) => ({ ...prev, [taskId]: "" }));
|
||||
} else {
|
||||
alert("Failed to add note");
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error adding note");
|
||||
alert(t("errors.generic"));
|
||||
} finally {
|
||||
setLoadingNotes((prev) => ({ ...prev, [taskId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (noteId, taskId) => {
|
||||
if (!confirm("Are you sure you want to delete this note?")) return;
|
||||
if (!confirm(t("common.deleteConfirm"))) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/task-notes?note_id=${noteId}`, {
|
||||
const res = await fetch(`/api/task-notes/${noteId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -224,12 +252,74 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
const notes = await notesRes.json();
|
||||
setTaskNotes((prev) => ({ ...prev, [taskId]: notes }));
|
||||
} else {
|
||||
alert("Failed to delete note");
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error deleting note");
|
||||
console.error("Error deleting task note:", error);
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTask = (task) => {
|
||||
setEditingTask(task);
|
||||
|
||||
// Format date for HTML input (YYYY-MM-DD)
|
||||
let dateStarted = "";
|
||||
if (task.date_started) {
|
||||
const date = new Date(task.date_started);
|
||||
if (!isNaN(date.getTime())) {
|
||||
dateStarted = date.toISOString().split("T")[0];
|
||||
}
|
||||
}
|
||||
|
||||
const formData = {
|
||||
priority: task.priority || "",
|
||||
date_started: dateStarted,
|
||||
status: task.status || "",
|
||||
assigned_to: task.assigned_to || "",
|
||||
};
|
||||
|
||||
setEditTaskForm(formData);
|
||||
setShowEditTaskModal(true);
|
||||
};
|
||||
|
||||
const handleUpdateTask = async () => {
|
||||
if (!editingTask) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/project-tasks/${editingTask.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
priority: editTaskForm.priority,
|
||||
date_started: editTaskForm.date_started || null,
|
||||
status: editTaskForm.status,
|
||||
assigned_to: editTaskForm.assigned_to || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
refetchTasks();
|
||||
handleCloseEditModal();
|
||||
} else {
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
} catch (error) {
|
||||
alert(t("errors.generic"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
setShowEditTaskModal(false);
|
||||
setEditingTask(null);
|
||||
setEditTaskForm({
|
||||
priority: "",
|
||||
date_started: "",
|
||||
status: "",
|
||||
assigned_to: "",
|
||||
});
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
@@ -261,10 +351,10 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Project Tasks</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t("tasks.title")}</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="default" className="text-sm">
|
||||
{projectTasks.length} {projectTasks.length === 1 ? "task" : "tasks"}
|
||||
{projectTasks.length} {projectTasks.length === 1 ? t("tasks.task") : t("tasks.tasks")}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -284,7 +374,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Task
|
||||
{t("tasks.newTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>{" "}
|
||||
@@ -305,7 +395,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Add New Task
|
||||
{t("tasks.newTask")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddTaskModal(false)}
|
||||
@@ -338,7 +428,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
{/* Current Tasks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-medium text-gray-900">Current Tasks</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900">{t("tasks.title")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
@@ -353,62 +443,64 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
<div className="text-gray-400 mb-2">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
|
||||
clipRule="evenodd"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
No tasks assigned to this project yet.
|
||||
{t("tasks.noTasksMessage")}
|
||||
</p>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
Add a task above to get started.
|
||||
{t("tasks.addTaskMessage")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Task
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("tasks.taskName")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Priority
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("tasks.priority")}
|
||||
</th>{" "}
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Max Wait
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("tasks.maxWait")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date Started
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("tasks.dateStarted")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("tasks.status")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider w-48">
|
||||
{t("tasks.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{projectTasks.map((task) => (
|
||||
<React.Fragment key={task.id}>
|
||||
{/* Main task row */}
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{task.task_name}
|
||||
</h4>
|
||||
{task.description && (
|
||||
<button
|
||||
onClick={() => toggleDescription(task.id)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Toggle description"
|
||||
>
|
||||
<svg
|
||||
@@ -437,17 +529,17 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
variant={getPriorityVariant(task.priority)}
|
||||
size="sm"
|
||||
>
|
||||
{task.priority}
|
||||
{t(`tasks.${task.priority}`)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-gray-600">
|
||||
{task.max_wait_days} days
|
||||
<td className="px-4 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{task.max_wait_days} {t("tasks.days")}
|
||||
</td>{" "}
|
||||
<td className="px-4 py-4 text-sm text-gray-600">
|
||||
<td className="px-4 py-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{task.date_started
|
||||
? formatDate(task.date_started)
|
||||
: "Not started"}
|
||||
</td>{" "}
|
||||
: t("tasks.notStarted")}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<TaskStatusDropdownSimple
|
||||
task={task}
|
||||
@@ -456,21 +548,70 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleNotes(task.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
|
||||
title={`${taskNotes[task.id]?.length || 0} notes`}
|
||||
className="text-xs px-2 py-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 border-0"
|
||||
title={`${taskNotes[task.id]?.length || 0} ${t("tasks.notes")}`}
|
||||
>
|
||||
Notes ({taskNotes[task.id]?.length || 0})
|
||||
</button>
|
||||
<svg
|
||||
className="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
{taskNotes[task.id]?.length || 0}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditTask(task)}
|
||||
className="text-xs px-2 py-1 min-w-[60px]"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className="text-xs"
|
||||
className="text-xs px-2 py-1 min-w-[60px]"
|
||||
>
|
||||
Delete
|
||||
<svg
|
||||
className="w-3 h-3 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -481,7 +622,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
<td colSpan="6" className="px-4 py-3">
|
||||
<div className="text-sm text-gray-700">
|
||||
<span className="font-medium text-gray-900">
|
||||
Description:
|
||||
{t("tasks.description")}:
|
||||
</span>
|
||||
<p className="mt-1">{task.description}</p>
|
||||
</div>
|
||||
@@ -494,7 +635,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
<td colSpan="6" className="px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<h5 className="text-sm font-medium text-gray-900">
|
||||
Notes ({taskNotes[task.id]?.length || 0})
|
||||
{t("tasks.comments")} ({taskNotes[task.id]?.length || 0})
|
||||
</h5>
|
||||
|
||||
{/* Existing Notes */}
|
||||
@@ -512,11 +653,11 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{note.is_system && (
|
||||
{note.is_system ? (
|
||||
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||
System
|
||||
{t("admin.system")}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
{note.created_by_name && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full font-medium">
|
||||
{note.created_by_name}
|
||||
@@ -530,11 +671,6 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
{formatDate(note.note_date, {
|
||||
includeTime: true,
|
||||
})}
|
||||
{note.created_by_name && (
|
||||
<span className="ml-2">
|
||||
by {note.created_by_name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!note.is_system && (
|
||||
@@ -546,7 +682,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
)
|
||||
}
|
||||
className="ml-2 text-red-500 hover:text-red-700 text-xs font-bold"
|
||||
title="Delete note"
|
||||
title={t("tasks.deleteNote")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -560,7 +696,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a note..."
|
||||
placeholder={t("tasks.addComment")}
|
||||
value={newNote[task.id] || ""}
|
||||
onChange={(e) =>
|
||||
setNewNote((prev) => ({
|
||||
@@ -584,7 +720,7 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
!newNote[task.id]?.trim()
|
||||
}
|
||||
>
|
||||
{loadingNotes[task.id] ? "Adding..." : "Add"}
|
||||
{loadingNotes[task.id] ? t("common.saving") : t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,6 +735,133 @@ export default function ProjectTasksSection({ projectId }) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Edit Task Modal */}
|
||||
{showEditTaskModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{t("tasks.editTask")}:{" "}
|
||||
{editingTask?.custom_task_name || editingTask?.task_name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCloseEditModal}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Assignment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("tasks.assignedTo")}
|
||||
</label>
|
||||
<select
|
||||
value={editTaskForm.assigned_to}
|
||||
onChange={(e) =>
|
||||
setEditTaskForm((prev) => ({
|
||||
...prev,
|
||||
assigned_to: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">{t("projects.unassigned")}</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("tasks.priority")}
|
||||
</label>
|
||||
<select
|
||||
value={editTaskForm.priority}
|
||||
onChange={(e) =>
|
||||
setEditTaskForm((prev) => ({
|
||||
...prev,
|
||||
priority: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">{t("common.selectOption")}</option>
|
||||
<option value="low">{t("tasks.low")}</option>
|
||||
<option value="normal">{t("tasks.normal")}</option>
|
||||
<option value="high">{t("tasks.high")}</option>
|
||||
<option value="urgent">{t("tasks.urgent")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date Started */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("tasks.dateStarted")}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editTaskForm.date_started}
|
||||
onChange={(e) =>
|
||||
setEditTaskForm((prev) => ({
|
||||
...prev,
|
||||
date_started: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("tasks.status")}
|
||||
</label>
|
||||
<select
|
||||
value={editTaskForm.status}
|
||||
onChange={(e) =>
|
||||
setEditTaskForm((prev) => ({
|
||||
...prev,
|
||||
status: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">{t("common.selectOption")}</option>
|
||||
<option value="not_started">{t("taskStatus.not_started")}</option>
|
||||
<option value="pending">{t("taskStatus.pending")}</option>
|
||||
<option value="in_progress">{t("taskStatus.in_progress")}</option>
|
||||
<option value="completed">{t("taskStatus.completed")}</option>
|
||||
<option value="cancelled">{t("taskStatus.cancelled")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div> <div className="flex items-center justify-end gap-3 mt-6">
|
||||
<Button variant="outline" onClick={handleCloseEditModal}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleUpdateTask}>
|
||||
{t("tasks.updateTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
409
src/components/TaskCommentsModal.js
Normal file
409
src/components/TaskCommentsModal.js
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Button from "./ui/Button";
|
||||
import Badge from "./ui/Badge";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import { pl, enUS } from "date-fns/locale";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function TaskCommentsModal({ task, isOpen, onClose }) {
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
// Get locale for date-fns
|
||||
const locale = language === 'pl' ? pl : enUS;
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newNote, setNewNote] = useState("");
|
||||
const [loadingAdd, setLoadingAdd] = useState(false);
|
||||
|
||||
const parseNoteText = (text) => {
|
||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = linkRegex.exec(text)) !== null) {
|
||||
// Add text before the link
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
// Add the link
|
||||
parts.push(
|
||||
<a
|
||||
key={match.index}
|
||||
href={match[2]}
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && task) {
|
||||
fetchNotes();
|
||||
}
|
||||
}, [isOpen, task]);
|
||||
|
||||
const fetchNotes = async () => {
|
||||
if (!task?.id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/task-notes?task_id=${task.id}`);
|
||||
const data = await res.json();
|
||||
setNotes(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notes:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!newNote.trim() || !task?.id) return;
|
||||
|
||||
try {
|
||||
setLoadingAdd(true);
|
||||
const res = await fetch("/api/task-notes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
task_id: task.id,
|
||||
note: newNote.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNewNote("");
|
||||
await fetchNotes(); // Refresh notes
|
||||
} else {
|
||||
alert("Failed to add note");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error adding note");
|
||||
} finally {
|
||||
setLoadingAdd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (noteId) => {
|
||||
if (!confirm("Are you sure you want to delete this note?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/task-notes", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note_id: noteId }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await fetchNotes(); // Refresh notes
|
||||
} else {
|
||||
alert("Failed to delete note");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error deleting note");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
} else if (e.key === "Enter" && e.ctrlKey) {
|
||||
handleAddNote();
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityVariant = (priority) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "urgent";
|
||||
case "high":
|
||||
return "high";
|
||||
case "normal":
|
||||
return "normal";
|
||||
case "low":
|
||||
return "low";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusVariant = (status) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "cancelled":
|
||||
return "success";
|
||||
case "in_progress":
|
||||
return "secondary";
|
||||
case "pending":
|
||||
return "primary";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const formatTaskDate = (dateString, label) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = dateString.includes("T") ? parseISO(dateString) : new Date(dateString);
|
||||
return {
|
||||
label,
|
||||
relative: formatDistanceToNow(date, { addSuffix: true, locale: locale }),
|
||||
absolute: formatDate(date, { includeTime: true })
|
||||
};
|
||||
} catch (error) {
|
||||
return { label, relative: dateString, absolute: dateString };
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{task?.task_name}
|
||||
</h3>
|
||||
<Badge variant={getPriorityVariant(task?.priority)} size="sm">
|
||||
{t(`tasks.${task?.priority}`)}
|
||||
</Badge>
|
||||
<Badge variant={getStatusVariant(task?.status)} size="sm">
|
||||
{t(`taskStatus.${task?.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{t("tasks.project")}: <span className="font-medium">{task?.project_name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-xl font-semibold ml-4"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
{/* Location Information */}
|
||||
{(task?.city || task?.address) && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("projects.locationDetails")}</h4>
|
||||
{task?.city && (
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">{task.city}</p>
|
||||
)}
|
||||
{task?.address && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">{task.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment Information */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("tasks.assignedTo")}</h4>
|
||||
{task?.assigned_to_name ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">{task.assigned_to_name}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">{task.assigned_to_email}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">{t("projects.unassigned")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Timing */}
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("tasks.dateCreated")}</h4>
|
||||
{task?.max_wait_days && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">{t("tasks.maxWait")}: {task.max_wait_days} {t("tasks.days")}</p>
|
||||
)}
|
||||
{(() => {
|
||||
if (task?.status === "completed" && task?.date_completed) {
|
||||
const dateInfo = formatTaskDate(task.date_completed, t("taskStatus.completed"));
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-green-700 font-medium">{dateInfo.relative}</p>
|
||||
<p className="text-xs text-gray-600">{dateInfo.absolute}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (task?.status === "in_progress" && task?.date_started) {
|
||||
const dateInfo = formatTaskDate(task.date_started, t("tasks.dateStarted"));
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-blue-700 font-medium">{t("tasks.dateStarted")} {dateInfo.relative}</p>
|
||||
<p className="text-xs text-gray-600">{dateInfo.absolute}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (task?.date_added) {
|
||||
const dateInfo = formatTaskDate(task.date_added, t("tasks.dateCreated"));
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 font-medium">{t("tasks.dateCreated")} {dateInfo.relative}</p>
|
||||
<p className="text-xs text-gray-600">{dateInfo.absolute}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Task Description */}
|
||||
{task?.description && (
|
||||
<div className="space-y-1 md:col-span-2 lg:col-span-3">
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{t("tasks.description")}</h4>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed">{task.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/2"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h5 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{t("tasks.comments")}
|
||||
</h5>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{notes.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl">💬</span>
|
||||
</div>
|
||||
<p className="text-lg font-medium mb-1">{t("tasks.noComments")}</p>
|
||||
<p className="text-sm">Bądź pierwszy, który doda komentarz!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.note_id}
|
||||
className={`p-4 rounded-lg border flex justify-between items-start transition-colors ${
|
||||
note.is_system
|
||||
? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/30"
|
||||
: "bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{note.is_system ? (
|
||||
<span className="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 rounded-full font-medium flex items-center gap-1">
|
||||
<span>🤖</span>
|
||||
{t("admin.system")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-full font-medium flex items-center gap-1">
|
||||
<span>👤</span>
|
||||
{note.created_by_name || t("userRoles.user")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
{formatDate(note.note_date, {
|
||||
includeTime: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||
{parseNoteText(note.note)}
|
||||
</div>
|
||||
</div>
|
||||
{!note.is_system && (
|
||||
<button
|
||||
onClick={() => handleDeleteNote(note.note_id)}
|
||||
className="ml-3 p-1 text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<span className="text-sm">🗑️</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Add new comment */}
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-600">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">💬</span>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("tasks.addComment")}
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder={t("common.addNotePlaceholder")}
|
||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none shadow-sm"
|
||||
rows={3}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<span>⌨️</span>
|
||||
Naciśnij Ctrl+Enter aby wysłać lub Escape aby zamknąć
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleAddNote}
|
||||
disabled={loadingAdd || !newNote.trim()}
|
||||
>
|
||||
{loadingAdd ? t("common.saving") : `💾 ${t("tasks.addComment")}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user