mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 01:52:04 +00:00
feat: add settings for dropdowns and task templates
This commit is contained in:
parent
48f1685e4d
commit
6672d09e1f
15 changed files with 1037 additions and 0 deletions
297
src/app/(protected)/settings/dropdowns.tsx
Normal file
297
src/app/(protected)/settings/dropdowns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/app/(protected)/settings/task-templates.tsx
Normal file
274
src/app/(protected)/settings/task-templates.tsx
Normal 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 "{form.name}"?
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDelete(false)}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/domain/settings/task-templates/api/task-templates-api.ts
Normal file
46
src/domain/settings/task-templates/api/task-templates-api.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
23
src/domain/settings/task-templates/types/task-template.ts
Normal file
23
src/domain/settings/task-templates/types/task-template.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue