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

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

View File

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

View File

@@ -143,6 +143,7 @@ const Navigation = () => {
const navItems = [
{ href: "/projects", label: t('navigation.projects') },
{ href: "/contacts", label: t('navigation.contacts') || 'Kontakty' },
{ href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
{ href: "/project-tasks", label: t('navigation.tasks') || 'Tasks' },
{ href: "/contracts", label: t('navigation.contracts') },

View File

@@ -12,6 +12,7 @@ const translations = {
navigation: {
dashboard: "Panel główny",
projects: "Projekty",
contacts: "Kontakty",
calendar: "Kalendarz",
taskTemplates: "Szablony zadań",
projectTasks: "Zadania projektów",
@@ -245,6 +246,38 @@ const translations = {
}
},
// Contacts
contacts: {
title: "Kontakty",
subtitle: "Zarządzaj kontaktami",
contact: "Kontakt",
newContact: "Nowy kontakt",
editContact: "Edytuj kontakt",
deleteContact: "Usuń kontakt",
name: "Imię i nazwisko",
phone: "Telefon",
email: "Email",
company: "Firma",
position: "Stanowisko",
contactType: "Typ kontaktu",
notes: "Notatki",
active: "Aktywny",
inactive: "Nieaktywny",
searchPlaceholder: "Szukaj kontaktów...",
noContacts: "Brak kontaktów",
addFirstContact: "Dodaj pierwszy kontakt",
selectContact: "Wybierz kontakt",
addContact: "Dodaj kontakt",
linkedProjects: "Powiązane projekty",
types: {
project: "Kontakt projektowy",
contractor: "Wykonawca",
office: "Urząd",
supplier: "Dostawca",
other: "Inny"
}
},
// Contracts
contracts: {
title: "Umowy",

View File

@@ -515,4 +515,44 @@ export default function initializeDatabase() {
INSERT OR IGNORE INTO settings (key, value, description) VALUES
('backup_notification_user_id', '', 'User ID to receive backup completion notifications');
`);
// Contacts table for managing contact information
db.exec(`
CREATE TABLE IF NOT EXISTS contacts (
contact_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
company TEXT,
position TEXT,
contact_type TEXT CHECK(contact_type IN ('project', 'contractor', 'office', 'supplier', 'other')) DEFAULT 'other',
notes TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Project contacts junction table (many-to-many relationship)
CREATE TABLE IF NOT EXISTS project_contacts (
project_id INTEGER NOT NULL,
contact_id INTEGER NOT NULL,
relationship_type TEXT DEFAULT 'general',
is_primary INTEGER DEFAULT 0,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
added_by TEXT,
PRIMARY KEY (project_id, contact_id),
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE,
FOREIGN KEY (contact_id) REFERENCES contacts(contact_id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users(id)
);
-- Indexes for contacts
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE INDEX IF NOT EXISTS idx_contacts_type ON contacts(contact_type);
CREATE INDEX IF NOT EXISTS idx_contacts_active ON contacts(is_active);
CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts(phone);
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
CREATE INDEX IF NOT EXISTS idx_project_contacts_project ON project_contacts(project_id);
CREATE INDEX IF NOT EXISTS idx_project_contacts_contact ON project_contacts(contact_id);
`);
}

327
src/lib/queries/contacts.js Normal file
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);
}