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

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