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:
2025-12-03 16:23:05 +01:00
parent c9b7355f3c
commit 60b79fa360
18 changed files with 2332 additions and 10 deletions

174
CONTACTS_SYSTEM_README.md Normal file
View File

@@ -0,0 +1,174 @@
# Contacts Management System
## Overview
A comprehensive contacts management system has been implemented to replace the simple text field for project contacts. This system allows you to:
- **Create and manage a centralized contact database**
- **Link multiple contacts to each project**
- **Categorize contacts** (Project contacts, Contractors, Offices, Suppliers, etc.)
- **Track contact details** (name, phone, email, company, position)
- **Set primary contacts** for projects
- **Search and filter** contacts easily
## What Was Implemented
### 1. Database Schema
**New Tables:**
- **`contacts`** - Stores all contact information
- `contact_id` (Primary Key)
- `name`, `phone`, `email`, `company`, `position`
- `contact_type` (project/contractor/office/supplier/other)
- `notes`, `is_active`
- `created_at`, `updated_at`
- **`project_contacts`** - Junction table linking projects to contacts (many-to-many)
- `project_id`, `contact_id` (Composite Primary Key)
- `relationship_type`, `is_primary`
- `added_at`, `added_by`
### 2. API Endpoints
- **`GET /api/contacts`** - List all contacts (with filters)
- **`POST /api/contacts`** - Create new contact
- **`GET /api/contacts/[id]`** - Get contact details
- **`PUT /api/contacts/[id]`** - Update contact
- **`DELETE /api/contacts/[id]`** - Delete contact (soft/hard)
- **`GET /api/projects/[id]/contacts`** - Get project's contacts
- **`POST /api/projects/[id]/contacts`** - Link contact to project
- **`DELETE /api/projects/[id]/contacts`** - Unlink contact from project
- **`PATCH /api/projects/[id]/contacts`** - Set primary contact
### 3. UI Components
- **`ContactForm`** - Create/edit contact form
- **`/contacts` page** - Full contacts management interface with:
- Statistics dashboard
- Search and filtering
- Contact cards with quick actions
- CRUD operations
- **`ProjectContactSelector`** - Multi-contact selector for projects
- View linked contacts
- Add/remove contacts
- Set primary contact
- Real-time search
### 4. Integration
- **Navigation** - "Kontakty" link added to main navigation
- **ProjectForm** - Contact text field replaced with `ProjectContactSelector`
- **Translations** - Polish translations added to i18n
- **Query Functions** - Comprehensive database query functions in `src/lib/queries/contacts.js`
## How to Use
### Initial Setup
1. **Run the migration script** to create the new tables:
```bash
node migrate-contacts.mjs
```
2. **Start your development server**:
```bash
npm run dev
```
3. **Visit** `http://localhost:3000/contacts` to start adding contacts
### Managing Contacts
1. **Create Contacts**:
- Go to `/contacts`
- Click "Dodaj kontakt"
- Fill in contact details
- Select contact type (Project/Contractor/Office/Supplier/Other)
2. **Link Contacts to Projects**:
- Edit any project
- In the "Kontakty do projektu" section
- Click "+ Dodaj kontakt"
- Search and add contacts
- Set one as primary if needed
3. **View Contact Details**:
- Contacts page shows all contacts with:
- Contact information (phone, email, company)
- Number of linked projects
- Contact type badges
- Edit or delete contacts as needed
### Contact Types
- **Kontakt projektowy (Project)** - Project-specific contacts
- **Wykonawca (Contractor)** - Construction contractors
- **Urząd (Office)** - Government offices, municipalities
- **Dostawca (Supplier)** - Material suppliers, vendors
- **Inny (Other)** - Any other type of contact
### Features
- **Search** - Search by name, phone, email, or company
- **Filter** - Filter by contact type
- **Statistics** - See breakdown of contacts by type
- **Multiple Contacts per Project** - Link as many contacts as needed
- **Primary Contact** - Mark one contact as primary for each project
- **Bidirectional Links** - See which projects a contact is linked to
- **Soft Delete** - Deleted contacts are marked inactive, not removed
## Database Migration Notes
- The **old `contact` text field** in the `projects` table is still present
- It hasn't been removed for backward compatibility
- You can manually migrate old contact data by:
1. Creating contacts from the old text data
2. Linking them to the appropriate projects
3. The old field will remain for reference
## File Structure
```
src/
├── app/
│ ├── api/
│ │ ├── contacts/
│ │ │ ├── route.js # List/Create contacts
│ │ │ └── [id]/
│ │ │ └── route.js # Get/Update/Delete contact
│ │ └── projects/
│ │ └── [id]/
│ │ └── contacts/
│ │ └── route.js # Link/unlink contacts to project
│ └── contacts/
│ └── page.js # Contacts management page
├── components/
│ ├── ContactForm.js # Contact form component
│ └── ProjectContactSelector.js # Project contact selector
└── lib/
├── queries/
│ └── contacts.js # Database query functions
└── init-db.js # Database schema with new tables
```
## Future Enhancements
Potential improvements you could add:
- Contact import/export (CSV, Excel)
- Contact groups or tags
- Contact activity history
- Email integration
- Contact notes and history
- Duplicate contact detection
- Contact merge functionality
- Advanced relationship types
- Contact sharing between projects
- Contact reminders/follow-ups
## Support
The old contact text field remains in the database, so no existing data is lost. You can gradually migrate to the new system at your own pace.
Enjoy your new contacts management system! 🎉

