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,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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -10,8 +10,8 @@ export default function ProjectTasksPage() {
<PageHeader
title="Zadania"
description="Zarządzaj zadaniami projektów"
action={
<Link href="/tasks/templates">
actions={[
<Link href="/tasks/templates" key="templates">
<Button variant="secondary" size="md">
<svg
className="w-4 h-4 mr-2"
@@ -28,8 +28,26 @@ export default function ProjectTasksPage() {
</svg>
Szablony zadań
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="md">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
}
]}
/>
<ProjectTasksList />
</PageContainer>

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>
);
}

View File

@@ -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 (
<PageContainer>
<PageHeader
title="Nowy zestaw zadań"
description="Utwórz nowy zestaw zadań z szablonów"
/>
<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-end space-x-4 mt-6">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
>
{isSubmitting ? "Tworzenie..." : "Utwórz zestaw"}
</Button>
</div>
</form>
</PageContainer>
);
}

189
src/app/task-sets/page.js Normal file
View File

@@ -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 (
<Badge className={colors[taskCategory] || "bg-gray-100 text-gray-800"}>
{taskCategory === "design" ? "Zadania projektowe" : taskCategory === "construction" ? "Zadania budowlane" : taskCategory}
</Badge>
);
};
if (loading) {
return (
<PageContainer>
<PageHeader
title="Zestawy zadań"
description="Zarządzaj zestawami zadań"
/>
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Zestawy zadań"
description="Zarządzaj zestawami zadań"
action={
<Link href="/task-sets/new">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Nowy zestaw
</Button>
</Link>
}
/>
{/* Filter buttons */}
<div className="mb-6">
<div className="flex space-x-2">
{["all", "design", "construction"].map(type => (
<Button
key={type}
variant={filter === type ? "primary" : "secondary"}
size="sm"
onClick={() => setFilter(type)}
>
{type === "all" ? "Wszystkie" :
type === "design" ? "Projektowanie" :
"Budowa"}
</Button>
))}
</div>
</div>
{/* Task sets grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTaskSets.map((taskSet) => (
<Card key={taskSet.set_id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{taskSet.name}
</h3>
{taskSet.description && (
<p className="text-sm text-gray-600 mt-1">
{taskSet.description}
</p>
)}
</div>
{getTaskCategoryBadge(taskSet.task_category)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="text-sm text-gray-600">
<span className="font-medium">Szablony zadań:</span>{" "}
{taskSet.templates?.length || 0}
</div>
{taskSet.templates && taskSet.templates.length > 0 && (
<div className="text-xs text-gray-500">
<ul className="list-disc list-inside space-y-1">
{taskSet.templates.slice(0, 3).map((template) => (
<li key={template.task_id}>{template.name}</li>
))}
{taskSet.templates.length > 3 && (
<li>...i {taskSet.templates.length - 3} więcej</li>
)}
</ul>
</div>
)}
<div className="flex space-x-2 pt-2">
<Link href={`/task-sets/${taskSet.set_id}`}>
<Button variant="secondary" size="sm">
Edytuj
</Button>
</Link>
<Link href={`/task-sets/${taskSet.set_id}/apply`}>
<Button variant="primary" size="sm">
Zastosuj
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{filteredTaskSets.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500 mb-4">
{filter === "all"
? "Brak zestawów zadań"
: `Brak zestawów zadań dla typu "${filter}"`
}
</div>
<Link href="/task-sets/new">
<Button variant="primary">
Utwórz pierwszy zestaw
</Button>
</Link>
</div>
)}
</PageContainer>
);
}

View File

@@ -40,8 +40,8 @@ export default function TaskTemplatesPage() {
<PageHeader
title={t('taskTemplates.title')}
description={t('taskTemplates.subtitle')}
action={
<Link href="/tasks/templates/new">
actions={[
<Link href="/tasks/templates/new" key="new-template">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
@@ -58,8 +58,26 @@ export default function TaskTemplatesPage() {
</svg>
{t('taskTemplates.newTemplate')}
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
}
]}
/>
<div className="text-center py-12">
<div className="text-gray-500">{t('common.loading')}</div>
@@ -73,8 +91,8 @@ export default function TaskTemplatesPage() {
<PageHeader
title={t('taskTemplates.title')}
description={t('taskTemplates.subtitle')}
action={
<Link href="/tasks/templates/new">
actions={[
<Link href="/tasks/templates/new" key="new-template">
<Button variant="primary" size="lg">
<svg
className="w-5 h-5 mr-2"
@@ -91,8 +109,26 @@ export default function TaskTemplatesPage() {
</svg>
{t('taskTemplates.newTemplate')}
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="lg">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
}
]}
/>
{templates.length === 0 ? (

View File

@@ -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 }) {
<label className="block text-sm font-medium text-gray-700 mb-3">
{t("tasks.taskType")}
</label>
<div className="flex space-x-6">
<div className="flex flex-wrap gap-6">
<label className="flex items-center">
<input
type="radio"
@@ -94,6 +139,16 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
/>
<span className="ml-2 text-sm text-gray-900">{t("tasks.fromTemplate")}</span>
</label>
<label className="flex items-center">
<input
type="radio"
value="task_set"
checked={taskType === "task_set"}
onChange={(e) => setTaskType(e.target.value)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span className="ml-2 text-sm text-gray-900">Z zestawu zadań</span>
</label>
<label className="flex items-center">
<input
type="radio"
@@ -126,6 +181,41 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
))}
</select>
</div>
) : taskType === "task_set" ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Wybierz zestaw zadań
</label>
<select
value={selectedTaskSet}
onChange={(e) => setSelectedTaskSet(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Wybierz zestaw...</option>
{taskSets.map((taskSet) => (
<option key={taskSet.set_id} value={taskSet.set_id}>
{taskSet.name} ({taskSet.task_category === 'design' ? 'Zadania projektowe' : 'Zadania budowlane'}) - {taskSet.templates?.length || 0} zadań
</option>
))}
</select>
{selectedTaskSet && (
<div className="mt-2 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>Podgląd zestawu:</strong>
</p>
<ul className="text-sm text-blue-700 mt-1 list-disc list-inside">
{taskSets
.find(ts => ts.set_id === parseInt(selectedTaskSet))
?.templates?.map((template, index) => (
<li key={template.task_id}>
{template.name} ({template.max_wait_days} dni)
</li>
)) || []}
</ul>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div>
@@ -169,39 +259,43 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t("projects.unassigned")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
</div>
{taskType !== "task_set" && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{t("projects.unassigned")}</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name} ({user.email})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("tasks.priority")}
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="low">{t("tasks.low")}</option>
<option value="normal">{t("tasks.normal")}</option>
<option value="high">{t("tasks.high")}</option>
<option value="urgent">{t("tasks.urgent")}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("tasks.priority")}
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="low">{t("tasks.low")}</option>
<option value="normal">{t("tasks.normal")}</option>
<option value="high">{t("tasks.high")}</option>
<option value="urgent">{t("tasks.urgent")}</option>
</select>
</div>
</>
)}
<div className="flex justify-end">
<Button
@@ -210,10 +304,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
disabled={
isSubmitting ||
(taskType === "template" && !selectedTemplate) ||
(taskType === "custom" && !customTaskName.trim())
(taskType === "custom" && !customTaskName.trim()) ||
(taskType === "task_set" && !selectedTaskSet)
}
>
{isSubmitting ? t("tasks.adding") : t("tasks.addTask")}
{isSubmitting ? t("tasks.adding") : taskType === "task_set" ? "Zastosuj zestaw" : t("tasks.addTask")}
</Button>
</div>
</form>

View File

@@ -1,14 +1,24 @@
"use client";
const PageHeader = ({ title, description, children, action, className = "" }) => {
const PageHeader = ({ title, description, children, action, actions, className = "" }) => {
return (
<div className={`flex justify-between items-start mb-8 ${className}`}>
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">{title}</h1>
{description && <p className="text-gray-600 dark:text-gray-400 mt-1">{description}</p>}
</div>
{(children || action) && (
<div className="ml-6 flex-shrink-0">{action || children}</div>
{(children || action || actions) && (
<div className="ml-6 flex-shrink-0">
{actions ? (
<div className="flex flex-col sm:flex-row gap-2">
{actions.map((actionItem, index) => (
<div key={index}>{actionItem}</div>
))}
</div>
) : (
action || children
)}
</div>
)}
</div>
);

View File

@@ -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 (

View File

@@ -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;
}

71
test-task-sets.mjs Normal file
View File

@@ -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();