From 35569846bc7da2f18a1433e754617663a52440cd Mon Sep 17 00:00:00 2001
From: Chop <28534054+RChopin@users.noreply.github.com>
Date: Mon, 2 Jun 2025 23:21:04 +0200
Subject: [PATCH] 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.
---
.dockerignore | 14 +
docker-compose.yml | 1 +
next.config.mjs | 9 +-
src/app/api/all-project-tasks/route.js | 15 +
src/app/api/contracts/[id]/route.js | 59 ++
src/app/api/contracts/route.js | 13 +-
src/app/api/projects/route.js | 7 +-
src/app/api/tasks/[id]/route.js | 81 +++
src/app/api/tasks/route.js | 8 +-
src/app/contracts/[id]/page.js | 206 +++++++
src/app/contracts/page.js | 632 ++++++++++++++++++++++
src/app/contracts/route.js | 39 --
src/app/projects/page.js | 258 ++++++---
src/app/tasks/page.js | 487 +++++++++++++++++
src/app/tasks/templates/[id]/edit/page.js | 66 +++
src/app/tasks/templates/page.js | 12 +-
src/components/TaskTemplateForm.js | 131 ++++-
src/components/ui/Badge.js | 1 +
src/components/ui/Button.js | 14 +-
src/components/ui/Navigation.js | 9 +-
src/lib/init-db.js | 10 +-
src/lib/queries/contracts.js | 57 ++
src/lib/queries/projects.js | 33 +-
src/lib/queries/tasks.js | 26 +
24 files changed, 2019 insertions(+), 169 deletions(-)
create mode 100644 .dockerignore
create mode 100644 src/app/api/all-project-tasks/route.js
create mode 100644 src/app/api/contracts/[id]/route.js
create mode 100644 src/app/api/tasks/[id]/route.js
create mode 100644 src/app/contracts/[id]/page.js
create mode 100644 src/app/contracts/page.js
delete mode 100644 src/app/contracts/route.js
create mode 100644 src/app/tasks/page.js
create mode 100644 src/app/tasks/templates/[id]/edit/page.js
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..b25bfff
--- /dev/null
+++ b/.dockerignore
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index d4086c3..395da32 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,5 +8,6 @@ services:
volumes:
- .:/app
- /app/node_modules
+ - ./data:/app/data
environment:
- NODE_ENV=development
diff --git a/next.config.mjs b/next.config.mjs
index 4678774..b706309 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,4 +1,11 @@
/** @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;
diff --git a/src/app/api/all-project-tasks/route.js b/src/app/api/all-project-tasks/route.js
new file mode 100644
index 0000000..0ed991d
--- /dev/null
+++ b/src/app/api/all-project-tasks/route.js
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/contracts/[id]/route.js b/src/app/api/contracts/[id]/route.js
new file mode 100644
index 0000000..eae30af
--- /dev/null
+++ b/src/app/api/contracts/[id]/route.js
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/contracts/route.js b/src/app/api/contracts/route.js
index c07b354..4867ccb 100644
--- a/src/app/api/contracts/route.js
+++ b/src/app/api/contracts/route.js
@@ -5,8 +5,17 @@ export async function GET() {
const contracts = db
.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();
return NextResponse.json(contracts);
diff --git a/src/app/api/projects/route.js b/src/app/api/projects/route.js
index 336eb38..10ebd54 100644
--- a/src/app/api/projects/route.js
+++ b/src/app/api/projects/route.js
@@ -5,8 +5,11 @@ import { NextResponse } from "next/server";
// Make sure the DB is initialized before queries run
initializeDatabase();
-export async function GET() {
- const projects = getAllProjects();
+export async function GET(req) {
+ const { searchParams } = new URL(req.url);
+ const contractId = searchParams.get("contract_id");
+
+ const projects = getAllProjects(contractId);
return NextResponse.json(projects);
}
diff --git a/src/app/api/tasks/[id]/route.js b/src/app/api/tasks/[id]/route.js
new file mode 100644
index 0000000..fd14899
--- /dev/null
+++ b/src/app/api/tasks/[id]/route.js
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/tasks/route.js b/src/app/api/tasks/route.js
index aaa80be..ce0cd22 100644
--- a/src/app/api/tasks/route.js
+++ b/src/app/api/tasks/route.js
@@ -3,7 +3,7 @@ import { NextResponse } from "next/server";
// POST: create new template
export async function POST(req) {
- const { name, max_wait_days } = await req.json();
+ const { name, max_wait_days, description } = await req.json();
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
@@ -11,10 +11,10 @@ export async function POST(req) {
db.prepare(
`
- INSERT INTO tasks (name, max_wait_days, is_standard)
- VALUES (?, ?, 1)
+ INSERT INTO tasks (name, max_wait_days, description, is_standard)
+ VALUES (?, ?, ?, 1)
`
- ).run(name, max_wait_days || 0);
+ ).run(name, max_wait_days || 0, description || null);
return NextResponse.json({ success: true });
}
diff --git a/src/app/contracts/[id]/page.js b/src/app/contracts/[id]/page.js
new file mode 100644
index 0000000..43f1fa7
--- /dev/null
+++ b/src/app/contracts/[id]/page.js
@@ -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 (
+
+
Ładowanie szczegółów umowy...
+
+ );
+ }
+
+ if (!contract) {
+ return (
+
+
Nie znaleziono umowy.
+
+
+ ← Powrót do listy umów
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
Umowa {contract.contract_number}
+
+ ← Powrót do listy umów
+
+
+
+ {/* Contract Details */}
+
+
Szczegóły umowy
+
+
+
+ Numer umowy
+
+
{contract.contract_number}
+
+
+ {contract.contract_name && (
+
+
+ Nazwa umowy
+
+
{contract.contract_name}
+
+ )}
+
+ {contract.customer_contract_number && (
+
+
+ Numer umowy (Klienta)
+
+
{contract.customer_contract_number}
+
+ )}
+
+ {contract.customer && (
+
+
+ Zleceniodawca
+
+
{contract.customer}
+
+ )}
+
+ {contract.investor && (
+
+
+ Inwestor
+
+
{contract.investor}
+
+ )}
+
+ {contract.date_signed && (
+
+
+ Data zawarcia
+
+
+ {new Date(contract.date_signed).toLocaleDateString("pl-PL")}
+
+
+ )}
+
+ {contract.finish_date && (
+
+
+ Data zakończenia
+
+
+ {new Date(contract.finish_date).toLocaleDateString("pl-PL")}
+
+
+ )}
+
+
+
+ {/* Linked Projects */}
+
+
+
+ Projekty w ramach umowy ({projects.length})
+
+
+ ➕ Dodaj projekt
+
+
+
+ {projects.length === 0 ? (
+
+ Brak projektów przypisanych do tej umowy.
+
+ ) : (
+
+ {projects.map((project) => (
+
+
+
+
{project.project_name}
+
+ {project.project_number}
+
+ {project.address && (
+
+ 📍 {project.address}
+
+ )}
+ {project.finish_date && (
+
+ ⏰ Termin:{" "}
+ {new Date(project.finish_date).toLocaleDateString(
+ "pl-PL"
+ )}
+
+ )}
+
+
+ Szczegóły →
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/contracts/page.js b/src/app/contracts/page.js
new file mode 100644
index 0000000..7ee221d
--- /dev/null
+++ b/src/app/contracts/page.js
@@ -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 Aktywna ;
+ case "completed":
+ return Zakończona ;
+ case "ongoing":
+ return W trakcie ;
+ default:
+ return Nieznany ;
+ }
+ };
+
+ 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 (
+
+ );
+ }
+
+ const stats = getContractStats();
+
+ return (
+
+ {/* Header */}
+
+
+
Umowy
+
+ Zarządzaj swoimi umowami i kontraktami
+
+
{" "}
+
+
➕
+ Nowa umowa
+
+
+ {/* Statistics Cards */}
+
+
+
+
+
+
+
Wszystkie
+
+ {stats.total}
+
+
+
+
+
+
+
+
+
+
+
+
Aktywne
+
+ {stats.active}
+
+
+
+
+
+
+
+
+
+
+
+
Zakończone
+
+ {stats.completed}
+
+
+
+
+
+
+
+
+
+
+
+
W trakcie
+
+ {stats.withoutEndDate}
+
+
+
+
+
+
+ {/* Filters and Search */}
+
+
+
+ {/* Search */}
+
+
+ Wyszukaj
+
+ 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"
+ />
+
+
+ {/* Status Filter */}
+
+
+ Status
+
+ 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"
+ >
+ Wszystkie
+ Aktywne
+ Zakończone
+ W trakcie
+
+
+
+ {/* Sort */}
+
+
+ Sortuj według
+
+ {
+ 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"
+ >
+ Numer umowy (A-Z)
+ Numer umowy (Z-A)
+ Nazwa (A-Z)
+ Nazwa (Z-A)
+ Klient (A-Z)
+ Klient (Z-A)
+
+ Data zawarcia (najnowsze)
+
+
+ Data zawarcia (najstarsze)
+
+
+ Data zakończenia (najnowsze)
+
+
+ Data zakończenia (najstarsze)
+
+
+
+
+
+ {" "}
+ {/* Contracts List */}
+ {filteredContracts.length === 0 ? (
+
+
+
+
+ {searchTerm || statusFilter !== "all"
+ ? "Brak pasujących umów"
+ : "Brak umów"}
+
+
+ {searchTerm || statusFilter !== "all"
+ ? "Spróbuj zmienić kryteria wyszukiwania lub filtry."
+ : "Rozpocznij od dodania pierwszej umowy."}
+
{" "}
+ {!searchTerm && statusFilter === "all" && (
+
+ Dodaj pierwszą umowę
+
+ )}
+
+
+ ) : (
+
+ {filteredContracts.map((contract) => {
+ const status = getContractStatus(contract);
+ return (
+
+
+
+
+ {/* Header */}
+
+
+ {contract.contract_number}
+
+ {getStatusBadge(status)}
+ {contract.contract_name && (
+
+ — {contract.contract_name}
+
+ )}
+
+
+ {/* Details Grid */}
+
+ {contract.customer && (
+
+
+
+
+
+ Zleceniodawca:
+
+
+ {contract.customer}
+
+
+ )}
+ {contract.investor && (
+
+
+
+
+
+ Inwestor:
+
+
+ {contract.investor}
+
+
+ )}
+ {contract.date_signed && (
+
+
+
+
+
+ Zawarcie:
+
+
+ {new Date(
+ contract.date_signed
+ ).toLocaleDateString("pl-PL")}
+
+
+ )}
+ {contract.finish_date && (
+
+
+
+
+
+ Zakończenie:
+
+
+ {new Date(
+ contract.finish_date
+ ).toLocaleDateString("pl-PL")}
+
+
+ )}
+
+
{" "}
+ {/* Actions */}
+
+
+
+
+
+
+ Szczegóły
+
+
handleDelete(contract.contract_id)}
+ >
+
+
+
+ Usuń
+
+
+
+
+
+ );
+ })}
+
+ )}
+ {/* Results Summary */}
+ {filteredContracts.length > 0 && (
+
+
+ Wyświetlono {filteredContracts.length} z {contracts.length} umów
+ {(searchTerm || statusFilter !== "all") && (
+ {
+ setSearchTerm("");
+ setStatusFilter("all");
+ }}
+ className="ml-2 text-blue-600 hover:text-blue-800 underline"
+ >
+ Wyczyść filtry
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/app/contracts/route.js b/src/app/contracts/route.js
deleted file mode 100644
index c07b354..0000000
--- a/src/app/contracts/route.js
+++ /dev/null
@@ -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 });
-}
diff --git a/src/app/projects/page.js b/src/app/projects/page.js
index 188372f..3cc854e 100644
--- a/src/app/projects/page.js
+++ b/src/app/projects/page.js
@@ -5,16 +5,39 @@ 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";
export default function ProjectListPage() {
const [projects, setProjects] = useState([]);
-
+ const [searchTerm, setSearchTerm] = useState("");
+ const [filteredProjects, setFilteredProjects] = useState([]);
useEffect(() => {
fetch("/api/projects")
.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) {
const confirmed = confirm("Are you sure you want to delete this project?");
if (!confirmed) return;
@@ -22,11 +45,14 @@ export default function ProjectListPage() {
const res = await fetch(`/api/projects/${id}`, {
method: "DELETE",
});
-
if (res.ok) {
setProjects((prev) => prev.filter((p) => p.project_id !== id));
}
}
+
+ const handleSearchChange = (e) => {
+ setSearchTerm(e.target.value);
+ };
return (
@@ -52,10 +78,115 @@ export default function ProjectListPage() {
Add Project
-
+ {" "}
+
{" "}
+ {/* Search Bar */}
+
+
+
+
+
+
+ {searchTerm && (
+
setSearchTerm("")}
+ className="absolute inset-y-0 right-0 pr-3 flex items-center"
+ >
+
+
+
+
+ )}
+
+
+ {searchTerm && (
+
+
+
+ Found{" "}
+
+ {filteredProjects.length}
+ {" "}
+ project
+ {filteredProjects.length !== 1 ? "s" : ""} matching
+
+ {" "}
+ "{searchTerm}"
+
+
+ {filteredProjects.length === 0 && (
+
setSearchTerm("")}
+ >
+ Clear Search
+
+ )}
+
+
+ )}
+
-
- {projects.length === 0 ? (
+ {filteredProjects.length === 0 && searchTerm ? (
+
+
+
+
+ No projects found
+
+
+ No projects match your search criteria. Try adjusting your
+ search terms.
+
+ setSearchTerm("")}>
+ Clear Search
+
+
+
+ ) : projects.length === 0 ? (
@@ -83,68 +214,63 @@ export default function ProjectListPage() {
) : (
-
- {projects.map((project) => (
-
+ {/* Header Row */}
+
+ {" "}
+
Number
+
Project Name
+
WP
+
City
+
Address
+
Plot
{" "}
+
Finish Date
+
Actions
+
{" "}
+ {/* Data Rows */}
+ {filteredProjects.map((project, index) => (
+
-
-
-
-
-
- {project.project_name}
-
-
- {project.project_number}
-
-
-
-
-
- Location: {" "}
- {project.city}
-
-
- Finish Date: {" "}
- {project.finish_date}
-
-
- Contract: {" "}
- {project.contract_number}
-
-
-
-
-
-
- View Details
-
-
-
-
- Edit
-
-
-
-
-
-
- handleDelete(project.project_id)}
- >
- Delete
-
-
-
-
-
+
+
+ {project.project_number}
+
+
{" "}
+
+
+ {project.project_name}
+
+
+
+ {project.wp || "N/A"}
+
+
+ {project.city || "N/A"}
+
+
+ {project.address || "N/A"}
+
+
+ {project.plot || "N/A"}
+
{" "}
+
+ {project.finish_date || "N/A"}
+
+
+
+
+ View
+
+
+
+
))}
)}
diff --git a/src/app/tasks/page.js b/src/app/tasks/page.js
new file mode 100644
index 0000000..3b14a0f
--- /dev/null
+++ b/src/app/tasks/page.js
@@ -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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+
Project Tasks
+
+ Monitor and manage tasks across all projects
+
+
+
+
+ {/* Stats Cards */}
+
+
+
+
+
+
+
+ Total Tasks
+
+
+ {statusCounts.all}
+
+
+
+
+
+
+
+
+
+
+
+
Pending
+
+ {statusCounts.pending}
+
+
+
+
+
+
+
+
+
+
+
+
+ In Progress
+
+
+ {statusCounts.in_progress}
+
+
+
+
+
+
+
+
+
+
+
+
Completed
+
+ {statusCounts.completed}
+
+
+
+
+
+
+
+ {/* Filters */}
+
+
+
+
+
+ Search Tasks
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+
+ Status
+
+ setStatusFilter(e.target.value)}
+ >
+ All Statuses
+ Pending
+ In Progress
+ Completed
+
+
+
+
+
+ Priority
+
+ setPriorityFilter(e.target.value)}
+ >
+ All Priorities
+ High
+ Normal
+ Low
+
+
+
+
+
+
+ {/* Tasks List */}
+ {filteredTasks.length === 0 ? (
+
+
+
+
+ No tasks found
+
+
+ {searchTerm ||
+ statusFilter !== "all" ||
+ priorityFilter !== "all"
+ ? "Try adjusting your filters to see more tasks"
+ : "No tasks have been created yet"}
+
+
+
+ ) : (
+
+ {filteredTasks.map((task) => (
+
+
+
+
+
+
+ {task.task_name}
+
+
+ {getStatusDisplayName(task.status)}
+
+
+ {task.priority}
+
+ {task.task_type === "template" && (
+
+ Template
+
+ )}
+
+
+
+
+
Project
+
+ {task.project_name}
+
+
+ {task.wp && (
+
+ )}
+ {task.plot && (
+
+
Plot
+
+ {task.plot}
+
+
+ )}
+
+
+
+
+ Added{" "}
+ {formatDistanceToNow(parseISO(task.date_added), {
+ addSuffix: true,
+ })}
+
+ {task.max_wait_days > 0 && (
+ Max wait: {task.max_wait_days} days
+ )}
+
+
+
+
+ {task.status !== "completed" && (
+
+ handleStatusChange(task.id, e.target.value)
+ }
+ >
+ Pending
+ In Progress
+ Completed
+
+ )}
+
+
+
+ View Project
+
+
+
+ handleDeleteTask(task.id)}
+ >
+ Delete
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/tasks/templates/[id]/edit/page.js b/src/app/tasks/templates/[id]/edit/page.js
new file mode 100644
index 0000000..3fa63e6
--- /dev/null
+++ b/src/app/tasks/templates/[id]/edit/page.js
@@ -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 (
+
+
+
+
+
+
+
+
+ Back to Templates
+
+ {" "}
+
+
+ Edit Task Template
+
+
+ Update the details for “{template.name}”
+
+
+
+
+
+
+
+ Template Details
+
+
+ Modify the template information below
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/tasks/templates/page.js b/src/app/tasks/templates/page.js
index 6baef70..1e1337a 100644
--- a/src/app/tasks/templates/page.js
+++ b/src/app/tasks/templates/page.js
@@ -84,21 +84,21 @@ export default function TaskTemplatesPage() {
{template.max_wait_days} days
-
{template.description && (
{template.description}
- )}
-
+ )}{" "}
Template ID: {template.task_id}
-
- Edit
-
+
+
+ Edit
+
+
Duplicate
diff --git a/src/components/TaskTemplateForm.js b/src/components/TaskTemplateForm.js
index ef2bbb5..4968ecd 100644
--- a/src/components/TaskTemplateForm.js
+++ b/src/components/TaskTemplateForm.js
@@ -1,61 +1,132 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
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 [max_wait_days, setRequiredWaitDays] = useState("");
+ const [description, setDescription] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
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) {
e.preventDefault();
+ setLoading(true);
- const res = await fetch("/api/tasks", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name,
- max_wait_days: parseInt(max_wait_days, 10) || 0,
- }),
- });
+ try {
+ const url = isEditing ? `/api/tasks/${templateId}` : "/api/tasks";
+ const method = isEditing ? "PUT" : "POST";
- if (res.ok) {
- router.push("/tasks/templates");
- } else {
- alert("Failed to create task template.");
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ 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 (
-
);
}
diff --git a/src/components/ui/Badge.js b/src/components/ui/Badge.js
index 9a6c315..3bdfc41 100644
--- a/src/components/ui/Badge.js
+++ b/src/components/ui/Badge.js
@@ -9,6 +9,7 @@ const Badge = ({
const variants = {
default: "bg-gray-100 text-gray-800",
primary: "bg-blue-100 text-blue-800",
+ secondary: "bg-gray-200 text-gray-700",
success: "bg-green-100 text-green-800",
warning: "bg-yellow-100 text-yellow-800",
danger: "bg-red-100 text-red-800",
diff --git a/src/components/ui/Button.js b/src/components/ui/Button.js
index a05c13d..898cc75 100644
--- a/src/components/ui/Button.js
+++ b/src/components/ui/Button.js
@@ -33,13 +33,13 @@ const Button = forwardRef(
diff --git a/src/components/ui/Navigation.js b/src/components/ui/Navigation.js
index 57eaaab..a075997 100644
--- a/src/components/ui/Navigation.js
+++ b/src/components/ui/Navigation.js
@@ -5,16 +5,19 @@ import { usePathname } from "next/navigation";
const Navigation = () => {
const pathname = usePathname();
-
const isActive = (path) => {
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 = [
{ href: "/", label: "Dashboard" },
{ href: "/projects", label: "Projects" },
{ href: "/tasks/templates", label: "Task Templates" },
+ { href: "/tasks", label: "Project Tasks" },
{ href: "/contracts", label: "Contracts" },
];
diff --git a/src/lib/init-db.js b/src/lib/init-db.js
index 26d3c51..ab30ca6 100644
--- a/src/lib/init-db.js
+++ b/src/lib/init-db.js
@@ -65,7 +65,6 @@ export default function initializeDatabase() {
);
`);
-
// Migration: Add custom task columns if they don't exist
try {
db.exec(`
@@ -82,4 +81,13 @@ export default function initializeDatabase() {
} catch (e) {
// 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
+ }
}
diff --git a/src/lib/queries/contracts.js b/src/lib/queries/contracts.js
index e69de29..ec84ef6 100644
--- a/src/lib/queries/contracts.js
+++ b/src/lib/queries/contracts.js
@@ -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 };
+}
diff --git a/src/lib/queries/projects.js b/src/lib/queries/projects.js
index daa0f32..03d2964 100644
--- a/src/lib/queries/projects.js
+++ b/src/lib/queries/projects.js
@@ -1,6 +1,13 @@
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();
}
@@ -9,18 +16,28 @@ export function getProjectById(id) {
}
export function createProject(data) {
- // 1. Get the max project_number under this contract
- const existing = db
+ // 1. Get the contract number and count existing projects
+ const contractInfo = db
.prepare(
`
- SELECT MAX(project_number) as max_number
- FROM projects
- WHERE contract_id = ?
+ SELECT
+ c.contract_number,
+ 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);
- 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(`
INSERT INTO projects (
@@ -31,7 +48,7 @@ export function createProject(data) {
stmt.run(
data.contract_id,
data.project_name,
- parseInt(nextNumber),
+ projectNumber,
data.address,
data.plot,
data.district,
diff --git a/src/lib/queries/tasks.js b/src/lib/queries/tasks.js
index 6dd24ed..3e11c81 100644
--- a/src/lib/queries/tasks.js
+++ b/src/lib/queries/tasks.js
@@ -7,6 +7,32 @@ export function getAllTaskTemplates() {
.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
export function getProjectTasks(projectId) {
return db