feat: add settings for dropdowns and task templates

This commit is contained in:
Arthur Bassi 2026-06-12 11:56:32 -03:00
parent 48f1685e4d
commit 6672d09e1f
15 changed files with 1037 additions and 0 deletions

View file

@ -0,0 +1,297 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import { SettingsNav } from "@/components/common/settings-nav";
import {
DROPDOWN_OPTION_CATEGORIES,
type DropdownOption,
type DropdownOptionCategory,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
import { useCreateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-create-dropdown-option";
import { useDeleteDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { useUpdateDropdownOption } from "@/domain/settings/dropdown-options/use-cases/use-update-dropdown-option";
export default function DropdownOptionsPage() {
const [activeCategory, setActiveCategory] = useState<DropdownOptionCategory>("Trade");
const [newValue, setNewValue] = useState("");
const [newParent, setNewParent] = useState("");
const [editingId, setEditingId] = useState<string | number | null>(null);
const [editValue, setEditValue] = useState("");
const categoryConfig = useMemo(
() => DROPDOWN_OPTION_CATEGORIES.find((category) => category.key === activeCategory),
[activeCategory],
);
const parentCategory =
categoryConfig && "parent" in categoryConfig ? categoryConfig.parent : undefined;
const { data: options = [], isLoading, error } = useDropdownOptionsByCategory(activeCategory);
const { data: parentOptions = [] } = useDropdownOptionsByCategory("Trade", {
enabled: Boolean(parentCategory),
});
const createMutation = useCreateDropdownOption();
const updateMutation = useUpdateDropdownOption();
const deleteMutation = useDeleteDropdownOption();
const resetFormState = () => {
setNewValue("");
setNewParent("");
setEditingId(null);
setEditValue("");
};
const handleCategoryChange = (category: DropdownOptionCategory) => {
if (category !== activeCategory) {
resetFormState();
}
setActiveCategory(category);
};
const handleAdd = () => {
const value = newValue.trim();
if (!value) {
return;
}
createMutation.mutate(
{
category: activeCategory,
value,
parentValue: parentCategory ? newParent || null : null,
sortOrder: options.length,
isActive: true,
},
{
onSuccess: () => {
setNewValue("");
setNewParent("");
},
},
);
};
const handleEditSave = (item: DropdownOption) => {
const value = editValue.trim();
if (!value) {
return;
}
updateMutation.mutate(
{
id: item.id,
category: item.category,
value,
parentValue: item.parentValue,
sortOrder: item.sortOrder,
isActive: item.isActive,
},
{
onSuccess: () => {
setEditingId(null);
},
},
);
};
const handleDelete = (id: string | number) => {
deleteMutation.mutate(id);
};
const addPlaceholder = `Add new ${categoryConfig?.label.slice(0, -1) ?? "option"}...`;
const isMutating =
createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
return (
<Box className="flex flex-col gap-4 p-4">
<SettingsNav />
<Stack spacing={0.5}>
<Typography variant="h5" component="h1">
Dropdown Options
</Typography>
<Typography variant="body2" color="text.secondary">
Manage configurable dropdown values for work orders
</Typography>
</Stack>
<Paper variant="outlined" className="flex min-h-[400px] overflow-hidden">
<Stack className="w-[180px] shrink-0 border-r border-border bg-muted/30" spacing={0}>
{DROPDOWN_OPTION_CATEGORIES.map((category) => (
<Button
key={category.key}
onClick={() => handleCategoryChange(category.key)}
className={`justify-start rounded-none border-b border-border px-4 py-3 text-left normal-case ${
activeCategory === category.key
? "border-r-2 border-r-primary bg-background font-semibold text-primary"
: "font-medium text-muted-foreground hover:bg-muted/50"
}`}
>
{category.label}
</Button>
))}
</Stack>
<Box className="flex flex-1 flex-col gap-4 p-4">
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="stretch">
{parentCategory && (
<FormControl size="small" className="min-w-[160px]">
<InputLabel id="parent-trade-label">Parent Trade</InputLabel>
<Select
labelId="parent-trade-label"
label="Parent Trade"
value={newParent}
onChange={(event) => setNewParent(event.target.value)}
>
<MenuItem value="">Parent: All</MenuItem>
{parentOptions.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField
size="small"
value={newValue}
onChange={(event) => setNewValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAdd();
}
}}
placeholder={addPlaceholder}
className="flex-1"
/>
<Button
variant="contained"
onClick={handleAdd}
disabled={!newValue.trim() || isMutating}
className="shrink-0"
>
Add
</Button>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load options"}
</Alert>
)}
{isLoading ? (
<Stack alignItems="center" className="py-10">
<CircularProgress size={28} />
</Stack>
) : options.length === 0 ? (
<Typography align="center" color="text.secondary" className="py-10">
No options configured yet
</Typography>
) : (
<Stack spacing={1}>
{options.map((item) => (
<Paper
key={String(item.id)}
variant="outlined"
className="flex items-center justify-between gap-3 px-3 py-2"
>
{editingId === item.id ? (
<TextField
size="small"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleEditSave(item);
}
if (event.key === "Escape") {
setEditingId(null);
}
}}
autoFocus
className="flex-1"
/>
) : (
<Stack direction="row" spacing={1} alignItems="center" className="min-w-0">
<Typography variant="body2" className="truncate">
{item.value}
</Typography>
{item.parentValue && (
<Chip size="small" label={item.parentValue} variant="outlined" />
)}
</Stack>
)}
<Stack direction="row" spacing={1} className="shrink-0">
{editingId === item.id ? (
<>
<Button
size="small"
variant="contained"
color="success"
onClick={() => handleEditSave(item)}
disabled={isMutating}
>
Save
</Button>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => setEditingId(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button
size="small"
variant="outlined"
onClick={() => {
setEditingId(item.id);
setEditValue(item.value);
}}
>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleDelete(item.id)}
disabled={isMutating}
>
Delete
</Button>
</>
)}
</Stack>
</Paper>
))}
</Stack>
)}
</Box>
</Paper>
</Box>
);
}

