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:
40
src/app/api/contacts/[id]/projects/route.js
Normal file
40
src/app/api/contacts/[id]/projects/route.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import db from "@/lib/db";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET /api/contacts/[id]/projects - Get all projects linked to a contact
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
try {
|
||||
const contactId = params.id;
|
||||
|
||||
// Get all projects linked to this contact with relationship details
|
||||
const projects = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.project_status,
|
||||
pc.relationship_type,
|
||||
pc.is_primary,
|
||||
pc.added_at
|
||||
FROM projects p
|
||||
INNER JOIN project_contacts pc ON p.project_id = pc.project_id
|
||||
WHERE pc.contact_id = ?
|
||||
ORDER BY pc.is_primary DESC, p.project_name ASC
|
||||
`
|
||||
)
|
||||
.all(contactId);
|
||||
|
||||
return NextResponse.json({
|
||||
projects,
|
||||
count: projects.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact projects:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch projects" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
103
src/app/api/contacts/[id]/route.js
Normal file
103
src/app/api/contacts/[id]/route.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getContactById,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
hardDeleteContact,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get contact by ID
|
||||
async function getContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const contact = getContactById(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update contact
|
||||
async function updateContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const data = await req.json();
|
||||
|
||||
// Validate contact type if provided
|
||||
if (data.contact_type) {
|
||||
const validTypes = [
|
||||
"project",
|
||||
"contractor",
|
||||
"office",
|
||||
"supplier",
|
||||
"other",
|
||||
];
|
||||
if (!validTypes.includes(data.contact_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid contact type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contact = updateContact(contactId, data);
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error updating contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete contact (soft delete or hard delete)
|
||||
async function deleteContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const hard = searchParams.get("hard") === "true";
|
||||
|
||||
if (hard) {
|
||||
// Hard delete - permanently remove
|
||||
hardDeleteContact(contactId);
|
||||
} else {
|
||||
// Soft delete - set is_active to 0
|
||||
deleteContact(contactId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Contact deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withAuth(getContactHandler);
|
||||
export const PUT = withAuth(updateContactHandler);
|
||||
export const DELETE = withAuth(deleteContactHandler);
|
||||
73
src/app/api/contacts/route.js
Normal file
73
src/app/api/contacts/route.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllContacts,
|
||||
createContact,
|
||||
getContactStats,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all contacts with optional filters
|
||||
async function getContactsHandler(req) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const filters = {
|
||||
is_active: searchParams.get("is_active")
|
||||
? searchParams.get("is_active") === "true"
|
||||
: undefined,
|
||||
contact_type: searchParams.get("contact_type") || undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
};
|
||||
|
||||
// Check if stats are requested
|
||||
if (searchParams.get("stats") === "true") {
|
||||
const stats = getContactStats();
|
||||
return NextResponse.json(stats);
|
||||
}
|
||||
|
||||
const contacts = getAllContacts(filters);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch contacts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create new contact
|
||||
async function createContactHandler(req) {
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate contact type
|
||||
const validTypes = ["project", "contractor", "office", "supplier", "other"];
|
||||
if (data.contact_type && !validTypes.includes(data.contact_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid contact type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const contact = createContact(data);
|
||||
return NextResponse.json(contact, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withAuth(getContactsHandler);
|
||||
export const POST = withAuth(createContactHandler);
|
||||
111
src/app/api/projects/[id]/contacts/route.js
Normal file
111
src/app/api/projects/[id]/contacts/route.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getProjectContacts,
|
||||
linkContactToProject,
|
||||
unlinkContactFromProject,
|
||||
setPrimaryContact,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all contacts for a project
|
||||
async function getProjectContactsHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching project contacts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch project contacts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Link contact to project
|
||||
async function linkContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { contactId, relationshipType, isPrimary } = await req.json();
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
linkContactToProject(
|
||||
projectId,
|
||||
contactId,
|
||||
relationshipType || "general",
|
||||
isPrimary || false,
|
||||
userId
|
||||
);
|
||||
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error linking contact to project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to link contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Unlink contact from project
|
||||
async function unlinkContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const contactId = parseInt(searchParams.get("contactId"));
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
unlinkContactFromProject(projectId, contactId);
|
||||
return NextResponse.json({ message: "Contact unlinked successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error unlinking contact from project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to unlink contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Set primary contact
|
||||
async function setPrimaryContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { contactId } = await req.json();
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
setPrimaryContact(projectId, contactId);
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error setting primary contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to set primary contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuth(getProjectContactsHandler);
|
||||
export const POST = withAuth(linkContactHandler);
|
||||
export const DELETE = withAuth(unlinkContactHandler);
|
||||
export const PATCH = withAuth(setPrimaryContactHandler);
|
||||
598
src/app/contacts/page.js
Normal file
598
src/app/contacts/page.js
Normal file
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [filteredContacts, setFilteredContacts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [stats, setStats] = useState(null);
|
||||
const [selectedContact, setSelectedContact] = useState(null);
|
||||
const [contactProjects, setContactProjects] = useState([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/auth/signin");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Fetch contacts
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// Filter contacts
|
||||
useEffect(() => {
|
||||
let filtered = contacts;
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(contact) =>
|
||||
contact.name?.toLowerCase().includes(search) ||
|
||||
contact.phone?.toLowerCase().includes(search) ||
|
||||
contact.email?.toLowerCase().includes(search) ||
|
||||
contact.company?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(contact) => contact.contact_type === typeFilter
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredContacts(filtered);
|
||||
}, [contacts, searchTerm, typeFilter]);
|
||||
|
||||
async function fetchContacts() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?is_active=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Fetched contacts:', data);
|
||||
setContacts(data);
|
||||
} else {
|
||||
console.error('Failed to fetch contacts, status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?stats=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(contactId) {
|
||||
if (!confirm("Czy na pewno chcesz usunąć ten kontakt?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${contactId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
alert("Nie udało się usunąć kontaktu");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(contact) {
|
||||
setEditingContact(contact);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function handleViewDetails(contact) {
|
||||
setSelectedContact(contact);
|
||||
setLoadingProjects(true);
|
||||
|
||||
try {
|
||||
// Fetch projects linked to this contact
|
||||
const response = await fetch(`/api/contacts/${contact.contact_id}/projects`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setContactProjects(data.projects || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact projects:", error);
|
||||
setContactProjects([]);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetails() {
|
||||
setSelectedContact(null);
|
||||
setContactProjects([]);
|
||||
}
|
||||
|
||||
function handleFormSave(contact) {
|
||||
setShowForm(false);
|
||||
setEditingContact(null);
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}
|
||||
|
||||
function handleFormCancel() {
|
||||
setShowForm(false);
|
||||
setEditingContact(null);
|
||||
}
|
||||
|
||||
const getContactTypeBadge = (type) => {
|
||||
const types = {
|
||||
project: { label: "Projekt", variant: "primary" },
|
||||
contractor: { label: "Wykonawca", variant: "warning" },
|
||||
office: { label: "Urząd", variant: "info" },
|
||||
supplier: { label: "Dostawca", variant: "success" },
|
||||
other: { label: "Inny", variant: "secondary" },
|
||||
};
|
||||
return types[type] || types.other;
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="text-gray-600">Ładowanie...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showForm) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<ContactForm
|
||||
initialData={editingContact}
|
||||
onSave={handleFormSave}
|
||||
onCancel={handleFormCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Kontakty</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Zarządzaj kontaktami do projektów i współpracy
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Dodaj kontakt
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{stats.total_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Wszystkie</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats.project_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Projekty</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{stats.contractor_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Wykonawcy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{stats.office_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Urzędy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{stats.supplier_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Dostawcy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{stats.other_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Inne</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Szukaj po nazwie, telefonie, email lub firmie..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Wszystkie typy</option>
|
||||
<option value="project">Projekty</option>
|
||||
<option value="contractor">Wykonawcy</option>
|
||||
<option value="office">Urzędy</option>
|
||||
<option value="supplier">Dostawcy</option>
|
||||
<option value="other">Inne</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contacts List */}
|
||||
<div className="space-y-3">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<p className="text-gray-500">
|
||||
{searchTerm || typeFilter !== "all"
|
||||
? "Nie znaleziono kontaktów"
|
||||
: "Brak kontaktów. Dodaj pierwszy kontakt."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredContacts.map((contact) => {
|
||||
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||
return (
|
||||
<Card key={contact.contact_id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 cursor-pointer" onClick={() => handleViewDetails(contact)}>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold text-gray-900 text-lg hover:text-blue-600 transition-colors">
|
||||
{contact.name}
|
||||
</h3>
|
||||
<Badge variant={typeBadge.variant} size="sm">
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
{contact.project_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-500">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
{contact.project_count}{" "}
|
||||
{contact.project_count === 1 ? "projekt" : "projektów"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{contact.company && (
|
||||
<div className="flex items-center gap-1 text-gray-600">
|
||||
<span className="font-medium">🏢</span>
|
||||
<span>{contact.company}</span>
|
||||
{contact.position && (
|
||||
<span className="text-gray-500">• {contact.position}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!contact.company && contact.position && (
|
||||
<div className="flex items-center gap-1 text-gray-600">
|
||||
<span>{contact.position}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{contact.phone && (
|
||||
<a
|
||||
href={`tel:${contact.phone}`}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
{contact.phone}
|
||||
</a>
|
||||
)}
|
||||
{contact.email && (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{contact.email}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex sm:flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(contact);
|
||||
}}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 sm:mr-0 md:mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden md:inline">Edytuj</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(contact.contact_id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Details Modal */}
|
||||
{selectedContact && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" onClick={closeDetails}>
|
||||
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{selectedContact.name}</CardTitle>
|
||||
<div className="mt-2">
|
||||
<Badge variant={getContactTypeBadge(selectedContact.contact_type).variant}>
|
||||
{getContactTypeBadge(selectedContact.contact_type).label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={closeDetails}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Informacje kontaktowe</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedContact.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<a href={`tel:${selectedContact.phone}`} className="text-blue-600 hover:underline">
|
||||
{selectedContact.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedContact.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<a href={`mailto:${selectedContact.email}`} className="text-blue-600 hover:underline">
|
||||
{selectedContact.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedContact.company && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🏢</span>
|
||||
<span className="text-gray-700">
|
||||
{selectedContact.company}
|
||||
{selectedContact.position && ` • ${selectedContact.position}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!selectedContact.company && selectedContact.position && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">💼</span>
|
||||
<span className="text-gray-700">{selectedContact.position}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedContact.notes && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Notatki</h3>
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap bg-gray-50 p-3 rounded">
|
||||
{selectedContact.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Projects */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">
|
||||
Powiązane projekty ({contactProjects.length})
|
||||
</h3>
|
||||
{loadingProjects ? (
|
||||
<div className="text-center py-4 text-gray-500">Ładowanie projektów...</div>
|
||||
) : contactProjects.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Brak powiązanych projektów</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{contactProjects.map((project) => (
|
||||
<div
|
||||
key={project.project_id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/projects/${project.project_id}`)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{project.project_name}</span>
|
||||
{project.is_primary && (
|
||||
<Badge variant="primary" size="sm">Główny kontakt</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.relationship_type && (
|
||||
<span className="text-xs text-gray-500">{project.relationship_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
handleEdit(selectedContact);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edytuj kontakt
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={closeDetails}>
|
||||
Zamknij
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
src/components/ContactForm.js
Normal file
212
src/components/ContactForm.js
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
|
||||
export default function ContactForm({ initialData = null, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
company: "",
|
||||
position: "",
|
||||
contact_type: "other",
|
||||
notes: "",
|
||||
is_active: true,
|
||||
...initialData,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const isEdit = !!initialData;
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const url = isEdit
|
||||
? `/api/contacts/${initialData.contact_id}`
|
||||
: "/api/contacts";
|
||||
const method = isEdit ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to save contact");
|
||||
}
|
||||
|
||||
const contact = await response.json();
|
||||
onSave?.(contact);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEdit ? "Edytuj kontakt" : "Nowy kontakt"}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Imię i nazwisko <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Wprowadź imię i nazwisko"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="+48 123 456 789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Firma
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="company"
|
||||
value={form.company}
|
||||
onChange={handleChange}
|
||||
placeholder="Nazwa firmy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Stanowisko
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="position"
|
||||
value={form.position}
|
||||
onChange={handleChange}
|
||||
placeholder="Kierownik projektu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Typ kontaktu
|
||||
</label>
|
||||
<select
|
||||
name="contact_type"
|
||||
value={form.contact_type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="project">Kontakt projektowy</option>
|
||||
<option value="contractor">Wykonawca</option>
|
||||
<option value="office">Urząd</option>
|
||||
<option value="supplier">Dostawca</option>
|
||||
<option value="other">Inny</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notatki
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
value={form.notes}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Dodatkowe informacje..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_active"
|
||||
checked={form.is_active}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Kontakt aktywny
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
Anuluj
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Zapisywanie..." : isEdit ? "Zapisz zmiany" : "Dodaj kontakt"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
298
src/components/ProjectContactSelector.js
Normal file
298
src/components/ProjectContactSelector.js
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
|
||||
export default function ProjectContactSelector({ projectId, onChange }) {
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [projectContacts, setProjectContacts] = useState([]);
|
||||
const [availableContacts, setAvailableContacts] = useState([]);
|
||||
const [showSelector, setShowSelector] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllContacts();
|
||||
if (projectId) {
|
||||
fetchProjectContacts();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
async function fetchAllContacts() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?is_active=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setContacts(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectContacts() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/contacts`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjectContacts(data);
|
||||
updateAvailableContacts(data);
|
||||
onChange?.(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project contacts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvailableContacts(linkedContacts) {
|
||||
const linkedIds = linkedContacts.map((c) => c.contact_id);
|
||||
const available = contacts.filter(
|
||||
(c) => !linkedIds.includes(c.contact_id)
|
||||
);
|
||||
setAvailableContacts(available);
|
||||
}
|
||||
|
||||
async function handleAddContact(contactId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/contacts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contactId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchProjectContacts();
|
||||
setShowSelector(false);
|
||||
setSearchTerm("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error adding contact:", error);
|
||||
alert("Nie udało się dodać kontaktu");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveContact(contactId) {
|
||||
if (!confirm("Czy na pewno chcesz usunąć ten kontakt z projektu?"))
|
||||
return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/contacts?contactId=${contactId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await fetchProjectContacts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error removing contact:", error);
|
||||
alert("Nie udało się usunąć kontaktu");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetPrimary(contactId) {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/contacts`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contactId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchProjectContacts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error setting primary contact:", error);
|
||||
alert("Nie udało się ustawić głównego kontaktu");
|
||||
}
|
||||
}
|
||||
|
||||
const getContactTypeBadge = (type) => {
|
||||
const types = {
|
||||
project: { label: "Projekt", variant: "primary" },
|
||||
contractor: { label: "Wykonawca", variant: "warning" },
|
||||
office: { label: "Urząd", variant: "info" },
|
||||
supplier: { label: "Dostawca", variant: "success" },
|
||||
other: { label: "Inny", variant: "secondary" },
|
||||
};
|
||||
return types[type] || types.other;
|
||||
};
|
||||
|
||||
const filteredAvailable = searchTerm
|
||||
? availableContacts.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.phone?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
c.company?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
: availableContacts;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Kontakty do projektu
|
||||
</label>
|
||||
{projectId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowSelector(!showSelector)}
|
||||
>
|
||||
{showSelector ? "Anuluj" : "+ Dodaj kontakt"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current project contacts */}
|
||||
{projectContacts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{projectContacts.map((contact) => {
|
||||
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||
return (
|
||||
<div
|
||||
key={contact.contact_id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{contact.name}
|
||||
</span>
|
||||
{contact.is_primary === 1 && (
|
||||
<Badge variant="success" size="xs">
|
||||
Główny
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={typeBadge.variant} size="xs">
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1 space-y-0.5">
|
||||
{contact.phone && <div>📞 {contact.phone}</div>}
|
||||
{contact.email && <div>📧 {contact.email}</div>}
|
||||
{contact.company && (
|
||||
<div className="text-xs">🏢 {contact.company}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{contact.is_primary !== 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetPrimary(contact.contact_id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 px-2 py-1 rounded hover:bg-blue-50"
|
||||
title="Ustaw jako główny"
|
||||
>
|
||||
Ustaw główny
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveContact(contact.contact_id)}
|
||||
className="text-red-600 hover:text-red-700 p-1 rounded hover:bg-red-50"
|
||||
title="Usuń kontakt"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 italic p-3 bg-gray-50 rounded-md">
|
||||
{projectId
|
||||
? "Brak powiązanych kontaktów. Dodaj kontakt do projektu."
|
||||
: "Zapisz projekt, aby móc dodać kontakty."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact selector */}
|
||||
{showSelector && projectId && (
|
||||
<div className="border border-gray-300 rounded-md p-4 bg-white">
|
||||
<div className="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Szukaj kontaktu..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-2">
|
||||
{filteredAvailable.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
{searchTerm
|
||||
? "Nie znaleziono kontaktów"
|
||||
: "Wszystkie kontakty są już dodane"}
|
||||
</p>
|
||||
) : (
|
||||
filteredAvailable.map((contact) => {
|
||||
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||
return (
|
||||
<div
|
||||
key={contact.contact_id}
|
||||
className="flex items-center justify-between p-2 hover:bg-gray-50 rounded border border-gray-200"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-gray-900">
|
||||
{contact.name}
|
||||
</span>
|
||||
<Badge variant={typeBadge.variant} size="xs">
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{contact.phone && <span>{contact.phone}</span>}
|
||||
{contact.phone && contact.email && (
|
||||
<span className="mx-1">•</span>
|
||||
)}
|
||||
{contact.email && <span>{contact.email}</span>}
|
||||
</div>
|
||||
{contact.company && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{contact.company}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleAddContact(contact.contact_id)}
|
||||
disabled={loading}
|
||||
>
|
||||
Dodaj
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import Button from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { formatDateForInput } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import ProjectContactSelector from "@/components/ProjectContactSelector";
|
||||
|
||||
const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref) {
|
||||
const { t } = useTranslation();
|
||||
@@ -365,15 +366,8 @@ const ProjectForm = forwardRef(function ProjectForm({ initialData = null }, ref)
|
||||
)}
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('projects.contact')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
name="contact"
|
||||
value={form.contact || ""}
|
||||
onChange={handleChange}
|
||||
placeholder={t('projects.placeholders.contact')}
|
||||
<ProjectContactSelector
|
||||
projectId={initialData?.project_id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ const CardHeader = ({ children, className = "" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CardTitle = ({ children, className = "" }) => {
|
||||
return (
|
||||
<h3 className={`text-lg font-semibold text-gray-900 dark:text-gray-100 ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
const CardContent = ({ children, className = "" }) => {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
};
|
||||
@@ -39,4 +47,4 @@ const CardFooter = ({ children, className = "" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { Card, CardHeader, CardContent, CardFooter };
|
||||
export { Card, CardHeader, CardTitle, CardContent, CardFooter };
|
||||
|
||||
@@ -143,6 +143,7 @@ const Navigation = () => {
|
||||
|
||||
const navItems = [
|
||||
{ href: "/projects", label: t('navigation.projects') },
|
||||
{ href: "/contacts", label: t('navigation.contacts') || 'Kontakty' },
|
||||
{ href: "/calendar", label: t('navigation.calendar') || 'Kalendarz' },
|
||||
{ href: "/project-tasks", label: t('navigation.tasks') || 'Tasks' },
|
||||
{ href: "/contracts", label: t('navigation.contracts') },
|
||||
|
||||
@@ -12,6 +12,7 @@ const translations = {
|
||||
navigation: {
|
||||
dashboard: "Panel główny",
|
||||
projects: "Projekty",
|
||||
contacts: "Kontakty",
|
||||
calendar: "Kalendarz",
|
||||
taskTemplates: "Szablony zadań",
|
||||
projectTasks: "Zadania projektów",
|
||||
@@ -245,6 +246,38 @@ const translations = {
|
||||
}
|
||||
},
|
||||
|
||||
// Contacts
|
||||
contacts: {
|
||||
title: "Kontakty",
|
||||
subtitle: "Zarządzaj kontaktami",
|
||||
contact: "Kontakt",
|
||||
newContact: "Nowy kontakt",
|
||||
editContact: "Edytuj kontakt",
|
||||
deleteContact: "Usuń kontakt",
|
||||
name: "Imię i nazwisko",
|
||||
phone: "Telefon",
|
||||
email: "Email",
|
||||
company: "Firma",
|
||||
position: "Stanowisko",
|
||||
contactType: "Typ kontaktu",
|
||||
notes: "Notatki",
|
||||
active: "Aktywny",
|
||||
inactive: "Nieaktywny",
|
||||
searchPlaceholder: "Szukaj kontaktów...",
|
||||
noContacts: "Brak kontaktów",
|
||||
addFirstContact: "Dodaj pierwszy kontakt",
|
||||
selectContact: "Wybierz kontakt",
|
||||
addContact: "Dodaj kontakt",
|
||||
linkedProjects: "Powiązane projekty",
|
||||
types: {
|
||||
project: "Kontakt projektowy",
|
||||
contractor: "Wykonawca",
|
||||
office: "Urząd",
|
||||
supplier: "Dostawca",
|
||||
other: "Inny"
|
||||
}
|
||||
},
|
||||
|
||||
// Contracts
|
||||
contracts: {
|
||||
title: "Umowy",
|
||||
|
||||
@@ -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
327
src/lib/queries/contacts.js
Normal file
@@ -0,0 +1,327 @@
|
||||
import db from "../db.js";
|
||||
|
||||
// Get all contacts with optional filters
|
||||
export function getAllContacts(filters = {}) {
|
||||
let query = `
|
||||
SELECT
|
||||
c.*,
|
||||
COUNT(DISTINCT pc.project_id) as project_count
|
||||
FROM contacts c
|
||||
LEFT JOIN project_contacts pc ON c.contact_id = pc.contact_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
// Filter by active status
|
||||
if (filters.is_active !== undefined) {
|
||||
query += ` AND c.is_active = ?`;
|
||||
params.push(filters.is_active ? 1 : 0);
|
||||
}
|
||||
|
||||
// Filter by contact type
|
||||
if (filters.contact_type) {
|
||||
query += ` AND c.contact_type = ?`;
|
||||
params.push(filters.contact_type);
|
||||
}
|
||||
|
||||
// Search by name, phone, email, or company
|
||||
if (filters.search) {
|
||||
query += ` AND (
|
||||
c.name LIKE ? OR
|
||||
c.phone LIKE ? OR
|
||||
c.email LIKE ? OR
|
||||
c.company LIKE ?
|
||||
)`;
|
||||
const searchTerm = `%${filters.search}%`;
|
||||
params.push(searchTerm, searchTerm, searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
query += ` GROUP BY c.contact_id ORDER BY c.name ASC`;
|
||||
|
||||
return db.prepare(query).all(...params);
|
||||
}
|
||||
|
||||
// Get contact by ID
|
||||
export function getContactById(contactId) {
|
||||
const contact = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
WHERE c.contact_id = ?
|
||||
`
|
||||
)
|
||||
.get(contactId);
|
||||
|
||||
if (!contact) return null;
|
||||
|
||||
// Get associated projects
|
||||
const projects = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.project_number,
|
||||
pc.relationship_type,
|
||||
pc.is_primary,
|
||||
pc.added_at
|
||||
FROM project_contacts pc
|
||||
JOIN projects p ON pc.project_id = p.project_id
|
||||
WHERE pc.contact_id = ?
|
||||
ORDER BY pc.is_primary DESC, pc.added_at DESC
|
||||
`
|
||||
)
|
||||
.all(contactId);
|
||||
|
||||
return { ...contact, projects };
|
||||
}
|
||||
|
||||
// Create new contact
|
||||
export function createContact(data) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO contacts (
|
||||
name, phone, email, company, position, contact_type, notes, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
data.name,
|
||||
data.phone || null,
|
||||
data.email || null,
|
||||
data.company || null,
|
||||
data.position || null,
|
||||
data.contact_type || "other",
|
||||
data.notes || null,
|
||||
data.is_active !== undefined ? (data.is_active ? 1 : 0) : 1
|
||||
);
|
||||
|
||||
return getContactById(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
// Update contact
|
||||
export function updateContact(contactId, data) {
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (data.name !== undefined) {
|
||||
updates.push("name = ?");
|
||||
params.push(data.name);
|
||||
}
|
||||
if (data.phone !== undefined) {
|
||||
updates.push("phone = ?");
|
||||
params.push(data.phone || null);
|
||||
}
|
||||
if (data.email !== undefined) {
|
||||
updates.push("email = ?");
|
||||
params.push(data.email || null);
|
||||
}
|
||||
if (data.company !== undefined) {
|
||||
updates.push("company = ?");
|
||||
params.push(data.company || null);
|
||||
}
|
||||
if (data.position !== undefined) {
|
||||
updates.push("position = ?");
|
||||
params.push(data.position || null);
|
||||
}
|
||||
if (data.contact_type !== undefined) {
|
||||
updates.push("contact_type = ?");
|
||||
params.push(data.contact_type);
|
||||
}
|
||||
if (data.notes !== undefined) {
|
||||
updates.push("notes = ?");
|
||||
params.push(data.notes || null);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
updates.push("is_active = ?");
|
||||
params.push(data.is_active ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return getContactById(contactId);
|
||||
}
|
||||
|
||||
updates.push("updated_at = CURRENT_TIMESTAMP");
|
||||
params.push(contactId);
|
||||
|
||||
const query = `UPDATE contacts SET ${updates.join(", ")} WHERE contact_id = ?`;
|
||||
db.prepare(query).run(...params);
|
||||
|
||||
return getContactById(contactId);
|
||||
}
|
||||
|
||||
// Delete contact (soft delete by setting is_active = 0)
|
||||
export function deleteContact(contactId) {
|
||||
const stmt = db.prepare(`
|
||||
UPDATE contacts
|
||||
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE contact_id = ?
|
||||
`);
|
||||
return stmt.run(contactId);
|
||||
}
|
||||
|
||||
// Hard delete contact (permanent deletion)
|
||||
export function hardDeleteContact(contactId) {
|
||||
// First remove all project associations
|
||||
db.prepare(`DELETE FROM project_contacts WHERE contact_id = ?`).run(
|
||||
contactId
|
||||
);
|
||||
|
||||
// Then delete the contact
|
||||
const stmt = db.prepare(`DELETE FROM contacts WHERE contact_id = ?`);
|
||||
return stmt.run(contactId);
|
||||
}
|
||||
|
||||
// Get contacts for a specific project
|
||||
export function getProjectContacts(projectId) {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
c.*,
|
||||
pc.relationship_type,
|
||||
pc.is_primary,
|
||||
pc.added_at,
|
||||
u.name as added_by_name
|
||||
FROM project_contacts pc
|
||||
JOIN contacts c ON pc.contact_id = c.contact_id
|
||||
LEFT JOIN users u ON pc.added_by = u.id
|
||||
WHERE pc.project_id = ? AND c.is_active = 1
|
||||
ORDER BY pc.is_primary DESC, c.name ASC
|
||||
`
|
||||
)
|
||||
.all(projectId);
|
||||
}
|
||||
|
||||
// Link contact to project
|
||||
export function linkContactToProject(
|
||||
projectId,
|
||||
contactId,
|
||||
relationshipType = "general",
|
||||
isPrimary = false,
|
||||
userId = null
|
||||
) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO project_contacts (
|
||||
project_id, contact_id, relationship_type, is_primary, added_by
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
return stmt.run(
|
||||
projectId,
|
||||
contactId,
|
||||
relationshipType,
|
||||
isPrimary ? 1 : 0,
|
||||
userId
|
||||
);
|
||||
}
|
||||
|
||||
// Unlink contact from project
|
||||
export function unlinkContactFromProject(projectId, contactId) {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM project_contacts
|
||||
WHERE project_id = ? AND contact_id = ?
|
||||
`);
|
||||
return stmt.run(projectId, contactId);
|
||||
}
|
||||
|
||||
// Set primary contact for a project
|
||||
export function setPrimaryContact(projectId, contactId) {
|
||||
// First, remove primary flag from all contacts for this project
|
||||
db.prepare(`
|
||||
UPDATE project_contacts
|
||||
SET is_primary = 0
|
||||
WHERE project_id = ?
|
||||
`).run(projectId);
|
||||
|
||||
// Then set the specified contact as primary
|
||||
const stmt = db.prepare(`
|
||||
UPDATE project_contacts
|
||||
SET is_primary = 1
|
||||
WHERE project_id = ? AND contact_id = ?
|
||||
`);
|
||||
|
||||
return stmt.run(projectId, contactId);
|
||||
}
|
||||
|
||||
// Get contact statistics
|
||||
export function getContactStats() {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) as total_contacts,
|
||||
COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_contacts,
|
||||
COUNT(CASE WHEN is_active = 0 THEN 1 END) as inactive_contacts,
|
||||
COUNT(CASE WHEN contact_type = 'project' THEN 1 END) as project_contacts,
|
||||
COUNT(CASE WHEN contact_type = 'contractor' THEN 1 END) as contractor_contacts,
|
||||
COUNT(CASE WHEN contact_type = 'office' THEN 1 END) as office_contacts,
|
||||
COUNT(CASE WHEN contact_type = 'supplier' THEN 1 END) as supplier_contacts,
|
||||
COUNT(CASE WHEN contact_type = 'other' THEN 1 END) as other_contacts
|
||||
FROM contacts
|
||||
`
|
||||
)
|
||||
.get();
|
||||
}
|
||||
|
||||
// Search contacts by phone number
|
||||
export function searchContactsByPhone(phoneNumber) {
|
||||
const searchTerm = `%${phoneNumber}%`;
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM contacts
|
||||
WHERE phone LIKE ? AND is_active = 1
|
||||
ORDER BY name ASC
|
||||
`
|
||||
)
|
||||
.all(searchTerm);
|
||||
}
|
||||
|
||||
// Search contacts by email
|
||||
export function searchContactsByEmail(email) {
|
||||
const searchTerm = `%${email}%`;
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM contacts
|
||||
WHERE email LIKE ? AND is_active = 1
|
||||
ORDER BY name ASC
|
||||
`
|
||||
)
|
||||
.all(searchTerm);
|
||||
}
|
||||
|
||||
// Bulk link contacts to project
|
||||
export function bulkLinkContactsToProject(projectId, contactIds, userId = null) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO project_contacts (
|
||||
project_id, contact_id, added_by
|
||||
) VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
const results = contactIds.map((contactId) =>
|
||||
stmt.run(projectId, contactId, userId)
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get contacts not linked to a specific project (for selection)
|
||||
export function getAvailableContactsForProject(projectId) {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
WHERE c.is_active = 1
|
||||
AND c.contact_id NOT IN (
|
||||
SELECT contact_id
|
||||
FROM project_contacts
|
||||
WHERE project_id = ?
|
||||
)
|
||||
ORDER BY c.name ASC
|
||||
`
|
||||
)
|
||||
.all(projectId);
|
||||
}
|
||||
Reference in New Issue
Block a user