mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 08:33:14 +00:00
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:
parent
27fb70724b
commit
4de6c54fc0
6 changed files with 502 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
src/pages/settings/SettingsNav.js
Normal file
23
src/pages/settings/SettingsNav.js
Normal 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>
|
||||
);
|
||||
}
|
||||
263
src/pages/settings/TaskTemplatesPage.css
Normal file
263
src/pages/settings/TaskTemplatesPage.css
Normal 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;
|
||||
}
|
||||
203
src/pages/settings/TaskTemplatesPage.js
Normal file
203
src/pages/settings/TaskTemplatesPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue