feat: add contact management functionality
- Implemented ContactForm component for creating and editing contacts. - Added ProjectContactSelector component to manage project-specific contacts. - Updated ProjectForm to include ProjectContactSelector for associating contacts with projects. - Enhanced Card component with a new CardTitle subcomponent for better structure. - Updated Navigation to include a link to the contacts page. - Added translations for contact-related terms in the i18n module. - Initialized contacts database schema and created necessary tables for contact management. - Developed queries for CRUD operations on contacts, including linking and unlinking contacts to projects. - Created a test script to validate contact queries against the database.
This commit is contained in:
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! 🎉
|
||||||
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}`);
|
||||||
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 = `Original contact info: ${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 || `Migrated from project: ${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);
|
||||||
|
}
|
||||||
40
src/app/api/contacts/[id]/projects/route.js
Normal file
40
src/app/api/contacts/[id]/projects/route.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import db from "@/lib/db";
|
||||||
|
import { withAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET /api/contacts/[id]/projects - Get all projects linked to a contact
|
||||||
|
export const GET = withAuth(async (request, { params }) => {
|
||||||
|
try {
|
||||||
|
const contactId = params.id;
|
||||||
|
|
||||||
|
// Get all projects linked to this contact with relationship details
|
||||||
|
const projects = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.project_id,
|
||||||
|
p.project_name,
|
||||||
|
p.project_status,
|
||||||
|
pc.relationship_type,
|
||||||
|
pc.is_primary,
|
||||||
|
pc.added_at
|
||||||
|
FROM projects p
|
||||||
|
INNER JOIN project_contacts pc ON p.project_id = pc.project_id
|
||||||
|
WHERE pc.contact_id = ?
|
||||||
|
ORDER BY pc.is_primary DESC, p.project_name ASC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(contactId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
projects,
|
||||||
|
count: projects.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contact projects:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch projects" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
103
src/app/api/contacts/[id]/route.js
Normal file
103
src/app/api/contacts/[id]/route.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getContactById,
|
||||||
|
updateContact,
|
||||||
|
deleteContact,
|
||||||
|
hardDeleteContact,
|
||||||
|
} from "@/lib/queries/contacts";
|
||||||
|
import { withAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET: Get contact by ID
|
||||||
|
async function getContactHandler(req, { params }) {
|
||||||
|
try {
|
||||||
|
const contactId = parseInt(params.id);
|
||||||
|
const contact = getContactById(contactId);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Contact not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(contact);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contact:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch contact" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: Update contact
|
||||||
|
async function updateContactHandler(req, { params }) {
|
||||||
|
try {
|
||||||
|
const contactId = parseInt(params.id);
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
// Validate contact type if provided
|
||||||
|
if (data.contact_type) {
|
||||||
|
const validTypes = [
|
||||||
|
"project",
|
||||||
|
"contractor",
|
||||||
|
"office",
|
||||||
|
"supplier",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
if (!validTypes.includes(data.contact_type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid contact type" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = updateContact(contactId, data);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Contact not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(contact);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating contact:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update contact" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: Delete contact (soft delete or hard delete)
|
||||||
|
async function deleteContactHandler(req, { params }) {
|
||||||
|
try {
|
||||||
|
const contactId = parseInt(params.id);
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const hard = searchParams.get("hard") === "true";
|
||||||
|
|
||||||
|
if (hard) {
|
||||||
|
// Hard delete - permanently remove
|
||||||
|
hardDeleteContact(contactId);
|
||||||
|
} else {
|
||||||
|
// Soft delete - set is_active to 0
|
||||||
|
deleteContact(contactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Contact deleted successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting contact:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete contact" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withAuth(getContactHandler);
|
||||||
|
export const PUT = withAuth(updateContactHandler);
|
||||||
|
export const DELETE = withAuth(deleteContactHandler);
|
||||||
73
src/app/api/contacts/route.js
Normal file
73
src/app/api/contacts/route.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getAllContacts,
|
||||||
|
createContact,
|
||||||
|
getContactStats,
|
||||||
|
} from "@/lib/queries/contacts";
|
||||||
|
import { withAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET: Get all contacts with optional filters
|
||||||
|
async function getContactsHandler(req) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const filters = {
|
||||||
|
is_active: searchParams.get("is_active")
|
||||||
|
? searchParams.get("is_active") === "true"
|
||||||
|
: undefined,
|
||||||
|
contact_type: searchParams.get("contact_type") || undefined,
|
||||||
|
search: searchParams.get("search") || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if stats are requested
|
||||||
|
if (searchParams.get("stats") === "true") {
|
||||||
|
const stats = getContactStats();
|
||||||
|
return NextResponse.json(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = getAllContacts(filters);
|
||||||
|
return NextResponse.json(contacts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contacts:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch contacts" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Create new contact
|
||||||
|
async function createContactHandler(req) {
|
||||||
|
try {
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!data.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Contact name is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate contact type
|
||||||
|
const validTypes = ["project", "contractor", "office", "supplier", "other"];
|
||||||
|
if (data.contact_type && !validTypes.includes(data.contact_type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid contact type" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = createContact(data);
|
||||||
|
return NextResponse.json(contact, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating contact:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create contact" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withAuth(getContactsHandler);
|
||||||
|
export const POST = withAuth(createContactHandler);
|
||||||
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);
|
||||||
598
src/app/contacts/page.js
Normal file
598
src/app/contacts/page.js
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
"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="space-y-3">
|
||||||
|
{filteredContacts.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="text-center py-12">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{searchTerm || typeFilter !== "all"
|
||||||
|
? "Nie znaleziono kontaktów"
|
||||||
|
: "Brak kontaktów. Dodaj pierwszy kontakt."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
filteredContacts.map((contact) => {
|
||||||
|
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||||
|
return (
|
||||||
|
<Card key={contact.contact_id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 cursor-pointer" onClick={() => handleViewDetails(contact)}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-lg hover:text-blue-600 transition-colors">
|
||||||
|
{contact.name}
|
||||||
|
</h3>
|
||||||
|
<Badge variant={typeBadge.variant} size="sm">
|
||||||
|
{typeBadge.label}
|
||||||
|
</Badge>
|
||||||
|
{contact.project_count > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<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 className="flex flex-wrap gap-4 text-sm">
|
||||||
|
{contact.company && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-600">
|
||||||
|
<span className="font-medium">🏢</span>
|
||||||
|
<span>{contact.company}</span>
|
||||||
|
{contact.position && (
|
||||||
|
<span className="text-gray-500">• {contact.position}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!contact.company && contact.position && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-600">
|
||||||
|
<span>{contact.position}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 mt-2">
|
||||||
|
{contact.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${contact.phone}`}
|
||||||
|
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>
|
||||||
|
{contact.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
{contact.email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex sm:flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit(contact);
|
||||||
|
}}
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 sm:mr-0 md: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>
|
||||||
|
<span className="hidden md:inline">Edytuj</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(contact.contact_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</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 && (
|
||||||
|
<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 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:${selectedContact.phone}`} className="text-blue-600 hover:underline">
|
||||||
|
{selectedContact.phone}
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/ContactForm.js
Normal file
212
src/components/ContactForm.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { 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: "",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
company: "",
|
||||||
|
position: "",
|
||||||
|
contact_type: "other",
|
||||||
|
notes: "",
|
||||||
|
is_active: true,
|
||||||
|
...initialData,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const isEdit = !!initialData;
|
||||||
|
|
||||||
|
function handleChange(e) {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === "checkbox" ? checked : value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="+48 123 456 789"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
src/components/ProjectContactSelector.js
Normal file
298
src/components/ProjectContactSelector.js
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import Badge from "@/components/ui/Badge";
|
||||||
|
|
||||||
|
export default function ProjectContactSelector({ projectId, onChange }) {
|
||||||
|
const [contacts, setContacts] = useState([]);
|
||||||
|
const [projectContacts, setProjectContacts] = useState([]);
|
||||||
|
const [availableContacts, setAvailableContacts] = useState([]);
|
||||||
|
const [showSelector, setShowSelector] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllContacts();
|
||||||
|
if (projectId) {
|
||||||
|
fetchProjectContacts();
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
async function fetchAllContacts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/contacts?is_active=true");
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setContacts(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contacts:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectContacts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/contacts`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setProjectContacts(data);
|
||||||
|
updateAvailableContacts(data);
|
||||||
|
onChange?.(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching project contacts:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAvailableContacts(linkedContacts) {
|
||||||
|
const linkedIds = linkedContacts.map((c) => c.contact_id);
|
||||||
|
const available = contacts.filter(
|
||||||
|
(c) => !linkedIds.includes(c.contact_id)
|
||||||
|
);
|
||||||
|
setAvailableContacts(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddContact(contactId) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/contacts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ contactId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchProjectContacts();
|
||||||
|
setShowSelector(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding contact:", error);
|
||||||
|
alert("Nie udało się dodać kontaktu");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveContact(contactId) {
|
||||||
|
if (!confirm("Czy na pewno chcesz usunąć ten kontakt z projektu?"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/contacts?contactId=${contactId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchProjectContacts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error removing contact:", error);
|
||||||
|
alert("Nie udało się usunąć kontaktu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetPrimary(contactId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/contacts`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ contactId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchProjectContacts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting primary contact:", error);
|
||||||
|
alert("Nie udało się ustawić głównego kontaktu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContactTypeBadge = (type) => {
|
||||||
|
const types = {
|
||||||
|
project: { label: "Projekt", variant: "primary" },
|
||||||
|
contractor: { label: "Wykonawca", variant: "warning" },
|
||||||
|
office: { label: "Urząd", variant: "info" },
|
||||||
|
supplier: { label: "Dostawca", variant: "success" },
|
||||||
|
other: { label: "Inny", variant: "secondary" },
|
||||||
|
};
|
||||||
|
return types[type] || types.other;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAvailable = searchTerm
|
||||||
|
? availableContacts.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.phone?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.company?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
: availableContacts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Kontakty do projektu
|
||||||
|
</label>
|
||||||
|
{projectId && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowSelector(!showSelector)}
|
||||||
|
>
|
||||||
|
{showSelector ? "Anuluj" : "+ Dodaj kontakt"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current project contacts */}
|
||||||
|
{projectContacts.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{projectContacts.map((contact) => {
|
||||||
|
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.contact_id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{contact.name}
|
||||||
|
</span>
|
||||||
|
{contact.is_primary === 1 && (
|
||||||
|
<Badge variant="success" size="xs">
|
||||||
|
Główny
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant={typeBadge.variant} size="xs">
|
||||||
|
{typeBadge.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-1 space-y-0.5">
|
||||||
|
{contact.phone && <div>📞 {contact.phone}</div>}
|
||||||
|
{contact.email && <div>📧 {contact.email}</div>}
|
||||||
|
{contact.company && (
|
||||||
|
<div className="text-xs">🏢 {contact.company}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{contact.is_primary !== 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSetPrimary(contact.contact_id)}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700 px-2 py-1 rounded hover:bg-blue-50"
|
||||||
|
title="Ustaw jako główny"
|
||||||
|
>
|
||||||
|
Ustaw główny
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveContact(contact.contact_id)}
|
||||||
|
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
|
||||||
|
title="Usuń kontakt"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 italic p-3 bg-gray-50 rounded-md">
|
||||||
|
{projectId
|
||||||
|
? "Brak powiązanych kontaktów. Dodaj kontakt do projektu."
|
||||||
|
: "Zapisz projekt, aby móc dodać kontakty."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact selector */}
|
||||||
|
{showSelector && projectId && (
|
||||||
|
<div className="border border-gray-300 rounded-md p-4 bg-white">
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Szukaj kontaktu..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-2">
|
||||||
|
{filteredAvailable.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
{searchTerm
|
||||||
|
? "Nie znaleziono kontaktów"
|
||||||
|
: "Wszystkie kontakty są już dodane"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredAvailable.map((contact) => {
|
||||||
|
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.contact_id}
|
||||||
|
className="flex items-center justify-between p-2 hover:bg-gray-50 rounded border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-gray-900">
|
||||||
|
{contact.name}
|
||||||
|
</span>
|
||||||
|
<Badge variant={typeBadge.variant} size="xs">
|
||||||
|
{typeBadge.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
|
{contact.phone && <span>{contact.phone}</span>}
|
||||||
|
{contact.phone && contact.email && (
|
||||||
|
<span className="mx-1">•</span>
|
||||||
|
)}
|
||||||
|
{contact.email && <span>{contact.email}</span>}
|
||||||
|
</div>
|
||||||
|
{contact.company && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{contact.company}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddContact(contact.contact_id)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Dodaj
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import Button from "@/components/ui/Button";
|
|||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { formatDateForInput } from "@/lib/utils";
|
import { formatDateForInput } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import ProjectContactSelector from "@/components/ProjectContactSelector";
|
||||||
|
|
||||||
const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref) {
|
const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -365,15 +366,8 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<ProjectContactSelector
|
||||||
{t('projects.contact')}
|
projectId={initialData?.project_id}
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="contact"
|
|
||||||
value={form.contact || ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder={t('projects.placeholders.contact')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 = "" }) => {
|
const CardContent = ({ children, className = "" }) => {
|
||||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
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 = [
|
const navItems = [
|
||||||
{ href: "/projects", label: t('navigation.projects') },
|
{ href: "/projects", label: t('navigation.projects') },
|
||||||
|
{ href: "/contacts", label: t('navigation.contacts') || 'Kontakty' },
|
||||||
{ href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
|
{ href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
|
||||||
{ href: "/project-tasks", label: t('navigation.tasks') || 'Tasks' },
|
{ href: "/project-tasks", label: t('navigation.tasks') || 'Tasks' },
|
||||||
{ href: "/contracts", label: t('navigation.contracts') },
|
{ href: "/contracts", label: t('navigation.contracts') },
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const translations = {
|
|||||||
navigation: {
|
navigation: {
|
||||||
dashboard: "Panel główny",
|
dashboard: "Panel główny",
|
||||||
projects: "Projekty",
|
projects: "Projekty",
|
||||||
|
contacts: "Kontakty",
|
||||||
calendar: "Kalendarz",
|
calendar: "Kalendarz",
|
||||||
taskTemplates: "Szablony zadań",
|
taskTemplates: "Szablony zadań",
|
||||||
projectTasks: "Zadania projektów",
|
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
|
||||||
contracts: {
|
contracts: {
|
||||||
title: "Umowy",
|
title: "Umowy",
|
||||||
|
|||||||
@@ -515,4 +515,44 @@ export default function initializeDatabase() {
|
|||||||
INSERT OR IGNORE INTO settings (key, value, description) VALUES
|
INSERT OR IGNORE INTO settings (key, value, description) VALUES
|
||||||
('backup_notification_user_id', '', 'User ID to receive backup completion notifications');
|
('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);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user