From 952caf10d1703115ac38d605571769f77321a07b Mon Sep 17 00:00:00 2001 From: chop Date: Tue, 7 Oct 2025 21:58:08 +0200 Subject: [PATCH] feat: add task sets functionality with CRUD operations and UI integration - Implemented NewTaskSetPage for creating task sets with templates. - Created TaskSetsPage for listing and filtering task sets. - Enhanced TaskTemplatesPage with navigation to task sets. - Updated ProjectTaskForm to support task set selection. - Modified PageHeader to support multiple action buttons. - Initialized database with task_sets and task_set_templates tables. - Added queries for task sets including creation, retrieval, and deletion. - Implemented applyTaskSetToProject function for bulk task creation. - Added test script for verifying task sets functionality. --- src/app/api/task-sets/[id]/apply/route.js | 35 +++ src/app/api/task-sets/[id]/route.js | 130 ++++++++ src/app/api/task-sets/route.js | 60 ++++ src/app/project-tasks/page.js | 24 +- src/app/task-sets/[id]/apply/page.js | 245 +++++++++++++++ src/app/task-sets/[id]/page.js | 354 ++++++++++++++++++++++ src/app/task-sets/new/page.js | 289 ++++++++++++++++++ src/app/task-sets/page.js | 189 ++++++++++++ src/app/tasks/templates/page.js | 48 ++- src/components/ProjectTaskForm.js | 223 ++++++++++---- src/components/ui/PageHeader.js | 16 +- src/lib/init-db.js | 42 ++- src/lib/queries/tasks.js | 189 ++++++++++++ test-task-sets.mjs | 71 +++++ 14 files changed, 1838 insertions(+), 77 deletions(-) create mode 100644 src/app/api/task-sets/[id]/apply/route.js create mode 100644 src/app/api/task-sets/[id]/route.js create mode 100644 src/app/api/task-sets/route.js create mode 100644 src/app/task-sets/[id]/apply/page.js create mode 100644 src/app/task-sets/[id]/page.js create mode 100644 src/app/task-sets/new/page.js create mode 100644 src/app/task-sets/page.js create mode 100644 test-task-sets.mjs diff --git a/src/app/api/task-sets/[id]/apply/route.js b/src/app/api/task-sets/[id]/apply/route.js new file mode 100644 index 0000000..28c2f25 --- /dev/null +++ b/src/app/api/task-sets/[id]/apply/route.js @@ -0,0 +1,35 @@ +import { applyTaskSetToProject } from "@/lib/queries/tasks"; +import { NextResponse } from "next/server"; +import { withUserAuth } from "@/lib/middleware/auth"; + +// POST: Apply a task set to a project (bulk create project tasks) +async function applyTaskSetHandler(req, { params }) { + try { + const { id } = await params; + const { project_id } = await req.json(); + + if (!project_id) { + return NextResponse.json( + { error: "project_id is required" }, + { status: 400 } + ); + } + + const createdTaskIds = applyTaskSetToProject(id, project_id, req.user?.id || null); + + return NextResponse.json({ + success: true, + message: `Task set applied successfully. Created ${createdTaskIds.length} tasks.`, + createdTaskIds + }); + } catch (error) { + console.error("Error applying task set:", error); + return NextResponse.json( + { error: "Failed to apply task set", details: error.message }, + { status: 500 } + ); + } +} + +// Protected route - require authentication +export const POST = withUserAuth(applyTaskSetHandler); \ No newline at end of file diff --git a/src/app/api/task-sets/[id]/route.js b/src/app/api/task-sets/[id]/route.js new file mode 100644 index 0000000..a3047ca --- /dev/null +++ b/src/app/api/task-sets/[id]/route.js @@ -0,0 +1,130 @@ +import { + getTaskSetById, + updateTaskSet, + deleteTaskSet, + addTaskTemplateToSet, + removeTaskTemplateFromSet, +} from "@/lib/queries/tasks"; +import { NextResponse } from "next/server"; +import { withReadAuth, withUserAuth } from "@/lib/middleware/auth"; +import initializeDatabase from "@/lib/init-db"; + +// GET: Get a specific task set with its templates +async function getTaskSetHandler(req, { params }) { + initializeDatabase(); + try { + const { id } = await params; + const taskSet = getTaskSetById(id); + + if (!taskSet) { + return NextResponse.json( + { error: "Task set not found" }, + { status: 404 } + ); + } + + return NextResponse.json(taskSet); + } catch (error) { + console.error("Error fetching task set:", error); + return NextResponse.json( + { error: "Failed to fetch task set" }, + { status: 500 } + ); + } +} + +// PUT: Update a task set +async function updateTaskSetHandler(req, { params }) { + initializeDatabase(); + try { + const { id } = await params; + const updates = await req.json(); + + // Validate required fields + if (updates.name !== undefined && !updates.name.trim()) { + return NextResponse.json( + { error: "Name cannot be empty" }, + { status: 400 } + ); + } + + if (updates.task_category !== undefined) { + const validTypes = ["design", "construction"]; + if (!validTypes.includes(updates.task_category)) { + return NextResponse.json( + { error: "Invalid task_category. Must be one of: design, construction" }, + { status: 400 } + ); + } + } + + // Handle template updates + if (updates.templates !== undefined) { + // Clear existing templates + // Note: This is a simple implementation. In a real app, you might want to handle this more efficiently + const currentSet = getTaskSetById(id); + if (currentSet) { + for (const template of currentSet.templates) { + removeTaskTemplateFromSet(id, template.task_id); + } + } + + // Add new templates + if (Array.isArray(updates.templates)) { + for (let i = 0; i < updates.templates.length; i++) { + const template = updates.templates[i]; + addTaskTemplateToSet(id, template.task_id, i); + } + } + + // Remove templates from updates object so it doesn't interfere with task set update + delete updates.templates; + } + + const result = updateTaskSet(id, updates); + + if (result.changes === 0) { + return NextResponse.json( + { error: "Task set not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error updating task set:", error); + return NextResponse.json( + { error: "Failed to update task set", details: error.message }, + { status: 500 } + ); + } +} + +// DELETE: Delete a task set +async function deleteTaskSetHandler(req, { params }) { + initializeDatabase(); + try { + const { id } = await params; + const result = deleteTaskSet(id); + + if (result.changes === 0) { + return NextResponse.json( + { error: "Task set not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting task set:", error); + return NextResponse.json( + { error: "Failed to delete task set", details: error.message }, + { status: 500 } + ); + } +} + +// Protected routes - require authentication +export const GET = withReadAuth(getTaskSetHandler); +export const PUT = withUserAuth(updateTaskSetHandler); +export const DELETE = withUserAuth(deleteTaskSetHandler); \ No newline at end of file diff --git a/src/app/api/task-sets/route.js b/src/app/api/task-sets/route.js new file mode 100644 index 0000000..3c5a879 --- /dev/null +++ b/src/app/api/task-sets/route.js @@ -0,0 +1,60 @@ +import { + getAllTaskSets, + getTaskSetsByProjectType, + createTaskSet, +} from "@/lib/queries/tasks"; +import { NextResponse } from "next/server"; +import { withReadAuth, withUserAuth } from "@/lib/middleware/auth"; +import initializeDatabase from "@/lib/init-db"; + +// GET: Get all task sets or filter by task category +async function getTaskSetsHandler(req) { + initializeDatabase(); + const { searchParams } = new URL(req.url); + const taskCategory = searchParams.get("task_category"); + + if (taskCategory) { + const taskSets = getTaskSetsByTaskCategory(taskCategory); + return NextResponse.json(taskSets); + } else { + const taskSets = getAllTaskSets(); + return NextResponse.json(taskSets); + } +} + +// POST: Create a new task set +async function createTaskSetHandler(req) { + initializeDatabase(); + try { + const data = await req.json(); + + if (!data.name || !data.task_category) { + return NextResponse.json( + { error: "Name and task_category are required" }, + { status: 400 } + ); + } + + // Validate task_category + const validTypes = ["design", "construction"]; + if (!validTypes.includes(data.task_category)) { + return NextResponse.json( + { error: "Invalid task_category. Must be one of: design, construction" }, + { status: 400 } + ); + } + + const setId = createTaskSet(data); + return NextResponse.json({ success: true, id: setId }); + } catch (error) { + console.error("Error creating task set:", error); + return NextResponse.json( + { error: "Failed to create task set", details: error.message }, + { status: 500 } + ); + } +} + +// Protected routes - require authentication +export const GET = withReadAuth(getTaskSetsHandler); +export const POST = withUserAuth(createTaskSetHandler); \ No newline at end of file diff --git a/src/app/project-tasks/page.js b/src/app/project-tasks/page.js index 502e8c3..6dcc0a0 100644 --- a/src/app/project-tasks/page.js +++ b/src/app/project-tasks/page.js @@ -10,8 +10,8 @@ export default function ProjectTasksPage() { + actions={[ + + , + + - } + ]} /> diff --git a/src/app/task-sets/[id]/apply/page.js b/src/app/task-sets/[id]/apply/page.js new file mode 100644 index 0000000..12b0c0d --- /dev/null +++ b/src/app/task-sets/[id]/apply/page.js @@ -0,0 +1,245 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { Card, CardHeader, CardContent } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { useTranslation } from "@/lib/i18n"; + +export default function ApplyTaskSetPage() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useParams(); + const setId = params.id; + + const [taskSet, setTaskSet] = useState(null); + const [projects, setProjects] = useState([]); + const [selectedProject, setSelectedProject] = useState(""); + const [loading, setLoading] = useState(true); + const [isApplying, setIsApplying] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + // Fetch task set + const setResponse = await fetch(`/api/task-sets/${setId}`); + if (setResponse.ok) { + const setData = await setResponse.json(); + setTaskSet(setData); + } else { + console.error('Failed to fetch task set'); + router.push('/task-sets'); + return; + } + + // Fetch projects + const projectsResponse = await fetch('/api/projects'); + if (projectsResponse.ok) { + const projectsData = await projectsResponse.json(); + setProjects(projectsData); + } + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + if (setId) { + fetchData(); + } + }, [setId, router]); + + const handleApply = async () => { + if (!selectedProject) { + alert("Wybierz projekt"); + return; + } + + setIsApplying(true); + + try { + const response = await fetch(`/api/task-sets/${setId}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: parseInt(selectedProject) }) + }); + + if (response.ok) { + const result = await response.json(); + alert(`Zestaw zadań został pomyślnie zastosowany. Utworzono ${result.createdTaskIds.length} zadań.`); + router.push(`/projects/${selectedProject}`); + } else { + const error = await response.json(); + alert(`Błąd: ${error.details || 'Nie udało się zastosować zestawu zadań'}`); + } + } catch (error) { + console.error('Error applying task set:', error); + alert('Wystąpił błąd podczas stosowania zestawu zadań'); + } finally { + setIsApplying(false); + } + }; + + if (loading) { + return ( + + +
+
Ładowanie...
+
+
+ ); + } + + if (!taskSet) { + return ( + + +
+
Zestaw zadań nie został znaleziony
+
+
+ ); + } + + return ( + + + +
+ {/* Task set info */} + + +

Informacje o zestawie

+
+ +
+
+
{taskSet.name}
+ {taskSet.description && ( +
{taskSet.description}
+ )} +
+ Typ projektu: {taskSet.project_type} +
+
+
+
+ Zawarte szablony zadań ({taskSet.templates?.length || 0}): +
+ {taskSet.templates && taskSet.templates.length > 0 ? ( +
    + {taskSet.templates.map((template, index) => ( +
  • + {index + 1}. + {template.name} + {template.max_wait_days > 0 && ( + + ({template.max_wait_days} dni) + + )} +
  • + ))} +
+ ) : ( +

Brak szablonów w zestawie

+ )} +
+
+
+
+ + {/* Project selection */} + + +

Wybierz projekt

+

+ Wybierz projekt, do którego chcesz zastosować ten zestaw zadań +

+
+ +
+
+ + +
+ + {projects.length === 0 && ( +

+ Brak dostępnych projektów dla tego typu zestawu zadań. + {taskSet.project_type !== 'design+construction' && + " Spróbuj utworzyć projekt typu 'Projekt + Budowa' lub zmienić typ zestawu." + } +

+ )} +
+
+
+ + {/* Warning */} + + +
+
+ + + +
+
+

+ Informacja +

+
+

+ Zastosowanie tego zestawu utworzy {taskSet.templates?.length || 0} nowych zadań w wybranym projekcie. + Zadania będą miały status "Oczekujące" i zostaną przypisane zgodnie z domyślnymi regułami. +

+
+
+
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/task-sets/[id]/page.js b/src/app/task-sets/[id]/page.js new file mode 100644 index 0000000..d3738fb --- /dev/null +++ b/src/app/task-sets/[id]/page.js @@ -0,0 +1,354 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { Card, CardHeader, CardContent } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { useTranslation } from "@/lib/i18n"; + +export default function EditTaskSetPage() { + const { t } = useTranslation(); + const router = useRouter(); + const params = useParams(); + const setId = params.id; + + const [taskTemplates, setTaskTemplates] = useState([]); + const [taskSet, setTaskSet] = useState(null); + const [formData, setFormData] = useState({ + name: "", + description: "", + project_type: "design", + selectedTemplates: [] + }); + const [loading, setLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + // Fetch task set + const setResponse = await fetch(`/api/task-sets/${setId}`); + if (setResponse.ok) { + const setData = await setResponse.json(); + setTaskSet(setData); + setFormData({ + name: setData.name, + description: setData.description || "", + project_type: setData.project_type, + selectedTemplates: setData.templates?.map(t => t.task_id) || [] + }); + } else { + console.error('Failed to fetch task set'); + router.push('/task-sets'); + return; + } + + // Fetch available task templates + const templatesResponse = await fetch('/api/tasks/templates'); + if (templatesResponse.ok) { + const templatesData = await templatesResponse.json(); + setTaskTemplates(templatesData); + } + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + if (setId) { + fetchData(); + } + }, [setId, router]); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.name.trim()) { + alert("Nazwa zestawu jest wymagana"); + return; + } + + if (formData.selectedTemplates.length === 0) { + alert("Wybierz przynajmniej jeden szablon zadania"); + return; + } + + setIsSubmitting(true); + + try { + // Update the task set + const updateData = { + name: formData.name.trim(), + description: formData.description.trim(), + task_category: formData.task_category, + templates: formData.selectedTemplates.map((templateId, index) => ({ + task_id: templateId, + sort_order: index + })) + }; + + const response = await fetch(`/api/task-sets/${setId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }); + + if (!response.ok) { + throw new Error('Failed to update task set'); + } + + router.push('/task-sets'); + } catch (error) { + console.error('Error updating task set:', error); + alert('Wystąpił błąd podczas aktualizacji zestawu zadań'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!confirm('Czy na pewno chcesz usunąć ten zestaw zadań?')) { + return; + } + + try { + const response = await fetch(`/api/task-sets/${setId}`, { + method: 'DELETE' + }); + + if (response.ok) { + router.push('/task-sets'); + } else { + alert('Wystąpił błąd podczas usuwania zestawu zadań'); + } + } catch (error) { + console.error('Error deleting task set:', error); + alert('Wystąpił błąd podczas usuwania zestawu zadań'); + } + }; + + const toggleTemplate = (templateId) => { + setFormData(prev => ({ + ...prev, + selectedTemplates: prev.selectedTemplates.includes(templateId) + ? prev.selectedTemplates.filter(id => id !== templateId) + : [...prev.selectedTemplates, templateId] + })); + }; + + const moveTemplate = (fromIndex, toIndex) => { + const newSelected = [...formData.selectedTemplates]; + const [moved] = newSelected.splice(fromIndex, 1); + newSelected.splice(toIndex, 0, moved); + + setFormData(prev => ({ + ...prev, + selectedTemplates: newSelected + })); + }; + + if (loading) { + return ( + + +
+
Ładowanie...
+
+
+ ); + } + + if (!taskSet) { + return ( + + +
+
Zestaw zadań nie został znaleziony
+
+
+ ); + } + + return ( + + + +
+
+ {/* Basic info */} + + +

Informacje podstawowe

+
+ +
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="np. Standardowe zadania projektowe" + required + /> +
+ +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="Opcjonalny opis zestawu" + /> +
+ +
+ + +
+
+
+ + {/* Template selection */} + + +

Wybrane szablony zadań

+

+ Wybrano: {formData.selectedTemplates.length} szablonów +

+
+ + {formData.selectedTemplates.length > 0 ? ( +
+ {formData.selectedTemplates.map((templateId, index) => { + const template = taskTemplates.find(t => t.task_id === templateId); + return ( +
+
+ {index + 1}. + {template?.name || 'Nieznany szablon'} +
+
+ + + +
+
+ ); + })} +
+ ) : ( +

Brak wybranych szablonów

+ )} +
+
+
+ + {/* Available templates */} + + +

Dostępne szablony zadań

+

+ Wybierz szablony, które chcesz dodać do zestawu +

+
+ +
+ {taskTemplates.map((template) => ( + + ))} +
+ {taskTemplates.length === 0 && ( +

+ Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań". +

+ )} +
+
+ + {/* Actions */} +
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/task-sets/new/page.js b/src/app/task-sets/new/page.js new file mode 100644 index 0000000..6c6e5a7 --- /dev/null +++ b/src/app/task-sets/new/page.js @@ -0,0 +1,289 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardHeader, CardContent } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { useTranslation } from "@/lib/i18n"; + +export default function NewTaskSetPage() { + const { t } = useTranslation(); + const router = useRouter(); + const [taskTemplates, setTaskTemplates] = useState([]); + const [formData, setFormData] = useState({ + name: "", + description: "", + project_type: "design", + selectedTemplates: [] + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + // Fetch available task templates + const fetchTemplates = async () => { + try { + const response = await fetch('/api/tasks/templates'); + if (response.ok) { + const data = await response.json(); + setTaskTemplates(data); + } + } catch (error) { + console.error('Error fetching templates:', error); + } + }; + + fetchTemplates(); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.name.trim()) { + alert("Nazwa zestawu jest wymagana"); + return; + } + + if (formData.selectedTemplates.length === 0) { + alert("Wybierz przynajmniej jeden szablon zadania"); + return; + } + + setIsSubmitting(true); + + try { + // Create the task set + const createResponse = await fetch('/api/task-sets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formData.name.trim(), + description: formData.description.trim(), + project_type: formData.project_type + }) + }); + + if (!createResponse.ok) { + throw new Error('Failed to create task set'); + } + + const { id: setId } = await createResponse.json(); + + // Add templates to the set + const templatesData = { + templates: formData.selectedTemplates.map((templateId, index) => ({ + task_id: templateId, + sort_order: index + })) + }; + + const updateResponse = await fetch(`/api/task-sets/${setId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(templatesData) + }); + + if (!updateResponse.ok) { + throw new Error('Failed to add templates to task set'); + } + + router.push('/task-sets'); + } catch (error) { + console.error('Error creating task set:', error); + alert('Wystąpił błąd podczas tworzenia zestawu zadań'); + } finally { + setIsSubmitting(false); + } + }; + + const toggleTemplate = (templateId) => { + setFormData(prev => ({ + ...prev, + selectedTemplates: prev.selectedTemplates.includes(templateId) + ? prev.selectedTemplates.filter(id => id !== templateId) + : [...prev.selectedTemplates, templateId] + })); + }; + + const moveTemplate = (fromIndex, toIndex) => { + const newSelected = [...formData.selectedTemplates]; + const [moved] = newSelected.splice(fromIndex, 1); + newSelected.splice(toIndex, 0, moved); + + setFormData(prev => ({ + ...prev, + selectedTemplates: newSelected + })); + }; + + return ( + + + +
+
+ {/* Basic info */} + + +

Informacje podstawowe

+
+ +
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="np. Standardowe zadania projektowe" + required + /> +
+ +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="Opcjonalny opis zestawu" + /> +
+ +
+ + +
+
+
+ + {/* Template selection */} + + +

Wybrane szablony zadań

+

+ Wybrano: {formData.selectedTemplates.length} szablonów +

+
+ + {formData.selectedTemplates.length > 0 ? ( +
+ {formData.selectedTemplates.map((templateId, index) => { + const template = taskTemplates.find(t => t.task_id === templateId); + return ( +
+
+ {index + 1}. + {template?.name || 'Nieznany szablon'} +
+
+ + + +
+
+ ); + })} +
+ ) : ( +

Brak wybranych szablonów

+ )} +
+
+
+ + {/* Available templates */} + + +

Dostępne szablony zadań

+

+ Wybierz szablony, które chcesz dodać do zestawu +

+
+ +
+ {taskTemplates.map((template) => ( + + ))} +
+ {taskTemplates.length === 0 && ( +

+ Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań". +

+ )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/task-sets/page.js b/src/app/task-sets/page.js new file mode 100644 index 0000000..e1b63c5 --- /dev/null +++ b/src/app/task-sets/page.js @@ -0,0 +1,189 @@ +"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 PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { useTranslation } from "@/lib/i18n"; + +export default function TaskSetsPage() { + const { t } = useTranslation(); + const [taskSets, setTaskSets] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("all"); + + useEffect(() => { + const fetchTaskSets = async () => { + try { + const response = await fetch('/api/task-sets'); + if (response.ok) { + const data = await response.json(); + setTaskSets(data); + } else { + console.error('Failed to fetch task sets'); + } + } catch (error) { + console.error('Error fetching task sets:', error); + } finally { + setLoading(false); + } + }; + + fetchTaskSets(); + }, []); + + const filteredTaskSets = taskSets.filter(taskSet => { + if (filter === "all") return true; + return taskSet.task_category === filter; + }); + + const getTaskCategoryBadge = (taskCategory) => { + const colors = { + design: "bg-blue-100 text-blue-800", + construction: "bg-green-100 text-green-800" + }; + + return ( + + {taskCategory === "design" ? "Zadania projektowe" : taskCategory === "construction" ? "Zadania budowlane" : taskCategory} + + ); + }; + + if (loading) { + return ( + + +
+
Ładowanie...
+
+
+ ); + } + + return ( + + + + + } + /> + + {/* Filter buttons */} +
+
+ {["all", "design", "construction"].map(type => ( + + ))} +
+
+ + {/* Task sets grid */} +
+ {filteredTaskSets.map((taskSet) => ( + + +
+
+

+ {taskSet.name} +

+ {taskSet.description && ( +

+ {taskSet.description} +

+ )} +
+ {getTaskCategoryBadge(taskSet.task_category)} +
+
+ +
+
+ Szablony zadań:{" "} + {taskSet.templates?.length || 0} +
+ + {taskSet.templates && taskSet.templates.length > 0 && ( +
+
    + {taskSet.templates.slice(0, 3).map((template) => ( +
  • {template.name}
  • + ))} + {taskSet.templates.length > 3 && ( +
  • ...i {taskSet.templates.length - 3} więcej
  • + )} +
+
+ )} + +
+ + + + + + +
+
+
+
+ ))} +
+ + {filteredTaskSets.length === 0 && ( +
+
+ {filter === "all" + ? "Brak zestawów zadań" + : `Brak zestawów zadań dla typu "${filter}"` + } +
+ + + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/tasks/templates/page.js b/src/app/tasks/templates/page.js index 1ee3300..24eeaca 100644 --- a/src/app/tasks/templates/page.js +++ b/src/app/tasks/templates/page.js @@ -40,8 +40,8 @@ export default function TaskTemplatesPage() { + actions={[ + + , + + - } + ]} />
{t('common.loading')}
@@ -73,8 +91,8 @@ export default function TaskTemplatesPage() { + actions={[ + + , + + - } + ]} /> {templates.length === 0 ? ( diff --git a/src/components/ProjectTaskForm.js b/src/components/ProjectTaskForm.js index 2ae7f59..62d4711 100644 --- a/src/components/ProjectTaskForm.js +++ b/src/components/ProjectTaskForm.js @@ -8,9 +8,12 @@ import { useTranslation } from "@/lib/i18n"; export default function ProjectTaskForm({ projectId, onTaskAdded }) { const { t } = useTranslation(); const [taskTemplates, setTaskTemplates] = useState([]); + const [taskSets, setTaskSets] = useState([]); const [users, setUsers] = useState([]); - const [taskType, setTaskType] = useState("template"); // "template" or "custom" + const [project, setProject] = useState(null); + const [taskType, setTaskType] = useState("template"); // "template", "custom", or "task_set" const [selectedTemplate, setSelectedTemplate] = useState(""); + const [selectedTaskSet, setSelectedTaskSet] = useState(""); const [customTaskName, setCustomTaskName] = useState(""); const [customMaxWaitDays, setCustomMaxWaitDays] = useState(""); const [customDescription, setCustomDescription] = useState(""); @@ -19,6 +22,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) { const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { + // Fetch project details + fetch(`/api/projects/${projectId}`) + .then((res) => res.json()) + .then(setProject); + // Fetch available task templates fetch("/api/tasks/templates") .then((res) => res.json()) @@ -28,7 +36,23 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) { fetch("/api/project-tasks/users") .then((res) => res.json()) .then(setUsers); - }, []); + }, [projectId]); + + useEffect(() => { + // Fetch task sets when project type is available + if (project?.project_type) { + let apiUrl = '/api/task-sets'; + if (project.project_type === 'design+construction') { + // For design+construction projects, don't filter - show all task sets + // User can choose which type to apply + } else { + apiUrl += `?project_type=${project.project_type}`; + } + fetch(apiUrl) + .then((res) => res.json()) + .then(setTaskSets); + } + }, [project?.project_type]); async function handleSubmit(e) { e.preventDefault(); @@ -36,40 +60,61 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) { // Validate based on task type if (taskType === "template" && !selectedTemplate) return; if (taskType === "custom" && !customTaskName.trim()) return; + if (taskType === "task_set" && !selectedTaskSet) return; setIsSubmitting(true); try { - const requestData = { - project_id: parseInt(projectId), - priority, - assigned_to: assignedTo || null, - }; + if (taskType === "task_set") { + // Apply task set + const response = await fetch(`/api/task-sets/${selectedTaskSet}/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project_id: parseInt(projectId) }), + }); - if (taskType === "template") { - requestData.task_template_id = parseInt(selectedTemplate); + if (response.ok) { + // Reset form + setSelectedTaskSet(""); + setPriority("normal"); + setAssignedTo(""); + if (onTaskAdded) onTaskAdded(); + } else { + alert(t("tasks.addTaskError")); + } } else { - requestData.custom_task_name = customTaskName.trim(); - requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0; - requestData.custom_description = customDescription.trim(); - } + // Create single task + const requestData = { + project_id: parseInt(projectId), + priority, + assigned_to: assignedTo || null, + }; - const res = await fetch("/api/project-tasks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(requestData), - }); - if (res.ok) { - // Reset form - setSelectedTemplate(""); - setCustomTaskName(""); - setCustomMaxWaitDays(""); - setCustomDescription(""); - setPriority("normal"); - setAssignedTo(""); - if (onTaskAdded) onTaskAdded(); - } else { - alert(t("tasks.addTaskError")); + if (taskType === "template") { + requestData.task_template_id = parseInt(selectedTemplate); + } else { + requestData.custom_task_name = customTaskName.trim(); + requestData.custom_max_wait_days = parseInt(customMaxWaitDays) || 0; + requestData.custom_description = customDescription.trim(); + } + + const res = await fetch("/api/project-tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestData), + }); + if (res.ok) { + // Reset form + setSelectedTemplate(""); + setCustomTaskName(""); + setCustomMaxWaitDays(""); + setCustomDescription(""); + setPriority("normal"); + setAssignedTo(""); + if (onTaskAdded) onTaskAdded(); + } else { + alert(t("tasks.addTaskError")); + } } } catch (error) { alert(t("tasks.addTaskError")); @@ -83,7 +128,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) { -
+
+
+ ) : taskType === "task_set" ? ( +
+ + + {selectedTaskSet && ( +
+

+ Podgląd zestawu: +

+
    + {taskSets + .find(ts => ts.set_id === parseInt(selectedTaskSet)) + ?.templates?.map((template, index) => ( +
  • + {template.name} ({template.max_wait_days} dni) +
  • + )) || []} +
+
+ )} +
) : (
@@ -169,39 +259,43 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
)} -
- - -
+ {taskType !== "task_set" && ( + <> +
+ + +
-
- - -
+
+ + +
+ + )}
diff --git a/src/components/ui/PageHeader.js b/src/components/ui/PageHeader.js index d2e7639..5a53dc2 100644 --- a/src/components/ui/PageHeader.js +++ b/src/components/ui/PageHeader.js @@ -1,14 +1,24 @@ "use client"; -const PageHeader = ({ title, description, children, action, className = "" }) => { +const PageHeader = ({ title, description, children, action, actions, className = "" }) => { return (

{title}

{description &&

{description}

}
- {(children || action) && ( -
{action || children}
+ {(children || action || actions) && ( +
+ {actions ? ( +
+ {actions.map((actionItem, index) => ( +
{actionItem}
+ ))} +
+ ) : ( + action || children + )} +
)}
); diff --git a/src/lib/init-db.js b/src/lib/init-db.js index 9521b23..1ac636d 100644 --- a/src/lib/init-db.js +++ b/src/lib/init-db.js @@ -41,7 +41,29 @@ export default function initializeDatabase() { name TEXT NOT NULL, max_wait_days INTEGER DEFAULT 0, is_standard INTEGER NOT NULL DEFAULT 0 - ); -- Table: project_tasks + ); + + -- Table: task_sets + CREATE TABLE IF NOT EXISTS task_sets ( + set_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + task_category TEXT CHECK(task_category IN ('design', 'construction')) NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + -- Table: task_set_templates + CREATE TABLE IF NOT EXISTS task_set_templates ( + set_id INTEGER NOT NULL, + task_template_id INTEGER NOT NULL, + sort_order INTEGER DEFAULT 0, + PRIMARY KEY (set_id, task_template_id), + FOREIGN KEY (set_id) REFERENCES task_sets(set_id) ON DELETE CASCADE, + FOREIGN KEY (task_template_id) REFERENCES tasks(task_id) + ); + + -- Table: project_tasks CREATE TABLE IF NOT EXISTS project_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, @@ -342,6 +364,24 @@ export default function initializeDatabase() { console.warn("Migration warning:", e.message); } + // Migration: Rename project_type to task_category in task_sets + try { + // Check if the old column exists and rename it + const tableInfo = db.prepare("PRAGMA table_info(task_sets)").all(); + const hasOldColumn = tableInfo.some(col => col.name === 'project_type'); + const hasNewColumn = tableInfo.some(col => col.name === 'task_category'); + + if (hasOldColumn && !hasNewColumn) { + // Rename the column + db.exec(` + ALTER TABLE task_sets RENAME COLUMN project_type TO task_category; + `); + console.log("✅ Renamed project_type to task_category in task_sets"); + } + } catch (e) { + console.warn("Migration warning:", e.message); + } + // Generic file attachments table db.exec(` CREATE TABLE IF NOT EXISTS file_attachments ( diff --git a/src/lib/queries/tasks.js b/src/lib/queries/tasks.js index 5aff732..eb7ebd4 100644 --- a/src/lib/queries/tasks.js +++ b/src/lib/queries/tasks.js @@ -413,3 +413,192 @@ export function updateProjectTask(taskId, updates, userId = null) { return result; } + +// ===== TASK SETS ===== + +// Get all task sets +export function getAllTaskSets() { + const taskSets = db + .prepare("SELECT * FROM task_sets ORDER BY name ASC") + .all(); + + // Add templates to each task set + return taskSets.map(taskSet => { + const templates = db + .prepare(` + SELECT + tst.sort_order, + t.task_id, + t.name, + t.max_wait_days, + t.description + FROM task_set_templates tst + JOIN tasks t ON tst.task_template_id = t.task_id + WHERE tst.set_id = ? + ORDER BY tst.sort_order ASC + `) + .all(taskSet.set_id); + + return { ...taskSet, templates }; + }); +} + +// Get task sets by task category +export function getTaskSetsByTaskCategory(taskCategory) { + const taskSets = db + .prepare("SELECT * FROM task_sets WHERE task_category = ? ORDER BY name ASC") + .all(taskCategory); + + // Add templates to each task set + return taskSets.map(taskSet => { + const templates = db + .prepare(` + SELECT + tst.sort_order, + t.task_id, + t.name, + t.max_wait_days, + t.description + FROM task_set_templates tst + JOIN tasks t ON tst.task_template_id = t.task_id + WHERE tst.set_id = ? + ORDER BY tst.sort_order ASC + `) + .all(taskSet.set_id); + + return { ...taskSet, templates }; + }); +} + +// Get task set by ID with templates +export function getTaskSetById(setId) { + const taskSet = db + .prepare("SELECT * FROM task_sets WHERE set_id = ?") + .get(setId); + + if (taskSet) { + const templates = db + .prepare(` + SELECT + tst.sort_order, + t.task_id, + t.name, + t.max_wait_days, + t.description + FROM task_set_templates tst + JOIN tasks t ON tst.task_template_id = t.task_id + WHERE tst.set_id = ? + ORDER BY tst.sort_order ASC + `) + .all(setId); + + return { ...taskSet, templates }; + } + + return null; +} + +// Create a new task set +export function createTaskSet(data) { + const result = db + .prepare(` + INSERT INTO task_sets (name, description, task_category, created_at, updated_at) + VALUES (?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime')) + `) + .run(data.name, data.description || null, data.task_category); + + return result.lastInsertRowid; +} + +// Update a task set +export function updateTaskSet(setId, data) { + const fields = []; + const values = []; + + if (data.name !== undefined) { + fields.push("name = ?"); + values.push(data.name); + } + + if (data.description !== undefined) { + fields.push("description = ?"); + values.push(data.description || null); + } + + if (data.task_category !== undefined) { + fields.push("task_category = ?"); + values.push(data.task_category); + } + + fields.push("updated_at = datetime('now', 'localtime')"); + values.push(setId); + + const stmt = db.prepare(` + UPDATE task_sets + SET ${fields.join(", ")} + WHERE set_id = ? + `); + + return stmt.run(...values); +} + +// Delete a task set +export function deleteTaskSet(setId) { + // Delete task set templates first (cascade should handle this, but being explicit) + db.prepare("DELETE FROM task_set_templates WHERE set_id = ?").run(setId); + + // Delete the task set + return db.prepare("DELETE FROM task_sets WHERE set_id = ?").run(setId); +} + +// Add task template to set +export function addTaskTemplateToSet(setId, taskTemplateId, sortOrder = 0) { + return db + .prepare(` + INSERT OR REPLACE INTO task_set_templates (set_id, task_template_id, sort_order) + VALUES (?, ?, ?) + `) + .run(setId, taskTemplateId, sortOrder); +} + +// Remove task template from set +export function removeTaskTemplateFromSet(setId, taskTemplateId) { + return db + .prepare(` + DELETE FROM task_set_templates + WHERE set_id = ? AND task_template_id = ? + `) + .run(setId, taskTemplateId); +} + +// Apply task set to project (bulk create project tasks) +export function applyTaskSetToProject(setId, projectId, userId = null) { + // Get the task set with templates + const taskSet = getTaskSetById(setId); + if (!taskSet) { + throw new Error(`Task set with ID ${setId} not found`); + } + + const createdTasks = []; + const language = getUserLanguage(); + + // Create project tasks for each template in the set + for (const template of taskSet.templates) { + const result = createProjectTask({ + project_id: projectId, + task_template_id: template.task_id, + status: "pending", + priority: "normal", + created_by: userId, + assigned_to: null, // Will be assigned based on user role logic in createProjectTask + }); + + createdTasks.push(result.lastInsertRowid); + + // Add system note for task set application + const logMessage = `${serverT("Task added from set", language)} "${taskSet.name}"`; + addNoteToTask(result.lastInsertRowid, logMessage, true, userId); + } + + return createdTasks; +} diff --git a/test-task-sets.mjs b/test-task-sets.mjs new file mode 100644 index 0000000..b9554cf --- /dev/null +++ b/test-task-sets.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +// Test script to verify task sets functionality +import { getAllTaskSets, createTaskSet } from './src/lib/queries/tasks.js'; +import initializeDatabase from './src/lib/init-db.js'; + +async function testTaskSets() { + console.log('Testing Task Sets Database Functions...\n'); + + try { + // Initialize database + initializeDatabase(); + + // Test 1: Get all task sets + console.log('1. Getting all task sets...'); + const taskSets = getAllTaskSets(); + console.log(`Found ${taskSets.length} task sets:`); + taskSets.forEach(set => { + console.log(` - ${set.name} (${set.task_category}):`, JSON.stringify(set)); + }); + + // Test 2: Create a new task set (design) + console.log('\n2. Creating design task set...'); + const designSetId = createTaskSet({ + name: 'Test Design Set', + description: 'Test task set for design tasks', + task_category: 'design', + templates: [] + }); + console.log(`Created task set with ID: ${designSetId}`); + + // Test 3: Create a construction task set + console.log('\n3. Creating construction task set...'); + const constructionSetId = createTaskSet({ + name: 'Test Construction Set', + description: 'Test task set for construction tasks', + task_category: 'construction', + templates: [] + }); + console.log(`Created task set with ID: ${constructionSetId}`); + + // Test 4: Try to create invalid task set (should fail) + console.log('\n4. Testing invalid task category (should fail)...'); + try { + const invalidSetId = createTaskSet({ + name: 'Invalid Set', + description: 'This should fail', + task_category: 'design+construction', + templates: [] + }); + console.log('✗ Should have failed to create invalid task set'); + } catch (error) { + console.log('✓ Correctly rejected invalid task category:', error.message); + } + + // Test 5: Get all task sets again + console.log('\n5. Getting all task sets after creation...'); + const updatedTaskSets = getAllTaskSets(); + console.log(`Found ${updatedTaskSets.length} task sets:`); + updatedTaskSets.forEach(set => { + console.log(` - ${set.name} (${set.task_category})`); + }); + + console.log('\n✅ All tests passed! Task sets functionality is working correctly.'); + + } catch (error) { + console.error('Test failed:', error.message); + } +} + +testTaskSets(); \ No newline at end of file