sozsoft-platform/ui/src/utils/workflow/workflowHelpers.ts

559 lines
15 KiB
TypeScript
Raw Normal View History

2026-05-22 09:40:35 +00:00
import { getNodeHeight, nodeSize } from "./workflowConstants";
2026-05-22 11:50:09 +00:00
import type {
CompareOutcomeDto,
SaveCriteriaInput,
WorkflowConditionDto,
WorkflowCriteriaDto,
WorkflowItemDto,
} from "@/services/workflow.service";
export type WorkflowCriteriaForm = Partial<WorkflowCriteriaDto> & {
id?: string | null;
workflowItemId: string;
compareOutcomes: CompareOutcomeDto[];
};
export type WorkflowOutcome = {
field: string;
label: string;
targetId?: string | null;
};
export type WorkflowLinkPort = {
field?: string;
index?: number;
count?: number;
sourceSlotIndex?: number;
sourceSlotCount?: number;
targetSlotIndex?: number;
targetSlotCount?: number;
routeIndex?: number;
routeCount?: number;
};
export type WorkflowLink = {
key: string;
source: WorkflowCriteriaDto;
target: WorkflowCriteriaDto;
label: string;
sourcePort: WorkflowLinkPort;
};
type Endpoint = {
link: WorkflowLink;
role: "source" | "target";
};
export function isPendingApproval(
item: WorkflowItemDto | undefined | null,
criteria: WorkflowCriteriaDto[],
) {
2026-05-22 09:40:35 +00:00
if (!item) return false;
return criteria.some(
(candidate) =>
candidate.workflowItemId === item.id &&
candidate.id === item.currentNodeId &&
candidate.kind === "Approval",
);
}
2026-05-22 11:50:09 +00:00
export function buildFitLayout(criteria: WorkflowCriteriaDto[]) {
2026-05-22 09:40:35 +00:00
const links = collectLinks(criteria);
const rankById = buildTraversalRanks(criteria, links);
2026-05-22 11:50:09 +00:00
const groups = new Map<number, WorkflowCriteriaDto[]>();
2026-05-22 09:40:35 +00:00
criteria.forEach((item) => {
const column = fitColumn(item);
if (!groups.has(column)) groups.set(column, []);
2026-05-22 11:50:09 +00:00
groups.get(column)?.push(item);
2026-05-22 09:40:35 +00:00
});
const sortedColumns = [...groups.keys()].sort((a, b) => a - b);
const yGap = 74;
const maxGroupHeight = Math.max(
1,
...[...groups.values()].map(
(items) =>
items.reduce((sum, item) => sum + getNodeHeight(item), 0) +
Math.max(0, items.length - 1) * yGap,
),
);
const top = 72;
const left = 72;
const xGap = 128;
2026-05-22 11:50:09 +00:00
const positions = new Map<string, { x: number; y: number }>();
2026-05-22 09:40:35 +00:00
sortedColumns.forEach((column, columnIndex) => {
2026-05-22 11:50:09 +00:00
const items = (groups.get(column) || []).sort((a, b) =>
compareLayoutNodes(a, b, rankById),
);
2026-05-22 09:40:35 +00:00
const groupHeight =
items.reduce((sum, item) => sum + getNodeHeight(item), 0) +
Math.max(0, items.length - 1) * yGap;
let y = top + Math.max(0, (maxGroupHeight - groupHeight) / 2);
items.forEach((item) => {
positions.set(item.id, {
x: left + columnIndex * (nodeSize.width + xGap),
y: Math.round(y),
});
y += getNodeHeight(item) + yGap;
});
});
return positions;
}
2026-05-22 11:50:09 +00:00
function fitColumn(item: WorkflowCriteriaDto) {
const priority: Record<string, number> = {
2026-05-22 09:40:35 +00:00
Start: 0,
Compare: 1,
Approval: 2,
Inform: 3,
End: 4,
};
return priority[item.kind] ?? 2;
}
2026-05-22 11:50:09 +00:00
function compareLayoutNodes(
a: WorkflowCriteriaDto,
b: WorkflowCriteriaDto,
rankById = new Map<string, number>(),
) {
2026-05-22 09:40:35 +00:00
return (
(rankById.get(a.id) ?? 999) - (rankById.get(b.id) ?? 999) ||
a.title.localeCompare(b.title, "tr")
);
}
2026-05-22 11:50:09 +00:00
function buildTraversalRanks(
criteria: WorkflowCriteriaDto[],
links: WorkflowLink[],
) {
const rankById = new Map<string, number>();
const outgoing = new Map<string, string[]>(
criteria.map((item) => [item.id, []]),
);
2026-05-22 09:40:35 +00:00
links.forEach((link) => {
outgoing.get(link.source.id)?.push(link.target.id);
});
const roots = criteria.filter((item) => item.kind === "Start");
const queue = roots.length
? roots.map((item) => item.id)
: criteria.map((item) => item.id);
while (queue.length) {
const id = queue.shift();
2026-05-22 11:50:09 +00:00
if (!id) continue;
2026-05-22 09:40:35 +00:00
if (rankById.has(id)) continue;
rankById.set(id, rankById.size);
(outgoing.get(id) || []).forEach((targetId) => {
if (targetId && !rankById.has(targetId)) queue.push(targetId);
});
}
criteria.forEach((item) => {
if (!rankById.has(item.id)) rankById.set(item.id, rankById.size);
});
return rankById;
}
2026-05-22 11:50:09 +00:00
export function collectLinks(criteria: WorkflowCriteriaDto[]) {
const links: WorkflowLink[] = [];
2026-05-22 09:40:35 +00:00
criteria.forEach((source) => {
if (source.kind === "Compare" && source.compareOutcomes?.length) {
source.compareOutcomes.forEach((outcome, index) => {
addLink(
links,
criteria,
source,
outcome.targetId,
outcome.label,
`compare-${index}`,
{
index,
count: source.compareOutcomes.length,
field: `compareOutcomes:${index}`,
},
);
});
return;
}
addLink(links, criteria, source, source.nextOnStart, "Sonraki", "next", {
index: 0,
count: 1,
field: "nextOnStart",
});
addLink(links, criteria, source, source.nextOnTrue, "Doğru", "true", {
index: 0,
count: 2,
field: "nextOnTrue",
});
addLink(links, criteria, source, source.nextOnFalse, "Yanlış", "false", {
index: 1,
count: 2,
field: "nextOnFalse",
});
addLink(links, criteria, source, source.nextOnApprove, "Onay", "approve", {
index: 0,
count: 2,
field: "nextOnApprove",
});
addLink(links, criteria, source, source.nextOnReject, "Red", "reject", {
index: 1,
count: 2,
field: "nextOnReject",
});
});
return assignLinkSlots(links, criteria);
}
2026-05-22 11:50:09 +00:00
export function assignLinkSlots(
links: WorkflowLink[],
criteria: WorkflowCriteriaDto[],
) {
const endpointGroups = new Map<string, Endpoint[]>();
const addEndpoint = (nodeId: string, side: string, endpoint: Endpoint) => {
2026-05-22 09:40:35 +00:00
const key = `${nodeId}:${side}`;
if (!endpointGroups.has(key)) endpointGroups.set(key, []);
2026-05-22 11:50:09 +00:00
endpointGroups.get(key)?.push(endpoint);
2026-05-22 09:40:35 +00:00
};
links.forEach((link) => {
addEndpoint(link.source.id, sideToward(link.source, link.target), {
link,
role: "source",
});
addEndpoint(link.target.id, sideToward(link.target, link.source), {
link,
role: "target",
});
});
endpointGroups.forEach((endpoints) => {
endpoints.forEach((endpoint, index) => {
if (endpoint.role === "source") {
endpoint.link.sourcePort.sourceSlotIndex = index;
endpoint.link.sourcePort.sourceSlotCount = endpoints.length;
} else {
endpoint.link.sourcePort.targetSlotIndex = index;
endpoint.link.sourcePort.targetSlotCount = endpoints.length;
}
});
});
links.forEach((link) => {
link.sourcePort.routeIndex = link.sourcePort.targetSlotIndex ?? 0;
link.sourcePort.routeCount = link.sourcePort.targetSlotCount ?? 1;
});
return links;
}
2026-05-22 11:50:09 +00:00
function sideToward(from: WorkflowCriteriaDto, to: WorkflowCriteriaDto) {
2026-05-22 09:40:35 +00:00
const fromLeft = Number(from.positionX || 0);
const fromTop = Number(from.positionY || 0);
const fromCenter = {
x: fromLeft + nodeSize.width / 2,
y: fromTop + getNodeHeight(from) / 2,
};
const toCenter = {
x: Number(to.positionX || 0) + nodeSize.width / 2,
y: Number(to.positionY || 0) + getNodeHeight(to) / 2,
};
const dx = toCenter.x - fromCenter.x;
const dy = toCenter.y - fromCenter.y;
const horizontalDistance = Math.abs(dx) / (nodeSize.width / 2);
const verticalDistance = Math.abs(dy) / (getNodeHeight(from) / 2);
if (horizontalDistance >= verticalDistance) return dx >= 0 ? "right" : "left";
return dy >= 0 ? "bottom" : "top";
}
2026-05-22 11:50:09 +00:00
export function getNodeOutcomes(item: WorkflowCriteriaDto): WorkflowOutcome[] {
2026-05-22 09:40:35 +00:00
if (item.kind === "Compare") {
const outcomes = item.compareOutcomes?.length
? item.compareOutcomes
: [
{ label: "Doğru", targetId: item.nextOnTrue },
{ label: "Yanlış", targetId: item.nextOnFalse },
];
return outcomes.slice(0, 4).map((outcome, index) => ({
field: `compareOutcomes:${index}`,
label: outcome.label || `Durum ${index + 1}`,
targetId: outcome.targetId,
}));
}
if (item.kind === "Approval") {
return [
{ field: "nextOnApprove", label: "Onay", targetId: item.nextOnApprove },
{ field: "nextOnReject", label: "Red", targetId: item.nextOnReject },
];
}
if (item.kind === "End") return [];
return [
{ field: "nextOnStart", label: "Sonraki", targetId: item.nextOnStart },
];
}
2026-05-22 11:50:09 +00:00
export function outcomeLabel(field?: string) {
2026-05-22 09:40:35 +00:00
if (field?.startsWith("compareOutcomes:")) return "Karşılaştırma durumu";
2026-05-22 11:50:09 +00:00
const labels: Record<string, string> = {
2026-05-22 09:40:35 +00:00
nextOnStart: "Sonraki",
nextOnTrue: "Doğru",
nextOnFalse: "Yanlış",
nextOnApprove: "Onay",
nextOnReject: "Red",
2026-05-22 11:50:09 +00:00
};
return field ? labels[field] : undefined;
2026-05-22 09:40:35 +00:00
}
export function addLink(
2026-05-22 11:50:09 +00:00
links: WorkflowLink[],
criteria: WorkflowCriteriaDto[],
source: WorkflowCriteriaDto,
targetId: string | null | undefined,
label: string,
type: string,
sourcePort: WorkflowLinkPort = {},
2026-05-22 09:40:35 +00:00
) {
if (!targetId) return;
const target = criteria.find((item) => item.id === targetId);
if (target) {
links.push({
key: `${source.id}-${target.id}-${type}`,
source,
target,
label,
sourcePort,
});
}
}
2026-05-22 11:50:09 +00:00
export function emptyCriteria(
kind = "Compare",
workflowItemId = "",
): WorkflowCriteriaForm {
2026-05-22 09:40:35 +00:00
return {
id: "",
workflowItemId,
kind,
title: defaultTitle(kind),
column: "Tutar",
operator: ">",
compareValue: 5000,
approver: "",
informPerson: "",
nextOnStart: "",
nextOnTrue: "",
nextOnFalse: "",
nextOnApprove: "",
nextOnReject: "",
compareOutcomes:
kind === "Compare"
? [emptyCompareOutcome("Durum 1"), emptyCompareOutcome("Durum 2")]
: [],
positionX: 32,
positionY: 150,
};
}
2026-05-22 11:50:09 +00:00
export function toCriteriaForm(item: WorkflowCriteriaDto): WorkflowCriteriaForm {
2026-05-22 09:40:35 +00:00
const sharedPerson = item.approver || item.informPerson || "";
return {
...emptyCriteria(item.kind),
...item,
approver: sharedPerson,
informPerson: sharedPerson,
nextOnStart: item.nextOnStart || "",
nextOnTrue: item.nextOnTrue || "",
nextOnFalse: item.nextOnFalse || "",
nextOnApprove: item.nextOnApprove || "",
nextOnReject: item.nextOnReject || "",
compareOutcomes: item.compareOutcomes?.length
? item.compareOutcomes.map(toCompareOutcomeForm)
: emptyCriteria(item.kind).compareOutcomes,
};
}
2026-05-22 11:50:09 +00:00
export function normalizeCriteria(item: WorkflowCriteriaForm): SaveCriteriaInput {
2026-05-22 09:40:35 +00:00
const sharedPerson = item.approver || item.informPerson || "";
return {
...item,
id: item.id || null,
2026-05-22 11:50:09 +00:00
workflowItemId: item.workflowItemId || "",
2026-05-22 09:40:35 +00:00
compareValue: Number(item.compareValue || 0),
approver: sharedPerson,
informPerson: sharedPerson,
positionX: Number(item.positionX || 32),
positionY: Number(item.positionY || 150),
compareOutcomes: (item.compareOutcomes || [])
.slice(0, 4)
.filter((outcome) => outcome.label?.trim())
.map(normalizeCompareOutcome),
};
}
2026-05-22 11:50:09 +00:00
export function defaultTitle(kind: string) {
2026-05-22 09:40:35 +00:00
return {
Start: "İş Akışı Başlat",
Compare: "Tutar > 5000 TL",
Approval: "Onaylanacak Kişi",
Inform: "Bilgilendirme Yapılacak Personel",
End: "Akışı Bitir",
2026-05-22 11:50:09 +00:00
}[kind] ?? "İş Akışı Adımı";
2026-05-22 09:40:35 +00:00
}
2026-05-22 11:50:09 +00:00
export function emptyCompareOutcome(label = "Durum"): CompareOutcomeDto {
2026-05-22 09:40:35 +00:00
return {
label,
targetId: "",
conditions: [{ column: "Tutar", operator: ">", compareValue: 5000 }],
};
}
2026-05-22 11:50:09 +00:00
export function toCompareOutcomeForm(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {
conditions?: Partial<WorkflowConditionDto>[];
},
): CompareOutcomeDto {
2026-05-22 09:40:35 +00:00
const conditions = outcome.conditions?.length
? outcome.conditions
: [
{
column: outcome.column || "Tutar",
operator: outcome.operator || ">",
compareValue: outcome.compareValue || 0,
},
];
return {
label: outcome.label || "",
targetId: outcome.targetId || "",
conditions: conditions.map((condition) => ({
column: condition.column || "Tutar",
operator: condition.operator || ">",
compareValue: condition.compareValue ?? 0,
})),
};
}
2026-05-22 11:50:09 +00:00
export function normalizeCompareOutcome(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {
conditions?: Partial<WorkflowConditionDto>[];
},
): CompareOutcomeDto {
2026-05-22 09:40:35 +00:00
const conditions = (
outcome.conditions?.length
? outcome.conditions
: [
{
column: outcome.column || "Tutar",
operator: outcome.operator || ">",
compareValue: outcome.compareValue || 0,
},
]
)
2026-05-22 11:50:09 +00:00
.filter(
(condition) =>
condition.operator && String(condition.compareValue ?? "") !== "",
)
2026-05-22 09:40:35 +00:00
.map((condition) => ({
column: condition.column || "Tutar",
operator: condition.operator || ">",
compareValue: Number(condition.compareValue || 0),
}));
return {
2026-05-22 11:50:09 +00:00
label: (outcome.label || "").trim(),
2026-05-22 09:40:35 +00:00
targetId: outcome.targetId || null,
conditions,
};
}
2026-05-22 11:50:09 +00:00
export function compareOutcomeRuleText(
outcome: Partial<CompareOutcomeDto> & Partial<WorkflowConditionDto>,
) {
2026-05-22 09:40:35 +00:00
const conditions = outcome.conditions?.length
? outcome.conditions
: outcome.operator
? [
{
column: outcome.column || "Tutar",
operator: outcome.operator,
compareValue: outcome.compareValue,
},
]
: [];
return conditions.length
? conditions
.map(
(condition) =>
`${condition.column} ${condition.operator} ${formatCompactValue(condition.compareValue)}`,
)
.join(" ve ")
: "Kural yok";
}
2026-05-22 11:50:09 +00:00
export function formatCompactValue(value: number | string | null | undefined) {
2026-05-22 09:40:35 +00:00
return new Intl.NumberFormat("tr-TR", {
maximumFractionDigits: 2,
}).format(Number(value || 0));
}
2026-05-22 11:50:09 +00:00
export function criteriaSummary(item: WorkflowCriteriaDto) {
2026-05-22 09:40:35 +00:00
if (item.kind === "Compare") {
return (
(item.compareOutcomes || [])
.map(
(outcome) => `${outcome.label}: ${compareOutcomeRuleText(outcome)}`,
)
.join(" / ") || "-"
);
}
if (item.kind === "Approval")
return item.approver || item.informPerson || "-";
if (item.kind === "Inform") return item.approver || item.informPerson || "-";
return item.title;
}
2026-05-22 11:50:09 +00:00
export function targetTitle(
criteria: WorkflowCriteriaDto[],
id?: string | null,
) {
2026-05-22 09:40:35 +00:00
if (!id) return "-";
const item = criteria.find((candidate) => candidate.id === id);
return item ? `${item.id} - ${item.title}` : id;
}
2026-05-22 11:50:09 +00:00
export function statusClass(status?: string) {
2026-05-22 09:40:35 +00:00
if (status === "Onay Bekliyor") return "pending";
if (status === "Bitti") return "done";
if (status === "Bilgilendirildi") return "info";
return "";
}
2026-05-22 11:50:09 +00:00
export function formatMoney(value?: number | string | null) {
2026-05-22 09:40:35 +00:00
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
2026-05-22 11:50:09 +00:00
}).format(Number(value || 0));
2026-05-22 09:40:35 +00:00
}