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:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
15
src/app/api/all-project-tasks/route.js
Normal file
15
src/app/api/all-project-tasks/route.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/contracts/[id]/route.js
Normal file
59
src/app/api/contracts/[id]/route.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,16 @@ 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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
src/app/api/tasks/[id]/route.js
Normal file
81
src/app/api/tasks/[id]/route.js
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
206
src/app/contracts/[id]/page.js
Normal file
206
src/app/contracts/[id]/page.js
Normal 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
632
src/app/contracts/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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>
|
</div>
|
||||||
|
<Input
|
||||||
{projects.length === 0 ? (
|
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">
|
||||||
|
{" "}
|
||||||
|
"{searchTerm}"
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{filteredProjects.length === 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filteredProjects.length === 0 && searchTerm ? (
|
||||||
|
<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">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<Link
|
|
||||||
href={`/projects/${project.project_id}`}
|
|
||||||
className="text-xl font-semibold text-blue-600 hover:text-blue-800 transition-colors truncate"
|
|
||||||
>
|
|
||||||
{project.project_name}
|
|
||||||
</Link>
|
|
||||||
<Badge variant="primary" size="sm">
|
<Badge variant="primary" size="sm">
|
||||||
{project.project_number}
|
{project.project_number}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>{" "}
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${project.project_id}`}
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-800 transition-colors truncate block"
|
||||||
|
>
|
||||||
|
{project.project_name}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-2 text-sm text-gray-600 truncate">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600 mb-4">
|
{project.wp || "N/A"}
|
||||||
<div>
|
|
||||||
<span className="font-medium">Location:</span>{" "}
|
|
||||||
{project.city}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="col-span-1 text-sm text-gray-600 truncate">
|
||||||
<span className="font-medium">Finish Date:</span>{" "}
|
{project.city || "N/A"}
|
||||||
{project.finish_date}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="col-span-2 text-sm text-gray-600 truncate">
|
||||||
<span className="font-medium">Contract:</span>{" "}
|
{project.address || "N/A"}
|
||||||
{project.contract_number}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-1 text-sm text-gray-600 truncate">
|
||||||
|
{project.plot || "N/A"}
|
||||||
|
</div>{" "}
|
||||||
|
<div className="col-span-1 text-sm text-gray-600 truncate">
|
||||||
|
{project.finish_date || "N/A"}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link href={`/projects/${project.project_id}`}>
|
<Link href={`/projects/${project.project_id}`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
View Details
|
View
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href={`/projects/${project.project_id}/edit`}>
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
Edit
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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
487
src/app/tasks/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/app/tasks/templates/[id]/edit/page.js
Normal file
66
src/app/tasks/templates/[id]/edit/page.js
Normal 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 “{template.name}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<Link href={`/tasks/templates/${template.task_id}/edit`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
Duplicate
|
Duplicate
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
const method = isEditing ? "PUT" : "POST";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
max_wait_days: parseInt(max_wait_days, 10) || 0,
|
max_wait_days: parseInt(max_wait_days, 10) || 0,
|
||||||
|
description: description || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
router.push("/tasks/templates");
|
router.push("/tasks/templates");
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to create task template.");
|
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 ? (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
>
|
>
|
||||||
Create Template
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user