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
+ ) : (
+ <>
+
+
+
+
+
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;