Implemented most of the Budget capability editor

Can now delete a task from the tasks list.
This commit is contained in:
Colin Dawson 2026-02-24 20:56:18 +00:00
parent 6734e9b83a
commit 083d79caaa
11 changed files with 276 additions and 15 deletions

View File

@ -0,0 +1,5 @@
{
"DoNotShow": "Do Not Show",
"ShowOnly": "Show Only",
"ShowAndEdit": "Show And Edit"
}

View File

@ -0,0 +1,5 @@
{
"Low": "Low",
"Normal": "Normal",
"High": "High"
}

View File

@ -0,0 +1,61 @@
import React, { useEffect, useState, useCallback } from "react";
import Select from "../common/Select";
import Option from "../common/option";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
interface BudgetOptionPickerProps {
name: string;
label: string;
error?: string;
value: string;
onChange?: (name: string, value: string) => void;
includeLabel?: boolean;
}
export default function BudgetOptionPicker({
name,
label,
error,
value,
onChange,
includeLabel = true,
}: BudgetOptionPickerProps) {
const [options, setOptions] = useState<Option[] | undefined>(undefined);
const { t } = useTranslation(Namespaces.BudgetOption);
useEffect(() => {
async function load() {
const opts: Option[] = [
{ _id: "DoNotShow", name: t("DoNotShow") },
{ _id: "ShowOnly", name: t("ShowOnly") },
{ _id: "ShowAndEdit", name: t("ShowAndEdit") },
];
setOptions(opts);
}
load();
}, [t]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const input = e.currentTarget;
if (onChange) onChange(input.name, input.value);
},
[onChange],
);
return (
<Select
name={name}
label={label}
error={error}
value={value}
options={options}
includeBlankFirstEntry={false}
onChange={handleChange}
includeLabel={includeLabel}
/>
);
}

View File

@ -0,0 +1,61 @@
import React, { useEffect, useState, useCallback } from "react";
import Select from "../common/Select";
import Option from "../common/option";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
interface PriorityPickerProps {
name: string;
label: string;
error?: string;
value: string;
onChange?: (name: string, value: string) => void;
includeLabel?: boolean;
}
export default function PriorityPicker({
name,
label,
error,
value,
onChange,
includeLabel = true,
}: PriorityPickerProps) {
const [options, setOptions] = useState<Option[] | undefined>(undefined);
const { t } = useTranslation(Namespaces.Priority);
useEffect(() => {
async function load() {
const opts: Option[] = [
{ _id: "Low", name: t("Low") },
{ _id: "Normal", name: t("Normal") },
{ _id: "High", name: t("High") },
];
setOptions(opts);
}
load();
}, [t]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const input = e.currentTarget;
if (onChange) onChange(input.name, input.value);
},
[onChange],
);
return (
<Select
name={name}
label={label}
error={error}
value={value}
options={options}
includeBlankFirstEntry={false}
onChange={handleChange}
includeLabel={includeLabel}
/>
);
}

View File

@ -4,13 +4,16 @@ import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import { determineInitialLocale } from "../modules/frame/services/lanugageService";
import { fallbackLng } from "./generatedLocales";
import { PrintSpecificationsGlossary } from "../modules/manager/glossary/services/glossaryService";
export const Namespaces = {
BudgetOption: "budgetOption",
Common: "common",
MailTypes: "mailTypes",
HtmlIsland: "htmlIsland",
TaskTypes: "taskTypes",
MailTypes: "mailTypes",
Priority: "priority",
Raci: "raci",
TaskTypes: "taskTypes",
Verdict: "verdict",
} as const;

View File

@ -0,0 +1,87 @@
import { InputType } from "../../../../../components/common/Input";
import BudgetOptionPicker from "../../../../../components/pickers/BudgetOptionPicker";
import PriorityPicker from "../../../../../components/pickers/PriorityPicker";
import { TaskDefinition } from "../../services/WorkflowTemplateService";
import { renderTaskField } from "../taskEditorHelpers";
import {
CapabilityEditorProps,
capabilityEditorRegistryEntry,
defaultsContext,
} from "../useCapabilityDefaults";
export const BudgetEditor: React.FC<CapabilityEditorProps> = (props) => {
const { task, onChange, fieldErrors } = props;
function updateField(fieldname: string, val: string) {
const clone = structuredClone(task);
clone.config[fieldname] = val;
onChange(clone);
}
return (
<>
{renderTaskField(
task,
onChange,
"unitsOfWork",
"Units Of Work",
InputType.number,
fieldErrors,
)}
<div>Cost goes here</div>
<BudgetOptionPicker
includeLabel={true}
name="budgetOption"
label="Budget Option"
value={task.config.budgetOption as string}
error={fieldErrors["budgetOption"]}
onChange={(name: string, val: string) =>
updateField("budgetOption", val)
}
/>
{renderTaskField(
task,
onChange,
"allowTimeTracking",
"Allow Time Tracking",
InputType.checkbox,
fieldErrors,
)}
<PriorityPicker
includeLabel={true}
name="priority"
label="Priority"
value={task.config.priority as string}
error={fieldErrors["priority"]}
onChange={(name: string, val: string) => updateField("priority", val)}
/>
</>
);
};
const runValidation = (
task: TaskDefinition,
tasks: TaskDefinition[],
): Record<string, string> => {
const errors: Record<string, string> = {};
return errors;
};
export function defaultsAssignment(
task: TaskDefinition,
tasks: TaskDefinition[],
ctx: defaultsContext,
) {
task.config.unitsOfWork = 1;
task.config.cost = { amount: 0, isoCurrencySymbol: "GBP" }; //todo remove this hard coded currency and make it user defined at some point
task.config.budgetOption = "DoNotShow";
task.config.allowTimeTracking = false;
task.config.priority = "Normal";
}
export const budgetEditorRegistryEntry: capabilityEditorRegistryEntry = {
Editor: BudgetEditor,
DefaultsAssignment: defaultsAssignment,
ValidationRunner: runValidation,
};