22
check-contacts.mjs Normal file
View File

@@ -0,0 +1,22 @@
import db from './src/lib/db.js';
console.log('Checking contacts in database...\n');
const contacts = db.prepare('SELECT contact_id, name, phone, email, is_active, contact_type FROM contacts LIMIT 10').all();
console.log(`Total contacts found: ${contacts.length}\n`);
if (contacts.length > 0) {
console.log('Sample contacts:');
contacts.forEach(c => {
console.log(` ID: ${c.contact_id}, Name: ${c.name}, Phone: ${c.phone || 'N/A'}, Email: ${c.email || 'N/A'}, Active: ${c.is_active}, Type: ${c.contact_type}`);
});
} else {
console.log('No contacts found in database!');
}
const activeCount = db.prepare('SELECT COUNT(*) as count FROM contacts WHERE is_active = 1').get();
console.log(`\nActive contacts: ${activeCount.count}`);
const totalCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
console.log(`Total contacts: ${totalCount.count}`);

46
migrate-contacts.mjs Normal file
View File

@@ -0,0 +1,46 @@
import db from './src/lib/db.js';
import initializeDatabase from './src/lib/init-db.js';
console.log('🚀 Initializing contacts tables...\n');
try {
// Run database initialization which will create the new contacts tables
initializeDatabase();
console.log('✅ Contacts tables created successfully!\n');
// Check if there are projects with contact data in the old text field
const projectsWithContacts = db.prepare(`
SELECT project_id, project_name, contact
FROM projects
WHERE contact IS NOT NULL AND contact != ''
`).all();
if (projectsWithContacts.length > 0) {
console.log(`📋 Found ${projectsWithContacts.length} projects with contact information in the old text field.\n`);
console.log('Sample contacts that could be migrated:');
projectsWithContacts.slice(0, 5).forEach(p => {
console.log(` - ${p.project_name}: "${p.contact}"`);
});
console.log('\n You can manually create contacts from the /contacts page and link them to projects.');
console.log(' The old contact field will remain in the database for reference.\n');
} else {
console.log(' No existing contact data found in projects.\n');
}
// Show table statistics
const contactsCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
const projectContactsCount = db.prepare('SELECT COUNT(*) as count FROM project_contacts').get();
console.log('📊 Database Statistics:');
console.log(` - Contacts: ${contactsCount.count}`);
console.log(` - Project-Contact Links: ${projectContactsCount.count}`);
console.log('\n✨ Migration complete! You can now:');
console.log(' 1. Visit /contacts to manage your contacts');
console.log(' 2. Add/edit projects to link contacts');
console.log(' 3. View linked contacts in project details\n');
} catch (error) {
console.error('❌ Error during migration:', error);
process.exit(1);
}