View file

@ -0,0 +1,274 @@
import { useState } from "react";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItemButton,
ListItemText,
Paper,
Stack,
TextField,
Typography,
} from "@mui/material";
import { SettingsNav } from "@/components/common/settings-nav";
import { mapTaskTemplateToFormValues } from "@/domain/settings/task-templates/mappers/task-template-mapper";
import type {
TaskTemplate,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
import {
useCreateTaskTemplate,
useDeleteTaskTemplate,
useUpdateTaskTemplate,
} from "@/domain/settings/task-templates/use-cases/use-task-template-mutations";
import { useTaskTemplates } from "@/domain/settings/task-templates/use-cases/use-task-templates";
const emptyForm: TaskTemplateFormValues = { name: "", description: "", items: [] };
export default function TaskTemplatesPage() {
const { data: templates = [], isLoading, error } = useTaskTemplates();
const createTemplate = useCreateTaskTemplate();
const updateTemplate = useUpdateTaskTemplate();
const deleteTemplate = useDeleteTaskTemplate();
const [selectedId, setSelectedId] = useState<string | number | "new" | null>(null);
const [form, setForm] = useState<TaskTemplateFormValues>(emptyForm);
const [newItemText, setNewItemText] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
const isNew = selectedId === "new";
const isSaving = createTemplate.isPending || updateTemplate.isPending;
const handleSelect = (template: TaskTemplate) => {
setSelectedId(template.id);
setForm(mapTaskTemplateToFormValues(template));
setNewItemText("");
};
const handleNew = () => {
setSelectedId("new");
setForm(emptyForm);
setNewItemText("");
};
const handleAddItem = () => {
if (!newItemText.trim()) return;
setForm((prev) => ({
...prev,
items: [...prev.items, { text: newItemText.trim(), sortOrder: prev.items.length }],
}));
setNewItemText("");
};
const handleRemoveItem = (index: number) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })),
}));
};
const handleMoveItem = (index: number, direction: -1 | 1) => {
setForm((prev) => {
const items = [...prev.items];
const target = index + direction;
if (target < 0 || target >= items.length) return prev;
[items[index], items[target]] = [items[target], items[index]];
return { ...prev, items: items.map((item, i) => ({ ...item, sortOrder: i })) };
});
};
const handleSave = () => {
if (!form.name.trim()) return;
if (isNew) {
createTemplate.mutate(form, {
onSuccess: (result) => setSelectedId(result.id),
});
return;
}
if (selectedId && selectedId !== "new") {
updateTemplate.mutate({ id: selectedId, values: form });
}
};
const handleDelete = () => {
if (!selectedId || selectedId === "new") return;
deleteTemplate.mutate(selectedId, {
onSuccess: () => {
setSelectedId(null);
setForm(emptyForm);
setConfirmDelete(false);
},
});
};
return (
<Box className="p-4">
<SettingsNav />
<Stack spacing={1} className="mb-4">
<Typography variant="h5">Task List Templates</Typography>
<Typography variant="body2" color="text.secondary">
Create reusable checklists for work order dispatches
</Typography>
</Stack>
{error && (
<Alert severity="error" className="mb-4">
{error instanceof Error ? error.message : "Failed to load templates"}
</Alert>
)}
<Box className="grid grid-cols-1 gap-4 md:grid-cols-[280px_1fr]">
<Paper variant="outlined" className="p-3">
<Button fullWidth variant="contained" className="mb-3" onClick={handleNew}>
+ New Template
</Button>
{isLoading ? (
<Box className="flex justify-center p-4">
<CircularProgress size={24} />
</Box>
) : (
<List dense disablePadding>
{templates.map((template) => (
<ListItemButton
key={String(template.id)}
selected={selectedId === template.id}
onClick={() => handleSelect(template)}
>
<ListItemText
primary={template.name}
secondary={`${template.items.length} items`}
/>
</ListItemButton>
))}
</List>
)}
</Paper>
<Paper variant="outlined" className="p-4">
{!selectedId ? (
<Typography color="text.secondary" className="py-10 text-center">
Select a template or create a new one
</Typography>
) : (
<Stack spacing={3}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Template Name"
fullWidth
required
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g., HVAC Inspection"
/>
<TextField
label="Description"
fullWidth
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</Stack>
<Typography variant="subtitle2" className="font-semibold">
Checklist Items ({form.items.length})
</Typography>
<Stack spacing={1}>
{form.items.map((item, index) => (
<Box
key={`${item.sortOrder}-${index}`}
className="flex items-center gap-2 rounded border border-border p-2"
>
<Typography variant="body2" className="w-6 text-muted-foreground">
{index + 1}
</Typography>
<Typography variant="body2" className="flex-1">
{item.text}
</Typography>
<IconButton
size="small"
disabled={index === 0}
onClick={() => handleMoveItem(index, -1)}
>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
disabled={index === form.items.length - 1}
onClick={() => handleMoveItem(index, 1)}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => handleRemoveItem(index)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Box>
))}
</Stack>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
size="small"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddItem();
}
}}
placeholder="Add checklist item..."
/>
<Button variant="outlined" onClick={handleAddItem} disabled={!newItemText.trim()}>
Add
</Button>
</Stack>
<Stack direction="row" spacing={2} justifyContent="flex-end">
{!isNew && (
<Button variant="outlined" color="error" onClick={() => setConfirmDelete(true)}>
Delete
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? "Saving..." : isNew ? "Create Template" : "Save Changes"}
</Button>
</Stack>
</Stack>
)}
</Paper>
</Box>
<Dialog open={confirmDelete} onClose={() => setConfirmDelete(false)}>
<DialogTitle>Delete Template</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{form.name}&quot;?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
<Button color="error" variant="contained" onClick={handleDelete}>
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -0,0 +1,55 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
mapDropdownOption,
mapDropdownOptions,
} from "@/domain/settings/dropdown-options/mappers/dropdown-option-mapper";
import type {
CreateDropdownOptionInput,
DropdownOption,
DropdownOptionCategory,
UpdateDropdownOptionInput,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
const REST_RESOURCE = API_PATHS.dropdownOptions.delete;
export const dropdownOptionsApi = {
getByCategory: async (
category: DropdownOptionCategory,
parent?: string,
): Promise<DropdownOption[]> => {
const url = `${API_PATHS.dropdownOptions.byCategory}/${encodeURIComponent(category)}`;
const searchParams = parent ? { parent } : undefined;
const data = await apiGet<unknown>(url, { searchParams });
const items = handleApiResponse<unknown[]>(data);
return mapDropdownOptions(items);
},
create: async (input: CreateDropdownOptionInput): Promise<DropdownOption> => {
const data = await apiPost<unknown>(REST_RESOURCE, {
category: input.category,
value: input.value,
parentValue: input.parentValue ?? null,
sortOrder: input.sortOrder ?? 0,
isActive: input.isActive ?? true,
});
return mapDropdownOption(handleApiResponse(data));
},
update: async (input: UpdateDropdownOptionInput): Promise<DropdownOption> => {
const data = await apiPut<unknown>(`${REST_RESOURCE}/${input.id}`, {
id: input.id,
category: input.category,
value: input.value,
parentValue: input.parentValue,
sortOrder: input.sortOrder,
isActive: input.isActive,
});
return mapDropdownOption(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${REST_RESOURCE}/${id}`);
},
};

View file

@ -0,0 +1,32 @@
import type {
DropdownOption,
DropdownOptionCategory,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function mapDropdownOption(raw: unknown): DropdownOption {
const item = isRecord(raw) ? raw : {};
const id = item.id ?? item.Id ?? "";
return {
id: typeof id === "string" || typeof id === "number" ? id : "",
category: String(item.category ?? item.Category ?? "") as DropdownOptionCategory,
value: String(item.value ?? item.Value ?? ""),
parentValue:
(typeof item.parentValue === "string" ? item.parentValue : null) ??
(typeof item.ParentValue === "string" ? item.ParentValue : null),
sortOrder: Number(item.sortOrder ?? item.SortOrder ?? 0),
isActive: Boolean(item.isActive ?? item.IsActive ?? true),
};
}
export function mapDropdownOptions(raw: unknown): DropdownOption[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.map(mapDropdownOption);
}

View file

@ -0,0 +1,33 @@
export const DROPDOWN_OPTION_CATEGORIES = [
{ key: "Trade", label: "Trades", hasChildren: true },
{ key: "SubTrade", label: "Sub-Trades", parent: "Trade" as const },
{ key: "Problem", label: "Problems", hasChildren: false },
] as const;
export type DropdownOptionCategory = (typeof DROPDOWN_OPTION_CATEGORIES)[number]["key"];
export interface DropdownOption {
id: string | number;
category: DropdownOptionCategory;
value: string;
parentValue: string | null;
sortOrder: number;
isActive: boolean;
}
export interface CreateDropdownOptionInput {
category: DropdownOptionCategory;
value: string;
parentValue?: string | null;
sortOrder?: number;
isActive?: boolean;
}
export interface UpdateDropdownOptionInput {
id: string | number;
category: DropdownOptionCategory;
value: string;
parentValue: string | null;
sortOrder: number;
isActive: boolean;
}

View file

@ -0,0 +1,27 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { dropdownOptionsApi } from "@/domain/settings/dropdown-options/api/dropdown-options-api";
import type {
CreateDropdownOptionInput,
DropdownOption,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateDropdownOption(): UseMutationResult<
DropdownOption,
Error,
CreateDropdownOptionInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateDropdownOptionInput) => dropdownOptionsApi.create(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.dropdownOptions.all });
toast.success("Option added");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to add option");
},
});
}

View file

@ -0,0 +1,19 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { dropdownOptionsApi } from "@/domain/settings/dropdown-options/api/dropdown-options-api";
import { queryKeys } from "@/infra/query-key/query-key";
export function useDeleteDropdownOption(): UseMutationResult<void, Error, string | number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => dropdownOptionsApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.dropdownOptions.all });
toast.success("Option deleted");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete");
},
});
}

View file

@ -0,0 +1,25 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { dropdownOptionsApi } from "@/domain/settings/dropdown-options/api/dropdown-options-api";
import type {
DropdownOption,
DropdownOptionCategory,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
import { queryKeys } from "@/infra/query-key/query-key";
interface UseDropdownOptionsByCategoryOptions {
parent?: string;
enabled?: boolean;
}
export function useDropdownOptionsByCategory(
category: DropdownOptionCategory,
options?: UseDropdownOptionsByCategoryOptions,
): UseQueryResult<DropdownOption[], Error> {
const { parent, enabled = true } = options ?? {};
return useQuery({
queryKey: queryKeys.dropdownOptions.byCategory(category, parent),
queryFn: () => dropdownOptionsApi.getByCategory(category, parent),
enabled,
});
}

View file

@ -0,0 +1,27 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { dropdownOptionsApi } from "@/domain/settings/dropdown-options/api/dropdown-options-api";
import type {
DropdownOption,
UpdateDropdownOptionInput,
} from "@/domain/settings/dropdown-options/types/dropdown-option";
import { queryKeys } from "@/infra/query-key/query-key";
export function useUpdateDropdownOption(): UseMutationResult<
DropdownOption,
Error,
UpdateDropdownOptionInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateDropdownOptionInput) => dropdownOptionsApi.update(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.dropdownOptions.all });
toast.success("Option updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update");
},
});
}

View file

@ -0,0 +1,46 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
mapTaskTemplate,
mapTaskTemplates,
mapTaskTemplateToBackend,
} from "@/domain/settings/task-templates/mappers/task-template-mapper";
import type {
TaskTemplate,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
export const taskTemplatesApi = {
getAll: async (): Promise<TaskTemplate[]> => {
const data = await apiGet<unknown>(API_PATHS.rest.taskListTemplates);
const response = handleApiResponse<unknown>(data);
const items = Array.isArray(response) ? response : [];
return mapTaskTemplates(items);
},
getById: async (id: string | number): Promise<TaskTemplate> => {
const data = await apiGet<unknown>(`${API_PATHS.rest.taskListTemplates}/${id}`);
return mapTaskTemplate(handleApiResponse(data));
},
create: async (values: TaskTemplateFormValues): Promise<TaskTemplate> => {
const data = await apiPost<unknown>(
API_PATHS.rest.taskListTemplates,
mapTaskTemplateToBackend(values),
);
return mapTaskTemplate(handleApiResponse(data));
},
update: async (id: string | number, values: TaskTemplateFormValues): Promise<TaskTemplate> => {
const data = await apiPut<unknown>(
`${API_PATHS.rest.taskListTemplates}/${id}`,
mapTaskTemplateToBackend(values),
);
return mapTaskTemplate(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.rest.taskListTemplates}/${id}`);
},
};

View file

@ -0,0 +1,85 @@
import type {
TaskTemplate,
TaskTemplateFormValues,
TaskTemplateItem,
} from "@/domain/settings/task-templates/types/task-template";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
}
return "";
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") return value;
return "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") return value;
}
return true;
}
function mapItem(raw: unknown, index: number): TaskTemplateItem {
const item = asRecord(raw);
return {
itemText: readString(item, "itemText", "ItemText", "text"),
sortOrder:
(typeof item.sortOrder === "number" ? item.sortOrder : undefined) ??
(typeof item.SortOrder === "number" ? item.SortOrder : undefined) ??
index,
};
}
export function mapTaskTemplate(raw: unknown): TaskTemplate {
const item = asRecord(raw);
const items = (item.items ?? item.Items) as unknown[];
return {
id: readId(item),
name: readString(item, "name", "Name"),
description: readString(item, "description", "Description"),
isActive: readBool(item, "isActive", "IsActive"),
items: Array.isArray(items) ? items.map(mapItem) : [],
};
}
export function mapTaskTemplates(rawItems: unknown[]): TaskTemplate[] {
return rawItems.map(mapTaskTemplate);
}
export function mapTaskTemplateToBackend(
values: TaskTemplateFormValues,
isActive = true,
): Record<string, unknown> {
return {
name: values.name,
description: values.description,
isActive,
items: values.items.map((item) => ({
itemText: item.text,
sortOrder: item.sortOrder,
})),
};
}
export function mapTaskTemplateToFormValues(template: TaskTemplate): TaskTemplateFormValues {
return {
name: template.name,
description: template.description,
items: template.items.map((item, index) => ({
text: item.itemText,
sortOrder: item.sortOrder ?? index,
})),
};
}

