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