Added the approval IOutcome<verdict> capability to the codebase.
This commit is contained in:
parent
5a76155e8d
commit
0426099a59
8
public/locales/bg/verdict.json
Normal file
8
public/locales/bg/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Утврђено",
|
||||
"ApprovedWithComments": "Усвојено уз коментаре",
|
||||
"None": "Ниједан.",
|
||||
"Pending": "У очекивању",
|
||||
"Rejected": "Одбијено",
|
||||
"Reviewed": "Прегледано."
|
||||
}
|
||||
8
public/locales/cs/verdict.json
Normal file
8
public/locales/cs/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Schváleno",
|
||||
"ApprovedWithComments": "Schváleno s poznámkami",
|
||||
"None": "",
|
||||
"Pending": "Čeká se na další kroky.",
|
||||
"Rejected": "Odmítnuto.",
|
||||
"Reviewed": "Přezkoumáno"
|
||||
}
|
||||
8
public/locales/de/verdict.json
Normal file
8
public/locales/de/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Genehmigt",
|
||||
"ApprovedWithComments": "Genehmigt mit Anmerkungen",
|
||||
"None": "Nichts.",
|
||||
"Pending": "Wird noch bearbeitet.",
|
||||
"Rejected": "Abgelehnt",
|
||||
"Reviewed": "Überprüft."
|
||||
}
|
||||
8
public/locales/en/verdict.json
Normal file
8
public/locales/en/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Approved",
|
||||
"ApprovedWithComments": "Approved with Comments",
|
||||
"None": "None",
|
||||
"Pending": "Pending",
|
||||
"Rejected": "Rejected",
|
||||
"Reviewed": "Reviewed"
|
||||
}
|
||||
8
public/locales/es/verdict.json
Normal file
8
public/locales/es/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Aprobado",
|
||||
"ApprovedWithComments": "Aprobado con comentarios.",
|
||||
"None": "Ninguno",
|
||||
"Pending": "Pendiente",
|
||||
"Rejected": "Rechazado",
|
||||
"Reviewed": "Revisado"
|
||||
}
|
||||
8
public/locales/fr/verdict.json
Normal file
8
public/locales/fr/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Approuvé",
|
||||
"ApprovedWithComments": "Approuvé avec des observations.",
|
||||
"None": "Aucun",
|
||||
"Pending": "En attente",
|
||||
"Rejected": "Rejeté",
|
||||
"Reviewed": "Revisé"
|
||||
}
|
||||
8
public/locales/ko/verdict.json
Normal file
8
public/locales/ko/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "승인됨",
|
||||
"ApprovedWithComments": "의견을 반영하여 승인되었습니다.",
|
||||
"None": "",
|
||||
"Pending": "대기 중",
|
||||
"Rejected": "거절당했습니다.",
|
||||
"Reviewed": "리뷰 완료"
|
||||
}
|
||||
8
public/locales/nl/verdict.json
Normal file
8
public/locales/nl/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Goedkeurd",
|
||||
"ApprovedWithComments": "Goedkeurd met opmerkingen",
|
||||
"None": "Niet van toepassing.",
|
||||
"Pending": "In afwachting",
|
||||
"Rejected": "Afgekeurd",
|
||||
"Reviewed": "Beoordeeld."
|
||||
}
|
||||
8
public/locales/pl/verdict.json
Normal file
8
public/locales/pl/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Zatwierdzone",
|
||||
"ApprovedWithComments": "Zatwierdzone z komentarzami.",
|
||||
"None": "Nie ma nic.",
|
||||
"Pending": "W oczekiwaniu.",
|
||||
"Rejected": "Odrzucono.",
|
||||
"Reviewed": "Przegłoszono."
|
||||
}
|
||||
8
public/locales/pt/verdict.json
Normal file
8
public/locales/pt/verdict.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Approved": "Aprovado",
|
||||
"ApprovedWithComments": "Aprovado com comentários.",
|
||||
"None": "Nenhum.",
|
||||
"Pending": "A aguardar",
|
||||
"Rejected": "Rejeitado",
|
||||
"Reviewed": "Revisado"
|
||||
}
|
||||
7
public/locales/uk/verdict.json
Normal file
7
public/locales/uk/verdict.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"Approved": "Схвалено",
|
||||
"ApprovedWithComments": "Схвалено з коментарями",
|
||||
"Pending": "У підготовці.",
|
||||
"Rejected": "Відхилено",
|
||||
"Reviewed": "Оглянуто."
|
||||
}
|
||||
64
src/components/pickers/VerdictPicker.tsx
Normal file
64
src/components/pickers/VerdictPicker.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
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 VerdictPickerProps {
|
||||
name: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
value: string;
|
||||
onChange?: (name: string, value: string) => void;
|
||||
includeLabel?: boolean;
|
||||
}
|
||||
export default function VerdictPicker({
|
||||
name,
|
||||
label,
|
||||
error,
|
||||
value,
|
||||
onChange,
|
||||
includeLabel = true,
|
||||
}: VerdictPickerProps) {
|
||||
const [options, setOptions] = useState<Option[] | undefined>(undefined);
|
||||
const { t } = useTranslation(Namespaces.Verdict);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const opts: Option[] = [
|
||||
{ _id: "None", name: t("None") },
|
||||
{ _id: "Pending", name: t("Pending") },
|
||||
{ _id: "ApprovedWithComments", name: t("ApprovedWithComments") },
|
||||
{ _id: "Approved", name: t("Approved") },
|
||||
{ _id: "Rejected", name: t("Rejected") },
|
||||
{ _id: "Reviewed", name: t("Reviewed") },
|
||||
];
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
import Button, { ButtonType } from "../../../../../components/common/Button";
|
||||
import ErrorBlock from "../../../../../components/common/ErrorBlock";
|
||||
import VerdictPicker from "../../../../../components/pickers/VerdictPicker";
|
||||
import ValidationErrorIcon from "../../../../../components/validationErrorIcon";
|
||||
import { TaskDefinition } from "../../services/WorkflowTemplateService";
|
||||
import TaskPicker from "../TaskPicker";
|
||||
|
||||
import {
|
||||
CapabilityEditorProps,
|
||||
capabilityEditorRegistryEntry,
|
||||
defaultsContext,
|
||||
} from "../useCapabilityDefaults";
|
||||
|
||||
export interface IOutcomeAction {
|
||||
verdict: string;
|
||||
task: string | null;
|
||||
}
|
||||
|
||||
export const outcomeEditor: React.FC<CapabilityEditorProps> = (props) => {
|
||||
const { task, tasks, onChange, fieldErrors } = props;
|
||||
|
||||
function addOutcome() {
|
||||
const clone = structuredClone(task);
|
||||
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
|
||||
list.push({
|
||||
verdict: "None",
|
||||
task: null,
|
||||
});
|
||||
clone.config.outcomeActions = list;
|
||||
onChange(clone);
|
||||
}
|
||||
|
||||
function updateOutcome(index: number, updated: IOutcomeAction) {
|
||||
const clone = structuredClone(task);
|
||||
const list = (clone.config.outcomeActions ?? []) as IOutcomeAction[];
|
||||
list[index] = updated;
|
||||
clone.config.outcomeActions = list;
|
||||
onChange(clone);
|
||||
}
|
||||
|
||||
function removeOutcome(index: number) {
|
||||
const clone = structuredClone(task);
|
||||
const list = clone.config.outcomeActions ?? [];
|
||||
clone.config.outcomeActions = list.filter((_, i) => i !== index);
|
||||
onChange(clone);
|
||||
}
|
||||
|
||||
const outcomeActions = (task.config.outcomeActions ?? []) as IOutcomeAction[];
|
||||
const guid = task.config.guid as string;
|
||||
|
||||
const otherTasks = tasks.filter((t) => (t.config.guid as string) !== guid);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Verdict</th>
|
||||
<th>Task</th>
|
||||
<th>
|
||||
<Button onClick={addOutcome} buttonType={ButtonType.secondary}>
|
||||
Add Outcome
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{outcomeActions.map((outcomeAction, index) => (
|
||||
<tr key={index} className="align-top">
|
||||
<td className="form-group">
|
||||
<ValidationErrorIcon
|
||||
visible={
|
||||
!!fieldErrors?.[
|
||||
`${guid}.outcomeActions[${index}].verdict`
|
||||
] ||
|
||||
!!fieldErrors?.[`${guid}.outcomeActions[${index}].task`]
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<VerdictPicker
|
||||
includeLabel={false}
|
||||
name="verdict"
|
||||
label="Verdict"
|
||||
value={outcomeAction.verdict}
|
||||
error={
|
||||
fieldErrors?.[`${guid}.outcomeActions[${index}].verdict`]
|
||||
}
|
||||
onChange={(name: string, val: string) =>
|
||||
updateOutcome(index, { ...outcomeAction, verdict: val })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TaskPicker
|
||||
includeLabel={false}
|
||||
name="task"
|
||||
label="Task"
|
||||
value={(outcomeAction.task as string) ?? null}
|
||||
tasks={otherTasks}
|
||||
error={fieldErrors?.[`${guid}.outcomeActions[${index}].task`]}
|
||||
onChange={(name, val) =>
|
||||
updateOutcome(index, { ...outcomeAction, task: val })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="form-group">
|
||||
<Button
|
||||
onClick={() => removeOutcome(index)}
|
||||
buttonType={ButtonType.secondary}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ErrorBlock
|
||||
error={Object.values(fieldErrors ?? {})
|
||||
.filter((_, i) =>
|
||||
Object.keys(fieldErrors ?? {}).some(
|
||||
(key) => key === `${guid}.outcomeActions[${i}]`,
|
||||
),
|
||||
)
|
||||
.join("; ")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const runValidation = (
|
||||
task: TaskDefinition,
|
||||
tasks: TaskDefinition[],
|
||||
): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
const guid = task.config.guid as string;
|
||||
const outcomeActions =
|
||||
(task.config.outcomeActions as IOutcomeAction[]) ?? undefined;
|
||||
|
||||
if (!outcomeActions) {
|
||||
// No outcome actions is valid, it just means there are no outcomes configured
|
||||
return errors;
|
||||
}
|
||||
|
||||
// --- Rule 1: Task must be selected ---
|
||||
outcomeActions.forEach((o, index) => {
|
||||
if (!o.task) {
|
||||
errors[`${guid}.outcomeActions[${index}].task`] = "Can not be empty";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Rule 2: Detect duplicates (verdict + task) ---
|
||||
const keyCounts = new Map<string, number>();
|
||||
|
||||
outcomeActions.forEach((o) => {
|
||||
if (!o.task) return; // skip empties; already handled above
|
||||
const key = `${o.verdict}|${o.task}`;
|
||||
keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1);
|
||||
});
|
||||
|
||||
// Any key with count > 1 is invalid
|
||||
outcomeActions.forEach((o, index) => {
|
||||
if (!o.task) return;
|
||||
const key = `${o.verdict}|${o.task}`;
|
||||
if ((keyCounts.get(key) ?? 0) > 1) {
|
||||
const base = `${guid}.outcomeActions[${index}]`;
|
||||
errors[`${base}.verdict`] = "Duplicate verdict/task combination";
|
||||
errors[`${base}.task`] = "Duplicate verdict/task combination";
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export function defaultsAssignment(
|
||||
task: TaskDefinition,
|
||||
tasks: TaskDefinition[],
|
||||
ctx: defaultsContext,
|
||||
) {}
|
||||
|
||||
export const outcomeOfApprovalVerdictRegistryEntry: capabilityEditorRegistryEntry =
|
||||
{
|
||||
Editor: outcomeEditor,
|
||||
DefaultsAssignment: defaultsAssignment,
|
||||
ValidationRunner: runValidation,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user