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

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
npm-debug.log*
.next
.git
.gitignore
README.md
Dockerfile
docker-compose.yml
.dockerignore
.env*.local
coverage
.nyc_output
.DS_Store
*.log

View File

@@ -8,5 +8,6 @@ services:
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
- ./data:/app/data
environment: environment:
- NODE_ENV=development - NODE_ENV=development

View File

@@ -1,4 +1,11 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {}; const nextConfig = {
// Ensure the app works properly in Docker
output: "standalone",
experimental: {
// This helps with hot reloading in Docker
serverComponentsExternalPackages: ["better-sqlite3"],
},
};
export default nextConfig; export default nextConfig;

View File

@@ -0,0 +1,15 @@
import { getAllProjectTasks } from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
// GET: Get all project tasks across all projects
export async function GET() {
try {
const tasks = getAllProjectTasks();
return NextResponse.json(tasks);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch all project tasks" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import db from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET(req, { params }) {
const { id } = await params;
const contract = db
.prepare(
`
SELECT * FROM contracts
WHERE contract_id = ?
`
)
.get(id);
if (!contract) {
return NextResponse.json({ error: "Contract not found" }, { status: 404 });
}
return NextResponse.json(contract);
}
export async function DELETE(req, { params }) {
const { id } = params;
try {
// Check if there are any projects linked to this contract
const linkedProjects = db
.prepare("SELECT COUNT(*) as count FROM projects WHERE contract_id = ?")
.get(id);
if (linkedProjects.count > 0) {
return NextResponse.json(
{ error: "Nie można usunąć umowy z przypisanymi projektami" },
{ status: 400 }
);
}
// Delete the contract
const result = db
.prepare("DELETE FROM contracts WHERE contract_id = ?")
.run(id);
if (result.changes === 0) {
return NextResponse.json(
{ error: "Contract not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting contract:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -5,8 +5,17 @@ export async function GET() {
const contracts = db const contracts = db
.prepare( .prepare(
` `
SELECT contract_id, contract_number, contract_name FROM contracts SELECT
` contract_id,
contract_number,
contract_name,
customer,
investor,
date_signed,
finish_date
FROM contracts
ORDER BY contract_number
`
) )
.all(); .all();
return NextResponse.json(contracts); return NextResponse.json(contracts);

View File

@@ -5,8 +5,11 @@ import { NextResponse } from "next/server";
// Make sure the DB is initialized before queries run // Make sure the DB is initialized before queries run
initializeDatabase(); initializeDatabase();
export async function GET() { export async function GET(req) {
const projects = getAllProjects(); const { searchParams } = new URL(req.url);
const contractId = searchParams.get("contract_id");
const projects = getAllProjects(contractId);
return NextResponse.json(projects); return NextResponse.json(projects);
} }

View File

@@ -0,0 +1,81 @@
import db from "@/lib/db";
import { NextResponse } from "next/server";
// GET: Get a specific task template
export async function GET(req, { params }) {
try {
const template = db
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
.get(params.id);
if (!template) {
return NextResponse.json(
{ error: "Task template not found" },
{ status: 404 }
);
}
return NextResponse.json(template);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch task template" },
{ status: 500 }
);
}
}
// PUT: Update a task template
export async function PUT(req, { params }) {
try {
const { name, max_wait_days, description } = await req.json();
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
const result = db
.prepare(
`UPDATE tasks
SET name = ?, max_wait_days = ?, description = ?
WHERE task_id = ? AND is_standard = 1`
)
.run(name, max_wait_days || 0, description || null, params.id);
if (result.changes === 0) {
return NextResponse.json(
{ error: "Task template not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: "Failed to update task template" },
{ status: 500 }
);
}
}
// DELETE: Delete a task template
export async function DELETE(req, { params }) {
try {
const result = db
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
.run(params.id);
if (result.changes === 0) {
return NextResponse.json(
{ error: "Task template not found" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: "Failed to delete task template" },
{ status: 500 }
);
}
}

View File

@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
// POST: create new template // POST: create new template
export async function POST(req) { export async function POST(req) {
const { name, max_wait_days } = await req.json(); const { name, max_wait_days, description } = await req.json();
if (!name) { if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 }); return NextResponse.json({ error: "Name is required" }, { status: 400 });
@@ -11,10 +11,10 @@ export async function POST(req) {
db.prepare( db.prepare(
` `
INSERT INTO tasks (name, max_wait_days, is_standard) INSERT INTO tasks (name, max_wait_days, description, is_standard)
VALUES (?, ?, 1) VALUES (?, ?, ?, 1)
` `
).run(name, max_wait_days || 0); ).run(name, max_wait_days || 0, description || null);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

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

View File

@@ -5,16 +5,39 @@ import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card"; import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
import { Input } from "@/components/ui/Input";
export default function ProjectListPage() { export default function ProjectListPage() {
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [filteredProjects, setFilteredProjects] = useState([]);
useEffect(() => { useEffect(() => {
fetch("/api/projects") fetch("/api/projects")
.then((res) => res.json()) .then((res) => res.json())
.then(setProjects); .then((data) => {
setProjects(data);
setFilteredProjects(data);
});
}, []); }, []);
// Filter projects based on search term
useEffect(() => {
if (!searchTerm.trim()) {
setFilteredProjects(projects);
} else {
const filtered = projects.filter((project) => {
const searchLower = searchTerm.toLowerCase();
return (
project.project_name?.toLowerCase().includes(searchLower) ||
project.wp?.toLowerCase().includes(searchLower) ||
project.plot?.toLowerCase().includes(searchLower) ||
project.investment_number?.toLowerCase().includes(searchLower)
);
});
setFilteredProjects(filtered);
}
}, [searchTerm, projects]);
async function handleDelete(id) { async function handleDelete(id) {
const confirmed = confirm("Are you sure you want to delete this project?"); const confirmed = confirm("Are you sure you want to delete this project?");
if (!confirmed) return; if (!confirmed) return;
@@ -22,11 +45,14 @@ export default function ProjectListPage() {
const res = await fetch(`/api/projects/${id}`, { const res = await fetch(`/api/projects/${id}`, {
method: "DELETE", method: "DELETE",
}); });
if (res.ok) { if (res.ok) {
setProjects((prev) => prev.filter((p) => p.project_id !== id)); setProjects((prev) => prev.filter((p) => p.project_id !== id));
} }
} }
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6"> <div className="max-w-6xl mx-auto p-6">
@@ -52,10 +78,115 @@ export default function ProjectListPage() {
</svg> </svg>
Add Project Add Project
</Button> </Button>
</Link> </Link>{" "}
</div>{" "}
{/* Search Bar */}
<div className="mb-8">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center space-x-4">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<Input
type="text"
placeholder="Search by project name, WP, plot, or investment number..."
value={searchTerm}
onChange={handleSearchChange}
className="pl-10 pr-10 w-full border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
className="h-5 w-5 text-gray-400 hover:text-gray-600 transition-colors"
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>
{searchTerm && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
Found{" "}
<span className="font-medium text-gray-900">
{filteredProjects.length}
</span>{" "}
project
{filteredProjects.length !== 1 ? "s" : ""} matching
<span className="font-medium text-blue-600">
{" "}
&quot;{searchTerm}&quot;
</span>
</p>
{filteredProjects.length === 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setSearchTerm("")}
>
Clear Search
</Button>
)}
</div>
</div>
)}
</div>
</div> </div>
{filteredProjects.length === 0 && searchTerm ? (
{projects.length === 0 ? ( <Card>
<CardContent className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-16 h-16 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No projects found
</h3>
<p className="text-gray-500 mb-6">
No projects match your search criteria. Try adjusting your
search terms.
</p>
<Button variant="outline" onClick={() => setSearchTerm("")}>
Clear Search
</Button>
</CardContent>
</Card>
) : projects.length === 0 ? (
<Card> <Card>
<CardContent className="text-center py-12"> <CardContent className="text-center py-12">
<div className="text-gray-400 mb-4"> <div className="text-gray-400 mb-4">
@@ -83,68 +214,63 @@ export default function ProjectListPage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="bg-white rounded-lg shadow overflow-hidden">
{projects.map((project) => ( {/* Header Row */}
<Card <div className="grid grid-cols-12 gap-4 p-4 bg-gray-100 border-b font-semibold text-sm text-gray-700">
{" "}
<div className="col-span-1">Number</div>
<div className="col-span-3">Project Name</div>
<div className="col-span-2">WP</div>
<div className="col-span-1">City</div>
<div className="col-span-2">Address</div>
<div className="col-span-1">Plot</div>{" "}
<div className="col-span-1">Finish Date</div>
<div className="col-span-1">Actions</div>
</div>{" "}
{/* Data Rows */}
{filteredProjects.map((project, index) => (
<div
key={project.project_id} key={project.project_id}
className="hover:shadow-md transition-shadow" className={`grid grid-cols-12 gap-4 p-4 border-b hover:bg-gray-50 transition-colors items-center ${
index % 2 === 0 ? "bg-white" : "bg-gray-25"
}`}
> >
<CardContent className="p-6"> <div className="col-span-1">
<div className="flex items-start justify-between"> <Badge variant="primary" size="sm">
<div className="flex-1 min-w-0"> {project.project_number}
<div className="flex items-center gap-3 mb-2"> </Badge>
<Link </div>{" "}
href={`/projects/${project.project_id}`} <div className="col-span-3">
className="text-xl font-semibold text-blue-600 hover:text-blue-800 transition-colors truncate" <Link
> href={`/projects/${project.project_id}`}
{project.project_name} className="font-medium text-blue-600 hover:text-blue-800 transition-colors truncate block"
</Link> >
<Badge variant="primary" size="sm"> {project.project_name}
{project.project_number} </Link>
</Badge> </div>
</div> <div className="col-span-2 text-sm text-gray-600 truncate">
{project.wp || "N/A"}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600 mb-4"> </div>
<div> <div className="col-span-1 text-sm text-gray-600 truncate">
<span className="font-medium">Location:</span>{" "} {project.city || "N/A"}
{project.city} </div>
</div> <div className="col-span-2 text-sm text-gray-600 truncate">
<div> {project.address || "N/A"}
<span className="font-medium">Finish Date:</span>{" "} </div>
{project.finish_date} <div className="col-span-1 text-sm text-gray-600 truncate">
</div> {project.plot || "N/A"}
<div> </div>{" "}
<span className="font-medium">Contract:</span>{" "} <div className="col-span-1 text-sm text-gray-600 truncate">
{project.contract_number} {project.finish_date || "N/A"}
</div> </div>
</div> <div className="col-span-1">
<Link href={`/projects/${project.project_id}`}>
<div className="flex items-center gap-4"> <Button variant="outline" size="sm">
<Link href={`/projects/${project.project_id}`}> View
<Button variant="outline" size="sm"> </Button>
View Details </Link>
</Button> </div>
</Link> </div>
<Link href={`/projects/${project.project_id}/edit`}>
<Button variant="secondary" size="sm">
Edit
</Button>
</Link>
</div>
</div>
<div className="ml-4 flex-shrink-0">
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(project.project_id)}
>
Delete
</Button>
</div>
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}

487
src/app/tasks/page.js Normal file
View File

@@ -0,0 +1,487 @@
"use client";
import { useState, useEffect } 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";
import { Input } from "@/components/ui/Input";
import { formatDistanceToNow, parseISO } from "date-fns";
export default function ProjectTasksPage() {
const [allTasks, setAllTasks] = useState([]);
const [filteredTasks, setFilteredTasks] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [priorityFilter, setPriorityFilter] = useState("all");
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAllTasks = async () => {
try {
const res = await fetch("/api/all-project-tasks");
const tasks = await res.json();
setAllTasks(tasks);
setFilteredTasks(tasks);
} catch (error) {
console.error("Failed to fetch all project tasks:", error);
} finally {
setLoading(false);
}
};
fetchAllTasks();
}, []);
// Filter tasks based on search term and filters
useEffect(() => {
let filtered = allTasks;
// Apply search filter
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter((task) => {
return (
task.task_name?.toLowerCase().includes(searchLower) ||
task.project_name?.toLowerCase().includes(searchLower) ||
task.wp?.toLowerCase().includes(searchLower) ||
task.plot?.toLowerCase().includes(searchLower)
);
});
}
// Apply status filter
if (statusFilter !== "all") {
filtered = filtered.filter((task) => task.status === statusFilter);
}
// Apply priority filter
if (priorityFilter !== "all") {
filtered = filtered.filter((task) => task.priority === priorityFilter);
}
setFilteredTasks(filtered);
}, [searchTerm, statusFilter, priorityFilter, allTasks]);
const handleStatusChange = async (taskId, newStatus) => {
try {
const res = await fetch(`/api/project-tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (res.ok) {
// Update the task in the local state
setAllTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === taskId ? { ...task, status: newStatus } : task
)
);
} else {
alert("Failed to update task status");
}
} catch (error) {
alert("Error updating task status");
}
};
const handleDeleteTask = async (taskId) => {
if (!confirm("Are you sure you want to delete this task?")) return;
try {
const res = await fetch(`/api/project-tasks/${taskId}`, {
method: "DELETE",
});
if (res.ok) {
// Remove the task from local state
setAllTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} else {
alert("Failed to delete task");
}
} catch (error) {
alert("Error deleting task");
}
};
const getPriorityVariant = (priority) => {
switch (priority) {
case "high":
return "danger";
case "normal":
return "secondary";
case "low":
return "success";
default:
return "secondary";
}
};
const getStatusVariant = (status) => {
switch (status) {
case "completed":
return "success";
case "in_progress":
return "warning";
case "pending":
return "secondary";
default:
return "secondary";
}
};
const getStatusDisplayName = (status) => {
switch (status) {
case "in_progress":
return "In Progress";
case "completed":
return "Completed";
case "pending":
return "Pending";
default:
return status;
}
};
const statusCounts = {
all: allTasks.length,
pending: allTasks.filter((task) => task.status === "pending").length,
in_progress: allTasks.filter((task) => task.status === "in_progress")
.length,
completed: allTasks.filter((task) => task.status === "completed").length,
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6">
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-4 text-gray-600">Loading tasks...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto p-6">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Project Tasks</h1>
<p className="text-gray-600 mt-1">
Monitor and manage tasks across all projects
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
Total Tasks
</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.all}
</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="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">Pending</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.pending}
</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="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
In Progress
</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.in_progress}
</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="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Completed</p>
<p className="text-2xl font-bold text-gray-900">
{statusCounts.completed}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6">
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Tasks
</label>
<Input
type="text"
placeholder="Search by task name, project name, WP, or plot..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Status
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={priorityFilter}
onChange={(e) => setPriorityFilter(e.target.value)}
>
<option value="all">All Priorities</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
</div>
</div>
</CardContent>
</Card>
{/* Tasks List */}
{filteredTasks.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-16 h-16 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
clipRule="evenodd"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No tasks found
</h3>
<p className="text-gray-500 mb-6">
{searchTerm ||
statusFilter !== "all" ||
priorityFilter !== "all"
? "Try adjusting your filters to see more tasks"
: "No tasks have been created yet"}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{filteredTasks.map((task) => (
<Card key={task.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900">
{task.task_name}
</h3>
<Badge
variant={getStatusVariant(task.status)}
size="sm"
>
{getStatusDisplayName(task.status)}
</Badge>
<Badge
variant={getPriorityVariant(task.priority)}
size="sm"
>
{task.priority}
</Badge>
{task.task_type === "template" && (
<Badge variant="primary" size="sm">
Template
</Badge>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<p className="text-sm text-gray-600">Project</p>
<p className="font-medium text-gray-900">
{task.project_name}
</p>
</div>
{task.wp && (
<div>
<p className="text-sm text-gray-600">WP</p>
<p className="font-medium text-gray-900">
{task.wp}
</p>
</div>
)}
{task.plot && (
<div>
<p className="text-sm text-gray-600">Plot</p>
<p className="font-medium text-gray-900">
{task.plot}
</p>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>
Added{" "}
{formatDistanceToNow(parseISO(task.date_added), {
addSuffix: true,
})}
</span>
{task.max_wait_days > 0 && (
<span>Max wait: {task.max_wait_days} days</span>
)}
</div>
</div>
<div className="flex items-center space-x-2 ml-6">
{task.status !== "completed" && (
<select
className="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={task.status}
onChange={(e) =>
handleStatusChange(task.id, e.target.value)
}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</select>
)}
<Link href={`/projects/${task.project_id}`}>
<Button variant="outline" size="sm">
View Project
</Button>
</Link>
<Button
variant="secondary"
size="sm"
onClick={() => handleDeleteTask(task.id)}
>
Delete
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import db from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import TaskTemplateForm from "@/components/TaskTemplateForm";
export default async function EditTaskTemplatePage({ params }) {
// Fetch the task template
const template = db
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
.get(params.id);
if (!template) {
notFound();
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
<div className="flex items-center gap-4 mb-8">
<Link href="/tasks/templates">
<Button variant="outline" size="sm">
<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="M15 19l-7-7 7-7"
/>
</svg>
Back to Templates
</Button>
</Link>{" "}
<div>
<h1 className="text-3xl font-bold text-gray-900">
Edit Task Template
</h1>
<p className="text-gray-600 mt-1">
Update the details for &ldquo;{template.name}&rdquo;
</p>
</div>
</div>
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Template Details
</h2>
<p className="text-gray-600">
Modify the template information below
</p>
</CardHeader>
<CardContent>
<TaskTemplateForm templateId={params.id} initialData={template} />
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -84,21 +84,21 @@ export default function TaskTemplatesPage() {
{template.max_wait_days} days {template.max_wait_days} days
</Badge> </Badge>
</div> </div>
{template.description && ( {template.description && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2"> <p className="text-gray-600 text-sm mb-4 line-clamp-2">
{template.description} {template.description}
</p> </p>
)} )}{" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
Template ID: {template.task_id} Template ID: {template.task_id}
</span> </span>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" size="sm"> <Link href={`/tasks/templates/${template.task_id}/edit`}>
Edit <Button variant="outline" size="sm">
</Button> Edit
</Button>
</Link>
<Button variant="secondary" size="sm"> <Button variant="secondary" size="sm">
Duplicate Duplicate
</Button> </Button>

View File

@@ -1,61 +1,132 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Button from "./ui/Button";
import { Input } from "./ui/Input";
export default function TaskTemplateForm() { export default function TaskTemplateForm({
templateId = null,
initialData = null,
}) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [max_wait_days, setRequiredWaitDays] = useState(""); const [max_wait_days, setRequiredWaitDays] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const router = useRouter(); const router = useRouter();
// Load initial data for editing
useEffect(() => {
if (templateId) {
setIsEditing(true);
if (initialData) {
setName(initialData.name || "");
setRequiredWaitDays(initialData.max_wait_days?.toString() || "");
setDescription(initialData.description || "");
}
}
}, [templateId, initialData]);
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setLoading(true);
const res = await fetch("/api/tasks", { try {
method: "POST", const url = isEditing ? `/api/tasks/${templateId}` : "/api/tasks";
headers: { "Content-Type": "application/json" }, const method = isEditing ? "PUT" : "POST";
body: JSON.stringify({
name,
max_wait_days: parseInt(max_wait_days, 10) || 0,
}),
});
if (res.ok) { const res = await fetch(url, {
router.push("/tasks/templates"); method,
} else { headers: { "Content-Type": "application/json" },
alert("Failed to create task template."); body: JSON.stringify({
name,
max_wait_days: parseInt(max_wait_days, 10) || 0,
description: description || null,
}),
});
if (res.ok) {
router.push("/tasks/templates");
} else {
const error = await res.json();
alert(
error.error ||
`Failed to ${isEditing ? "update" : "create"} task template.`
);
}
} catch (error) {
alert(`Error ${isEditing ? "updating" : "creating"} task template.`);
} finally {
setLoading(false);
} }
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label className="block font-medium">Name</label> <label className="block text-sm font-medium text-gray-700 mb-2">
<input Template Name *
</label>
<Input
type="text" type="text"
name="name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="border p-2 w-full" placeholder="Enter template name"
required required
/> />
</div> </div>
<div> <div>
<label className="block font-medium">Required Wait Days</label> <label className="block text-sm font-medium text-gray-700 mb-2">
<input Max Wait Days
</label>
<Input
type="number" type="number"
name="max_wait_days"
value={max_wait_days} value={max_wait_days}
onChange={(e) => setRequiredWaitDays(e.target.value)} onChange={(e) => setRequiredWaitDays(e.target.value)}
className="border p-2 w-full" placeholder="Enter maximum wait days"
min="0"
/>
<p className="text-sm text-gray-500 mt-1">
Maximum number of days this task can wait before it needs attention
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter template description (optional)"
rows={3}
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-blue-500"
/> />
</div> </div>
<button
type="submit" <div className="flex gap-3">
className="bg-blue-600 text-white px-4 py-2 rounded" <Button type="submit" variant="primary" disabled={loading}>
> {loading ? (
Create Template <>
</button> <div className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{isEditing ? "Updating..." : "Creating..."}
</>
) : isEditing ? (
"Update Template"
) : (
"Create Template"
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push("/tasks/templates")}
disabled={loading}
>
Cancel
</Button>
</div>
</form> </form>
); );
} }

View File

@@ -9,6 +9,7 @@ const Badge = ({
const variants = { const variants = {
default: "bg-gray-100 text-gray-800", default: "bg-gray-100 text-gray-800",
primary: "bg-blue-100 text-blue-800", primary: "bg-blue-100 text-blue-800",
secondary: "bg-gray-200 text-gray-700",
success: "bg-green-100 text-green-800", success: "bg-green-100 text-green-800",
warning: "bg-yellow-100 text-yellow-800", warning: "bg-yellow-100 text-yellow-800",
danger: "bg-red-100 text-red-800", danger: "bg-red-100 text-red-800",

View File

@@ -33,13 +33,13 @@ const Button = forwardRef(
<button <button
ref={ref} ref={ref}
className={` className={`
inline-flex items-center justify-center rounded-lg font-medium transition-colors inline-flex items-center justify-center rounded-lg font-medium transition-colors
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed
${buttonVariants[variant]} ${buttonVariants[variant]}
${buttonSizes[size]} ${buttonSizes[size]}
${className} ${className}
`} `}
disabled={disabled} disabled={disabled}
{...props} {...props}
> >

View File

@@ -5,16 +5,19 @@ import { usePathname } from "next/navigation";
const Navigation = () => { const Navigation = () => {
const pathname = usePathname(); const pathname = usePathname();
const isActive = (path) => { const isActive = (path) => {
if (path === "/") return pathname === "/"; if (path === "/") return pathname === "/";
return pathname.startsWith(path); // Exact match for paths
if (pathname === path) return true;
// For nested paths, ensure we match the full path segment
if (pathname.startsWith(path + "/")) return true;
return false;
}; };
const navItems = [ const navItems = [
{ href: "/", label: "Dashboard" }, { href: "/", label: "Dashboard" },
{ href: "/projects", label: "Projects" }, { href: "/projects", label: "Projects" },
{ href: "/tasks/templates", label: "Task Templates" }, { href: "/tasks/templates", label: "Task Templates" },
{ href: "/tasks", label: "Project Tasks" },
{ href: "/contracts", label: "Contracts" }, { href: "/contracts", label: "Contracts" },
]; ];

View File

@@ -65,7 +65,6 @@ export default function initializeDatabase() {
); );
`); `);
// Migration: Add custom task columns if they don't exist // Migration: Add custom task columns if they don't exist
try { try {
db.exec(` db.exec(`
@@ -82,4 +81,13 @@ export default function initializeDatabase() {
} catch (e) { } catch (e) {
// Column already exists, ignore error // Column already exists, ignore error
} }
// Migration: Add description column to tasks table
try {
db.exec(`
ALTER TABLE tasks ADD COLUMN description TEXT;
`);
} catch (e) {
// Column already exists, ignore error
}
} }

View File

@@ -0,0 +1,57 @@
import db from "../db";
export function getAllContracts() {
return db
.prepare(
`
SELECT
contract_id,
contract_number,
contract_name,
customer,
investor,
date_signed,
finish_date
FROM contracts
ORDER BY contract_number
`
)
.all();
}
export function getContractById(id) {
return db
.prepare(
`
SELECT * FROM contracts
WHERE contract_id = ?
`
)
.get(id);
}
export function deleteContract(id) {
db.prepare("DELETE FROM contracts WHERE contract_id = ?").run(id);
}
export function getContractWithProjects(id) {
const contract = getContractById(id);
if (!contract) return null;
const projects = db
.prepare(
`
SELECT
project_id,
project_name,
project_number,
finish_date
FROM projects
WHERE contract_id = ?
ORDER BY project_name
`
)
.all(id);
return { ...contract, projects };
}

View File

@@ -1,6 +1,13 @@
import db from "../db.js"; import db from "../db.js";
export function getAllProjects() { export function getAllProjects(contractId = null) {
if (contractId) {
return db
.prepare(
"SELECT * FROM projects WHERE contract_id = ? ORDER BY finish_date DESC"
)
.all(contractId);
}
return db.prepare("SELECT * FROM projects ORDER BY finish_date DESC").all(); return db.prepare("SELECT * FROM projects ORDER BY finish_date DESC").all();
} }
@@ -9,18 +16,28 @@ export function getProjectById(id) {
} }
export function createProject(data) { export function createProject(data) {
// 1. Get the max project_number under this contract // 1. Get the contract number and count existing projects
const existing = db const contractInfo = db
.prepare( .prepare(
` `
SELECT MAX(project_number) as max_number SELECT
FROM projects c.contract_number,
WHERE contract_id = ? COUNT(p.project_id) as project_count
FROM contracts c
LEFT JOIN projects p ON c.contract_id = p.contract_id
WHERE c.contract_id = ?
GROUP BY c.contract_id, c.contract_number
` `
) )
.get(data.contract_id); .get(data.contract_id);
const nextNumber = (existing.max_number || 0) + 1; if (!contractInfo) {
throw new Error("Contract not found");
}
// 2. Generate sequential number and project number
const sequentialNumber = (contractInfo.project_count || 0) + 1;
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`;
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO projects ( INSERT INTO projects (
@@ -31,7 +48,7 @@ export function createProject(data) {
stmt.run( stmt.run(
data.contract_id, data.contract_id,
data.project_name, data.project_name,
parseInt(nextNumber), projectNumber,
data.address, data.address,
data.plot, data.plot,
data.district, data.district,

View File

@@ -7,6 +7,32 @@ export function getAllTaskTemplates() {
.all(); .all();
} }
// Get all project tasks across all projects
export function getAllProjectTasks() {
return db
.prepare(
`
SELECT
pt.*,
COALESCE(pt.custom_task_name, t.name) as task_name,
COALESCE(pt.custom_max_wait_days, t.max_wait_days) as max_wait_days,
CASE
WHEN pt.task_template_id IS NOT NULL THEN 'template'
ELSE 'custom'
END as task_type,
p.project_name,
p.wp,
p.plot,
p.finish_date
FROM project_tasks pt
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
LEFT JOIN projects p ON pt.project_id = p.project_id
ORDER BY pt.date_added DESC
`
)
.all();
}
// Get project tasks for a specific project // Get project tasks for a specific project
export function getProjectTasks(projectId) { export function getProjectTasks(projectId) {
return db return db