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:
35
src/app/api/task-sets/[id]/apply/route.js
Normal file
35
src/app/api/task-sets/[id]/apply/route.js
Normal 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);
|
||||||
130
src/app/api/task-sets/[id]/route.js
Normal file
130
src/app/api/task-sets/[id]/route.js
Normal 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);
|
||||||
60
src/app/api/task-sets/route.js
Normal file
60
src/app/api/task-sets/route.js
Normal 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);
|
||||||
@@ -10,8 +10,8 @@ export default function ProjectTasksPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Zadania"
|
title="Zadania"
|
||||||
description="Zarządzaj zadaniami projektów"
|
description="Zarządzaj zadaniami projektów"
|
||||||
action={
|
actions={[
|
||||||
<Link href="/tasks/templates">
|
<Link href="/tasks/templates" key="templates">
|
||||||
<Button variant="secondary" size="md">
|
<Button variant="secondary" size="md">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-2"
|
className="w-4 h-4 mr-2"
|
||||||
@@ -28,8 +28,26 @@ export default function ProjectTasksPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Szablony zadań
|
Szablony zadań
|
||||||
</Button>
|
</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>
|
</Link>
|
||||||
}
|
]}
|
||||||
/>
|
/>
|
||||||
<ProjectTasksList />
|
<ProjectTasksList />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
245
src/app/task-sets/[id]/apply/page.js
Normal file
245
src/app/task-sets/[id]/apply/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
src/app/task-sets/[id]/page.js
Normal file
354
src/app/task-sets/[id]/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
src/app/task-sets/new/page.js
Normal file
289
src/app/task-sets/new/page.js
Normal 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
189
src/app/task-sets/page.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,8 +40,8 @@ export default function TaskTemplatesPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('taskTemplates.title')}
|
title={t('taskTemplates.title')}
|
||||||
description={t('taskTemplates.subtitle')}
|
description={t('taskTemplates.subtitle')}
|
||||||
action={
|
actions={[
|
||||||
<Link href="/tasks/templates/new">
|
<Link href="/tasks/templates/new" key="new-template">
|
||||||
<Button variant="primary" size="lg">
|
<Button variant="primary" size="lg">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 mr-2"
|
className="w-5 h-5 mr-2"
|
||||||
@@ -58,8 +58,26 @@ export default function TaskTemplatesPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
{t('taskTemplates.newTemplate')}
|
{t('taskTemplates.newTemplate')}
|
||||||
</Button>
|
</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>
|
</Link>
|
||||||
}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-gray-500">{t('common.loading')}</div>
|
<div className="text-gray-500">{t('common.loading')}</div>
|
||||||
@@ -73,8 +91,8 @@ export default function TaskTemplatesPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={t('taskTemplates.title')}
|
title={t('taskTemplates.title')}
|
||||||
description={t('taskTemplates.subtitle')}
|
description={t('taskTemplates.subtitle')}
|
||||||
action={
|
actions={[
|
||||||
<Link href="/tasks/templates/new">
|
<Link href="/tasks/templates/new" key="new-template">
|
||||||
<Button variant="primary" size="lg">
|
<Button variant="primary" size="lg">
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5 mr-2"
|
className="w-5 h-5 mr-2"
|
||||||
@@ -91,8 +109,26 @@ export default function TaskTemplatesPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
{t('taskTemplates.newTemplate')}
|
{t('taskTemplates.newTemplate')}
|
||||||
</Button>
|
</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>
|
</Link>
|
||||||
}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{templates.length === 0 ? (
|
{templates.length === 0 ? (
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import { useTranslation } from "@/lib/i18n";
|
|||||||
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [taskTemplates, setTaskTemplates] = useState([]);
|
const [taskTemplates, setTaskTemplates] = useState([]);
|
||||||
|
const [taskSets, setTaskSets] = useState([]);
|
||||||
const [users, setUsers] = 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 [selectedTemplate, setSelectedTemplate] = useState("");
|
||||||
|
const [selectedTaskSet, setSelectedTaskSet] = useState("");
|
||||||
const [customTaskName, setCustomTaskName] = useState("");
|
const [customTaskName, setCustomTaskName] = useState("");
|
||||||
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
|
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
|
||||||
const [customDescription, setCustomDescription] = useState("");
|
const [customDescription, setCustomDescription] = useState("");
|
||||||
@@ -19,6 +22,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Fetch project details
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(setProject);
|
||||||
|
|
||||||
// Fetch available task templates
|
// Fetch available task templates
|
||||||
fetch("/api/tasks/templates")
|
fetch("/api/tasks/templates")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@@ -28,7 +36,23 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
fetch("/api/project-tasks/users")
|
fetch("/api/project-tasks/users")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then(setUsers);
|
.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) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -36,10 +60,30 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
// Validate based on task type
|
// Validate based on task type
|
||||||
if (taskType === "template" && !selectedTemplate) return;
|
if (taskType === "template" && !selectedTemplate) return;
|
||||||
if (taskType === "custom" && !customTaskName.trim()) return;
|
if (taskType === "custom" && !customTaskName.trim()) return;
|
||||||
|
if (taskType === "task_set" && !selectedTaskSet) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
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 (response.ok) {
|
||||||
|
// Reset form
|
||||||
|
setSelectedTaskSet("");
|
||||||
|
setPriority("normal");
|
||||||
|
setAssignedTo("");
|
||||||
|
if (onTaskAdded) onTaskAdded();
|
||||||
|
} else {
|
||||||
|
alert(t("tasks.addTaskError"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create single task
|
||||||
const requestData = {
|
const requestData = {
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
priority,
|
priority,
|
||||||
@@ -71,6 +115,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
} else {
|
} else {
|
||||||
alert(t("tasks.addTaskError"));
|
alert(t("tasks.addTaskError"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(t("tasks.addTaskError"));
|
alert(t("tasks.addTaskError"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,7 +128,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
{t("tasks.taskType")}
|
{t("tasks.taskType")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex space-x-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
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>
|
<span className="ml-2 text-sm text-gray-900">{t("tasks.fromTemplate")}</span>
|
||||||
</label>
|
</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">
|
<label className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -126,6 +181,41 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -169,6 +259,8 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{taskType !== "task_set" && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
{t("tasks.assignedTo")} <span className="text-gray-500 text-xs">({t("common.optional")})</span>
|
||||||
@@ -202,6 +294,8 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
<option value="urgent">{t("tasks.urgent")}</option>
|
<option value="urgent">{t("tasks.urgent")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -210,10 +304,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
disabled={
|
disabled={
|
||||||
isSubmitting ||
|
isSubmitting ||
|
||||||
(taskType === "template" && !selectedTemplate) ||
|
(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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
const PageHeader = ({ title, description, children, action, className = "" }) => {
|
const PageHeader = ({ title, description, children, action, actions, className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex justify-between items-start mb-8 ${className}`}>
|
<div className={`flex justify-between items-start mb-8 ${className}`}>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">{title}</h1>
|
<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>}
|
{description && <p className="text-gray-600 dark:text-gray-400 mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{(children || action) && (
|
{(children || action || actions) && (
|
||||||
<div className="ml-6 flex-shrink-0">{action || children}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,7 +41,29 @@ export default function initializeDatabase() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
max_wait_days INTEGER DEFAULT 0,
|
max_wait_days INTEGER DEFAULT 0,
|
||||||
is_standard INTEGER NOT NULL 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 (
|
CREATE TABLE IF NOT EXISTS project_tasks (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
project_id INTEGER NOT NULL,
|
project_id INTEGER NOT NULL,
|
||||||
@@ -342,6 +364,24 @@ export default function initializeDatabase() {
|
|||||||
console.warn("Migration warning:", e.message);
|
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
|
// Generic file attachments table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS file_attachments (
|
CREATE TABLE IF NOT EXISTS file_attachments (
|
||||||
|
|||||||
@@ -413,3 +413,192 @@ export function updateProjectTask(taskId, updates, userId = null) {
|
|||||||
|
|
||||||
return result;
|
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
71
test-task-sets.mjs
Normal 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();
|
||||||
Reference in New Issue
Block a user