View File

@@ -0,0 +1,188 @@
import db from './src/lib/db.js';
import initializeDatabase from './src/lib/init-db.js';
console.log('🚀 Migrating contact data from projects...\n');
try {
// Run database initialization to ensure tables exist
initializeDatabase();
console.log('✅ Database tables verified\n');
// Get all projects with contact data
const projectsWithContacts = db.prepare(`
SELECT project_id, project_name, contact
FROM projects
WHERE contact IS NOT NULL AND contact != '' AND TRIM(contact) != ''
`).all();
if (projectsWithContacts.length === 0) {
console.log(' No contact data found in projects to migrate.\n');
process.exit(0);
}
console.log(`📋 Found ${projectsWithContacts.length} projects with contact information\n`);
let created = 0;
let linked = 0;
let skipped = 0;
const createContact = db.prepare(`
INSERT INTO contacts (name, phone, email, contact_type, notes, is_active)
VALUES (?, ?, ?, 'project', ?, 1)
`);
const linkContact = db.prepare(`
INSERT OR IGNORE INTO project_contacts (project_id, contact_id, is_primary, relationship_type)
VALUES (?, ?, 1, 'general')
`);
// Process each project
for (const project of projectsWithContacts) {
try {
const contactText = project.contact.trim();
// Parse contact information - common formats:
// "Jan Kowalski, tel. 123-456-789"
// "Jan Kowalski 123-456-789"
// "123-456-789"
// "Jan Kowalski"
let name = '';
let phone = '';
let email = '';
let notes = '';
// Try to extract email
const emailPattern = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/;
const emailMatch = contactText.match(emailPattern);
if (emailMatch) {
email = emailMatch[1].trim();
}
// Try to extract phone number (various formats)
const phonePatterns = [
/(?:\+?48)?[\s-]?(\d{3}[\s-]?\d{3}[\s-]?\d{3})/, // Polish: 123-456-789, 123 456 789, +48 123456789
/(?:\+?48)?[\s-]?(\d{9})/, // 9 digits
/tel\.?\s*[:.]?\s*([+\d\s-]+)/i, // tel. 123-456-789
/phone\s*[:.]?\s*([+\d\s-]+)/i, // phone: 123-456-789
/(\d{3}[-\s]?\d{3}[-\s]?\d{3})/, // Generic phone pattern
];
for (const pattern of phonePatterns) {
const match = contactText.match(pattern);
if (match) {
phone = match[1] || match[0];
phone = phone.replace(/\s+/g, ' ').trim();
break;
}
}
// Extract name (text before phone/email or comma)
let textForName = contactText;
if (phone) {
// Remove phone from text to get name
textForName = textForName.replace(phone, '');
}
if (email) {
// Remove email from text to get name
textForName = textForName.replace(email, '');
}
// Remove common prefixes like "tel.", "phone:", "email:", commas, etc.
name = textForName.replace(/tel\.?|phone:?|email:?|e-mail:?|,/gi, '').trim();
// Clean up name
name = name.replace(/^[,\s-]+|[,\s-]+$/g, '').trim();
// If we couldn't extract structured data, use project name and put original text in notes
if (!phone && !email) {
// No structured contact info found, put everything in notes
notes = `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);
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import db from "@/lib/db";
import { withAuth } from "@/lib/middleware/auth";
// GET /api/contacts/[id]/projects - Get all projects linked to a contact
export const GET = withAuth(async (request, { params }) => {
try {
const contactId = params.id;
// Get all projects linked to this contact with relationship details
const projects = db
.prepare(
`
SELECT
p.project_id,
p.project_name,
p.project_status,
pc.relationship_type,
pc.is_primary,
pc.added_at
FROM projects p
INNER JOIN project_contacts pc ON p.project_id = pc.project_id
WHERE pc.contact_id = ?
ORDER BY pc.is_primary DESC, p.project_name ASC
`
)
.all(contactId);
return NextResponse.json({
projects,
count: projects.length,
});
} catch (error) {
console.error("Error fetching contact projects:", error);
return NextResponse.json(
{ error: "Failed to fetch projects" },
{ status: 500 }
);
}
});

View File

@@ -0,0 +1,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);

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

View File

@@ -0,0 +1,111 @@
import { NextResponse } from "next/server";
import {
getProjectContacts,
linkContactToProject,
unlinkContactFromProject,
setPrimaryContact,
} from "@/lib/queries/contacts";
import { withAuth } from "@/lib/middleware/auth";
// GET: Get all contacts for a project
async function getProjectContactsHandler(req, { params }) {
try {
const projectId = parseInt(params.id);
const contacts = getProjectContacts(projectId);
return NextResponse.json(contacts);
} catch (error) {
console.error("Error fetching project contacts:", error);
return NextResponse.json(
{ error: "Failed to fetch project contacts" },
{ status: 500 }
);
}
}
// POST: Link contact to project
async function linkContactHandler(req, { params }) {
try {
const projectId = parseInt(params.id);
const { contactId, relationshipType, isPrimary } = await req.json();
const userId = req.user?.id;
if (!contactId) {
return NextResponse.json(
{ error: "Contact ID is required" },
{ status: 400 }
);
}
linkContactToProject(
projectId,
contactId,
relationshipType || "general",
isPrimary || false,
userId
);
const contacts = getProjectContacts(projectId);
return NextResponse.json(contacts);
} catch (error) {
console.error("Error linking contact to project:", error);
return NextResponse.json(
{ error: "Failed to link contact" },
{ status: 500 }
);
}
}
// DELETE: Unlink contact from project
async function unlinkContactHandler(req, { params }) {
try {
const projectId = parseInt(params.id);
const { searchParams } = new URL(req.url);
const contactId = parseInt(searchParams.get("contactId"));
if (!contactId) {
return NextResponse.json(
{ error: "Contact ID is required" },
{ status: 400 }
);
}
unlinkContactFromProject(projectId, contactId);
return NextResponse.json({ message: "Contact unlinked successfully" });
} catch (error) {
console.error("Error unlinking contact from project:", error);
return NextResponse.json(
{ error: "Failed to unlink contact" },
{ status: 500 }
);
}
}
// PATCH: Set primary contact
async function setPrimaryContactHandler(req, { params }) {
try {
const projectId = parseInt(params.id);
const { contactId } = await req.json();
if (!contactId) {
return NextResponse.json(
{ error: "Contact ID is required" },
{ status: 400 }
);
}
setPrimaryContact(projectId, contactId);
const contacts = getProjectContacts(projectId);
return NextResponse.json(contacts);
} catch (error) {
console.error("Error setting primary contact:", error);
return NextResponse.json(
{ error: "Failed to set primary contact" },
{ status: 500 }
);
}
}
export const GET = withAuth(getProjectContactsHandler);
export const POST = withAuth(linkContactHandler);
export const DELETE = withAuth(unlinkContactHandler);
export const PATCH = withAuth(setPrimaryContactHandler);

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

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

View File

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

View File

@@ -8,6 +8,7 @@ import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { 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>

View File

@@ -27,6 +27,14 @@ const CardHeader = ({ children, className = "" }) => {
); );
}; };
const CardTitle = ({ children, className = "" }) => {
return (
<h3 className={`text-lg font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
{children}
</h3>
);
};
const CardContent = ({ children, className = "" }) => { 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 };

View File

@@ -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') },

View File

@@ -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",

View File

@@ -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
View File

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

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

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