Compare commits

..

16 Commits

Author SHA1 Message Date
ad6338ecae fix: update contact URL structure for Radicale sync functions 2025-12-04 12:02:08 +01:00
1bc9dc2dd5 feat: refactor Radicale contact sync logic to include retry mechanism and improve error handling 2025-12-04 11:56:07 +01:00
3292435e68 feat: implement Radicale CardDAV sync utility and update contact handling for asynchronous sync 2025-12-04 11:37:13 +01:00
22503e1ce0 feat: add script to export contacts as VCARDs and upload to Radicale 2025-12-04 11:25:38 +01:00
05ec244107 feat: update createContractHandler to return the newly created contract with its ID 2025-12-03 22:22:26 +01:00
77f4c80a79 feat: add customer contract number to project view and update project query 2025-12-03 22:15:50 +01:00
5abacdc8e1 feat: update contract handling to include customer contract number and improve status filtering 2025-12-03 22:04:48 +01:00
5b794a59bc feat: enhance contact phone handling to support multiple numbers and improve form submission 2025-12-03 21:06:42 +01:00
9dd208d168 feat: redesign contacts list to use a table layout for improved readability and organization 2025-12-03 20:58:18 +01:00
02f31cb444 fix: update notes handling in contact migration to preserve original contact info 2025-12-03 20:46:27 +01:00
60b79fa360 feat: add contact management functionality
- Implemented ContactForm component for creating and editing contacts.
- Added ProjectContactSelector component to manage project-specific contacts.
- Updated ProjectForm to include ProjectContactSelector for associating contacts with projects.
- Enhanced Card component with a new CardTitle subcomponent for better structure.
- Updated Navigation to include a link to the contacts page.
- Added translations for contact-related terms in the i18n module.
- Initialized contacts database schema and created necessary tables for contact management.
- Developed queries for CRUD operations on contacts, including linking and unlinking contacts to projects.
- Created a test script to validate contact queries against the database.
2025-12-03 16:23:05 +01:00
c9b7355f3c fix: update cron job command to use the correct node path for backups 2025-12-03 08:01:02 +01:00
eb41814c24 feat: add settings table and backup notification functionality 2025-12-02 11:30:13 +01:00
e6fab5ba31 feat: add deployment guide, backup functionality, and cron jobs for automated backups 2025-12-02 11:11:47 +01:00
99853bb755 Merge branch 'ui-fix' of https://git.wastpol.pl/admin/Panel into ui-fix 2025-12-02 11:06:34 +01:00
9b84c6b9e8 feat: implement notifications system with API endpoints for fetching and marking notifications as read 2025-12-02 11:06:31 +01:00
43 changed files with 4700 additions and 46 deletions

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ next-env.d.ts
# uploads
/public/uploads
/backups

174
CONTACTS_SYSTEM_README.md Normal file
View 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! 🎉

View 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!

View File

@@ -5,8 +5,8 @@ FROM node:22.11.0
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Install git
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# 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

View File

@@ -5,8 +5,8 @@ FROM node:22.11.0
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Install git for development
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
# 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

157
RADICALE_SYNC_README.md Normal file
View File

@@ -0,0 +1,157 @@
# Radicale CardDAV Sync Integration
This application now automatically syncs contacts to a Radicale CardDAV server whenever contacts are created, updated, or deleted.
## Features
-**Automatic Sync** - Contacts are automatically synced when created or updated
-**Automatic Deletion** - Contacts are removed from Radicale when soft/hard deleted
-**Non-Blocking** - Sync happens asynchronously without slowing down the API
-**Optional** - Sync is disabled by default, enable by configuring environment variables
-**VCARD 3.0** - Generates standard VCARD format with full contact details
## Setup
### 1. Configure Environment Variables
Add these to your `.env.local` or production environment:
```bash
RADICALE_URL=http://localhost:5232
RADICALE_USERNAME=your_username
RADICALE_PASSWORD=your_password
```
**Note:** If these variables are not set, sync will be disabled and the app will work normally.
### 2. Radicale Server Setup
Make sure your Radicale server:
- Is accessible from your application server
- Has a user created with the credentials you configured
- Has a contacts collection at: `{username}/contacts/`
### 3. One-Time Initial Sync
To sync all existing contacts to Radicale:
```bash
node export-contacts-to-radicale.mjs
```
This script will:
- Prompt for Radicale URL, username, and password
- Export all active contacts as VCARDs
- Upload them to your Radicale server
## How It Works
### When Creating a Contact
```javascript
// POST /api/contacts
const contact = createContact(data);
// Sync to Radicale asynchronously (non-blocking)
syncContactAsync(contact);
return NextResponse.json(contact);
```
### When Updating a Contact
```javascript
// PUT /api/contacts/[id]
const contact = updateContact(contactId, data);
// Sync updated contact to Radicale
syncContactAsync(contact);
return NextResponse.json(contact);
```
### When Deleting a Contact
```javascript
// DELETE /api/contacts/[id]
deleteContact(contactId);
// Delete from Radicale asynchronously
deleteContactAsync(contactId);
return NextResponse.json({ message: "Contact deleted" });
```
## VCARD Format
Each contact is exported with the following fields:
- **UID**: `contact-{id}@panel-app`
- **FN/N**: Full name and structured name
- **ORG**: Company
- **TITLE**: Position/Title
- **TEL**: Phone numbers (multiple supported - first as WORK, others as CELL)
- **EMAIL**: Email address
- **NOTE**: Contact type + notes
- **CATEGORIES**: Based on contact type (Projekty, Wykonawcy, Urzędy, etc.)
- **REV**: Last modified timestamp
## VCARD Storage Path
VCARDs are stored at:
```
{RADICALE_URL}/{RADICALE_USERNAME}/contacts/contact-{id}.vcf
```
Example:
```
http://localhost:5232/admin/contacts/contact-123.vcf
```
## Troubleshooting
### Sync Not Working
1. Check environment variables are set correctly
2. Verify Radicale server is accessible
3. Check application logs for sync errors
4. Test manually with the export script
### Check Sync Status
Sync operations are logged to console:
```
✅ Synced contact 123 to Radicale
❌ Failed to sync contact 456 to Radicale: 401 - Unauthorized
```
### Disable Sync
Simply remove or comment out the Radicale environment variables:
```bash
# RADICALE_URL=
# RADICALE_USERNAME=
# RADICALE_PASSWORD=
```
## Files
- **`src/lib/radicale-sync.js`** - Main sync utility with VCARD generation
- **`src/app/api/contacts/route.js`** - Integrated sync on create
- **`src/app/api/contacts/[id]/route.js`** - Integrated sync on update/delete
- **`export-contacts-to-radicale.mjs`** - One-time bulk export script
## Security Notes
- ⚠️ Store credentials securely in environment variables
- ⚠️ Use HTTPS for production Radicale servers
- ⚠️ Consider using environment-specific credentials
- ⚠️ Sync happens in background - errors won't block API responses
## Future Enhancements
- Bi-directional sync (import changes from Radicale)
- Batch sync operations
- Sync queue with retry logic
- Webhook notifications for sync status
- Admin UI to trigger manual sync

