shoc-frontend-new/src/app/(protected)/settings/dropdowns.tsx

298 lines
9.7 KiB
TypeScript
Raw Normal View History

Feat/vite typescript migration (#16) * chore: add eslint, prettier, husky and commitlint tooling * ci: add GitHub Actions workflow for lint and build * build: migrate from CRA to Vite with TypeScript config * docs: add architecture plan and design system documentation * fix: scope ESLint to new components and hooks directories * feat: add HTTP client, query cache and shared utilities * feat: add theme system and global application styles * feat: add shared UI, layout and domain badge components * feat: add auth domain, provider and login page * feat: add app shell, file-based routing and bootstrap * feat: add protected layout and dashboard module * feat: add accounts CRUD module * feat: add assets CRUD module * feat: add contacts CRUD module * feat: add employees CRUD module * feat: add locations CRUD module * feat: add calendar events module * feat: add follow-ups CRUD module * feat: add PM schedules CRUD module * feat: add work orders module with dispatch modals * feat: add vendors CRUD and portal token panel * feat: add vendor purchase orders module * feat: add uplifts queue module * feat: add settings for dropdowns and task templates * feat: add vendor portal routes and signature capture * test: add Vitest setup and Playwright login e2e spec * chore: remove legacy CRA pages, Redux store and JS hooks * chore: update gitignore and env example for Vite * docs: translate pt-BR docs and Cursor rules to English * fix(ci): fix login e2e session mock and prettier formatting * fix(build): use mjs router script for Node 20 CI compatibility * chore: remove migration scripts and unused generouted router * fix(auth): restore JWT and align CRUD with backend routes * fix(api): add no-content helpers and align paths with backend * fix(accounts): align detail and mutation payloads with backend * fix(contacts): resolve detail via GetContacts and map address DTOs * fix(work-orders): align routes, delete body, and create payload * fix(vendors): delete vendors via REST route * fix(pm-schedules): handle empty save and delete responses * fix(employees): add fallbacks for detail and dropdown calls * chore(calendar): disable routes until backend exists * chore(env): switch tracked env vars to Vite prefixes * fix(api): align frontend contracts with backend review findings Correct Work Order getById query param, asset site options via Location API, Employee JobTitleId payload, and remove stale API paths. * fix(employees,pm-schedules): align forms with backend API contracts Align PM Schedule form and save payload with PMSchedule_DTO fields. Bind employee Job Title select to jobTitleId for create/update payloads. Add regression tests for both flows. * fix(pm-schedules): gate edit/delete for Dev API contract Production Dev API exposes only GetList and Save. Hide unsupported edit/delete UI and block the edit route. Add regression tests for disabled actions. * docs(env): document VITE_API_URL must include /api suffix * refactor(api): extract shared API prefix URL resolution * feat(build): fail build on misconfigured absolute VITE_API_URL * test(api): cover API URL contract and prefix resolution * feat(auth): disable login submit until email and password are valid * test(auth): align login tests with disabled submit behavior * Feat/ab/menu-and-header (#17) * chore(deps): add lucide-react for layout icon migration * feat(auth): add getPrimaryUserRole helper for header display * style(theme): add sidebar active tokens and nav group typography * refactor(menu): migrate nav icons to lucide and trim menu groups * feat(layout): redesign sidebar, topbar, and admin shell viewport * chore(menu): hide Reports and Documents from sidebar --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> * ci: add frontend PR quality baseline (#18) * chore(deps): add lucide-react for layout icon migration * feat(auth): add getPrimaryUserRole helper for header display * style(theme): add sidebar active tokens and nav group typography * refactor(menu): migrate nav icons to lucide and trim menu groups * feat(layout): redesign sidebar, topbar, and admin shell viewport * chore(menu): hide Reports and Documents from sidebar * ci: add PR quality baseline checks * ci: avoid self-matching standards guard * ci: split frontend quality gates --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> --------- Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-06-18 14:41:17 -03:00
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>
);
}