diff --git a/src/App.js b/src/App.js index 65961e6e..ccb45c25 100644 --- a/src/App.js +++ b/src/App.js @@ -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 */} } /> + } /> diff --git a/src/pages/settings/DropdownOptionsPage.js b/src/pages/settings/DropdownOptionsPage.js index 135daf2b..219e7eee 100644 --- a/src/pages/settings/DropdownOptionsPage.js +++ b/src/pages/settings/DropdownOptionsPage.js @@ -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 (
+

Dropdown Options

Manage configurable dropdown values for work orders

diff --git a/src/pages/settings/SettingsNav.js b/src/pages/settings/SettingsNav.js new file mode 100644 index 00000000..4df76995 --- /dev/null +++ b/src/pages/settings/SettingsNav.js @@ -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 ( +
+ {links.map((link) => ( + `settingsNavLink ${isActive ? "active" : ""}`} + > + {link.label} + + ))} +
+ ); +} diff --git a/src/pages/settings/TaskTemplatesPage.css b/src/pages/settings/TaskTemplatesPage.css new file mode 100644 index 00000000..c0d9b465 --- /dev/null +++ b/src/pages/settings/TaskTemplatesPage.css @@ -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; +} diff --git a/src/pages/settings/TaskTemplatesPage.js b/src/pages/settings/TaskTemplatesPage.js new file mode 100644 index 00000000..db36e45a --- /dev/null +++ b/src/pages/settings/TaskTemplatesPage.js @@ -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 ( +
+ +
+

Task List Templates

+

Create reusable checklists for work order dispatches

+
+ +
+
+ + {loading ?
Loading...
: templates.map((t) => ( +
handleSelect(t)} + > + {t.name} + {t.items?.length || 0} items +
+ ))} +
+ +
+ {!selectedId ? ( +
Select a template or create a new one
+ ) : ( + <> +
+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g., HVAC Inspection" + /> +
+
+ + setForm((f) => ({ ...f, description: e.target.value }))} + placeholder="Optional description" + /> +
+
+ +
+

Checklist Items ({form.items.length})

+
+ +
+ {form.items.map((item, idx) => ( +
+ {idx + 1} + {item.text} +
+ + + +
+
+ ))} +
+ +
+ setNewItemText(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddItem(); } }} + placeholder="Add checklist item..." + /> + +
+
+ +
+ {selectedId !== "new" && ( + + )} + +
+ + )} +
+
+
+ ); +} diff --git a/src/services/api.js b/src/services/api.js index 60f8e764..62187361 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -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;