View File

@ -198,6 +198,7 @@ export function defaultsAssignment(
tasks: TaskDefinition[],
ctx: defaultsContext,
) {
task.config.outcomeActions = [] as IOutcomeAction[];
task.config.overrideDefaultTaskProgression = true;
}

View File

@ -5,8 +5,6 @@ import {
} from "../services/WorkflowTemplateService";
import AddTaskButton from "./AddTaskButton";
import { SelectableList } from "../../../../components/common/SelectableList";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { sortTasksTopologically } from "./workflowGraphUtils";
import { useCapabilityDefaults, validateTask } from "./useCapabilityDefaults";
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
@ -67,17 +65,23 @@ const TaskList: React.FC<TaskListProps> = ({
<SelectableList
items={sortedTasks}
selectedValue={selectedTask}
renderLabel={(x) => (
<>
{x.config.name as string}
renderLabel={(x) => {
if (x) {
return (
<>
{x.config.name as string}
{
<ValidationErrorIcon
visible={validTasksList[x.config.guid as string] === false}
/>
}
</>
)}
{
<ValidationErrorIcon
visible={
validTasksList[x.config.guid as string] === false
}
/>
}
</>
);
} else return <></>;
}}
onSelect={(item) => onSelectTask(item)}
/>
</div>

View File

@ -5,6 +5,8 @@ import {
} from "../services/WorkflowTemplateService";
import { capabilityEditorRegistry } from "./capabilityEditorRegistry";
import { validateTask } from "./useCapabilityDefaults";
import Button, { ButtonType } from "../../../../components/common/Button";
import ConfirmButton from "../../../../components/common/ConfirmButton";
interface TaskEditorProps {
task: TaskDefinition;
@ -12,6 +14,7 @@ interface TaskEditorProps {
tasksMetadata: TaskMetadata[];
onChange: (updatedTask: TaskDefinition) => void;
onValidate: (taskId: string, isValid: boolean) => void;
onDelete: (taskId: string) => void;
}
const TaskEditorComponent: React.FC<TaskEditorProps> = ({
@ -20,6 +23,7 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
tasksMetadata,
onChange,
onValidate,
onDelete,
}) => {
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>(
{},
@ -91,6 +95,12 @@ const TaskEditorComponent: React.FC<TaskEditorProps> = ({
/>
);
})}
<ConfirmButton
buttonType={ButtonType.primary}
onClick={() => onDelete(task.config.guid as string)}
>
Delete {task.config.name || "Task"}
</ConfirmButton>
</div>
);
};
@ -109,7 +119,8 @@ export const TaskEditor = React.memo(
taskIdChanged ||
metaChanged ||
onChangeChanged ||
onValidateChanged
onValidateChanged ||
prevProps.onDelete !== nextProps.onDelete
);
},
);

View File

@ -86,6 +86,26 @@ const TasksTab: React.FC<TasksTabProps> = ({
[tasks, handleTasksChange],
);
const handleTaskDelete = React.useCallback(
(taskId: string) => {
const newTasks = tasks.filter((t) => t.config.guid !== taskId);
for (const t of newTasks) {
// If any task has a dependency on the deleted task, remove that dependency
if (t.config.predecessors) {
t.config.predecessors = (t.config.predecessors as string[]).filter(
(d) => d !== taskId,
);
}
}
onValidate(taskId, true); // Clear validation state for deleted task
setSelectedTask(null);
handleTasksChange(newTasks);
},
[tasks, handleTasksChange],
);
return (
<div className="two-column-grid no-scroll">
<div className="fit-content-width`">
@ -107,6 +127,7 @@ const TasksTab: React.FC<TasksTabProps> = ({
tasks={tasks}
onChange={handleTaskEditorChange}
onValidate={onValidate}
onDelete={handleTaskDelete}
/>
</div>
)}

View File

@ -3,6 +3,7 @@ import { assigneesOfITaskAssigneeRegistryEntry } from "./CapabilityEditors/Assig
import { taskCoreEditorRegistryEntry } from "./CapabilityEditors/TaskCoreEditor";
import { capabilityEditorRegistryEntry } from "./useCapabilityDefaults";
import { outcomeOfApprovalVerdictRegistryEntry } from "./CapabilityEditors/OutcomeOfApprovalVerdictRegistryEntry";
import { budgetEditorRegistryEntry } from "./CapabilityEditors/BudgetEditorRegistryEntry";
export const capabilityEditorRegistry: Record<
string,
@ -10,6 +11,7 @@ export const capabilityEditorRegistry: Record<
> = {
ITask: taskCoreEditorRegistryEntry,
ITags: tagsEditorRegistryEntry,
IBudget: budgetEditorRegistryEntry,
"IAssignees<ITaskAssignee>": assigneesOfITaskAssigneeRegistryEntry,
"IOutcome<ApprovalVerdict>": outcomeOfApprovalVerdictRegistryEntry,
};