View file

@ -0,0 +1,14 @@
import { z } from "zod";
const taskTemplateItemSchema = z.object({
text: z.string().min(1),
sortOrder: z.number(),
});
export const taskTemplateSchema = z.object({
name: z.string().min(1, "Template name is required"),
description: z.string().optional().default(""),
items: z.array(taskTemplateItemSchema).default([]),
});
export type TaskTemplateSchemaValues = z.infer<typeof taskTemplateSchema>;

View file

@ -0,0 +1,23 @@
export interface TaskTemplateItem {
itemText: string;
sortOrder: number;
}
export interface TaskTemplate {
id: string | number;
name: string;
description: string;
isActive: boolean;
items: TaskTemplateItem[];
}
export interface TaskTemplateFormItem {
text: string;
sortOrder: number;
}
export interface TaskTemplateFormValues {
name: string;
description: string;
items: TaskTemplateFormItem[];
}

View file

@ -0,0 +1,69 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { taskTemplatesApi } from "@/domain/settings/task-templates/api/task-templates-api";
import type {
TaskTemplate,
TaskTemplateFormValues,
} from "@/domain/settings/task-templates/types/task-template";
import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateTaskTemplateInput {
id: string | number;
values: TaskTemplateFormValues;
}
export function useCreateTaskTemplate(): UseMutationResult<
TaskTemplate,
Error,
TaskTemplateFormValues
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: TaskTemplateFormValues) => taskTemplatesApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.taskTemplates.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.taskTemplates() });
toast.success("Template created");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create template");
},
});
}
export function useUpdateTaskTemplate(): UseMutationResult<
TaskTemplate,
Error,
UpdateTaskTemplateInput
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, values }: UpdateTaskTemplateInput) => taskTemplatesApi.update(id, values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.taskTemplates.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.taskTemplates() });
toast.success("Template updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update template");
},
});
}
export function useDeleteTaskTemplate(): UseMutationResult<void, Error, string | number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => taskTemplatesApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.taskTemplates.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.taskTemplates() });
toast.success("Template deleted");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete template");
},
});
}

View file

@ -0,0 +1,11 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { taskTemplatesApi } from "@/domain/settings/task-templates/api/task-templates-api";
import type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
import { queryKeys } from "@/infra/query-key/query-key";
export function useTaskTemplates(): UseQueryResult<TaskTemplate[], Error> {
return useQuery({
queryKey: queryKeys.taskTemplates.all,
queryFn: () => taskTemplatesApi.getAll(),
});
}