61
backup-db.mjs Normal file
View 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
View 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}`);

View File

@@ -14,6 +14,7 @@ services:
volumes:
- ./data:/app/data
- ./uploads:/app/public/uploads
- ./backups:/app/backups
environment:
- NODE_ENV=production
- TZ=Europe/Warsaw

View File

@@ -11,6 +11,7 @@ services:
- .:/app
- /app/node_modules
- ./data:/app/data
- ./backups:/app/backups
environment:
- NODE_ENV=development
- TZ=Europe/Warsaw

View File

@@ -20,6 +20,13 @@ chmod -R 755 /app/public/uploads
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

View File

@@ -24,6 +24,13 @@ node scripts/create-admin.js
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

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env node
/**
* One-time script to export all contacts as VCARDs and upload to Radicale
* Usage: node export-contacts-to-radicale.mjs
*/
import db from './src/lib/db.js';
import readline from 'readline';
import { createInterface } from 'readline';
// VCARD generation helper
function generateVCard(contact) {
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
// UID - unique identifier
lines.push(`UID:contact-${contact.contact_id}@panel-app`);
// Name (FN = Formatted Name, N = Structured Name)
if (contact.name) {
lines.push(`FN:${escapeVCardValue(contact.name)}`);
// Try to split name into components (Last;First;Middle;Prefix;Suffix)
const nameParts = contact.name.trim().split(/\s+/);
if (nameParts.length === 1) {
lines.push(`N:${escapeVCardValue(nameParts[0])};;;;`);
} else if (nameParts.length === 2) {
lines.push(`N:${escapeVCardValue(nameParts[1])};${escapeVCardValue(nameParts[0])};;;`);
} else {
// More than 2 parts - first is first name, rest is last name
const firstName = nameParts[0];
const lastName = nameParts.slice(1).join(' ');
lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`);
}
}
// Organization
if (contact.company) {
lines.push(`ORG:${escapeVCardValue(contact.company)}`);
}
// Title/Position
if (contact.position) {
lines.push(`TITLE:${escapeVCardValue(contact.position)}`);
}
// Phone numbers - handle multiple phones
if (contact.phone) {
let phones = [];
try {
// Try to parse as JSON array
const parsed = JSON.parse(contact.phone);
phones = Array.isArray(parsed) ? parsed : [contact.phone];
} catch {
// Fall back to comma-separated or single value
phones = contact.phone.includes(',')
? contact.phone.split(',').map(p => p.trim()).filter(p => p)
: [contact.phone];
}
phones.forEach((phone, index) => {
if (phone) {
// First phone is WORK, others are CELL
const type = index === 0 ? 'WORK' : 'CELL';
lines.push(`TEL;TYPE=${type},VOICE:${escapeVCardValue(phone)}`);
}
});
}
// Email
if (contact.email) {
lines.push(`EMAIL;TYPE=INTERNET,WORK:${escapeVCardValue(contact.email)}`);
}
// Notes - combine contact type, position context, and notes
const noteParts = [];
if (contact.contact_type) {
const typeLabels = {
project: 'Kontakt projektowy',
contractor: 'Wykonawca',
office: 'Urząd',
supplier: 'Dostawca',
other: 'Inny'
};
noteParts.push(`Typ: ${typeLabels[contact.contact_type] || contact.contact_type}`);
}
if (contact.notes) {
noteParts.push(contact.notes);
}
if (noteParts.length > 0) {
lines.push(`NOTE:${escapeVCardValue(noteParts.join('\\n'))}`);
}
// Categories based on contact type
if (contact.contact_type) {
const categories = {
project: 'Projekty',
contractor: 'Wykonawcy',
office: 'Urzędy',
supplier: 'Dostawcy',
other: 'Inne'
};
lines.push(`CATEGORIES:${categories[contact.contact_type] || 'Inne'}`);
}
// Timestamps
if (contact.created_at) {
const created = new Date(contact.created_at).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
lines.push(`REV:${created}`);
}
lines.push('END:VCARD');
return lines.join('\r\n') + '\r\n';
}
// Escape special characters in VCARD values
function escapeVCardValue(value) {
if (!value) return '';
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
// Prompt for input
function prompt(question) {
const rl = createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Upload VCARD to Radicale via CardDAV
async function uploadToRadicale(vcard, contactId, radicaleUrl, username, password, forceUpdate = false) {
const auth = Buffer.from(`${username}:${password}`).toString('base64');
// Ensure URL ends with /
const baseUrl = radicaleUrl.endsWith('/') ? radicaleUrl : radicaleUrl + '/';
// Construct the URL for this specific contact
// Format: {base_url}{username}/{addressbook_name}/{contact_id}.vcf
const vcardUrl = `${baseUrl}${username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contactId}.vcf`;
try {
const headers = {
'Authorization': `Basic ${auth}`,
'Content-Type': 'text/vcard; charset=utf-8'
};
// If not forcing update, only create if doesn't exist
if (!forceUpdate) {
headers['If-None-Match'] = '*';
}
const response = await fetch(vcardUrl, {
method: 'PUT',
headers: headers,
body: vcard
});
// Handle conflict - try again with force update
if (response.status === 412 || response.status === 409) {
// Conflict - contact already exists, update it instead
return await uploadToRadicale(vcard, contactId, radicaleUrl, username, password, true);
}
if (response.ok || response.status === 201 || response.status === 204) {
return { success: true, status: response.status, updated: forceUpdate };
} else {
const text = await response.text();
return { success: false, status: response.status, error: text };
}
} catch (error) {
return { success: false, error: error.message };
}
}
// Main execution
async function main() {
console.log('🚀 Export Contacts to Radicale (CardDAV)\n');
console.log('This script will export all active contacts as VCARDs and upload them to your Radicale server.\n');
// Get Radicale connection details
const radicaleUrl = await prompt('Radicale URL (e.g., http://localhost:5232): ');
const username = await prompt('Username: ');
const password = await prompt('Password: ');
if (!radicaleUrl || !username || !password) {
console.error('❌ All fields are required!');
process.exit(1);
}
console.log('\n📊 Fetching contacts from database...\n');
// Get all active contacts
const contacts = db.prepare(`
SELECT * FROM contacts
WHERE is_active = 1
ORDER BY name ASC
`).all();
if (contacts.length === 0) {
console.log(' No active contacts found.');
process.exit(0);
}
console.log(`Found ${contacts.length} active contacts\n`);
console.log('📤 Uploading to Radicale...\n');
let uploaded = 0;
let updated = 0;
let failed = 0;
const errors = [];
for (const contact of contacts) {
const vcard = generateVCard(contact);
const result = await uploadToRadicale(vcard, contact.contact_id, radicaleUrl, username, password);
if (result.success) {
if (result.updated) {
updated++;
console.log(`🔄 ${contact.name} (${contact.contact_id}) - updated`);
} else {
uploaded++;
console.log(`${contact.name} (${contact.contact_id}) - created`);
}
} else {
failed++;
const errorMsg = `${contact.name} (${contact.contact_id}): ${result.error || `HTTP ${result.status}`}`;
console.log(errorMsg);
errors.push(errorMsg);
}
// Small delay to avoid overwhelming the server
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('\n' + '='.repeat(60));
console.log('📊 Upload Summary:');
console.log(` ✅ Created: ${uploaded}`);
console.log(` 🔄 Updated: ${updated}`);
console.log(` ❌ Failed: ${failed}`);
console.log(` 📋 Total: ${contacts.length}`);
if (errors.length > 0 && errors.length <= 10) {
console.log('\n❌ Failed uploads:');
errors.forEach(err => console.log(` ${err}`));
}
if (uploaded > 0 || updated > 0) {
console.log('\n✨ Success! Your contacts have been exported to Radicale.');
console.log(` Access them at: ${radicaleUrl}`);
}
console.log('');
}
main().catch(error => {
console.error('❌ Error:', error);
process.exit(1);
});

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

46
migrate-contacts.mjs Normal file
View 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);
}

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

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

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

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

View File

@@ -0,0 +1,111 @@
import { NextResponse } from "next/server";
import {
getContactById,
updateContact,
deleteContact,
hardDeleteContact,
} from "@/lib/queries/contacts";
import { withAuth } from "@/lib/middleware/auth";
import { syncContactAsync, deleteContactAsync } from "@/lib/radicale-sync";
// 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 }
);
}
// Sync to Radicale asynchronously (non-blocking)
syncContactAsync(contact);
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);
// Delete from Radicale asynchronously
deleteContactAsync(contactId);
} else {
// Soft delete - set is_active to 0
deleteContact(contactId);
// Delete from Radicale asynchronously
deleteContactAsync(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);

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import {
getAllContacts,
createContact,
getContactStats,
} from "@/lib/queries/contacts";
import { withAuth } from "@/lib/middleware/auth";
import { syncContactAsync } from "@/lib/radicale-sync";
// 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);
// Sync to Radicale asynchronously (non-blocking)
syncContactAsync(contact);
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);

View File

