diff --git a/src/app/(protected)/settings/dropdowns.tsx b/src/app/(protected)/settings/dropdowns.tsx new file mode 100644 index 00000000..bd379e1a --- /dev/null +++ b/src/app/(protected)/settings/dropdowns.tsx @@ -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("Trade"); + const [newValue, setNewValue] = useState(""); + const [newParent, setNewParent] = useState(""); + const [editingId, setEditingId] = useState(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 ( + + + + + + Dropdown Options + + + Manage configurable dropdown values for work orders + + + + + + {DROPDOWN_OPTION_CATEGORIES.map((category) => ( + + ))} + + + + + {parentCategory && ( + + Parent Trade + + + )} + + setNewValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAdd(); + } + }} + placeholder={addPlaceholder} + className="flex-1" + /> + + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load options"} + + )} + + {isLoading ? ( + + + + ) : options.length === 0 ? ( + + No options configured yet + + ) : ( + + {options.map((item) => ( + + {editingId === item.id ? ( + setEditValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleEditSave(item); + } + if (event.key === "Escape") { + setEditingId(null); + } + }} + autoFocus + className="flex-1" + /> + ) : ( + + + {item.value} + + {item.parentValue && ( + + )} + + )} + + + {editingId === item.id ? ( + <> + + + + ) : ( + <> + + + + )} + + + ))} + + )} + + + + ); +} diff --git a/src/app/(protected)/settings/task-templates.tsx b/src/app/(protected)/settings/task-templates.tsx new file mode 100644 index 00000000..2b45aa59 --- /dev/null +++ b/src/app/(protected)/settings/task-templates.tsx @@ -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(null); + const [form, setForm] = useState(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 ( + + + + + Task List Templates + + Create reusable checklists for work order dispatches + + + + {error && ( + + {error instanceof Error ? error.message : "Failed to load templates"} + + )} + + + + + {isLoading ? ( + + + + ) : ( + + {templates.map((template) => ( + handleSelect(template)} + > + + + ))} + + )} + + + + {!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 }))} + /> + + + + Checklist Items ({form.items.length}) + + + + {form.items.map((item, index) => ( + + + {index + 1} + + + {item.text} + + handleMoveItem(index, -1)} + > + + + handleMoveItem(index, 1)} + > + + + handleRemoveItem(index)}> + + + + ))} + + + + setNewItemText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddItem(); + } + }} + placeholder="Add checklist item..." + /> + + + + + {!isNew && ( + + )} + + + + )} + + + + setConfirmDelete(false)}> + Delete Template + + + Are you sure you want to delete "{form.name}"? + + + + + + + + + ); +} diff --git a/src/domain/settings/dropdown-options/api/dropdown-options-api.ts b/src/domain/settings/dropdown-options/api/dropdown-options-api.ts new file mode 100644 index 00000000..1dd98bf2 --- /dev/null +++ b/src/domain/settings/dropdown-options/api/dropdown-options-api.ts @@ -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 => { + const url = `${API_PATHS.dropdownOptions.byCategory}/${encodeURIComponent(category)}`; + const searchParams = parent ? { parent } : undefined; + const data = await apiGet(url, { searchParams }); + const items = handleApiResponse(data); + return mapDropdownOptions(items); + }, + + create: async (input: CreateDropdownOptionInput): Promise => { + const data = await apiPost(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 => { + const data = await apiPut(`${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 => { + await apiDelete(`${REST_RESOURCE}/${id}`); + }, +}; diff --git a/src/domain/settings/dropdown-options/mappers/dropdown-option-mapper.ts b/src/domain/settings/dropdown-options/mappers/dropdown-option-mapper.ts new file mode 100644 index 00000000..36408a1d --- /dev/null +++ b/src/domain/settings/dropdown-options/mappers/dropdown-option-mapper.ts @@ -0,0 +1,32 @@ +import type { + DropdownOption, + DropdownOptionCategory, +} from "@/domain/settings/dropdown-options/types/dropdown-option"; + +function isRecord(value: unknown): value is Record { + 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); +} diff --git a/src/domain/settings/dropdown-options/types/dropdown-option.ts b/src/domain/settings/dropdown-options/types/dropdown-option.ts new file mode 100644 index 00000000..1d3c3a3a --- /dev/null +++ b/src/domain/settings/dropdown-options/types/dropdown-option.ts @@ -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; +} diff --git a/src/domain/settings/dropdown-options/use-cases/use-create-dropdown-option.ts b/src/domain/settings/dropdown-options/use-cases/use-create-dropdown-option.ts new file mode 100644 index 00000000..dbb194d0 --- /dev/null +++ b/src/domain/settings/dropdown-options/use-cases/use-create-dropdown-option.ts @@ -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"); + }, + }); +} diff --git a/src/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option.ts b/src/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option.ts new file mode 100644 index 00000000..7b7efa51 --- /dev/null +++ b/src/domain/settings/dropdown-options/use-cases/use-delete-dropdown-option.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category.ts b/src/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category.ts new file mode 100644 index 00000000..b47720a1 --- /dev/null +++ b/src/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category.ts @@ -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 { + const { parent, enabled = true } = options ?? {}; + + return useQuery({ + queryKey: queryKeys.dropdownOptions.byCategory(category, parent), + queryFn: () => dropdownOptionsApi.getByCategory(category, parent), + enabled, + }); +} diff --git a/src/domain/settings/dropdown-options/use-cases/use-update-dropdown-option.ts b/src/domain/settings/dropdown-options/use-cases/use-update-dropdown-option.ts new file mode 100644 index 00000000..f7a41e17 --- /dev/null +++ b/src/domain/settings/dropdown-options/use-cases/use-update-dropdown-option.ts @@ -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"); + }, + }); +} diff --git a/src/domain/settings/task-templates/api/task-templates-api.ts b/src/domain/settings/task-templates/api/task-templates-api.ts new file mode 100644 index 00000000..908066b2 --- /dev/null +++ b/src/domain/settings/task-templates/api/task-templates-api.ts @@ -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 => { + const data = await apiGet(API_PATHS.rest.taskListTemplates); + const response = handleApiResponse(data); + const items = Array.isArray(response) ? response : []; + return mapTaskTemplates(items); + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.rest.taskListTemplates}/${id}`); + return mapTaskTemplate(handleApiResponse(data)); + }, + + create: async (values: TaskTemplateFormValues): Promise => { + const data = await apiPost( + API_PATHS.rest.taskListTemplates, + mapTaskTemplateToBackend(values), + ); + return mapTaskTemplate(handleApiResponse(data)); + }, + + update: async (id: string | number, values: TaskTemplateFormValues): Promise => { + const data = await apiPut( + `${API_PATHS.rest.taskListTemplates}/${id}`, + mapTaskTemplateToBackend(values), + ); + return mapTaskTemplate(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiDelete(`${API_PATHS.rest.taskListTemplates}/${id}`); + }, +}; diff --git a/src/domain/settings/task-templates/mappers/task-template-mapper.ts b/src/domain/settings/task-templates/mappers/task-template-mapper.ts new file mode 100644 index 00000000..035e69f6 --- /dev/null +++ b/src/domain/settings/task-templates/mappers/task-template-mapper.ts @@ -0,0 +1,85 @@ +import type { + TaskTemplate, + TaskTemplateFormValues, + TaskTemplateItem, +} from "@/domain/settings/task-templates/types/task-template"; + +function asRecord(raw: unknown): Record { + return typeof raw === "object" && raw !== null ? (raw as Record) : {}; +} + +function readString(record: Record, ...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 | number { + const value = record.id ?? record.Id; + if (typeof value === "string" || typeof value === "number") return value; + return ""; +} + +function readBool(record: Record, ...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 { + 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, + })), + }; +} diff --git a/src/domain/settings/task-templates/schemas/task-template-schema.ts b/src/domain/settings/task-templates/schemas/task-template-schema.ts new file mode 100644 index 00000000..07d4c344 --- /dev/null +++ b/src/domain/settings/task-templates/schemas/task-template-schema.ts @@ -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; diff --git a/src/domain/settings/task-templates/types/task-template.ts b/src/domain/settings/task-templates/types/task-template.ts new file mode 100644 index 00000000..6044559f --- /dev/null +++ b/src/domain/settings/task-templates/types/task-template.ts @@ -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[]; +} diff --git a/src/domain/settings/task-templates/use-cases/use-task-template-mutations.ts b/src/domain/settings/task-templates/use-cases/use-task-template-mutations.ts new file mode 100644 index 00000000..6d494d94 --- /dev/null +++ b/src/domain/settings/task-templates/use-cases/use-task-template-mutations.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/settings/task-templates/use-cases/use-task-templates.ts b/src/domain/settings/task-templates/use-cases/use-task-templates.ts new file mode 100644 index 00000000..33ce491b --- /dev/null +++ b/src/domain/settings/task-templates/use-cases/use-task-templates.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.taskTemplates.all, + queryFn: () => taskTemplatesApi.getAll(), + }); +}