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.
This commit is contained in:
2025-10-07 21:58:08 +02:00
parent e19172d2bb
commit 952caf10d1
14 changed files with 1838 additions and 77 deletions

View File

@@ -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 (
<PageContainer>
<PageHeader title="Zastosuj zestaw zadań" />
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
if (!taskSet) {
return (
<PageContainer>
<PageHeader title="Zastosuj zestaw zadań" />
<div className="text-center py-12">
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Zastosuj zestaw zadań"
description={`Zastosuj zestaw "${taskSet.name}" do projektu`}
/>
<div className="max-w-4xl">
{/* Task set info */}
<Card className="mb-6">
<CardHeader>
<h3 className="text-lg font-semibold">Informacje o zestawie</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="font-medium text-gray-900">{taskSet.name}</div>
{taskSet.description && (
<div className="text-sm text-gray-600 mt-1">{taskSet.description}</div>
)}
<div className="text-sm text-gray-500 mt-2">
Typ projektu: <span className="capitalize">{taskSet.project_type}</span>
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-700 mb-2">
Zawarte szablony zadań ({taskSet.templates?.length || 0}):
</div>
{taskSet.templates && taskSet.templates.length > 0 ? (
<ul className="text-sm text-gray-600 space-y-1">
{taskSet.templates.map((template, index) => (
<li key={template.task_id} className="flex items-center">
<span className="text-gray-400 mr-2">{index + 1}.</span>
{template.name}
{template.max_wait_days > 0 && (
<span className="text-xs text-gray-500 ml-2">
({template.max_wait_days} dni)
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">Brak szablonów w zestawie</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Project selection */}
<Card className="mb-6">
<CardHeader>
<h3 className="text-lg font-semibold">Wybierz projekt</h3>
<p className="text-sm text-gray-600">
Wybierz projekt, do którego chcesz zastosować ten zestaw zadań
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Projekt *
</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(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"
required
>
<option value="">Wybierz projekt...</option>
{projects.map((project) => (
<option key={project.project_id} value={project.project_id}>
{project.project_name} - {project.city} ({project.project_type})
</option>
))}
</select>
</div>
{projects.length === 0 && (
<p className="text-gray-500 text-sm">
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."
}
</p>
)}
</div>
</CardContent>
</Card>
{/* Warning */}
<Card className="mb-6 bg-yellow-50 border-yellow-200">
<CardContent className="pt-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Informacja
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
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.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isApplying}
>
Anuluj
</Button>
<Button
type="button"
variant="primary"
onClick={handleApply}
disabled={isApplying || !selectedProject}
>
{isApplying ? "Stosowanie..." : "Zastosuj zestaw"}
</Button>
</div>
</div>
</PageContainer>
);
}

View File

@@ -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 (
<PageContainer>
<PageHeader title="Edycja zestawu zadań" />
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
if (!taskSet) {
return (
<PageContainer>
<PageHeader title="Edycja zestawu zadań" />
<div className="text-center py-12">
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Edycja zestawu zadań"
description={`Edytuj zestaw: ${taskSet.name}`}
/>
<form onSubmit={handleSubmit} className="max-w-4xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic info */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nazwa zestawu *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="np. Standardowe zadania projektowe"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opis
</label>
<Input
type="text"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Opcjonalny opis zestawu"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategoria zadań *
</label>
<select
value={formData.task_category}
onChange={(e) => setFormData(prev => ({ ...prev, task_category: 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"
required
>
<option value="design">Zadania projektowe</option>
<option value="construction">Zadania budowlane</option>
</select>
</div>
</CardContent>
</Card>
{/* Template selection */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybrano: {formData.selectedTemplates.length} szablonów
</p>
</CardHeader>
<CardContent>
{formData.selectedTemplates.length > 0 ? (
<div className="space-y-2">
{formData.selectedTemplates.map((templateId, index) => {
const template = taskTemplates.find(t => t.task_id === templateId);
return (
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
</div>
<div className="flex space-x-1">
<button
type="button"
onClick={() => index > 0 && moveTemplate(index, index - 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === 0}
>
</button>
<button
type="button"
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === formData.selectedTemplates.length - 1}
>
</button>
<button
type="button"
onClick={() => toggleTemplate(templateId)}
className="text-red-400 hover:text-red-600"
>
×
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
)}
</CardContent>
</Card>
</div>
{/* Available templates */}
<Card className="mt-6">
<CardHeader>
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybierz szablony, które chcesz dodać do zestawu
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{taskTemplates.map((template) => (
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.selectedTemplates.includes(template.task_id)}
onChange={() => toggleTemplate(template.task_id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
{template.description && (
<div className="text-xs text-gray-600">{template.description}</div>
)}
</div>
</label>
))}
</div>
{taskTemplates.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
</p>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-between mt-6">
<Button
type="button"
variant="danger"
onClick={handleDelete}
disabled={isSubmitting}
>
Usuń zestaw
</Button>
<div className="flex space-x-4">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
>
{isSubmitting ? "Zapisywanie..." : "Zapisz zmiany"}
</Button>
</div>
</div>
</form>
</PageContainer>
);
}