@@ -10,6 +10,7 @@ async function getContractsHandler() {
contract_id,
contract_number,
contract_name,
customer_contract_number,
customer,
investor,
date_signed,
@@ -24,7 +25,7 @@ async function getContractsHandler() {
async function createContractHandler(req) {
const data = await req.json();
db.prepare(
const result = db.prepare(
`
INSERT INTO contracts (
contract_number,
@@ -45,7 +46,10 @@ async function createContractHandler(req) {
data.date_signed,
data.finish_date
);
return NextResponse.json({ success: true });
// Return the newly created contract with its ID
const contract = db.prepare("SELECT * FROM contracts WHERE contract_id = ?").get(result.lastInsertRowid);
return NextResponse.json(contract);
}
// Protected routes - require authentication

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

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

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

615
src/app/contacts/page.js Normal file
View 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>
);
}

View File

@@ -19,8 +19,8 @@ export default function ContractsMainPage() {
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [filteredContracts, setFilteredContracts] = useState([]);
const [sortBy, setSortBy] = useState("contract_number");
const [sortOrder, setSortOrder] = useState("asc");
const [sortBy, setSortBy] = useState("date_signed");
const [sortOrder, setSortOrder] = useState("desc");
const [statusFilter, setStatusFilter] = useState("all");
useEffect(() => {
@@ -53,6 +53,9 @@ export default function ContractsMainPage() {
contract.contract_name
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
contract.customer_contract_number
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
contract.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
contract.investor?.toLowerCase().includes(searchTerm.toLowerCase())
);
@@ -64,9 +67,9 @@ export default function ContractsMainPage() {
filtered = filtered.filter((contract) => {
if (statusFilter === "active" && contract.finish_date) {
return new Date(contract.finish_date) >= currentDate;
} else if (statusFilter === "completed" && contract.finish_date) {
} else if (statusFilter === "expired" && contract.finish_date) {
return new Date(contract.finish_date) < currentDate;
} else if (statusFilter === "no_end_date") {
} else if (statusFilter === "ongoing") {
return !contract.finish_date;
}
return true;
@@ -117,27 +120,27 @@ export default function ContractsMainPage() {
const active = contracts.filter(
(c) => !c.finish_date || new Date(c.finish_date) >= currentDate
).length;
const completed = contracts.filter(
const expired = contracts.filter(
(c) => c.finish_date && new Date(c.finish_date) < currentDate
).length;
const withoutEndDate = contracts.filter((c) => !c.finish_date).length;
return { total, active, completed, withoutEndDate };
return { total, active, expired, withoutEndDate };
};
const getContractStatus = (contract) => {
if (!contract.finish_date) return "ongoing";
const currentDate = new Date();
const finishDate = new Date(contract.finish_date);
return finishDate >= currentDate ? "active" : "completed";
return finishDate >= currentDate ? "active" : "expired";
};
const getStatusBadge = (status) => {
switch (status) {
case "active":
return <Badge variant="success">{t('contracts.active')}</Badge>;
case "completed":
return <Badge variant="secondary">{t('common.completed')}</Badge>;
case "expired":
return <Badge variant="danger">{t('contracts.expired')}</Badge>;
case "ongoing":
return <Badge variant="primary">{t('contracts.withoutEndDate')}</Badge>;
default:
@@ -209,8 +212,8 @@ export default function ContractsMainPage() {
options: [
{ value: "all", label: "Wszystkie" },
{ value: "active", label: "Aktywne" },
{ value: "completed", label: "Zakończone" },
{ value: "no_end_date", label: "Bez daty końca" },
{ value: "expired", label: "Przeterminowane" },
{ value: "ongoing", label: "W trakcie" },
],
},
{
@@ -221,7 +224,7 @@ export default function ContractsMainPage() {
{ value: "contract_number", label: "Numer umowy" },
{ value: "contract_name", label: "Nazwa umowy" },
{ value: "customer", label: "Klient" },
{ value: "start_date", label: "Data rozpoczęcia" },
{ value: "date_signed", label: "Data podpisania" },
{ value: "finish_date", label: "Data zakończenia" },
],
},
@@ -338,9 +341,9 @@ export default function ContractsMainPage() {
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Zakończone</p>
<p className="text-sm font-medium text-gray-600">Przeterminowane</p>
<p className="text-2xl font-bold text-gray-900">
{stats.completed}
{stats.expired}
</p>
</div>
</div>

View File

@@ -588,10 +588,19 @@ export default function ProjectViewPage() {
</div>
<div>
<span className="text-sm font-medium text-gray-500 block mb-1">
Nazwa umowy
Numer umowy klienta
</span>
<p className="text-gray-900 font-medium">
{project.contract_name || "N/A"}
{project.customer_contract_number ? (
<Link
href={`/contracts/${project.contract_id}`}
className="text-inherit hover:text-inherit no-underline"
>
{project.customer_contract_number}
</Link>
) : (
"N/A"
)}
</p>
</div>
<div>

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

View File

@@ -0,0 +1,298 @@
"use client";
import { useState, useEffect } from "react";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
export default function ProjectContactSelector({ projectId, onChange }) {
const [contacts, setContacts] = useState([]);
const [projectContacts, setProjectContacts] = useState([]);
const [availableContacts, setAvailableContacts] = useState([]);
const [showSelector, setShowSelector] = useState(false);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
useEffect(() => {
fetchAllContacts();
if (projectId) {
fetchProjectContacts();
}
}, [projectId]);
async function fetchAllContacts() {
try {
const response = await fetch("/api/contacts?is_active=true");
if (response.ok) {
const data = await response.json();
setContacts(data);
}
} catch (error) {
console.error("Error fetching contacts:", error);
}
}
async function fetchProjectContacts() {
try {
const response = await fetch(`/api/projects/${projectId}/contacts`);
if (response.ok) {
const data = await response.json();
setProjectContacts(data);
updateAvailableContacts(data);
onChange?.(data);
}
} catch (error) {
console.error("Error fetching project contacts:", error);
}
}
function updateAvailableContacts(linkedContacts) {
const linkedIds = linkedContacts.map((c) => c.contact_id);
const available = contacts.filter(
(c) => !linkedIds.includes(c.contact_id)
);
setAvailableContacts(available);
}
async function handleAddContact(contactId) {
setLoading(true);
try {
const response = await fetch(`/api/projects/${projectId}/contacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contactId }),
});
if (response.ok) {
await fetchProjectContacts();
setShowSelector(false);
setSearchTerm("");
}
} catch (error) {
console.error("Error adding contact:", error);
alert("Nie udało się dodać kontaktu");
} finally {
setLoading(false);
}
}
async function handleRemoveContact(contactId) {
if (!confirm("Czy na pewno chcesz usunąć ten kontakt z projektu?"))
return;
try {
const response = await fetch(
`/api/projects/${projectId}/contacts?contactId=${contactId}`,
{
method: "DELETE",
}
);
if (response.ok) {
await fetchProjectContacts();
}
} catch (error) {
console.error("Error removing contact:", error);
alert("Nie udało się usunąć kontaktu");
}
}
async function handleSetPrimary(contactId) {
try {
const response = await fetch(`/api/projects/${projectId}/contacts`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contactId }),
});
if (response.ok) {
await fetchProjectContacts();
}
} catch (error) {
console.error("Error setting primary contact:", error);
alert("Nie udało się ustawić głównego kontaktu");
}
}
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;
};
const filteredAvailable = searchTerm
? availableContacts.filter(
(c) =>
c.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.phone?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.company?.toLowerCase().includes(searchTerm.toLowerCase())
)
: availableContacts;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700">
Kontakty do projektu
</label>
{projectId && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setShowSelector(!showSelector)}
>
{showSelector ? "Anuluj" : "+ Dodaj kontakt"}
</Button>
)}
</div>
{/* Current project contacts */}
{projectContacts.length > 0 ? (
<div className="space-y-2">
{projectContacts.map((contact) => {
const typeBadge = getContactTypeBadge(contact.contact_type);
return (
<div
key={contact.contact_id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">
{contact.name}
</span>
{contact.is_primary === 1 && (
<Badge variant="success" size="xs">
Główny
</Badge>
)}
<Badge variant={typeBadge.variant} size="xs">
{typeBadge.label}
</Badge>
</div>
<div className="text-sm text-gray-600 mt-1 space-y-0.5">
{contact.phone && <div>📞 {contact.phone}</div>}
{contact.email && <div>📧 {contact.email}</div>}
{contact.company && (
<div className="text-xs">🏢 {contact.company}</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{contact.is_primary !== 1 && (
<button
type="button"
onClick={() => handleSetPrimary(contact.contact_id)}
className="text-xs text-blue-600 hover:text-blue-700 px-2 py-1 rounded hover:bg-blue-50"
title="Ustaw jako główny"
>
Ustaw główny
</button>
)}
<button
type="button"
onClick={() => handleRemoveContact(contact.contact_id)}
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
title="Usuń kontakt"
>
<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>
);
})}
</div>
) : (
<div className="text-sm text-gray-500 italic p-3 bg-gray-50 rounded-md">
{projectId
? "Brak powiązanych kontaktów. Dodaj kontakt do projektu."
: "Zapisz projekt, aby móc dodać kontakty."}
</div>
)}
{/* Contact selector */}
{showSelector && projectId && (
<div className="border border-gray-300 rounded-md p-4 bg-white">
<div className="mb-3">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Szukaj kontaktu..."
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="max-h-60 overflow-y-auto space-y-2">
{filteredAvailable.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">
{searchTerm
? "Nie znaleziono kontaktów"
: "Wszystkie kontakty są już dodane"}
</p>
) : (
filteredAvailable.map((contact) => {
const typeBadge = getContactTypeBadge(contact.contact_type);
return (
<div
key={contact.contact_id}
className="flex items-center justify-between p-2 hover:bg-gray-50 rounded border border-gray-200"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-900">
{contact.name}
</span>
<Badge variant={typeBadge.variant} size="xs">
{typeBadge.label}
</Badge>
</div>
<div className="text-xs text-gray-600 mt-1">
{contact.phone && <span>{contact.phone}</span>}
{contact.phone && contact.email && (
<span className="mx-1"></span>
)}
{contact.email && <span>{contact.email}</span>}
</div>
{contact.company && (
<div className="text-xs text-gray-500">
{contact.company}
</div>
)}
</div>
<Button
type="button"
size="sm"
onClick={() => handleAddContact(contact.contact_id)}
disabled={loading}
>
Dodaj
</Button>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { formatDateForInput } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import ProjectContactSelector from "@/components/ProjectContactSelector";
const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref) {
const { t } = useTranslation();
@@ -365,15 +366,8 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
)}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('projects.contact')}
</label>
<Input
type="text"
name="contact"
value={form.contact || ""}
onChange={handleChange}
placeholder={t('projects.placeholders.contact')}
<ProjectContactSelector
projectId={initialData?.project_id}
/>
</div>

View File

@@ -27,6 +27,14 @@ const CardHeader = ({ children, className = "" }) => {
);
};
const CardTitle = ({ children, className = "" }) => {
return (
<h3 className={`text-lg font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
{children}
</h3>
);
};
const CardContent = ({ children, className = "" }) => {
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
};
@@ -39,4 +47,4 @@ const CardFooter = ({ children, className = "" }) => {
);
};
export { Card, CardHeader, CardContent, CardFooter };
export { Card, CardHeader, CardTitle, CardContent, CardFooter };

View File

@@ -12,6 +12,9 @@ const Navigation = () => {
const { t } = useTranslation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false);
const notificationsRef = useRef(null);
// Close notifications dropdown when clicking outside
@@ -28,6 +31,109 @@ const Navigation = () => {
};
}, []);
// Fetch notifications when component mounts or session changes
useEffect(() => {
if (session?.user?.id) {
fetchUnreadCount();
}
}, [session]);
// Fetch notifications when dropdown opens
useEffect(() => {
if (isNotificationsOpen && session?.user?.id) {
fetchNotifications();
}
}, [isNotificationsOpen, session]);
const fetchUnreadCount = async () => {
try {
const response = await fetch('/api/notifications/unread-count');
if (response.ok) {
const data = await response.json();
setUnreadCount(data.unreadCount);
}
} catch (error) {
console.error('Failed to fetch unread count:', error);
}
};
const fetchNotifications = async () => {
setIsLoadingNotifications(true);
try {
const response = await fetch('/api/notifications?limit=10');
if (response.ok) {
const data = await response.json();
setNotifications(data.notifications);
}
} catch (error) {
console.error('Failed to fetch notifications:', error);
} finally {
setIsLoadingNotifications(false);
}
};
const markAsRead = async (notificationIds = null) => {
try {
const response = await fetch('/api/notifications', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'markAsRead',
notificationIds,
}),
});
if (response.ok) {
const data = await response.json();
setUnreadCount(data.unreadCount);
// Update local notifications state
if (notificationIds) {
setNotifications(prev =>
prev.map(notif =>
notificationIds.includes(notif.id)
? { ...notif, is_read: 1 }
: notif
)
);
} else {
setNotifications(prev =>
prev.map(notif => ({ ...notif, is_read: 1 }))
);
}
}
} catch (error) {
console.error('Failed to mark notifications as read:', error);
}
};
const handleNotificationClick = (notification) => {
// Mark as read if not already read
if (!notification.is_read) {
markAsRead([notification.id]);
}
// Navigate to action URL if available
if (notification.action_url) {
window.location.href = notification.action_url;
}
setIsNotificationsOpen(false);
};
const formatTimeAgo = (timestamp) => {
const now = new Date();
const notificationTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - notificationTime) / (1000 * 60));
if (diffInMinutes < 1) return t('notifications.justNow') || 'Just now';
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`;
return `${Math.floor(diffInMinutes / 1440)}d ago`;
};
const isActive = (path) => {
if (path === "/") return pathname === "/";
if (pathname === path) return true;
@@ -37,6 +143,7 @@ const Navigation = () => {
const navItems = [
{ href: "/projects", label: t('navigation.projects') },
{ href: "/contacts", label: t('navigation.contacts') || 'Kontakty' },
{ href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
{ href: "/project-tasks", label: t('navigation.tasks') || 'Tasks' },
{ href: "/contracts", label: t('navigation.contracts') },
@@ -101,8 +208,8 @@ const Navigation = () => {
<div className="text-blue-100">{t('navigation.loading')}</div>
) : session ? (
<>
{/* Notifications - Admin only for now */}
{session?.user?.role === 'admin' && (
{/* Notifications - Admin and Team Lead */}
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
<div className="relative" ref={notificationsRef}>
<button
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
@@ -113,21 +220,74 @@ const Navigation = () => {
</svg>
{/* Notification badge */}
<span className="absolute -top-1 -right-1 h-4 w-4 bg-gray-400 dark:bg-gray-500 text-white text-xs rounded-full flex items-center justify-center">
0
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</button>
{/* Notifications Dropdown */}
{isNotificationsOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('notifications.title')}</h3>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{t('notifications.title')}
</h3>
{notifications.length > 0 && (
<button
onClick={() => markAsRead()}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{t('notifications.markAllRead') || 'Mark all read'}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{isLoadingNotifications ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.loading') || 'Loading...'}</p>
</div>
) : notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer ${
!notification.is_read ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${
notification.priority === 'urgent' ? 'bg-red-500' :
notification.priority === 'high' ? 'bg-orange-500' :
notification.priority === 'low' ? 'bg-gray-400' :
'bg-blue-500'
}`}></div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{notification.title}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatTimeAgo(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<div className="flex-shrink-0">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
</div>
)}
</div>
</div>
))
) : (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.noNotifications')}</p>
<p className="text-xs mt-1">{t('notifications.placeholder')}</p>
</div>
)}
</div>
</div>
)}
@@ -227,8 +387,8 @@ const Navigation = () => {
</div>
</Link>
<div className="flex items-center space-x-2">
{/* Mobile Notifications - Admin only for now */}
{session?.user?.role === 'admin' && (
{/* Mobile Notifications - Admin and Team Lead */}
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
<button
onClick={() => {
setIsNotificationsOpen(!isNotificationsOpen);
@@ -241,7 +401,7 @@ const Navigation = () => {
</svg>
{/* Notification badge */}
<span className="absolute -top-1 -right-1 h-3 w-3 bg-gray-400 dark:bg-gray-500 text-white text-xs rounded-full flex items-center justify-center">
0
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</button>
)}

View File

@@ -12,6 +12,7 @@ const translations = {
navigation: {
dashboard: "Panel główny",
projects: "Projekty",
contacts: "Kontakty",
calendar: "Kalendarz",
taskTemplates: "Szablony zadań",
projectTasks: "Zadania projektów",
@@ -28,7 +29,10 @@ const translations = {
notifications: {
title: "Powiadomienia",
noNotifications: "Brak powiadomień",
placeholder: "To jest miejsce na przyszłe powiadomienia"
placeholder: "To jest miejsce na przyszłe powiadomienia",
markAllRead: "Oznacz wszystkie jako przeczytane",
loading: "Ładowanie...",
justNow: "Przed chwilą"
},
// Common UI elements
@@ -242,6 +246,38 @@ const translations = {
}
},
// Contacts
contacts: {
title: "Kontakty",
subtitle: "Zarządzaj kontaktami",
contact: "Kontakt",
newContact: "Nowy kontakt",
editContact: "Edytuj kontakt",
deleteContact: "Usuń kontakt",
name: "Imię i nazwisko",
phone: "Telefon",
email: "Email",
company: "Firma",
position: "Stanowisko",
contactType: "Typ kontaktu",
notes: "Notatki",
active: "Aktywny",
inactive: "Nieaktywny",
searchPlaceholder: "Szukaj kontaktów...",
noContacts: "Brak kontaktów",
addFirstContact: "Dodaj pierwszy kontakt",
selectContact: "Wybierz kontakt",
addContact: "Dodaj kontakt",
linkedProjects: "Powiązane projekty",
types: {
project: "Kontakt projektowy",
contractor: "Wykonawca",
office: "Urząd",
supplier: "Dostawca",
other: "Inny"
}
},
// Contracts
contracts: {
title: "Umowy",

View File

@@ -477,5 +477,82 @@ export default function initializeDatabase() {
-- Create index for password reset tokens
CREATE INDEX IF NOT EXISTS idx_password_reset_token ON password_reset_tokens(token);
CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id);
-- Notifications table
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('task_assigned', 'task_status_changed', 'project_updated', 'due_date_reminder', 'system_announcement', 'mention')),
title TEXT NOT NULL,
message TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
is_read INTEGER DEFAULT 0,
priority TEXT DEFAULT 'normal' CHECK(priority IN ('low', 'normal', 'high', 'urgent')),
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
action_url TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Create indexes for notifications
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications(type);
-- Settings table for application configuration
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)
);
-- Insert default settings
INSERT OR IGNORE INTO settings (key, value, description) VALUES
('backup_notification_user_id', '', 'User ID to receive backup completion notifications');
`);
// Contacts table for managing contact information
db.exec(`
CREATE TABLE IF NOT EXISTS contacts (
contact_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
company TEXT,
position TEXT,
contact_type TEXT CHECK(contact_type IN ('project', 'contractor', 'office', 'supplier', 'other')) DEFAULT 'other',
notes TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Project contacts junction table (many-to-many relationship)
CREATE TABLE IF NOT EXISTS project_contacts (
project_id INTEGER NOT NULL,
contact_id INTEGER NOT NULL,
relationship_type TEXT DEFAULT 'general',
is_primary INTEGER DEFAULT 0,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
added_by TEXT,
PRIMARY KEY (project_id, contact_id),
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE,
FOREIGN KEY (contact_id) REFERENCES contacts(contact_id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users(id)
);
-- Indexes for contacts
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE INDEX IF NOT EXISTS idx_contacts_type ON contacts(contact_type);
CREATE INDEX IF NOT EXISTS idx_contacts_active ON contacts(is_active);
CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts(phone);
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
CREATE INDEX IF NOT EXISTS idx_project_contacts_project ON project_contacts(project_id);
CREATE INDEX IF NOT EXISTS idx_project_contacts_contact ON project_contacts(contact_id);
`);
}

319
src/lib/notifications.js Normal file
View File

@@ -0,0 +1,319 @@
/**
* Notification types - standardized notification types
*/
export const NOTIFICATION_TYPES = {
// Task notifications
TASK_ASSIGNED: "task_assigned",
TASK_STATUS_CHANGED: "task_status_changed",
// Project notifications
PROJECT_UPDATED: "project_updated",
// System notifications
DUE_DATE_REMINDER: "due_date_reminder",
SYSTEM_ANNOUNCEMENT: "system_announcement",
MENTION: "mention",
};
/**
* Notification priorities
*/
export const NOTIFICATION_PRIORITIES = {
LOW: "low",
NORMAL: "normal",
HIGH: "high",
URGENT: "urgent",
};
/**
* Create a notification
* @param {Object} params - Notification parameters
* @param {string} params.userId - User to receive the notification
* @param {string} params.type - Notification type (use NOTIFICATION_TYPES constants)
* @param {string} params.title - Notification title
* @param {string} params.message - Notification message
* @param {string} [params.resourceType] - Type of related resource
* @param {string} [params.resourceId] - ID of the related resource
* @param {string} [params.priority] - Priority level (default: normal)
* @param {string} [params.actionUrl] - URL to navigate to when clicked
* @param {string} [params.expiresAt] - When the notification expires
*/
export async function createNotification({
userId,
type,
title,
message,
resourceType = null,
resourceId = null,
priority = NOTIFICATION_PRIORITIES.NORMAL,
actionUrl = null,
expiresAt = null,
}) {
try {
// Check if we're in Edge Runtime - if so, skip database operations
if (
typeof EdgeRuntime !== "undefined" ||
process.env.NEXT_RUNTIME === "edge"
) {
console.log(
`[Notification - Edge Runtime] ${type} notification for user ${userId}: ${title}`
);
return;
}
// Dynamic import to avoid Edge Runtime issues
const { default: db } = await import("./db.js");
const stmt = db.prepare(`
INSERT INTO notifications (
user_id, type, title, message, resource_type, resource_id,
priority, action_url, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
userId,
type,
title,
message,
resourceType,
resourceId,
priority,
actionUrl,
expiresAt
);
console.log(
`Notification created: ${type} for user ${userId} - ${title}`
);
return result.lastInsertRowid;
} catch (error) {
console.error("Failed to create notification:", error);
// Don't throw error to avoid breaking the main application flow
}
}
/**
* Get notifications for a user
* @param {string} userId - User ID
* @param {Object} options - Query options
* @param {boolean} [options.includeRead] - Include read notifications (default: false)
* @param {number} [options.limit] - Maximum number of notifications to return
* @param {number} [options.offset] - Number of notifications to skip
* @returns {Array} Array of notifications
*/
export async function getUserNotifications(
userId,
{ includeRead = false, limit = 50, offset = 0 } = {}
) {
try {
// Check if we're in Edge Runtime - if so, return empty array
if (
typeof EdgeRuntime !== "undefined" ||
process.env.NEXT_RUNTIME === "edge"
) {
console.log(
"[Notification - Edge Runtime] Cannot query notifications in Edge Runtime"
);
return [];
}
// Dynamic import to avoid Edge Runtime issues
const { default: db } = await import("./db.js");
let query = `
SELECT * FROM notifications
WHERE user_id = ?
`;
const params = [userId];
if (!includeRead) {
query += " AND is_read = 0";
}
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
params.push(limit, offset);
const stmt = db.prepare(query);
const notifications = stmt.all(...params);
return notifications;
} catch (error) {
console.error("Failed to get user notifications:", error);
return [];
}
}
/**
* Mark notifications as read
* @param {string} userId - User ID
* @param {Array<number>} [notificationIds] - Specific notification IDs to mark as read (if not provided, marks all as read)
*/
export async function markNotificationsAsRead(userId, notificationIds = null) {
try {
// Check if we're in Edge Runtime - if so, skip database operations
if (
typeof EdgeRuntime !== "undefined" ||
process.env.NEXT_RUNTIME === "edge"
) {
console.log(
`[Notification - Edge Runtime] Cannot mark notifications as read in Edge Runtime`
);
return;
}
// Dynamic import to avoid Edge Runtime issues
const { default: db } = await import("./db.js");
let query;
let params;
if (notificationIds && notificationIds.length > 0) {
// Mark specific notifications as read
const placeholders = notificationIds.map(() => "?").join(",");
query = `
UPDATE notifications
SET is_read = 1
WHERE user_id = ? AND id IN (${placeholders})
`;
params = [userId, ...notificationIds];
} else {
// Mark all notifications as read
query = `
UPDATE notifications
SET is_read = 1
WHERE user_id = ?
`;
params = [userId];
}
const stmt = db.prepare(query);
stmt.run(...params);
console.log(`Marked notifications as read for user ${userId}`);
} catch (error) {
console.error("Failed to mark notifications as read:", error);
}
}
/**
* Get unread notification count for a user
* @param {string} userId - User ID
* @returns {number} Number of unread notifications
*/
export async function getUnreadNotificationCount(userId) {
try {
// Check if we're in Edge Runtime - if so, return 0
if (
typeof EdgeRuntime !== "undefined" ||
process.env.NEXT_RUNTIME === "edge"
) {
return 0;
}
// Dynamic import to avoid Edge Runtime issues
const { default: db } = await import("./db.js");
const stmt = db.prepare(`
SELECT COUNT(*) as count
FROM notifications
WHERE user_id = ? AND is_read = 0
`);
const result = stmt.get(userId);
return result.count || 0;
} catch (error) {
console.error("Failed to get unread notification count:", error);
return 0;
}
}
/**
* Delete old notifications (cleanup function)
* @param {number} daysOld - Delete notifications older than this many days
*/
export async function cleanupOldNotifications(daysOld = 30) {
try {
// Check if we're in Edge Runtime - if so, skip database operations
if (
typeof EdgeRuntime !== "undefined" ||
process.env.NEXT_RUNTIME === "edge"
) {
console.log(
`[Notification - Edge Runtime] Cannot cleanup notifications in Edge Runtime`
);
return;
}
// Dynamic import to avoid Edge Runtime issues
const { default: db } = await import("./db.js");
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
const cutoffIso = cutoffDate.toISOString();
const stmt = db.prepare(`
DELETE FROM notifications
WHERE created_at < ? AND is_read = 1
`);
const result = stmt.run(cutoffIso);
console.log(`Cleaned up ${result.changes} old notifications`);
} catch (error) {
console.error("Failed to cleanup old notifications:", error);
}
}
/**
* Create notification from audit event (helper function)
* @param {Object} auditEvent - Audit event data
* @param {string} targetUserId - User to notify
* @param {string} notificationType - Type of notification
* @param {string} title - Notification title
* @param {string} message - Notification message
*/
export async function createNotificationFromAuditEvent(
auditEvent,
targetUserId,
notificationType,
title,
message
) {
// Don't notify the user who performed the action
if (auditEvent.userId === targetUserId) {
return;
}
await createNotification({
userId: targetUserId,
type: notificationType,
title,
message,
resourceType: auditEvent.resourceType,
resourceId: auditEvent.resourceId,
actionUrl: getActionUrl(auditEvent.resourceType, auditEvent.resourceId),
});
}
/**
* Generate action URL for notification
* @param {string} resourceType - Type of resource
* @param {string} resourceId - Resource ID
* @returns {string} Action URL
*/
function getActionUrl(resourceType, resourceId) {
switch (resourceType) {
case "project":
return `/projects/${resourceId}`;
case "project_task":
return `/project-tasks/${resourceId}`;
case "task":
return `/tasks/${resourceId}`;
case "contract":
return `/contracts/${resourceId}`;
default:
return null;
}
}

327
src/lib/queries/contacts.js Normal file
View File

@@ -0,0 +1,327 @@
import db from "../db.js";
// Get all contacts with optional filters
export function getAllContacts(filters = {}) {
let query = `
SELECT
c.*,
COUNT(DISTINCT pc.project_id) as project_count
FROM contacts c
LEFT JOIN project_contacts pc ON c.contact_id = pc.contact_id
WHERE 1=1
`;
const params = [];
// Filter by active status
if (filters.is_active !== undefined) {
query += ` AND c.is_active = ?`;
params.push(filters.is_active ? 1 : 0);
}
// Filter by contact type
if (filters.contact_type) {
query += ` AND c.contact_type = ?`;
params.push(filters.contact_type);
}
// Search by name, phone, email, or company
if (filters.search) {
query += ` AND (
c.name LIKE ? OR
c.phone LIKE ? OR
c.email LIKE ? OR
c.company LIKE ?
)`;
const searchTerm = `%${filters.search}%`;
params.push(searchTerm, searchTerm, searchTerm, searchTerm);
}
query += ` GROUP BY c.contact_id ORDER BY c.name ASC`;
return db.prepare(query).all(...params);
}
// Get contact by ID
export function getContactById(contactId) {
const contact = db
.prepare(
`
SELECT c.*
FROM contacts c
WHERE c.contact_id = ?
`
)
.get(contactId);
if (!contact) return null;
// Get associated projects
const projects = db
.prepare(
`
SELECT
p.project_id,
p.project_name,
p.project_number,
pc.relationship_type,
pc.is_primary,
pc.added_at
FROM project_contacts pc
JOIN projects p ON pc.project_id = p.project_id
WHERE pc.contact_id = ?
ORDER BY pc.is_primary DESC, pc.added_at DESC
`
)
.all(contactId);
return { ...contact, projects };
}
// Create new contact
export function createContact(data) {
const stmt = db.prepare(`
INSERT INTO contacts (
name, phone, email, company, position, contact_type, notes, is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
data.name,
data.phone || null,
data.email || null,
data.company || null,
data.position || null,
data.contact_type || "other",
data.notes || null,
data.is_active !== undefined ? (data.is_active ? 1 : 0) : 1
);
return getContactById(result.lastInsertRowid);
}
// Update contact
export function updateContact(contactId, data) {
const updates = [];
const params = [];
if (data.name !== undefined) {
updates.push("name = ?");
params.push(data.name);
}
if (data.phone !== undefined) {
updates.push("phone = ?");
params.push(data.phone || null);
}
if (data.email !== undefined) {
updates.push("email = ?");
params.push(data.email || null);
}
if (data.company !== undefined) {
updates.push("company = ?");
params.push(data.company || null);
}
if (data.position !== undefined) {
updates.push("position = ?");
params.push(data.position || null);
}
if (data.contact_type !== undefined) {
updates.push("contact_type = ?");
params.push(data.contact_type);
}
if (data.notes !== undefined) {
updates.push("notes = ?");
params.push(data.notes || null);
}
if (data.is_active !== undefined) {
updates.push("is_active = ?");
params.push(data.is_active ? 1 : 0);
}
if (updates.length === 0) {
return getContactById(contactId);
}
updates.push("updated_at = CURRENT_TIMESTAMP");
params.push(contactId);
const query = `UPDATE contacts SET ${updates.join(", ")} WHERE contact_id = ?`;
db.prepare(query).run(...params);
return getContactById(contactId);
}
// Delete contact (soft delete by setting is_active = 0)
export function deleteContact(contactId) {
const stmt = db.prepare(`
UPDATE contacts
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
WHERE contact_id = ?
`);
return stmt.run(contactId);
}
// Hard delete contact (permanent deletion)
export function hardDeleteContact(contactId) {
// First remove all project associations
db.prepare(`DELETE FROM project_contacts WHERE contact_id = ?`).run(
contactId
);
// Then delete the contact
const stmt = db.prepare(`DELETE FROM contacts WHERE contact_id = ?`);
return stmt.run(contactId);
}
// Get contacts for a specific project
export function getProjectContacts(projectId) {
return db
.prepare(
`
SELECT
c.*,
pc.relationship_type,
pc.is_primary,
pc.added_at,
u.name as added_by_name
FROM project_contacts pc
JOIN contacts c ON pc.contact_id = c.contact_id
LEFT JOIN users u ON pc.added_by = u.id
WHERE pc.project_id = ? AND c.is_active = 1
ORDER BY pc.is_primary DESC, c.name ASC
`
)
.all(projectId);
}
// Link contact to project
export function linkContactToProject(
projectId,
contactId,
relationshipType = "general",
isPrimary = false,
userId = null
) {
const stmt = db.prepare(`
INSERT OR REPLACE INTO project_contacts (
project_id, contact_id, relationship_type, is_primary, added_by
) VALUES (?, ?, ?, ?, ?)
`);
return stmt.run(
projectId,
contactId,
relationshipType,
isPrimary ? 1 : 0,
userId
);
}
// Unlink contact from project
export function unlinkContactFromProject(projectId, contactId) {
const stmt = db.prepare(`
DELETE FROM project_contacts
WHERE project_id = ? AND contact_id = ?
`);
return stmt.run(projectId, contactId);
}
// Set primary contact for a project
export function setPrimaryContact(projectId, contactId) {
// First, remove primary flag from all contacts for this project
db.prepare(`
UPDATE project_contacts
SET is_primary = 0
WHERE project_id = ?
`).run(projectId);
// Then set the specified contact as primary
const stmt = db.prepare(`
UPDATE project_contacts
SET is_primary = 1
WHERE project_id = ? AND contact_id = ?
`);
return stmt.run(projectId, contactId);
}
// Get contact statistics
export function getContactStats() {
return db
.prepare(
`
SELECT
COUNT(*) as total_contacts,
COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_contacts,
COUNT(CASE WHEN is_active = 0 THEN 1 END) as inactive_contacts,
COUNT(CASE WHEN contact_type = 'project' THEN 1 END) as project_contacts,
COUNT(CASE WHEN contact_type = 'contractor' THEN 1 END) as contractor_contacts,
COUNT(CASE WHEN contact_type = 'office' THEN 1 END) as office_contacts,
COUNT(CASE WHEN contact_type = 'supplier' THEN 1 END) as supplier_contacts,
COUNT(CASE WHEN contact_type = 'other' THEN 1 END) as other_contacts
FROM contacts
`
)
.get();
}
// Search contacts by phone number
export function searchContactsByPhone(phoneNumber) {
const searchTerm = `%${phoneNumber}%`;
return db
.prepare(
`
SELECT * FROM contacts
WHERE phone LIKE ? AND is_active = 1
ORDER BY name ASC
`
)
.all(searchTerm);
}
// Search contacts by email
export function searchContactsByEmail(email) {
const searchTerm = `%${email}%`;
return db
.prepare(
`
SELECT * FROM contacts
WHERE email LIKE ? AND is_active = 1
ORDER BY name ASC
`
)
.all(searchTerm);
}
// Bulk link contacts to project
export function bulkLinkContactsToProject(projectId, contactIds, userId = null) {
const stmt = db.prepare(`
INSERT OR IGNORE INTO project_contacts (
project_id, contact_id, added_by
) VALUES (?, ?, ?)
`);
const results = contactIds.map((contactId) =>
stmt.run(projectId, contactId, userId)
);
return results;
}
// Get contacts not linked to a specific project (for selection)
export function getAvailableContactsForProject(projectId) {
return db
.prepare(
`
SELECT c.*
FROM contacts c
WHERE c.is_active = 1
AND c.contact_id NOT IN (
SELECT contact_id
FROM project_contacts
WHERE project_id = ?
)
ORDER BY c.name ASC
`
)
.all(projectId);
}

View File

@@ -222,6 +222,7 @@ export function getProjectWithContract(id) {
p.*,
c.contract_number,
c.contract_name,
c.customer_contract_number,
c.customer,
c.investor,
creator.name as created_by_name,

283
src/lib/radicale-sync.js Normal file
View File

@@ -0,0 +1,283 @@
/**
* Radicale CardDAV Sync Utility
* Automatically syncs contacts to Radicale server
*/
// VCARD generation helper
export function generateVCard(contact) {
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
// UID - unique identifier
lines.push(`UID:contact-${contact.contact_id}@panel-app`);
// Name (FN = Formatted Name, N = Structured Name)
if (contact.name) {
lines.push(`FN:${escapeVCardValue(contact.name)}`);
// Try to split name into components (Last;First;Middle;Prefix;Suffix)
const nameParts = contact.name.trim().split(/\s+/);
if (nameParts.length === 1) {
lines.push(`N:${escapeVCardValue(nameParts[0])};;;;`);
} else if (nameParts.length === 2) {
lines.push(`N:${escapeVCardValue(nameParts[1])};${escapeVCardValue(nameParts[0])};;;`);
} else {
// More than 2 parts - first is first name, rest is last name
const firstName = nameParts[0];
const lastName = nameParts.slice(1).join(' ');
lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`);
}
}
// Organization
if (contact.company) {
lines.push(`ORG:${escapeVCardValue(contact.company)}`);
}
// Title/Position
if (contact.position) {
lines.push(`TITLE:${escapeVCardValue(contact.position)}`);
}
// Phone numbers - handle multiple phones
if (contact.phone) {
let phones = [];
try {
// Try to parse as JSON array
const parsed = JSON.parse(contact.phone);
phones = Array.isArray(parsed) ? parsed : [contact.phone];
} catch {
// Fall back to comma-separated or single value
phones = contact.phone.includes(',')
? contact.phone.split(',').map(p => p.trim()).filter(p => p)
: [contact.phone];
}
phones.forEach((phone, index) => {
if (phone) {
// First phone is WORK, others are CELL
const type = index === 0 ? 'WORK' : 'CELL';
lines.push(`TEL;TYPE=${type},VOICE:${escapeVCardValue(phone)}`);
}
});
}
// Email
if (contact.email) {
lines.push(`EMAIL;TYPE=INTERNET,WORK:${escapeVCardValue(contact.email)}`);
}
// Notes - combine contact type, position context, and notes
const noteParts = [];
if (contact.contact_type) {
const typeLabels = {
project: 'Kontakt projektowy',
contractor: 'Wykonawca',
office: 'Urząd',
supplier: 'Dostawca',
other: 'Inny'
};
noteParts.push(`Typ: ${typeLabels[contact.contact_type] || contact.contact_type}`);
}
if (contact.notes) {
noteParts.push(contact.notes);
}
if (noteParts.length > 0) {
lines.push(`NOTE:${escapeVCardValue(noteParts.join('\\n'))}`);
}
// Categories based on contact type
if (contact.contact_type) {
const categories = {
project: 'Projekty',
contractor: 'Wykonawcy',
office: 'Urzędy',
supplier: 'Dostawcy',
other: 'Inne'
};
lines.push(`CATEGORIES:${categories[contact.contact_type] || 'Inne'}`);
}
// Timestamps
if (contact.created_at) {
const created = new Date(contact.created_at).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
lines.push(`REV:${created}`);
}
lines.push('END:VCARD');
return lines.join('\r\n') + '\r\n';
}
// Escape special characters in VCARD values
function escapeVCardValue(value) {
if (!value) return '';
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
// Get Radicale configuration from environment
export function getRadicaleConfig() {
const url = process.env.RADICALE_URL;
const username = process.env.RADICALE_USERNAME;
const password = process.env.RADICALE_PASSWORD;
// Return null if not configured
if (!url || !username || !password) {
return null;
}
return { url, username, password };
}
// Check if Radicale sync is enabled
export function isRadicaleEnabled() {
return getRadicaleConfig() !== null;
}
// Sync contact to Radicale (internal function with retry logic)
async function syncContactToRadicaleInternal(contact, forceUpdate = false) {
const config = getRadicaleConfig();
// Skip if not configured
if (!config) {
return { success: false, reason: 'not_configured' };
}
try {
const vcard = generateVCard(contact);
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64');
// Ensure URL ends with /
const baseUrl = config.url.endsWith('/') ? config.url : config.url + '/';
// Construct the URL for this specific contact
const vcardUrl = `${baseUrl}${config.username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contact.contact_id}.vcf`;
const headers = {
'Authorization': `Basic ${auth}`,
'Content-Type': 'text/vcard; charset=utf-8'
};
// If not forcing update, only create if doesn't exist
if (!forceUpdate) {
headers['If-None-Match'] = '*';
}
const response = await fetch(vcardUrl, {
method: 'PUT',
headers: headers,
body: vcard
});
// Handle conflict - try again with force update
if (response.status === 412 || response.status === 409) {
// Conflict - contact already exists, update it instead
return await syncContactToRadicaleInternal(contact, true);
}
if (response.ok || response.status === 201 || response.status === 204) {
return { success: true, status: response.status, updated: forceUpdate };
} else {
const text = await response.text();
return { success: false, status: response.status, error: text };
}
} catch (error) {
return { success: false, error: error.message };
}
}
// Sync contact to Radicale
export async function syncContactToRadicale(contact) {
const config = getRadicaleConfig();
// Skip if not configured
if (!config) {
console.log('Radicale sync skipped - not configured');
return { success: false, reason: 'not_configured' };
}
// Skip if contact is inactive
if (contact.is_active === 0) {
console.log(`Skipping inactive contact ${contact.contact_id}`);
return { success: true, reason: 'inactive_skipped' };
}
const result = await syncContactToRadicaleInternal(contact);
if (result.success) {
const action = result.updated ? 'updated' : 'created';
console.log(`✅ Synced contact ${contact.contact_id} to Radicale (${action})`);
} else if (result.reason !== 'not_configured') {
console.error(`❌ Failed to sync contact ${contact.contact_id} to Radicale: ${result.status} - ${result.error || result.reason}`);
}
return result;
}
// Delete contact from Radicale
export async function deleteContactFromRadicale(contactId) {
const config = getRadicaleConfig();
// Skip if not configured
if (!config) {
console.log('Radicale delete skipped - not configured');
return { success: false, reason: 'not_configured' };
}
try {
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64');
// Ensure URL ends with /
const baseUrl = config.url.endsWith('/') ? config.url : config.url + '/';
// Construct the URL for this specific contact
const vcardUrl = `${baseUrl}${config.username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contactId}.vcf`;
const response = await fetch(vcardUrl, {
method: 'DELETE',
headers: {
'Authorization': `Basic ${auth}`
}
});
if (response.ok || response.status === 204 || response.status === 404) {
console.log(`✅ Deleted contact ${contactId} from Radicale`);
return { success: true, status: response.status };
} else {
const text = await response.text();
console.error(`❌ Failed to delete contact ${contactId} from Radicale: ${response.status} - ${text}`);
return { success: false, status: response.status, error: text };
}
} catch (error) {
console.error(`❌ Error deleting contact ${contactId} from Radicale:`, error);
return { success: false, error: error.message };
}
}
// Sync contact asynchronously (fire and forget)
export function syncContactAsync(contact) {
if (!isRadicaleEnabled()) {
return;
}
// Run sync in background without blocking
syncContactToRadicale(contact).catch(error => {
console.error('Background sync failed:', error);
});
}
// Delete contact asynchronously (fire and forget)
export function deleteContactAsync(contactId) {
if (!isRadicaleEnabled()) {
return;
}
// Run delete in background without blocking
deleteContactFromRadicale(contactId).catch(error => {
console.error('Background delete failed:', error);
});
}

54
test-contacts-query.mjs Normal file
View File

@@ -0,0 +1,54 @@
import db from './src/lib/db.js';
console.log('Testing contacts query...\n');
try {
// Test 1: Basic query
console.log('Test 1: Basic contact query');
const basic = db.prepare('SELECT contact_id, name FROM contacts WHERE is_active = 1 LIMIT 3').all();
console.log('✓ Basic query works:', basic.length, 'contacts\n');
// Test 2: With LEFT JOIN
console.log('Test 2: With project_contacts join');
const withJoin = db.prepare(`
SELECT c.contact_id, c.name, COUNT(pc.project_id) as count
FROM contacts c
LEFT JOIN project_contacts pc ON c.contact_id = pc.contact_id
WHERE c.is_active = 1
GROUP BY c.contact_id
LIMIT 3
`).all();
console.log('✓ Join query works:', withJoin.length, 'contacts\n');
// Test 3: With both joins
console.log('Test 3: With both joins (no CASE)');
const bothJoins = db.prepare(`
SELECT c.contact_id, c.name, COUNT(p.project_id) as count
FROM contacts c
LEFT JOIN project_contacts pc ON c.contact_id = pc.contact_id
LEFT JOIN projects p ON pc.project_id = p.project_id
WHERE c.is_active = 1
GROUP BY c.contact_id
LIMIT 3
`).all();
console.log('✓ Both joins work:', bothJoins.length, 'contacts\n');
// Test 4: With CASE statement
console.log('Test 4: With CASE statement');
const withCase = db.prepare(`
SELECT c.contact_id, c.name,
COUNT(DISTINCT CASE WHEN p.is_deleted = 0 THEN p.project_id ELSE NULL END) as count
FROM contacts c
LEFT JOIN project_contacts pc ON c.contact_id = pc.contact_id
LEFT JOIN projects p ON pc.project_id = p.project_id
WHERE c.is_active = 1
GROUP BY c.contact_id
LIMIT 3
`).all();
console.log('✓ CASE query works:', withCase.length, 'contacts');
withCase.forEach(c => console.log(` ${c.name}: ${c.count} active projects`));
} catch (error) {
console.error('❌ Query failed:', error.message);
console.error(error.stack);
}

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Test script to verify notifications API
*/
async function testNotificationsAPI() {
try {
console.log("Testing notifications API...");
// Test unread count endpoint
const unreadResponse = await fetch('http://localhost:3001/api/notifications/unread-count');
if (unreadResponse.ok) {
const unreadData = await unreadResponse.json();
console.log("✅ Unread count:", unreadData.unreadCount);
} else {
console.log("❌ Unread count endpoint failed:", unreadResponse.status);
}
// Test notifications list endpoint
const notificationsResponse = await fetch('http://localhost:3001/api/notifications');
if (notificationsResponse.ok) {
const notificationsData = await notificationsResponse.json();
console.log("✅ Notifications fetched:", notificationsData.notifications.length);
console.log("Sample notification:", notificationsData.notifications[0]);
} else {
console.log("❌ Notifications endpoint failed:", notificationsResponse.status);
}
} catch (error) {
console.error("Error testing API:", error);
}
}
testNotificationsAPI();

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* Test script to verify notifications are working
*/
async function testNotifications() {
try {
console.log("Testing notifications system...");
// Test unread count endpoint (this should work without auth for now)
console.log("1. Testing unread count endpoint...");
const unreadResponse = await fetch('http://localhost:3001/api/notifications/unread-count');
if (unreadResponse.status === 401) {
console.log("✅ Unread count endpoint requires auth (expected)");
} else if (unreadResponse.ok) {
const data = await unreadResponse.json();
console.log("✅ Unread count:", data.unreadCount);
} else {
console.log("❌ Unread count endpoint failed:", unreadResponse.status);
}
// Test notifications endpoint
console.log("2. Testing notifications endpoint...");
const notificationsResponse = await fetch('http://localhost:3001/api/notifications');
if (notificationsResponse.status === 401) {
console.log("✅ Notifications endpoint requires auth (expected)");
} else if (notificationsResponse.ok) {
const data = await notificationsResponse.json();
console.log("✅ Notifications fetched:", data.notifications?.length || 0);
} else {
console.log("❌ Notifications endpoint failed:", notificationsResponse.status);
}
console.log("\n🎉 Notification system test completed!");
console.log("Note: API endpoints require authentication, so 401 responses are expected.");
console.log("Test the UI by logging into the application and checking the notification dropdown.");
} catch (error) {
console.error("❌ Error testing notifications:", error);
}
}
testNotifications();

45
test-radicale-config.mjs Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Test Radicale sync configuration
*/
import { getRadicaleConfig, isRadicaleEnabled, generateVCard } from './src/lib/radicale-sync.js';
console.log('🧪 Testing Radicale Sync Configuration\n');
// Check if enabled
if (isRadicaleEnabled()) {
const config = getRadicaleConfig();
console.log('✅ Radicale sync is ENABLED');
console.log(` URL: ${config.url}`);
console.log(` Username: ${config.username}`);
console.log(` Password: ${config.password ? '***' + config.password.slice(-3) : 'not set'}`);
} else {
console.log('❌ Radicale sync is DISABLED');
console.log(' Set RADICALE_URL, RADICALE_USERNAME, and RADICALE_PASSWORD in .env.local to enable');
}
console.log('\n📝 Testing VCARD Generation\n');
// Test VCARD generation
const testContact = {
contact_id: 999,
name: 'Jan Kowalski',
phone: '["123-456-789", "987-654-321"]',
email: 'jan.kowalski@example.com',
company: 'Test Company',
position: 'Manager',
contact_type: 'project',
notes: 'Test contact for VCARD generation',
is_active: 1,
created_at: new Date().toISOString()
};
const vcard = generateVCard(testContact);
console.log('Generated VCARD:');
console.log('─'.repeat(60));
console.log(vcard);
console.log('─'.repeat(60));
console.log('\n✅ Test complete!');