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

+
+
+ +

{contract.contract_number}

+
+ + {contract.contract_name && ( +
+ +

{contract.contract_name}

+
+ )} + + {contract.customer_contract_number && ( +
+ +

{contract.customer_contract_number}

+
+ )} + + {contract.customer && ( +
+ +

{contract.customer}

+
+ )} + + {contract.investor && ( +
+ +

{contract.investor}

+
+ )} + + {contract.date_signed && ( +
+ +

+ {new Date(contract.date_signed).toLocaleDateString("pl-PL")} +

+
+ )} + + {contract.finish_date && ( +
+ +

+ {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 ( +
+
+
+

Ładowanie umów...

+
+
+ ); + } + + 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 */} +
+ + 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 */} +
+ + +
+ + {/* Sort */} +
+ + +
+
+
+
{" "} + {/* 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 + + +
+
+
+
+ ); + })} +
+ )} + {/* Results Summary */} + {filteredContracts.length > 0 && ( +
+

+ Wyświetlono {filteredContracts.length} z {contracts.length} umów + {(searchTerm || statusFilter !== "all") && ( + + )} +

+
+ )} +
+ ); +} 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 && ( + + )} +
+
+ {searchTerm && ( +
+
+

+ Found{" "} + + {filteredProjects.length} + {" "} + project + {filteredProjects.length !== 1 ? "s" : ""} matching + + {" "} + "{searchTerm}" + +

+ {filteredProjects.length === 0 && ( + + )} +
+
+ )} +
- - {projects.length === 0 ? ( + {filteredProjects.length === 0 && searchTerm ? ( + + +
+ + + +
+

+ No projects found +

+

+ No projects match your search criteria. Try adjusting your + search terms. +

+ +
+
+ ) : 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} -
-
- -
- - - - - - -
-
- -
- -
-
-
- +
+ + {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"} +
+
+ + + +
+
))}
)} 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 ( +
+
+
+
+

Loading tasks...

+
+
+
+ ); + } + + 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 */} + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+ + {/* 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 && ( +
+

WP

+

+ {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" && ( + + )} + + + + + + +
+
+
+
+ ))} +
+ )} +
+
+ ); +} 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 ( +
+
+
+ + + {" "} +
+

+ 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}
- + + + 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 ( -
+
- - + Template Name * + + setName(e.target.value)} - className="border p-2 w-full" + placeholder="Enter template name" required />
+
- - + Max Wait Days + + setRequiredWaitDays(e.target.value)} - className="border p-2 w-full" + placeholder="Enter maximum wait days" + min="0" + /> +

+ Maximum number of days this task can wait before it needs attention +

+
+ +
+ +