Compare commits
115 Commits
main
...
5b794a59bc
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b794a59bc | |||
| 9dd208d168 | |||
| 02f31cb444 | |||
| 60b79fa360 | |||
| c9b7355f3c | |||
| eb41814c24 | |||
| e6fab5ba31 | |||
| 99853bb755 | |||
| 9b84c6b9e8 | |||
| 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 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -44,4 +44,9 @@ next-env.d.ts
|
||||
/data
|
||||
|
||||
# kosz
|
||||
/kosz
|
||||
/kosz
|
||||
|
||||
# uploads
|
||||
/public/uploads
|
||||
|
||||
/backups
|
||||
174
CONTACTS_SYSTEM_README.md
Normal file
174
CONTACTS_SYSTEM_README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Contacts Management System
|
||||
|
||||
## Overview
|
||||
|
||||
A comprehensive contacts management system has been implemented to replace the simple text field for project contacts. This system allows you to:
|
||||
|
||||
- **Create and manage a centralized contact database**
|
||||
- **Link multiple contacts to each project**
|
||||
- **Categorize contacts** (Project contacts, Contractors, Offices, Suppliers, etc.)
|
||||
- **Track contact details** (name, phone, email, company, position)
|
||||
- **Set primary contacts** for projects
|
||||
- **Search and filter** contacts easily
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Database Schema
|
||||
|
||||
**New Tables:**
|
||||
|
||||
- **`contacts`** - Stores all contact information
|
||||
- `contact_id` (Primary Key)
|
||||
- `name`, `phone`, `email`, `company`, `position`
|
||||
- `contact_type` (project/contractor/office/supplier/other)
|
||||
- `notes`, `is_active`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
- **`project_contacts`** - Junction table linking projects to contacts (many-to-many)
|
||||
- `project_id`, `contact_id` (Composite Primary Key)
|
||||
- `relationship_type`, `is_primary`
|
||||
- `added_at`, `added_by`
|
||||
|
||||
### 2. API Endpoints
|
||||
|
||||
- **`GET /api/contacts`** - List all contacts (with filters)
|
||||
- **`POST /api/contacts`** - Create new contact
|
||||
- **`GET /api/contacts/[id]`** - Get contact details
|
||||
- **`PUT /api/contacts/[id]`** - Update contact
|
||||
- **`DELETE /api/contacts/[id]`** - Delete contact (soft/hard)
|
||||
- **`GET /api/projects/[id]/contacts`** - Get project's contacts
|
||||
- **`POST /api/projects/[id]/contacts`** - Link contact to project
|
||||
- **`DELETE /api/projects/[id]/contacts`** - Unlink contact from project
|
||||
- **`PATCH /api/projects/[id]/contacts`** - Set primary contact
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
- **`ContactForm`** - Create/edit contact form
|
||||
- **`/contacts` page** - Full contacts management interface with:
|
||||
- Statistics dashboard
|
||||
- Search and filtering
|
||||
- Contact cards with quick actions
|
||||
- CRUD operations
|
||||
- **`ProjectContactSelector`** - Multi-contact selector for projects
|
||||
- View linked contacts
|
||||
- Add/remove contacts
|
||||
- Set primary contact
|
||||
- Real-time search
|
||||
|
||||
### 4. Integration
|
||||
|
||||
- **Navigation** - "Kontakty" link added to main navigation
|
||||
- **ProjectForm** - Contact text field replaced with `ProjectContactSelector`
|
||||
- **Translations** - Polish translations added to i18n
|
||||
- **Query Functions** - Comprehensive database query functions in `src/lib/queries/contacts.js`
|
||||
|
||||
## How to Use
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. **Run the migration script** to create the new tables:
|
||||
```bash
|
||||
node migrate-contacts.mjs
|
||||
```
|
||||
|
||||
2. **Start your development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Visit** `http://localhost:3000/contacts` to start adding contacts
|
||||
|
||||
### Managing Contacts
|
||||
|
||||
1. **Create Contacts**:
|
||||
- Go to `/contacts`
|
||||
- Click "Dodaj kontakt"
|
||||
- Fill in contact details
|
||||
- Select contact type (Project/Contractor/Office/Supplier/Other)
|
||||
|
||||
2. **Link Contacts to Projects**:
|
||||
- Edit any project
|
||||
- In the "Kontakty do projektu" section
|
||||
- Click "+ Dodaj kontakt"
|
||||
- Search and add contacts
|
||||
- Set one as primary if needed
|
||||
|
||||
3. **View Contact Details**:
|
||||
- Contacts page shows all contacts with:
|
||||
- Contact information (phone, email, company)
|
||||
- Number of linked projects
|
||||
- Contact type badges
|
||||
- Edit or delete contacts as needed
|
||||
|
||||
### Contact Types
|
||||
|
||||
- **Kontakt projektowy (Project)** - Project-specific contacts
|
||||
- **Wykonawca (Contractor)** - Construction contractors
|
||||
- **Urząd (Office)** - Government offices, municipalities
|
||||
- **Dostawca (Supplier)** - Material suppliers, vendors
|
||||
- **Inny (Other)** - Any other type of contact
|
||||
|
||||
### Features
|
||||
|
||||
- **Search** - Search by name, phone, email, or company
|
||||
- **Filter** - Filter by contact type
|
||||
- **Statistics** - See breakdown of contacts by type
|
||||
- **Multiple Contacts per Project** - Link as many contacts as needed
|
||||
- **Primary Contact** - Mark one contact as primary for each project
|
||||
- **Bidirectional Links** - See which projects a contact is linked to
|
||||
- **Soft Delete** - Deleted contacts are marked inactive, not removed
|
||||
|
||||
## Database Migration Notes
|
||||
|
||||
- The **old `contact` text field** in the `projects` table is still present
|
||||
- It hasn't been removed for backward compatibility
|
||||
- You can manually migrate old contact data by:
|
||||
1. Creating contacts from the old text data
|
||||
2. Linking them to the appropriate projects
|
||||
3. The old field will remain for reference
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── contacts/
|
||||
│ │ │ ├── route.js # List/Create contacts
|
||||
│ │ │ └── [id]/
|
||||
│ │ │ └── route.js # Get/Update/Delete contact
|
||||
│ │ └── projects/
|
||||
│ │ └── [id]/
|
||||
│ │ └── contacts/
|
||||
│ │ └── route.js # Link/unlink contacts to project
|
||||
│ └── contacts/
|
||||
│ └── page.js # Contacts management page
|
||||
├── components/
|
||||
│ ├── ContactForm.js # Contact form component
|
||||
│ └── ProjectContactSelector.js # Project contact selector
|
||||
└── lib/
|
||||
├── queries/
|
||||
│ └── contacts.js # Database query functions
|
||||
└── init-db.js # Database schema with new tables
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements you could add:
|
||||
|
||||
- Contact import/export (CSV, Excel)
|
||||
- Contact groups or tags
|
||||
- Contact activity history
|
||||
- Email integration
|
||||
- Contact notes and history
|
||||
- Duplicate contact detection
|
||||
- Contact merge functionality
|
||||
- Advanced relationship types
|
||||
- Contact sharing between projects
|
||||
- Contact reminders/follow-ups
|
||||
|
||||
## Support
|
||||
|
||||
The old contact text field remains in the database, so no existing data is lost. You can gradually migrate to the new system at your own pace.
|
||||
|
||||
Enjoy your new contacts management system! 🎉
|
||||
410
DEPLOYMENT_GUIDE_TEMPLATE.md
Normal file
410
DEPLOYMENT_GUIDE_TEMPLATE.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# Docker Git Deployment Strategy - Quick Guide
|
||||
|
||||
A proven deployment strategy for Next.js apps (or any Node.js app) on a VPS using Docker with Git integration.
|
||||
|
||||
## Quick Overview
|
||||
|
||||
**Strategy**: Docker containers + Git repo integration + Zero-downtime deployments
|
||||
**Benefits**: Reproducible builds, easy rollbacks, consistent environments
|
||||
**Time to setup**: ~30 minutes
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Docker Files
|
||||
|
||||
### `Dockerfile` (Production)
|
||||
```dockerfile
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone (adjust for your region)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# Install git for repo cloning
|
||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Support building from Git repo
|
||||
ARG GIT_REPO_URL
|
||||
ARG GIT_BRANCH=main
|
||||
ARG GIT_COMMIT
|
||||
|
||||
# Clone from git OR use 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 ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
```
|
||||
|
||||
### `docker-entrypoint.sh`
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "🚀 Starting application..."
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p /app/data
|
||||
mkdir -p /app/public/uploads
|
||||
|
||||
# Initialize database, create admin, etc.
|
||||
node scripts/init-setup.js
|
||||
|
||||
# Start the app
|
||||
exec npm start
|
||||
```
|
||||
|
||||
### `docker-compose.prod.yml`
|
||||
```yaml
|
||||
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" # HOST:CONTAINER
|
||||
volumes:
|
||||
- ./data:/app/data # Persist database
|
||||
- ./uploads:/app/public/uploads # Persist files
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=Europe/Warsaw
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- AUTH_TRUST_HOST=true
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Create Deployment Script
|
||||
|
||||
### `deploy.sh` (Linux/Mac)
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
GIT_REPO_URL=${1:-""}
|
||||
GIT_BRANCH=${2:-"main"}
|
||||
GIT_COMMIT=${3:-""}
|
||||
|
||||
# Load environment variables
|
||||
if [ -f .env.production ]; then
|
||||
export $(grep -v '^#' .env.production | xargs)
|
||||
fi
|
||||
|
||||
# Validate critical vars
|
||||
if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$NEXTAUTH_URL" ]; then
|
||||
echo "ERROR: Set NEXTAUTH_SECRET and NEXTAUTH_URL in .env.production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build from Git or local files
|
||||
if [ -z "$GIT_REPO_URL" ]; then
|
||||
echo "Building from local files..."
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
else
|
||||
echo "Building from git: $GIT_REPO_URL (branch: $GIT_BRANCH)"
|
||||
GIT_REPO_URL=$GIT_REPO_URL GIT_BRANCH=$GIT_BRANCH GIT_COMMIT=$GIT_COMMIT \
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
fi
|
||||
|
||||
# Deploy
|
||||
echo "Deploying..."
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
echo "✅ Deployment completed!"
|
||||
echo "Application running at: $NEXTAUTH_URL (port 3001 on host)"
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Configure Environment
|
||||
|
||||
### `.env.production`
|
||||
```bash
|
||||
# Generate secret: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=your-super-long-random-secret-at-least-32-chars
|
||||
|
||||
# Your public URL
|
||||
NEXTAUTH_URL=https://yourdomain.com
|
||||
|
||||
NODE_ENV=production
|
||||
AUTH_TRUST_HOST=true
|
||||
```
|
||||
|
||||
**⚠️ NEVER commit `.env.production` to Git!**
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Setup VPS
|
||||
|
||||
### Initial VPS Setup
|
||||
```bash
|
||||
# SSH to VPS
|
||||
ssh user@your-vps-ip
|
||||
|
||||
# Install Docker & Docker Compose
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
sudo apt install docker-compose-plugin
|
||||
|
||||
# Logout and login again for docker group to take effect
|
||||
exit
|
||||
```
|
||||
|
||||
### Setup Project Directory
|
||||
```bash
|
||||
ssh user@your-vps-ip
|
||||
|
||||
# Create project directory
|
||||
mkdir -p ~/app
|
||||
cd ~/app
|
||||
|
||||
# Copy deployment files (from local machine):
|
||||
# scp docker-compose.prod.yml deploy.sh user@vps-ip:~/app/
|
||||
# scp .env.production user@vps-ip:~/app/
|
||||
|
||||
# OR clone entire repo if using local file deployment:
|
||||
git clone https://your-repo-url.git .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Setup Nginx Reverse Proxy
|
||||
|
||||
### Install Nginx
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### Configure Nginx
|
||||
Create `/etc/nginx/sites-available/yourapp`:
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable site:
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### Setup SSL (HTTPS)
|
||||
```bash
|
||||
sudo certbot --nginx -d yourdomain.com
|
||||
# Follow prompts to get free SSL certificate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Deploy!
|
||||
|
||||
### Deployment Methods
|
||||
|
||||
**Method 1: From Local Files**
|
||||
```bash
|
||||
cd ~/app
|
||||
git pull origin main # Update code
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
**Method 2: From Git Repository**
|
||||
```bash
|
||||
cd ~/app
|
||||
./deploy.sh https://git.yourserver.com/user/repo.git main
|
||||
```
|
||||
|
||||
**Method 3: Specific Commit**
|
||||
```bash
|
||||
./deploy.sh https://git.yourserver.com/user/repo.git main abc123def
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ongoing Maintenance
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
### Restart App
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
```
|
||||
|
||||
### Full Rebuild (for Dockerfile changes)
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Check Container Status
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
docker-compose -f docker-compose.prod.yml exec app date # Check timezone
|
||||
```
|
||||
|
||||
### Backup Data
|
||||
```bash
|
||||
# Automated daily database backups are scheduled at 2 AM
|
||||
# Backups are stored in ./backups/ directory, keeping last 30
|
||||
# Check backup logs: docker-compose -f docker-compose.prod.yml exec app cat /app/data/backup.log
|
||||
|
||||
# Manual backup (if needed)
|
||||
tar -czf backup-$(date +%Y%m%d).tar.gz data/ uploads/
|
||||
|
||||
# Download backups to local machine
|
||||
scp user@vps-ip:~/app/backups/backup-*.sqlite ./local-backups/
|
||||
```
|
||||
|
||||
### Rollback to Previous Version
|
||||
```bash
|
||||
# If using Git commits
|
||||
./deploy.sh https://git.yourserver.com/user/repo.git main PREVIOUS_COMMIT_HASH
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml logs app
|
||||
docker-compose -f docker-compose.prod.yml exec app sh # Get shell inside container
|
||||
```
|
||||
|
||||
### Timezone issues
|
||||
- Make sure `TZ` env var is set in docker-compose
|
||||
- Rebuild image: timezone config is baked in during build
|
||||
- Verify: `docker-compose -f docker-compose.prod.yml exec app date`
|
||||
|
||||
### Permission issues with volumes
|
||||
```bash
|
||||
# Fix ownership
|
||||
sudo chown -R $USER:$USER data/ uploads/
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
# Check what's using port 3001
|
||||
sudo netstat -tulpn | grep 3001
|
||||
# Change port in docker-compose.prod.yml if needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Strong `NEXTAUTH_SECRET` generated (min 32 chars)
|
||||
- [ ] `.env.production` has secure permissions: `chmod 600 .env.production`
|
||||
- [ ] Firewall configured: `sudo ufw allow 80,443/tcp`
|
||||
- [ ] SSL certificate installed via Certbot
|
||||
- [ ] Regular security updates: `sudo apt update && sudo apt upgrade`
|
||||
- [ ] Docker images updated periodically
|
||||
- [ ] Database backups automated
|
||||
- [ ] Git credentials NOT stored in environment files
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Deploy from Git
|
||||
./deploy.sh https://git.server.com/user/repo.git main
|
||||
|
||||
# Deploy from local files
|
||||
git pull && ./deploy.sh
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Restart
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
|
||||
# Rebuild completely
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# Backup
|
||||
tar -czf backup-$(date +%Y%m%d).tar.gz data/ uploads/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Advantages of This Strategy
|
||||
|
||||
✅ **Git Integration**: Deploy specific commits, branches, or tags
|
||||
✅ **Reproducible**: Same build every time
|
||||
✅ **Easy Rollbacks**: Just deploy previous commit
|
||||
✅ **Isolated**: Container doesn't pollute host system
|
||||
✅ **Persistent Data**: Volumes survive container rebuilds
|
||||
✅ **Zero-Config Deployment**: Clone and run `./deploy.sh`
|
||||
✅ **Works Offline**: Can build from local files without Git
|
||||
✅ **Auto-Restart**: Container restarts on crash or reboot
|
||||
|
||||
---
|
||||
|
||||
## Notes to Future Self
|
||||
|
||||
1. **Always use volumes** for data persistence (database, uploads)
|
||||
2. **Timezone matters**: Set it in both Dockerfile and docker-compose
|
||||
3. **Rebuild vs Restart**: Dockerfile changes need rebuild, code changes just restart
|
||||
4. **Port mapping**: Be consistent (I use 3001:3000 - HOST:CONTAINER)
|
||||
5. **Environment secrets**: Never commit, always use `.env.production`
|
||||
6. **Nginx**: Don't forget to setup reverse proxy and SSL
|
||||
7. **Git auth**: For private repos, use SSH keys or tokens in URL
|
||||
8. **Test locally first**: Use `docker-compose.yml` with `Dockerfile.dev`
|
||||
9. **Monitor logs**: Set up log rotation if app is chatty
|
||||
10. **Automate backups**: Cron job for daily database/file backups
|
||||
|
||||
---
|
||||
|
||||
**Time to deploy a new app with this strategy: ~20 minutes** ⚡
|
||||
|
||||
Copy these files, adjust for your app (mainly environment variables and init scripts), and you're production-ready!
|
||||
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 and cron
|
||||
RUN apt-get update && apt-get install -y git cron && 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 and cron for development
|
||||
RUN apt-get update && apt-get install -y git cron && 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.");
|
||||
61
backup-db.mjs
Normal file
61
backup-db.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = "data/database.sqlite";
|
||||
const backupDir = "backups";
|
||||
|
||||
// Ensure backup directory exists
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir);
|
||||
}
|
||||
|
||||
// Generate timestamp for backup filename
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const backupPath = path.join(backupDir, `backup-${timestamp}.sqlite`);
|
||||
|
||||
// Create backup by copying the database file
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
|
||||
console.log(`✅ Backup created: ${backupPath}`);
|
||||
|
||||
// Send notification if configured
|
||||
try {
|
||||
const { createNotification, NOTIFICATION_TYPES } = await import("./src/lib/notifications.js");
|
||||
const db = (await import("./src/lib/db.js")).default;
|
||||
|
||||
const setting = db.prepare("SELECT value FROM settings WHERE key = 'backup_notification_user_id'").get();
|
||||
if (setting && setting.value) {
|
||||
const userId = setting.value;
|
||||
await createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPES.SYSTEM_ANNOUNCEMENT,
|
||||
title: "Database Backup Completed",
|
||||
message: `Daily database backup completed successfully. Backup file: ${backupPath}`,
|
||||
priority: "normal"
|
||||
});
|
||||
console.log(`📢 Notification sent to user ${userId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send backup notification:", error);
|
||||
}
|
||||
|
||||
// Cleanup: keep only last 30 backups
|
||||
const files = fs.readdirSync(backupDir)
|
||||
.filter(f => f.startsWith('backup-'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(backupDir, f),
|
||||
mtime: fs.statSync(path.join(backupDir, f)).mtime
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime); // Sort by modification time, newest first
|
||||
|
||||
if (files.length > 30) {
|
||||
const toDelete = files.slice(30);
|
||||
toDelete.forEach(f => {
|
||||
fs.unlinkSync(f.path);
|
||||
console.log(`🗑️ Deleted old backup: ${f.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📁 Total backups kept: ${Math.min(files.length, 30)}`);
|
||||
22
check-contacts.mjs
Normal file
22
check-contacts.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import db from './src/lib/db.js';
|
||||
|
||||
console.log('Checking contacts in database...\n');
|
||||
|
||||
const contacts = db.prepare('SELECT contact_id, name, phone, email, is_active, contact_type FROM contacts LIMIT 10').all();
|
||||
|
||||
console.log(`Total contacts found: ${contacts.length}\n`);
|
||||
|
||||
if (contacts.length > 0) {
|
||||
console.log('Sample contacts:');
|
||||
contacts.forEach(c => {
|
||||
console.log(` ID: ${c.contact_id}, Name: ${c.name}, Phone: ${c.phone || 'N/A'}, Email: ${c.email || 'N/A'}, Active: ${c.is_active}, Type: ${c.contact_type}`);
|
||||
});
|
||||
} else {
|
||||
console.log('No contacts found in database!');
|
||||
}
|
||||
|
||||
const activeCount = db.prepare('SELECT COUNT(*) as count FROM contacts WHERE is_active = 1').get();
|
||||
console.log(`\nActive contacts: ${activeCount.count}`);
|
||||
|
||||
const totalCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
|
||||
console.log(`Total contacts: ${totalCount.count}`);
|
||||
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"
|
||||
24
docker-compose.prod.yml
Normal file
24
docker-compose.prod.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
- ./backups:/app/backups
|
||||
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,16 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Europe/Warsaw
|
||||
|
||||
32
docker-entrypoint-dev.sh
Normal file
32
docker-entrypoint-dev.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/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
|
||||
|
||||
# Set up daily backup cron job (runs at 2 AM daily)
|
||||
echo "⏰ Setting up daily backup cron job..."
|
||||
echo "0 2 * * * cd /app && node backup-db.mjs >> /app/data/backup.log 2>&1" > /etc/cron.d/backup-cron
|
||||
chmod 0644 /etc/cron.d/backup-cron
|
||||
crontab /etc/cron.d/backup-cron
|
||||
service cron start
|
||||
|
||||
# Start the development server
|
||||
echo "✅ Starting development server..."
|
||||
exec npm run dev
|
||||
36
docker-entrypoint.sh
Normal file
36
docker-entrypoint.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/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
|
||||
|
||||
# Set up daily backup cron job (runs at 2 AM daily)
|
||||
echo "⏰ Setting up daily backup cron job..."
|
||||
echo "0 2 * * * cd /app && /usr/local/bin/node backup-db.mjs >> /app/data/backup.log 2>&1" > /etc/cron.d/backup-cron
|
||||
chmod 0644 /etc/cron.d/backup-cron
|
||||
crontab /etc/cron.d/backup-cron
|
||||
service cron start
|
||||
|
||||
# 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);
|
||||
}
|
||||
25
migrate-add-settings-table.mjs
Normal file
25
migrate-add-settings-table.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
console.log("Adding settings table...");
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by TEXT,
|
||||
FOREIGN KEY (updated_by) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO settings (key, value, description) VALUES
|
||||
('backup_notification_user_id', '', 'User ID to receive backup completion notifications');
|
||||
`);
|
||||
|
||||
console.log("✅ Settings table created successfully");
|
||||
} catch (error) {
|
||||
console.error("Error creating settings table:", error);
|
||||
}
|
||||
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);
|
||||
}
|
||||
46
migrate-contacts.mjs
Normal file
46
migrate-contacts.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import db from './src/lib/db.js';
|
||||
import initializeDatabase from './src/lib/init-db.js';
|
||||
|
||||
console.log('🚀 Initializing contacts tables...\n');
|
||||
|
||||
try {
|
||||
// Run database initialization which will create the new contacts tables
|
||||
initializeDatabase();
|
||||
|
||||
console.log('✅ Contacts tables created successfully!\n');
|
||||
|
||||
// Check if there are projects with contact data in the old text field
|
||||
const projectsWithContacts = db.prepare(`
|
||||
SELECT project_id, project_name, contact
|
||||
FROM projects
|
||||
WHERE contact IS NOT NULL AND contact != ''
|
||||
`).all();
|
||||
|
||||
if (projectsWithContacts.length > 0) {
|
||||
console.log(`📋 Found ${projectsWithContacts.length} projects with contact information in the old text field.\n`);
|
||||
console.log('Sample contacts that could be migrated:');
|
||||
projectsWithContacts.slice(0, 5).forEach(p => {
|
||||
console.log(` - ${p.project_name}: "${p.contact}"`);
|
||||
});
|
||||
console.log('\nℹ️ You can manually create contacts from the /contacts page and link them to projects.');
|
||||
console.log(' The old contact field will remain in the database for reference.\n');
|
||||
} else {
|
||||
console.log('ℹ️ No existing contact data found in projects.\n');
|
||||
}
|
||||
|
||||
// Show table statistics
|
||||
const contactsCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
|
||||
const projectContactsCount = db.prepare('SELECT COUNT(*) as count FROM project_contacts').get();
|
||||
|
||||
console.log('📊 Database Statistics:');
|
||||
console.log(` - Contacts: ${contactsCount.count}`);
|
||||
console.log(` - Project-Contact Links: ${projectContactsCount.count}`);
|
||||
console.log('\n✨ Migration complete! You can now:');
|
||||
console.log(' 1. Visit /contacts to manage your contacts');
|
||||
console.log(' 2. Add/edit projects to link contacts');
|
||||
console.log(' 3. View linked contacts in project details\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error during migration:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
188
migrate-project-contacts.mjs
Normal file
188
migrate-project-contacts.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
import db from './src/lib/db.js';
|
||||
import initializeDatabase from './src/lib/init-db.js';
|
||||
|
||||
console.log('🚀 Migrating contact data from projects...\n');
|
||||
|
||||
try {
|
||||
// Run database initialization to ensure tables exist
|
||||
initializeDatabase();
|
||||
|
||||
console.log('✅ Database tables verified\n');
|
||||
|
||||
// Get all projects with contact data
|
||||
const projectsWithContacts = db.prepare(`
|
||||
SELECT project_id, project_name, contact
|
||||
FROM projects
|
||||
WHERE contact IS NOT NULL AND contact != '' AND TRIM(contact) != ''
|
||||
`).all();
|
||||
|
||||
if (projectsWithContacts.length === 0) {
|
||||
console.log('ℹ️ No contact data found in projects to migrate.\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${projectsWithContacts.length} projects with contact information\n`);
|
||||
|
||||
let created = 0;
|
||||
let linked = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const createContact = db.prepare(`
|
||||
INSERT INTO contacts (name, phone, email, contact_type, notes, is_active)
|
||||
VALUES (?, ?, ?, 'project', ?, 1)
|
||||
`);
|
||||
|
||||
const linkContact = db.prepare(`
|
||||
INSERT OR IGNORE INTO project_contacts (project_id, contact_id, is_primary, relationship_type)
|
||||
VALUES (?, ?, 1, 'general')
|
||||
`);
|
||||
|
||||
// Process each project
|
||||
for (const project of projectsWithContacts) {
|
||||
try {
|
||||
const contactText = project.contact.trim();
|
||||
|
||||
// Parse contact information - common formats:
|
||||
// "Jan Kowalski, tel. 123-456-789"
|
||||
// "Jan Kowalski 123-456-789"
|
||||
// "123-456-789"
|
||||
// "Jan Kowalski"
|
||||
|
||||
let name = '';
|
||||
let phone = '';
|
||||
let email = '';
|
||||
let notes = '';
|
||||
|
||||
// Try to extract email
|
||||
const emailPattern = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/;
|
||||
const emailMatch = contactText.match(emailPattern);
|
||||
if (emailMatch) {
|
||||
email = emailMatch[1].trim();
|
||||
}
|
||||
|
||||
// Try to extract phone number (various formats)
|
||||
const phonePatterns = [
|
||||
/(?:\+?48)?[\s-]?(\d{3}[\s-]?\d{3}[\s-]?\d{3})/, // Polish: 123-456-789, 123 456 789, +48 123456789
|
||||
/(?:\+?48)?[\s-]?(\d{9})/, // 9 digits
|
||||
/tel\.?\s*[:.]?\s*([+\d\s-]+)/i, // tel. 123-456-789
|
||||
/phone\s*[:.]?\s*([+\d\s-]+)/i, // phone: 123-456-789
|
||||
/(\d{3}[-\s]?\d{3}[-\s]?\d{3})/, // Generic phone pattern
|
||||
];
|
||||
|
||||
for (const pattern of phonePatterns) {
|
||||
const match = contactText.match(pattern);
|
||||
if (match) {
|
||||
phone = match[1] || match[0];
|
||||
phone = phone.replace(/\s+/g, ' ').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract name (text before phone/email or comma)
|
||||
let textForName = contactText;
|
||||
|
||||
if (phone) {
|
||||
// Remove phone from text to get name
|
||||
textForName = textForName.replace(phone, '');
|
||||
}
|
||||
if (email) {
|
||||
// Remove email from text to get name
|
||||
textForName = textForName.replace(email, '');
|
||||
}
|
||||
|
||||
// Remove common prefixes like "tel.", "phone:", "email:", commas, etc.
|
||||
name = textForName.replace(/tel\.?|phone:?|email:?|e-mail:?|,/gi, '').trim();
|
||||
|
||||
// Clean up name
|
||||
name = name.replace(/^[,\s-]+|[,\s-]+$/g, '').trim();
|
||||
|
||||
// If we couldn't extract structured data, use project name and put original text in notes
|
||||
if (!phone && !email) {
|
||||
// No structured contact info found, put everything in notes
|
||||
notes = `${contactText}`;
|
||||
name = project.project_name;
|
||||
} else if (!name) {
|
||||
// We have phone/email but no clear name
|
||||
name = project.project_name;
|
||||
}
|
||||
|
||||
// Check if this contact already exists (by name, phone, or email)
|
||||
let existingContact = null;
|
||||
if (phone) {
|
||||
existingContact = db.prepare(`
|
||||
SELECT contact_id FROM contacts
|
||||
WHERE phone LIKE ? OR phone LIKE ?
|
||||
`).get(`%${phone}%`, `%${phone.replace(/\s/g, '')}%`);
|
||||
}
|
||||
|
||||
if (!existingContact && email) {
|
||||
existingContact = db.prepare(`
|
||||
SELECT contact_id FROM contacts
|
||||
WHERE LOWER(email) = LOWER(?)
|
||||
`).get(email);
|
||||
}
|
||||
|
||||
if (!existingContact && name && name !== project.project_name) {
|
||||
existingContact = db.prepare(`
|
||||
SELECT contact_id FROM contacts
|
||||
WHERE LOWER(name) = LOWER(?)
|
||||
`).get(name);
|
||||
}
|
||||
|
||||
let contactId;
|
||||
|
||||
if (existingContact) {
|
||||
contactId = existingContact.contact_id;
|
||||
console.log(` ♻️ Using existing contact "${name}" for project "${project.project_name}"`);
|
||||
} else {
|
||||
// Create new contact
|
||||
const result = createContact.run(
|
||||
name,
|
||||
phone || null,
|
||||
email || null,
|
||||
notes || `Przeniesiono z projektu: ${project.project_name}`
|
||||
);
|
||||
contactId = result.lastInsertRowid;
|
||||
created++;
|
||||
|
||||
const contactInfo = [];
|
||||
if (phone) contactInfo.push(`📞 ${phone}`);
|
||||
if (email) contactInfo.push(`📧 ${email}`);
|
||||
const infoStr = contactInfo.length > 0 ? ` (${contactInfo.join(', ')})` : '';
|
||||
|
||||
console.log(` ✨ Created contact "${name}"${infoStr} for project "${project.project_name}"`);
|
||||
}
|
||||
|
||||
// Link contact to project
|
||||
linkContact.run(project.project_id, contactId);
|
||||
linked++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error processing project "${project.project_name}":`, error.message);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n📊 Migration Summary:');
|
||||
console.log(` - Contacts created: ${created}`);
|
||||
console.log(` - Project-contact links created: ${linked}`);
|
||||
console.log(` - Projects skipped: ${skipped}`);
|
||||
console.log(` - Total projects processed: ${projectsWithContacts.length}`);
|
||||
|
||||
// Show final statistics
|
||||
const contactsCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
|
||||
const projectContactsCount = db.prepare('SELECT COUNT(*) as count FROM project_contacts').get();
|
||||
|
||||
console.log('\n📈 Current Database Statistics:');
|
||||
console.log(` - Total contacts: ${contactsCount.count}`);
|
||||
console.log(` - Total project-contact links: ${projectContactsCount.count}`);
|
||||
|
||||
console.log('\n✨ Migration complete!');
|
||||
console.log(' - Visit /contacts to view and manage your contacts');
|
||||
console.log(' - Edit projects to see linked contacts');
|
||||
console.log(' - The old contact text field is preserved for reference\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error during migration:', error);
|
||||
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!');
|
||||
183
src/app/admin/settings/page.js
Normal file
183
src/app/admin/settings/page.js
Normal file
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
// Redirect if not admin
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (!session || session.user.role !== "admin") {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
fetchSettings();
|
||||
fetchUsers();
|
||||
}, [session, status, router]);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = async (key, value) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state
|
||||
setSettings(prev =>
|
||||
prev.map(setting =>
|
||||
setting.key === key ? { ...setting, value } : setting
|
||||
)
|
||||
);
|
||||
alert("Setting updated successfully!");
|
||||
} else {
|
||||
alert("Failed to update setting");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating setting:", error);
|
||||
alert("Error updating setting");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupUserChange = (userId) => {
|
||||
updateSetting("backup_notification_user_id", userId);
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || session.user.role !== "admin") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You need admin privileges to access this page.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const backupUserSetting = settings.find(s => s.key === "backup_notification_user_id");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Admin Settings
|
||||
</h1>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back to Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Backup Notifications Setting */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Backup Notifications
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Select which user should receive notifications when daily database backups are completed.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Notification Recipient
|
||||
</label>
|
||||
<select
|
||||
value={backupUserSetting?.value || ""}
|
||||
onChange={(e) => handleBackupUserChange(e.target.value)}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="">No notifications</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.username}) - {user.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{saving && (
|
||||
<p className="text-sm text-blue-600">Saving...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Future settings can be added here */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
System Information
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Daily database backups run automatically at 2 AM and keep the last 30 backups.
|
||||
Backups are stored in the <code className="bg-gray-100 px-1 rounded">./backups/</code> directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
52
src/app/api/admin/settings/route.js
Normal file
52
src/app/api/admin/settings/route.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
import db from "@/lib/db";
|
||||
|
||||
// GET: Get all settings
|
||||
async function getSettingsHandler() {
|
||||
try {
|
||||
const settings = db.prepare("SELECT * FROM settings ORDER BY key").all();
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error("Error fetching settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update a setting
|
||||
async function updateSettingHandler(request) {
|
||||
try {
|
||||
const { key, value } = await request.json();
|
||||
|
||||
if (!key || value === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Key and value are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedBy = request.user.id;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO settings (key, value, updated_at, updated_by)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, ?)
|
||||
`);
|
||||
|
||||
stmt.run(key, value, updatedBy);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating setting:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update setting" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require admin authentication
|
||||
export const GET = withAdminAuth(getSettingsHandler);
|
||||
export const PUT = withAdminAuth(updateSettingHandler);
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/app/api/contacts/[id]/projects/route.js
Normal file
40
src/app/api/contacts/[id]/projects/route.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import db from "@/lib/db";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET /api/contacts/[id]/projects - Get all projects linked to a contact
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
try {
|
||||
const contactId = params.id;
|
||||
|
||||
// Get all projects linked to this contact with relationship details
|
||||
const projects = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.project_status,
|
||||
pc.relationship_type,
|
||||
pc.is_primary,
|
||||
pc.added_at
|
||||
FROM projects p
|
||||
INNER JOIN project_contacts pc ON p.project_id = pc.project_id
|
||||
WHERE pc.contact_id = ?
|
||||
ORDER BY pc.is_primary DESC, p.project_name ASC
|
||||
`
|
||||
)
|
||||
.all(contactId);
|
||||
|
||||
return NextResponse.json({
|
||||
projects,
|
||||
count: projects.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact projects:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch projects" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
103
src/app/api/contacts/[id]/route.js
Normal file
103
src/app/api/contacts/[id]/route.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getContactById,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
hardDeleteContact,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get contact by ID
|
||||
async function getContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const contact = getContactById(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update contact
|
||||
async function updateContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const data = await req.json();
|
||||
|
||||
// Validate contact type if provided
|
||||
if (data.contact_type) {
|
||||
const validTypes = [
|
||||
"project",
|
||||
"contractor",
|
||||
"office",
|
||||
"supplier",
|
||||
"other",
|
||||
];
|
||||
if (!validTypes.includes(data.contact_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid contact type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contact = updateContact(contactId, data);
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error updating contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete contact (soft delete or hard delete)
|
||||
async function deleteContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const hard = searchParams.get("hard") === "true";
|
||||
|
||||
if (hard) {
|
||||
// Hard delete - permanently remove
|
||||
hardDeleteContact(contactId);
|
||||
} else {
|
||||
// Soft delete - set is_active to 0
|
||||
deleteContact(contactId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Contact deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withAuth(getContactHandler);
|
||||
export const PUT = withAuth(updateContactHandler);
|
||||
export const DELETE = withAuth(deleteContactHandler);
|
||||
73
src/app/api/contacts/route.js
Normal file
73
src/app/api/contacts/route.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllContacts,
|
||||
createContact,
|
||||
getContactStats,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all contacts with optional filters
|
||||
async function getContactsHandler(req) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const filters = {
|
||||
is_active: searchParams.get("is_active")
|
||||
? searchParams.get("is_active") === "true"
|
||||
: undefined,
|
||||
contact_type: searchParams.get("contact_type") || undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
};
|
||||
|
||||
// Check if stats are requested
|
||||
if (searchParams.get("stats") === "true") {
|
||||
const stats = getContactStats();
|
||||
return NextResponse.json(stats);
|
||||
}
|
||||
|
||||
const contacts = getAllContacts(filters);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch contacts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create new contact
|
||||
async function createContactHandler(req) {
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate contact type
|
||||
const validTypes = ["project", "contractor", "office", "supplier", "other"];
|
||||
if (data.contact_type && !validTypes.includes(data.contact_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid contact type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const contact = createContact(data);
|
||||
return NextResponse.json(contact, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withAuth(getContactsHandler);
|
||||
export const POST = withAuth(createContactHandler);
|
||||
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);
|
||||
|
||||
73
src/app/api/notifications/route.js
Normal file
73
src/app/api/notifications/route.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
getUserNotifications,
|
||||
markNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
} from "@/lib/notifications";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeRead = searchParams.get("includeRead") === "true";
|
||||
const limit = parseInt(searchParams.get("limit") || "50");
|
||||
const offset = parseInt(searchParams.get("offset") || "0");
|
||||
|
||||
const notifications = await getUserNotifications(session.user.id, {
|
||||
includeRead,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
notifications,
|
||||
unreadCount,
|
||||
hasMore: notifications.length === limit,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching notifications:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, notificationIds } = body;
|
||||
|
||||
if (action === "markAsRead") {
|
||||
await markNotificationsAsRead(session.user.id, notificationIds);
|
||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
unreadCount,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("Error updating notifications:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/notifications/unread-count/route.js
Normal file
23
src/app/api/notifications/unread-count/route.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getUnreadNotificationCount } from "@/lib/notifications";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
||||
|
||||
return NextResponse.json({ unreadCount });
|
||||
} catch (error) {
|
||||
console.error("Error fetching unread notification count:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
111
src/app/api/projects/[id]/contacts/route.js
Normal file
111
src/app/api/projects/[id]/contacts/route.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getProjectContacts,
|
||||
linkContactToProject,
|
||||
unlinkContactFromProject,
|
||||
setPrimaryContact,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all contacts for a project
|
||||
async function getProjectContactsHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching project contacts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch project contacts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Link contact to project
|
||||
async function linkContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { contactId, relationshipType, isPrimary } = await req.json();
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
linkContactToProject(
|
||||
projectId,
|
||||
contactId,
|
||||
relationshipType || "general",
|
||||
isPrimary || false,
|
||||
userId
|
||||
);
|
||||
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error linking contact to project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to link contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Unlink contact from project
|
||||
async function unlinkContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const contactId = parseInt(searchParams.get("contactId"));
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
unlinkContactFromProject(projectId, contactId);
|
||||
return NextResponse.json({ message: "Contact unlinked successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error unlinking contact from project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to unlink contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Set primary contact
|
||||
async function setPrimaryContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { contactId } = await req.json();
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
setPrimaryContact(projectId, contactId);
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error setting primary contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to set primary contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuth(getProjectContactsHandler);
|
||||
export const POST = withAuth(linkContactHandler);
|
||||
export const DELETE = withAuth(unlinkContactHandler);
|
||||
export const PATCH = withAuth(setPrimaryContactHandler);
|
||||
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>
|
||||
);
|
||||
}
|
||||
615
src/app/contacts/page.js
Normal file
615
src/app/contacts/page.js
Normal file
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [filteredContacts, setFilteredContacts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [stats, setStats] = useState(null);
|
||||
const [selectedContact, setSelectedContact] = useState(null);
|
||||
const [contactProjects, setContactProjects] = useState([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/auth/signin");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Fetch contacts
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// Filter contacts
|
||||
useEffect(() => {
|
||||
let filtered = contacts;
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(contact) =>
|
||||
contact.name?.toLowerCase().includes(search) ||
|
||||
contact.phone?.toLowerCase().includes(search) ||
|
||||
contact.email?.toLowerCase().includes(search) ||
|
||||
contact.company?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(contact) => contact.contact_type === typeFilter
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredContacts(filtered);
|
||||
}, [contacts, searchTerm, typeFilter]);
|
||||
|
||||
async function fetchContacts() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?is_active=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Fetched contacts:', data);
|
||||
setContacts(data);
|
||||
} else {
|
||||
console.error('Failed to fetch contacts, status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?stats=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(contactId) {
|
||||
if (!confirm("Czy na pewno chcesz usunąć ten kontakt?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${contactId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
alert("Nie udało się usunąć kontaktu");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(contact) {
|
||||
setEditingContact(contact);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function handleViewDetails(contact) {
|
||||
setSelectedContact(contact);
|
||||
setLoadingProjects(true);
|
||||
|
||||
try {
|
||||
// Fetch projects linked to this contact
|
||||
const response = await fetch(`/api/contacts/${contact.contact_id}/projects`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setContactProjects(data.projects || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact projects:", error);
|
||||
setContactProjects([]);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetails() {
|
||||
setSelectedContact(null);
|
||||
setContactProjects([]);
|
||||
}
|
||||
|
||||
function handleFormSave(contact) {
|
||||
setShowForm(false);
|
||||
setEditingContact(null);
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}
|
||||
|
||||
function handleFormCancel() {
|
||||
setShowForm(false);
|
||||
setEditingContact(null);
|
||||
}
|
||||
|
||||
const getContactTypeBadge = (type) => {
|
||||
const types = {
|
||||
project: { label: "Projekt", variant: "primary" },
|
||||
contractor: { label: "Wykonawca", variant: "warning" },
|
||||
office: { label: "Urząd", variant: "info" },
|
||||
supplier: { label: "Dostawca", variant: "success" },
|
||||
other: { label: "Inny", variant: "secondary" },
|
||||
};
|
||||
return types[type] || types.other;
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="text-gray-600">Ładowanie...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showForm) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<ContactForm
|
||||
initialData={editingContact}
|
||||
onSave={handleFormSave}
|
||||
onCancel={handleFormCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Kontakty</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Zarządzaj kontaktami do projektów i współpracy
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<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>
|
||||
Dodaj kontakt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{stats.total_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Wszystkie</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats.project_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Projekty</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{stats.contractor_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Wykonawcy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{stats.office_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Urzędy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{stats.supplier_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Dostawcy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{stats.other_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Inne</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Szukaj po nazwie, telefonie, email lub firmie..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Wszystkie typy</option>
|
||||
<option value="project">Projekty</option>
|
||||
<option value="contractor">Wykonawcy</option>
|
||||
<option value="office">Urzędy</option>
|
||||
<option value="supplier">Dostawcy</option>
|
||||
<option value="other">Inne</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contacts List */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Kontakt
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Firma / Stanowisko
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Telefon
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Akcje
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="px-4 py-12 text-center text-gray-500">
|
||||
{searchTerm || typeFilter !== "all"
|
||||
? "Nie znaleziono kontaktów"
|
||||
: "Brak kontaktów. Dodaj pierwszy kontakt."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredContacts.map((contact) => {
|
||||
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||
return (
|
||||
<tr key={contact.contact_id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => handleViewDetails(contact)}>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm hover:text-blue-600 transition-colors">
|
||||
{contact.name}
|
||||
</h3>
|
||||
<Badge variant={typeBadge.variant} size="sm" className="text-xs">
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{contact.project_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{contact.project_count} {contact.project_count === 1 ? "projekt" : "projektów"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{contact.company && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{contact.company}</span>
|
||||
{contact.position && <span className="text-gray-500 ml-1">• {contact.position}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!contact.company && contact.position && (
|
||||
<div>{contact.position}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{contact.phone && (
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
// Handle multiple phones (could be comma-separated or JSON)
|
||||
let phones = [];
|
||||
try {
|
||||
// Try to parse as JSON array first
|
||||
const parsed = JSON.parse(contact.phone);
|
||||
phones = Array.isArray(parsed) ? parsed : [contact.phone];
|
||||
} catch {
|
||||
// Fall back to comma-separated string
|
||||
phones = contact.phone.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
const primaryPhone = phones[0];
|
||||
const additionalPhones = phones.slice(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={`tel:${primaryPhone}`}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<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>
|
||||
{primaryPhone}
|
||||
</a>
|
||||
{additionalPhones.length > 0 && (
|
||||
<div className="text-xs text-gray-500 pl-5">
|
||||
{additionalPhones.length === 1 ? (
|
||||
<a
|
||||
href={`tel:${additionalPhones[0]}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{additionalPhones[0]}
|
||||
</a>
|
||||
) : (
|
||||
<span>+{additionalPhones.length} więcej</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{contact.email && (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[200px]">{contact.email}</span>
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(contact);
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<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
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(contact.contact_id);
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Contact Details Modal */}
|
||||
{selectedContact && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" onClick={closeDetails}>
|
||||
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{selectedContact.name}</CardTitle>
|
||||
<div className="mt-2">
|
||||
<Badge variant={getContactTypeBadge(selectedContact.contact_type).variant}>
|
||||
{getContactTypeBadge(selectedContact.contact_type).label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={closeDetails}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Informacje kontaktowe</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedContact.phone && (() => {
|
||||
let phones = [];
|
||||
try {
|
||||
const parsed = JSON.parse(selectedContact.phone);
|
||||
phones = Array.isArray(parsed) ? parsed : [selectedContact.phone];
|
||||
} catch {
|
||||
phones = selectedContact.phone.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
return phones.map((phone, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400" 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>
|
||||
<a href={`tel:${phone}`} className="text-blue-600 hover:underline">
|
||||
{phone}
|
||||
</a>
|
||||
{index === 0 && phones.length > 1 && (
|
||||
<span className="text-xs text-gray-500">(główny)</span>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
{selectedContact.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<a href={`mailto:${selectedContact.email}`} className="text-blue-600 hover:underline">
|
||||
{selectedContact.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedContact.company && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🏢</span>
|
||||
<span className="text-gray-700">
|
||||
{selectedContact.company}
|
||||
{selectedContact.position && ` • ${selectedContact.position}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!selectedContact.company && selectedContact.position && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">💼</span>
|
||||
<span className="text-gray-700">{selectedContact.position}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedContact.notes && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Notatki</h3>
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap bg-gray-50 p-3 rounded">
|
||||
{selectedContact.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Projects */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">
|
||||
Powiązane projekty ({contactProjects.length})
|
||||
</h3>
|
||||
{loadingProjects ? (
|
||||
<div className="text-center py-4 text-gray-500">Ładowanie projektów...</div>
|
||||
) : contactProjects.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Brak powiązanych projektów</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{contactProjects.map((project) => (
|
||||
<div
|
||||
key={project.project_id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/projects/${project.project_id}`)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{project.project_name}</span>
|
||||
{project.is_primary && (
|
||||
<Badge variant="primary" size="sm">Główny kontakt</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.relationship_type && (
|
||||
<span className="text-xs text-gray-500">{project.relationship_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
handleEdit(selectedContact);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<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 kontakt
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={closeDetails}>
|
||||
Zamknij
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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");
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{project.notes && (
|
||||
</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>
|
||||
)}
|
||||
|
||||
296
src/components/ContactForm.js
Normal file
296
src/components/ContactForm.js
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
|
||||
export default function ContactForm({ initialData = null, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
phones: [""],
|
||||
email: "",
|
||||
company: "",
|
||||
position: "",
|
||||
contact_type: "other",
|
||||
notes: "",
|
||||
is_active: true,
|
||||
...initialData,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Handle initial data with phones
|
||||
React.useEffect(() => {
|
||||
if (initialData) {
|
||||
let phones = [""];
|
||||
if (initialData.phone) {
|
||||
try {
|
||||
// Try to parse as JSON array first
|
||||
const parsed = JSON.parse(initialData.phone);
|
||||
phones = Array.isArray(parsed) ? parsed : [initialData.phone];
|
||||
} catch {
|
||||
// Fall back to comma-separated string
|
||||
phones = initialData.phone.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
}
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
...initialData,
|
||||
phones: phones.length > 0 ? phones : [""]
|
||||
}));
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const isEdit = !!initialData;
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
}
|
||||
|
||||
function handlePhoneChange(index, value) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
phones: prev.phones.map((phone, i) => i === index ? value : phone)
|
||||
}));
|
||||
}
|
||||
|
||||
function addPhone() {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
phones: [...prev.phones, ""]
|
||||
}));
|
||||
}
|
||||
|
||||
function removePhone(index) {
|
||||
if (form.phones.length > 1) {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
phones: prev.phones.filter((_, i) => i !== index)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Filter out empty phones and prepare data
|
||||
const filteredPhones = form.phones.filter(phone => phone.trim());
|
||||
const submitData = {
|
||||
...form,
|
||||
phone: filteredPhones.length > 1 ? JSON.stringify(filteredPhones) : (filteredPhones[0] || null),
|
||||
phones: undefined // Remove phones array from submission
|
||||
};
|
||||
|
||||
const url = isEdit
|
||||
? `/api/contacts/${initialData.contact_id}`
|
||||
: "/api/contacts";
|
||||
const method = isEdit ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to save contact");
|
||||
}
|
||||
|
||||
const contact = await response.json();
|
||||
onSave?.(contact);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEdit ? "Edytuj kontakt" : "Nowy kontakt"}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Imię i nazwisko <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Wprowadź imię i nazwisko"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{form.phones.map((phone, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => handlePhoneChange(index, e.target.value)}
|
||||
placeholder={index === 0 ? "+48 123 456 789" : "Dodatkowy numer"}
|
||||
className="flex-1"
|
||||
/>
|
||||
{form.phones.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removePhone(index)}
|
||||
className="px-2"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={addPhone}
|
||||
className="w-full"
|
||||
>
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Dodaj kolejny numer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Firma
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="company"
|
||||
value={form.company}
|
||||
onChange={handleChange}
|
||||
placeholder="Nazwa firmy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Stanowisko
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="position"
|
||||
value={form.position}
|
||||
onChange={handleChange}
|
||||
placeholder="Kierownik projektu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Typ kontaktu
|
||||
</label>
|
||||
<select
|
||||
name="contact_type"
|
||||
value={form.contact_type}
|
||||
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="project">Kontakt projektowy</option>
|
||||
<option value="contractor">Wykonawca</option>
|
||||
<option value="office">Urząd</option>
|
||||
<option value="supplier">Dostawca</option>
|
||||
<option value="other">Inny</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notatki
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
value={form.notes}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
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="Dodatkowe informacje..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={form.is_active}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Kontakt aktywny
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
Anuluj
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Zapisywanie..." : isEdit ? "Zapisz zmiany" : "Dodaj kontakt"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user