Add Task Templates settings page with shared settings nav

- Task Templates page: create/edit templates with checklist items
- Add/remove/reorder items, save template with name + description
- SettingsNav component with tab links between Dropdown Options and Task Templates
- taskTemplateApi in services/api.js
- Route at /settings/task-templates
This commit is contained in:
Adam Moussa 2026-04-17 14:57:18 -04:00
parent 27fb70724b
commit 4de6c54fc0
6 changed files with 502 additions and 0 deletions

View file

@ -52,6 +52,7 @@ import VendorFormPage from "./pages/vendors/form/VendorFormPage";
// SETTINGS
import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage";
import TaskTemplatesPage from "./pages/settings/TaskTemplatesPage";
// Protected Route Component
function ProtectedRoute({ children }) {
@ -136,6 +137,7 @@ export default function App() {
{/* SETTINGS */}
<Route path="/settings/dropdowns" element={<DropdownOptionsPage />} />
<Route path="/settings/task-templates" element={<TaskTemplatesPage />} />
</Routes>
</div>
</div>

View file

@ -2,6 +2,8 @@ import React, { useState, useEffect } from "react";
import { get, post, put, deleteItem } from "../../apiUtil";
import { API_URL } from "../../constants";
import { toast } from "react-toastify";
import SettingsNav from "./SettingsNav";
import "../settings/TaskTemplatesPage.css";
import "./DropdownOptionsPage.css";
const CATEGORIES = [
@ -102,6 +104,7 @@ export default function DropdownOptionsPage() {
return (
<section className="dropdownSettingsPage">
<SettingsNav />
<div className="dropdownSettingsHeader">
<h2>Dropdown Options</h2>
<p>Manage configurable dropdown values for work orders</p>

View file

@ -0,0 +1,23 @@
import React from "react";
import { NavLink } from "react-router-dom";
const links = [
{ path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" },
];
export default function SettingsNav() {
return (
<div className="settingsNav">
{links.map((link) => (
<NavLink
key={link.path}
to={link.path}
className={({ isActive }) => `settingsNavLink ${isActive ? "active" : ""}`}
>
{link.label}
</NavLink>
))}
</div>
);
}

View file

@ -0,0 +1,263 @@
.settingsNav {
display: flex;
gap: 0;
margin-bottom: 20px;
border-bottom: 2px solid #e5e5e5;
}
.settingsNavLink {
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
color: #999;
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.settingsNavLink:hover {
color: #333;
}
.settingsNavLink.active {
color: #007bff;
border-bottom-color: #007bff;
}
.taskTemplatesPage {
padding: 20px;
}
.taskTemplatesHeader {
margin-bottom: 24px;
}
.taskTemplatesHeader h2 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
.taskTemplatesHeader p {
margin: 0;
font-size: 13px;
color: #999;
}
.taskTemplatesLayout {
display: flex;
gap: 0;
border: 1px solid #ddd;
border-radius: 6px;
overflow: hidden;
min-height: 500px;
}
.taskTemplatesSidebar {
width: 240px;
background: #f8f9fa;
border-right: 1px solid #ddd;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.taskTemplatesNewBtn {
display: block;
width: 100%;
padding: 12px 16px;
border: none;
background: #007bff;
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.taskTemplatesNewBtn:hover {
background: #0069d9;
}
.taskTemplatesSidebarItem {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid #eee;
display: flex;
flex-direction: column;
gap: 2px;
}
.taskTemplatesSidebarItem:hover {
background: #eee;
}
.taskTemplatesSidebarItem.active {
background: #fff;
border-right: 2px solid #007bff;
}
.taskTemplatesSidebarName {
font-size: 13px;
font-weight: 500;
color: #333;
}
.taskTemplatesSidebarCount {
font-size: 11px;
color: #999;
}
.taskTemplatesContent {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
}
.taskTemplatesEmpty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #999;
font-size: 14px;
}
.taskTemplatesLoading {
padding: 20px;
text-align: center;
color: #999;
font-size: 13px;
}
.taskTemplatesForm {
flex: 1;
}
.taskTemplatesFormRow {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.taskTemplatesFormField {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.taskTemplatesFormField label {
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
}
.taskTemplatesItemsHeader {
margin-bottom: 8px;
}
.taskTemplatesItemsHeader h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #333;
}
.taskTemplatesItemList {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.taskTemplatesItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #f8f9fa;
border: 1px solid #eee;
border-radius: 4px;
}
.taskTemplatesItemNum {
font-size: 12px;
font-weight: 600;
color: #999;
min-width: 20px;
}
.taskTemplatesItemText {
flex: 1;
font-size: 13px;
color: #333;
}
.taskTemplatesItemActions {
display: flex;
gap: 2px;
}
.taskTemplatesItemBtn {
width: 24px;
height: 24px;
border: none;
background: none;
color: #999;
cursor: pointer;
font-size: 12px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
}
.taskTemplatesItemBtn:hover:not(:disabled) {
background: #e0e0e0;
color: #333;
}
.taskTemplatesItemBtn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.taskTemplatesItemBtn.delete {
font-size: 16px;
}
.taskTemplatesItemBtn.delete:hover {
color: #dc3545;
background: #fff5f5;
}
.taskTemplatesAddItem {
display: flex;
gap: 8px;
}
.taskTemplatesAddItem .form-control {
flex: 1;
}
.taskTemplatesActions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #eee;
margin-top: 16px;
}
.taskTemplatesDeleteBtn {
margin-right: auto;
color: #dc3545;
}
.taskTemplatesDeleteBtn:hover {
background: #fff5f5;
}

View file

@ -0,0 +1,203 @@
import React, { useState, useEffect } from "react";
import { taskTemplateApi } from "../../services/api";
import { toast } from "react-toastify";
import SettingsNav from "./SettingsNav";
import "./TaskTemplatesPage.css";
export default function TaskTemplatesPage() {
const [templates, setTemplates] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [form, setForm] = useState({ name: "", description: "", items: [] });
const [newItemText, setNewItemText] = useState("");
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const loadTemplates = async () => {
try {
const data = await taskTemplateApi.getAll();
setTemplates(Array.isArray(data) ? data : []);
} catch {
toast.error("Failed to load templates");
} finally {
setLoading(false);
}
};
useEffect(() => { loadTemplates(); }, []);
const handleSelect = (template) => {
setSelectedId(template.id);
setForm({
name: template.name || "",
description: template.description || "",
items: (template.items || []).map((i, idx) => ({ text: i.itemText, sortOrder: i.sortOrder ?? idx })),
});
setNewItemText("");
};
const handleNew = () => {
setSelectedId("new");
setForm({ name: "", description: "", items: [] });
setNewItemText("");
};
const handleAddItem = () => {
if (!newItemText.trim()) return;
setForm((f) => ({
...f,
items: [...f.items, { text: newItemText.trim(), sortOrder: f.items.length }],
}));
setNewItemText("");
};
const handleRemoveItem = (idx) => {
setForm((f) => ({
...f,
items: f.items.filter((_, i) => i !== idx).map((item, i) => ({ ...item, sortOrder: i })),
}));
};
const handleMoveItem = (idx, dir) => {
setForm((f) => {
const items = [...f.items];
const target = idx + dir;
if (target < 0 || target >= items.length) return f;
[items[idx], items[target]] = [items[target], items[idx]];
return { ...f, items: items.map((item, i) => ({ ...item, sortOrder: i })) };
});
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error("Template name is required"); return; }
setSaving(true);
try {
const payload = {
name: form.name,
description: form.description,
isActive: true,
items: form.items.map((i) => ({ itemText: i.text, sortOrder: i.sortOrder })),
};
if (selectedId === "new") {
const result = await taskTemplateApi.create(payload);
setSelectedId(result.id);
toast.success("Template created");
} else {
await taskTemplateApi.update(selectedId, payload);
toast.success("Template updated");
}
loadTemplates();
} catch {
toast.error("Failed to save template");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!selectedId || selectedId === "new") return;
try {
await taskTemplateApi.delete(selectedId);
setSelectedId(null);
setForm({ name: "", description: "", items: [] });
loadTemplates();
toast.success("Template deleted");
} catch {
toast.error("Failed to delete template");
}
};
return (
<section className="taskTemplatesPage">
<SettingsNav />
<div className="taskTemplatesHeader">
<h2>Task List Templates</h2>
<p>Create reusable checklists for work order dispatches</p>
</div>
<div className="taskTemplatesLayout">
<div className="taskTemplatesSidebar">
<button className="taskTemplatesNewBtn" onClick={handleNew}>+ New Template</button>
{loading ? <div className="taskTemplatesLoading">Loading...</div> : templates.map((t) => (
<div
key={t.id}
className={`taskTemplatesSidebarItem ${selectedId === t.id ? "active" : ""}`}
onClick={() => handleSelect(t)}
>
<span className="taskTemplatesSidebarName">{t.name}</span>
<span className="taskTemplatesSidebarCount">{t.items?.length || 0} items</span>
</div>
))}
</div>
<div className="taskTemplatesContent">
{!selectedId ? (
<div className="taskTemplatesEmpty">Select a template or create a new one</div>
) : (
<>
<div className="taskTemplatesForm">
<div className="taskTemplatesFormRow">
<div className="taskTemplatesFormField">
<label>Template Name</label>
<input
className="form-control"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g., HVAC Inspection"
/>
</div>
<div className="taskTemplatesFormField">
<label>Description</label>
<input
className="form-control"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Optional description"
/>
</div>
</div>
<div className="taskTemplatesItemsHeader">
<h4>Checklist Items ({form.items.length})</h4>
</div>
<div className="taskTemplatesItemList">
{form.items.map((item, idx) => (
<div key={idx} className="taskTemplatesItem">
<span className="taskTemplatesItemNum">{idx + 1}</span>
<span className="taskTemplatesItemText">{item.text}</span>
<div className="taskTemplatesItemActions">
<button className="taskTemplatesItemBtn" onClick={() => handleMoveItem(idx, -1)} disabled={idx === 0}>▲</button>
<button className="taskTemplatesItemBtn" onClick={() => handleMoveItem(idx, 1)} disabled={idx === form.items.length - 1}>▼</button>
<button className="taskTemplatesItemBtn delete" onClick={() => handleRemoveItem(idx)}>×</button>
</div>
</div>
))}
</div>
<div className="taskTemplatesAddItem">
<input
className="form-control"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddItem(); } }}
placeholder="Add checklist item..."
/>
<button className="btn btn-default" onClick={handleAddItem} disabled={!newItemText.trim()}>Add</button>
</div>
</div>
<div className="taskTemplatesActions">
{selectedId !== "new" && (
<button className="btn btn-default taskTemplatesDeleteBtn" onClick={handleDelete}>Delete</button>
)}
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : selectedId === "new" ? "Create Template" : "Save Changes"}
</button>
</div>
</>
)}
</div>
</div>
</section>
);
}

View file

@ -141,5 +141,13 @@ export const vendorApi = {
delete: (id) => api.delete(`/vendors/${id}`),
};
export const taskTemplateApi = {
getAll: () => api.get('/tasklisttemplates'),
getById: (id) => api.get(`/tasklisttemplates/${id}`),
create: (data) => api.post('/tasklisttemplates', data),
update: (id, data) => api.put(`/tasklisttemplates/${id}`, data),
delete: (id) => api.delete(`/tasklisttemplates/${id}`),
};
export default api;