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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user