feat: Implement project search functionality and task management features

- Added search functionality to the Project List page, allowing users to filter projects by name, WP, plot, or investment number.
- Created a new Project Tasks page to manage tasks across all projects, including filtering by status and priority.
- Implemented task status updates and deletion functionality.
- Added a new Task Template Edit page for modifying existing task templates.
- Enhanced Task Template Form to include a description field and loading state during submission.
- Updated UI components for better user experience, including badges for task status and priority.
- Introduced new database queries for managing contracts and projects, including fetching tasks related to projects.
- Added migrations to the database for new columns and improved data handling.
This commit is contained in:
Chop
2025-06-02 23:21:04 +02:00
parent b06aad72b8
commit 35569846bc
24 changed files with 2019 additions and 169 deletions

View File

@@ -0,0 +1,206 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
export default function ContractDetailsPage() {
const params = useParams();
const contractId = params.id;
const [contract, setContract] = useState(null);
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchContractDetails() {
setLoading(true);
try {
// Fetch contract details
const contractRes = await fetch(`/api/contracts/${contractId}`);
if (contractRes.ok) {
const contractData = await contractRes.json();
setContract(contractData);
}
// Fetch projects for this contract
const projectsRes = await fetch(
`/api/projects?contract_id=${contractId}`
);
if (projectsRes.ok) {
const projectsData = await projectsRes.json();
setProjects(projectsData);
}
} catch (error) {
console.error("Error fetching contract details:", error);
} finally {
setLoading(false);
}
}
if (contractId) {
fetchContractDetails();
}
}, [contractId]);
if (loading) {
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="text-center">Ładowanie szczegółów umowy...</div>
</div>
);
}
if (!contract) {
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="text-center text-red-600">Nie znaleziono umowy.</div>
<div className="text-center mt-4">
<Link href="/contracts" className="text-blue-600 hover:underline">
Powrót do listy umów
</Link>
</div>
</div>
);
}
return (
<div className="p-4 max-w-4xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Umowa {contract.contract_number}</h1>
<Link href="/contracts" className="text-blue-600 hover:underline">
Powrót do listy umów
</Link>
</div>
{/* Contract Details */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Szczegóły umowy</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600">
Numer umowy
</label>
<p className="text-lg">{contract.contract_number}</p>
</div>
{contract.contract_name && (
<div>
<label className="block text-sm font-medium text-gray-600">
Nazwa umowy
</label>
<p className="text-lg">{contract.contract_name}</p>
</div>
)}
{contract.customer_contract_number && (
<div>
<label className="block text-sm font-medium text-gray-600">
Numer umowy (Klienta)
</label>
<p>{contract.customer_contract_number}</p>
</div>
)}
{contract.customer && (
<div>
<label className="block text-sm font-medium text-gray-600">
Zleceniodawca
</label>
<p>{contract.customer}</p>
</div>
)}
{contract.investor && (
<div>
<label className="block text-sm font-medium text-gray-600">
Inwestor
</label>
<p>{contract.investor}</p>
</div>
)}
{contract.date_signed && (
<div>
<label className="block text-sm font-medium text-gray-600">
Data zawarcia
</label>
<p>
{new Date(contract.date_signed).toLocaleDateString("pl-PL")}
</p>
</div>
)}
{contract.finish_date && (
<div>
<label className="block text-sm font-medium text-gray-600">
Data zakończenia
</label>
<p>
{new Date(contract.finish_date).toLocaleDateString("pl-PL")}
</p>
</div>
)}
</div>
</div>
{/* Linked Projects */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">
Projekty w ramach umowy ({projects.length})
</h2>
<Link
href={`/projects/new?contract_id=${contractId}`}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm"
>
Dodaj projekt
</Link>
</div>
{projects.length === 0 ? (
<p className="text-gray-500 text-center py-8">
Brak projektów przypisanych do tej umowy.
</p>
) : (
<div className="space-y-3">
{projects.map((project) => (
<div
key={project.project_id}
className="border border-gray-100 rounded p-4 hover:bg-gray-50"
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium">{project.project_name}</h3>
<p className="text-sm text-gray-600">
{project.project_number}
</p>
{project.address && (
<p className="text-sm text-gray-500">
📍 {project.address}
</p>
)}
{project.finish_date && (
<p className="text-sm text-gray-500">
Termin:{" "}
{new Date(project.finish_date).toLocaleDateString(
"pl-PL"
)}
</p>
)}
</div>
<Link
href={`/projects/${project.project_id}`}
className="text-blue-600 hover:underline text-sm"
>
Szczegóły
</Link>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

632
src/app/contracts/page.js Normal file
View File

@@ -0,0 +1,632 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
export default function ContractsMainPage() {
const [contracts, setContracts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [filteredContracts, setFilteredContracts] = useState([]);
const [sortBy, setSortBy] = useState("contract_number");
const [sortOrder, setSortOrder] = useState("asc");
const [statusFilter, setStatusFilter] = useState("all");
useEffect(() => {
async function fetchContracts() {
setLoading(true);
try {
const res = await fetch("/api/contracts");
const data = await res.json();
setContracts(data);
setFilteredContracts(data);
} catch (error) {
console.error("Error fetching contracts:", error);
} finally {
setLoading(false);
}
}
fetchContracts();
}, []);
// Filter and sort contracts
useEffect(() => {
let filtered = [...contracts];
// Apply search filter
if (searchTerm) {
filtered = filtered.filter(
(contract) =>
contract.contract_number
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
contract.contract_name
?.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
contract.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
contract.investor?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply status filter
if (statusFilter !== "all") {
const currentDate = new Date();
filtered = filtered.filter((contract) => {
if (statusFilter === "active" && contract.finish_date) {
return new Date(contract.finish_date) >= currentDate;
} else if (statusFilter === "completed" && contract.finish_date) {
return new Date(contract.finish_date) < currentDate;
} else if (statusFilter === "no_end_date") {
return !contract.finish_date;
}
return true;
});
}
// Apply sorting
filtered.sort((a, b) => {
let aVal = a[sortBy] || "";
let bVal = b[sortBy] || "";
if (sortBy.includes("date")) {
aVal = new Date(aVal || "1900-01-01");
bVal = new Date(bVal || "1900-01-01");
}
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
return 0;
});
setFilteredContracts(filtered);
}, [searchTerm, contracts, sortBy, sortOrder, statusFilter]);
async function handleDelete(id) {
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
if (!confirmed) return;
try {
const res = await fetch(`/api/contracts/${id}`, {
method: "DELETE",
});
if (res.ok) {
setContracts(contracts.filter((c) => c.contract_id !== id));
} else {
alert("Błąd podczas usuwania umowy.");
}
} catch (error) {
console.error("Error deleting contract:", error);
alert("Błąd podczas usuwania umowy.");
}
}
// Get contract statistics
const getContractStats = () => {
const currentDate = new Date();
const total = contracts.length;
const active = contracts.filter(
(c) => !c.finish_date || new Date(c.finish_date) >= currentDate
).length;
const completed = contracts.filter(
(c) => c.finish_date && new Date(c.finish_date) < currentDate
).length;
const withoutEndDate = contracts.filter((c) => !c.finish_date).length;
return { total, active, completed, withoutEndDate };
};
const getContractStatus = (contract) => {
if (!contract.finish_date) return "ongoing";
const currentDate = new Date();
const finishDate = new Date(contract.finish_date);
return finishDate >= currentDate ? "active" : "completed";
};
const getStatusBadge = (status) => {
switch (status) {
case "active":
return <Badge variant="success">Aktywna</Badge>;
case "completed":
return <Badge variant="secondary">Zakończona</Badge>;
case "ongoing":
return <Badge variant="primary">W trakcie</Badge>;
default:
return <Badge>Nieznany</Badge>;
}
};
async function handleDelete(id) {
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
if (!confirmed) return;
try {
const res = await fetch(`/api/contracts/${id}`, {
method: "DELETE",
});
if (res.ok) {
setContracts(contracts.filter((c) => c.contract_id !== id));
} else {
alert("Błąd podczas usuwania umowy.");
}
} catch (error) {
console.error("Error deleting contract:", error);
alert("Błąd podczas usuwania umowy.");
}
}
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
if (loading) {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-600">Ładowanie umów...</p>
</div>
</div>
);
}
const stats = getContractStats();
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Umowy</h1>
<p className="text-gray-600 mt-1">
Zarządzaj swoimi umowami i kontraktami
</p>
</div>{" "}
<Link
href="/contracts/new"
className="inline-flex items-center justify-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<span className="mr-2"></span>
Nowa umowa
</Link>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Wszystkie</p>
<p className="text-2xl font-bold text-gray-900">
{stats.total}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<svg
className="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Aktywne</p>
<p className="text-2xl font-bold text-gray-900">
{stats.active}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-gray-100 rounded-lg">
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Zakończone</p>
<p className="text-2xl font-bold text-gray-900">
{stats.completed}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<div className="p-2 bg-yellow-100 rounded-lg">
<svg
className="w-6 h-6 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">W trakcie</p>
<p className="text-2xl font-bold text-gray-900">
{stats.withoutEndDate}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<label
htmlFor="search"
className="block text-sm font-medium text-gray-700 mb-1"
>
Wyszukaj
</label>
<input
id="search"
type="text"
placeholder="Szukaj po numerze, nazwie, kliencie lub inwestorze..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Status Filter */}
<div className="w-full lg:w-48">
<label
htmlFor="status"
className="block text-sm font-medium text-gray-700 mb-1"
>
Status
</label>
<select
id="status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all">Wszystkie</option>
<option value="active">Aktywne</option>
<option value="completed">Zakończone</option>
<option value="no_end_date">W trakcie</option>
</select>
</div>
{/* Sort */}
<div className="w-full lg:w-48">
<label
htmlFor="sort"
className="block text-sm font-medium text-gray-700 mb-1"
>
Sortuj według
</label>
<select
id="sort"
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split("-");
setSortBy(field);
setSortOrder(order);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="contract_number-asc">Numer umowy (A-Z)</option>
<option value="contract_number-desc">Numer umowy (Z-A)</option>
<option value="contract_name-asc">Nazwa (A-Z)</option>
<option value="contract_name-desc">Nazwa (Z-A)</option>
<option value="customer-asc">Klient (A-Z)</option>
<option value="customer-desc">Klient (Z-A)</option>
<option value="date_signed-desc">
Data zawarcia (najnowsze)
</option>
<option value="date_signed-asc">
Data zawarcia (najstarsze)
</option>
<option value="finish_date-desc">
Data zakończenia (najnowsze)
</option>
<option value="finish_date-asc">
Data zakończenia (najstarsze)
</option>
</select>
</div>
</div>
</CardContent>
</Card>{" "}
{/* Contracts List */}
{filteredContracts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm || statusFilter !== "all"
? "Brak pasujących umów"
: "Brak umów"}
</h3>
<p className="text-gray-500 mb-4">
{searchTerm || statusFilter !== "all"
? "Spróbuj zmienić kryteria wyszukiwania lub filtry."
: "Rozpocznij od dodania pierwszej umowy."}
</p>{" "}
{!searchTerm && statusFilter === "all" && (
<Link
href="/contracts/new"
className="inline-flex items-center justify-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Dodaj pierwszą umowę
</Link>
)}
</CardContent>
</Card>
) : (
<div className="space-y-4">
{filteredContracts.map((contract) => {
const status = getContractStatus(contract);
return (
<Card
key={contract.contract_id}
className="hover:shadow-md transition-shadow"
>
<CardContent className="p-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-3 mb-3">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{contract.contract_number}
</h3>
{getStatusBadge(status)}
{contract.contract_name && (
<span className="text-gray-600 truncate">
{contract.contract_name}
</span>
)}
</div>
{/* Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
{contract.customer && (
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span className="font-medium text-gray-700">
Zleceniodawca:
</span>
<span className="text-gray-600 truncate">
{contract.customer}
</span>
</div>
)}
{contract.investor && (
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span className="font-medium text-gray-700">
Inwestor:
</span>
<span className="text-gray-600 truncate">
{contract.investor}
</span>
</div>
)}
{contract.date_signed && (
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span className="font-medium text-gray-700">
Zawarcie:
</span>
<span className="text-gray-600">
{new Date(
contract.date_signed
).toLocaleDateString("pl-PL")}
</span>
</div>
)}
{contract.finish_date && (
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium text-gray-700">
Zakończenie:
</span>
<span className="text-gray-600">
{new Date(
contract.finish_date
).toLocaleDateString("pl-PL")}
</span>
</div>
)}
</div>
</div>{" "}
{/* Actions */}
<div className="flex gap-2 flex-shrink-0">
<Link
href={`/contracts/${contract.contract_id}`}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm border border-blue-600 text-blue-600 hover:bg-blue-50 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
Szczegóły
</Link>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(contract.contract_id)}
>
<svg
className="w-4 h-4 mr-1"
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>
Usuń
</Button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Results Summary */}
{filteredContracts.length > 0 && (
<div className="mt-6 text-center">
<p className="text-sm text-gray-500">
Wyświetlono {filteredContracts.length} z {contracts.length} umów
{(searchTerm || statusFilter !== "all") && (
<button
onClick={() => {
setSearchTerm("");
setStatusFilter("all");
}}
className="ml-2 text-blue-600 hover:text-blue-800 underline"
>
Wyczyść filtry
</button>
)}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,39 +0,0 @@
import db from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET() {
const contracts = db
.prepare(
`
SELECT contract_id, contract_number, contract_name FROM contracts
`
)
.all();
return NextResponse.json(contracts);
}
export async function POST(req) {
const data = await req.json();
db.prepare(
`
INSERT INTO contracts (
contract_number,
contract_name,
customer_contract_number,
customer,
investor,
date_signed,
finish_date
) VALUES (?, ?, ?, ?, ?, ?, ?)
`
).run(
data.contract_number,
data.contract_name,
data.customer_contract_number,
data.customer,
data.investor,
data.date_signed,
data.finish_date
);
return NextResponse.json({ success: true });
}