sozsoft-platform/ui/src/utils/workflow/workflowHelpers.ts
2026-08-17 08:56:52 +03:00

663 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MULTIVALUE_DELIMITER } from '@/constants/app.constant'
import { getNodeHeight, nodeSize } from './workflowConstants'
import type {
CompareOutcomeDto,
SaveCriteriaInput,
WorkflowConditionDto,
WorkflowCriteriaDto,
} from '@/services/workflow.service'
export type WorkflowCriteriaForm = Partial<WorkflowCriteriaDto> & {
id?: string | null
listFormCode: 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 buildFitLayout(criteria: WorkflowCriteriaDto[]) {
const links = collectLinks(criteria)
const rankById = buildTraversalRanks(criteria, links)
const groups = new Map<number, WorkflowCriteriaDto[]>()
criteria.forEach((item) => {
const column = fitColumn(item)
if (!groups.has(column)) groups.set(column, [])
groups.get(column)?.push(item)
})
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
const positions = new Map<string, { x: number; y: number }>()
sortedColumns.forEach((column, columnIndex) => {
const items = (groups.get(column) || []).sort((a, b) => compareLayoutNodes(a, b, rankById))
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
}
function fitColumn(item: WorkflowCriteriaDto) {
const priority: Record<string, number> = {
Start: 0,
Compare: 1,
Approval: 2,
Inform: 3,
End: 4,
}
return priority[item.kind] ?? 2
}
function compareLayoutNodes(
a: WorkflowCriteriaDto,
b: WorkflowCriteriaDto,
rankById = new Map<string, number>(),
) {
return (
(rankById.get(a.id) ?? 999) - (rankById.get(b.id) ?? 999) ||
a.title.localeCompare(b.title, 'tr')
)
}
function buildTraversalRanks(criteria: WorkflowCriteriaDto[], links: WorkflowLink[]) {
const rankById = new Map<string, number>()
const outgoing = new Map<string, string[]>(criteria.map((item) => [item.id, []]))
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()
if (!id) continue
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
}
export function collectLinks(criteria: WorkflowCriteriaDto[]) {
const links: WorkflowLink[] = []
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, OUTCOME_KEYS.nextOnStart, 'next', {
index: 0,
count: 1,
field: 'nextOnStart',
})
addLink(links, criteria, source, source.nextOnTrue, OUTCOME_KEYS.nextOnTrue, 'true', {
index: 0,
count: 2,
field: 'nextOnTrue',
})
addLink(links, criteria, source, source.nextOnFalse, OUTCOME_KEYS.nextOnFalse, 'false', {
index: 1,
count: 2,
field: 'nextOnFalse',
})
addLink(links, criteria, source, source.nextOnApprove, OUTCOME_KEYS.nextOnApprove, 'approve', {
index: 0,
count: 2,
field: 'nextOnApprove',
})
addLink(links, criteria, source, source.nextOnReject, OUTCOME_KEYS.nextOnReject, 'reject', {
index: 1,
count: 2,
field: 'nextOnReject',
})
})
return assignLinkSlots(links)
}
export function assignLinkSlots(links: WorkflowLink[]) {
const endpointGroups = new Map<string, Endpoint[]>()
const addEndpoint = (nodeId: string, side: string, endpoint: Endpoint) => {
const key = `${nodeId}:${side}`
if (!endpointGroups.has(key)) endpointGroups.set(key, [])
endpointGroups.get(key)?.push(endpoint)
}
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
}
function sideToward(from: WorkflowCriteriaDto, to: WorkflowCriteriaDto) {
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'
}
/** Çıkış etiketleri lokalizasyon anahtarı olarak taşınır; ekranda `translate` ile çözülür. */
export const OUTCOME_KEYS = {
nextOnStart: 'App.WorkflowOutcome.Next',
nextOnTrue: 'App.WorkflowOutcome.True',
nextOnFalse: 'App.WorkflowOutcome.False',
nextOnApprove: 'App.ListFormWorkflow.Approve',
nextOnReject: 'App.ListFormWorkflow.Reject',
compare: 'App.WorkflowOutcome.CompareState',
} as const
export function getNodeOutcomes(item: WorkflowCriteriaDto): WorkflowOutcome[] {
if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes?.length
? item.compareOutcomes
: [
{ label: OUTCOME_KEYS.nextOnTrue, targetId: item.nextOnTrue },
{ label: OUTCOME_KEYS.nextOnFalse, targetId: item.nextOnFalse },
]
return outcomes.slice(0, 4).map((outcome, index) => ({
field: `compareOutcomes:${index}`,
label: outcome.label || `#${index + 1}`,
targetId: outcome.targetId,
}))
}
if (item.kind === 'Approval') {
return [
{ field: 'nextOnApprove', label: OUTCOME_KEYS.nextOnApprove, targetId: item.nextOnApprove },
{ field: 'nextOnReject', label: OUTCOME_KEYS.nextOnReject, targetId: item.nextOnReject },
]
}
if (item.kind === 'End') return []
return [{ field: 'nextOnStart', label: OUTCOME_KEYS.nextOnStart, targetId: item.nextOnStart }]
}
export function outcomeLabel(field?: string) {
if (field?.startsWith('compareOutcomes:')) return OUTCOME_KEYS.compare
const labels: Record<string, string> = {
nextOnStart: OUTCOME_KEYS.nextOnStart,
nextOnTrue: OUTCOME_KEYS.nextOnTrue,
nextOnFalse: OUTCOME_KEYS.nextOnFalse,
nextOnApprove: OUTCOME_KEYS.nextOnApprove,
nextOnReject: OUTCOME_KEYS.nextOnReject,
}
return field ? labels[field] : undefined
}
export function addLink(
links: WorkflowLink[],
criteria: WorkflowCriteriaDto[],
source: WorkflowCriteriaDto,
targetId: string | null | undefined,
label: string,
type: string,
sourcePort: WorkflowLinkPort = {},
) {
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,
})
}
}
export function emptyCriteria(kind = 'Compare', listFormCode = ''): WorkflowCriteriaForm {
return {
id: '',
listFormCode,
kind,
title: defaultTitle(kind),
compareColumn: 'Price',
compareOperator: '>',
compareValue: 5000,
approver: '',
nextOnStart: '',
nextOnTrue: '',
nextOnFalse: '',
nextOnApprove: '',
nextOnReject: '',
compareOutcomes:
kind === 'Compare' ? [emptyCompareOutcome1('>5000'), emptyCompareOutcome2('<=5000')] : [],
positionX: 32,
positionY: 150,
}
}
export function uniqueCriteriaTitle(
kind: string,
criteria: Array<Pick<WorkflowCriteriaDto, 'id' | 'kind' | 'title'>>,
currentId?: string | null,
preferredTitle?: string | null,
) {
const hasPreferredTitle = Boolean(preferredTitle?.trim())
const baseTitle = (preferredTitle || defaultTitle(kind)).trim()
const usedTitles = new Set(
criteria
.filter((item) => !currentId || item.id !== currentId)
.map((item) => (item.title || '').trim().toLocaleLowerCase('tr-TR'))
.filter(Boolean),
)
if (!hasPreferredTitle) {
const sameKindCount = criteria.filter(
(item) =>
(!currentId || item.id !== currentId) &&
item.kind === kind &&
isDefaultTitleVariant(item.title, baseTitle),
).length
let index = sameKindCount + 1
let candidate = `${baseTitle}${index}`
while (usedTitles.has(candidate.toLocaleLowerCase('tr-TR'))) {
index += 1
candidate = `${baseTitle}${index}`
}
return candidate
}
if (!usedTitles.has(baseTitle.toLocaleLowerCase('tr-TR'))) {
return baseTitle
}
let index = 1
let candidate = `${baseTitle}${index}`
while (usedTitles.has(candidate.toLocaleLowerCase('tr-TR'))) {
index += 1
candidate = `${baseTitle}${index}`
}
return candidate
}
export function uniqueCriteriaId(
criteria: Array<Pick<WorkflowCriteriaDto, 'id'>>,
reservedIds: string[] = [],
) {
const usedIds = new Set(
[...criteria.map((item) => item.id), ...reservedIds]
.map((id) => (id || '').trim().toLocaleLowerCase('tr-TR'))
.filter(Boolean),
)
const maxNumber = [...usedIds].reduce((max, id) => Math.max(max, parseCriteriaIdNumber(id)), 0)
let nextNumber = maxNumber + 1
let candidate = formatCriteriaId(nextNumber)
while (usedIds.has(candidate.toLocaleLowerCase('tr-TR'))) {
nextNumber += 1
candidate = formatCriteriaId(nextNumber)
}
return candidate
}
function parseCriteriaIdNumber(id: string) {
const match = id.match(/^(?:n)?(\d+)$/iu)
return match ? Number(match[1]) : 0
}
function formatCriteriaId(number: number) {
return `N${String(number).padStart(3, '0')}`.slice(-4)
}
function isDefaultTitleVariant(title: string | null | undefined, baseTitle: string) {
const normalized = (title || '').trim()
return (
normalized === baseTitle || new RegExp(`^${escapeRegExp(baseTitle)}\\d+$`, 'u').test(normalized)
)
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function toCriteriaForm(item: WorkflowCriteriaDto): WorkflowCriteriaForm {
const sharedPerson = item.approver || ''
return {
...emptyCriteria(item.kind),
...item,
approver: 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,
}
}
export function normalizeCriteria(item: WorkflowCriteriaForm): SaveCriteriaInput {
const sharedPerson = item.kind === 'Approval' || item.kind === 'Inform' ? item.approver || '' : ''
const compareOutcomes = (item.compareOutcomes || [])
.slice(0, 4)
.filter((outcome) => outcome.label?.trim())
.map(normalizeCompareOutcome)
const firstCompareColumn = compareOutcomes
.flatMap((outcome) => outcome.conditions || [])
.find((condition) => condition.compareColumn)?.compareColumn
return {
id: item.id || null,
listFormCode: item.listFormCode || '',
kind: item.kind || 'Compare',
title: item.title || defaultTitle(item.kind || 'Compare'),
compareColumn: firstCompareColumn || item.compareColumn || 'Price',
compareOperator: item.compareOperator || '>',
compareValue: Number(item.compareValue || 0),
approver: sharedPerson,
nextOnStart: item.nextOnStart || '',
nextOnTrue: compareOutcomes[0]?.targetId || item.nextOnTrue || '',
nextOnFalse: compareOutcomes[1]?.targetId || item.nextOnFalse || '',
nextOnApprove: item.nextOnApprove || '',
nextOnReject: item.nextOnReject || '',
positionX: Number(item.positionX || 32),
positionY: Number(item.positionY || 150),
compareOutcomes,
}
}
export function defaultTitle(kind: string) {
return (
{
Start: 'App.WorkflowKind.Start',
Compare: 'App.WorkflowKind.Compare',
Approval: 'App.WorkflowKind.Approval',
Inform: 'App.WorkflowKind.Inform',
End: 'App.WorkflowKind.End',
}[kind] ?? 'App.WorkflowKind.Step'
)
}
/**
* Node basliklari lokalizasyon anahtari olarak uretilir ve benzersizlik icin
* sonuna sira numarasi eklenir (`App.WorkflowKind.Approval1`). Bu yuzden ham
* baslik dogrudan `translate` edilemez; once numara ayrilir, anahtar cevrilir,
* numara tekrar eklenir. Anahtar bulunamazsa ham baslik gosterilir.
*/
const LOCALIZATION_KEY_PATTERN = /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$/u
export function translateWorkflowLabel(
title: string | null | undefined,
translate: (key: string) => string,
) {
const raw = String(title ?? '').trim()
if (!raw) return ''
const match = /^(.*?)(\d*)$/u.exec(raw)
const base = (match?.[1] || raw).trim()
const suffix = match?.[2] || ''
if (!LOCALIZATION_KEY_PATTERN.test(base)) return raw
const translated = translate('::' + base)
if (!translated || translated === base) return raw
return `${translated}${suffix}`
}
export function emptyCompareOutcome1(label = 'Durum', compareColumn = 'Price'): CompareOutcomeDto {
return {
label,
targetId: '',
conditions: [{ compareColumn, compareOperator: '>', compareValue: 5000 }],
}
}
export function emptyCompareOutcome2(label = 'Durum', compareColumn = 'Price'): CompareOutcomeDto {
return {
label,
targetId: '',
conditions: [{ compareColumn, compareOperator: '<=', compareValue: 5000 }],
}
}
export function toCompareOutcomeForm(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {
conditions?: Partial<WorkflowConditionDto>[]
},
): CompareOutcomeDto {
const conditions = outcome.conditions?.length
? outcome.conditions
: [
{
compareColumn: outcome.compareColumn || 'Price',
compareOperator: outcome.compareOperator || '>',
compareValue: outcome.compareValue || 0,
},
]
return {
label: outcome.label || '',
targetId: outcome.targetId || '',
conditions: conditions.map((condition) => ({
compareColumn: condition.compareColumn || 'Price',
compareOperator: condition.compareOperator || '>',
compareValue: condition.compareValue ?? 0,
})),
}
}
export function normalizeCompareOutcome(
outcome: Partial<CompareOutcomeDto> &
Partial<WorkflowConditionDto> & {
conditions?: Partial<WorkflowConditionDto>[]
},
): CompareOutcomeDto {
const conditions = (
outcome.conditions?.length
? outcome.conditions
: [
{
compareColumn: outcome.compareColumn || 'Price',
compareOperator: outcome.compareOperator || '>',
compareValue: outcome.compareValue || 0,
},
]
)
.filter((condition) => condition.compareOperator && String(condition.compareValue ?? '') !== '')
.map((condition) => ({
compareColumn: condition.compareColumn || 'Price',
compareOperator: condition.compareOperator || '>',
compareValue: Number(condition.compareValue || 0),
}))
return {
label: (outcome.label || '').trim(),
targetId: outcome.targetId || null,
conditions,
}
}
export function compareOutcomeRuleText(
outcome: Partial<CompareOutcomeDto> & Partial<WorkflowConditionDto>,
) {
const conditions = outcome.conditions?.length
? outcome.conditions
: outcome.compareOperator
? [
{
compareColumn: outcome.compareColumn,
compareOperator: outcome.compareOperator,
compareValue: outcome.compareValue,
},
]
: []
return conditions.length
? conditions
.map(
(condition) =>
`${condition.compareColumn} ${condition.compareOperator} ${formatCompactValue(condition.compareValue)}`,
)
.join(' ve ')
: 'App.ListFormWorkflow.NoRule'
}
export function formatCompactValue(value: number | string | null | undefined) {
return new Intl.NumberFormat('tr-TR', {
maximumFractionDigits: 2,
}).format(Number(value || 0))
}
export function criteriaSummary(item: WorkflowCriteriaDto) {
if (item.kind === 'Compare') {
return (
(item.compareOutcomes || [])
.map((outcome) => `${outcome.label}: ${compareOutcomeRuleText(outcome)}`)
.join(' / ') || '-'
)
}
if (item.kind === 'Approval' || item.kind === 'Inform') {
return `${item.title} ${item.approver ? `- ${formatWorkflowApprovers(item.approver)}` : ''}`
}
return item.title
}
export function splitWorkflowApprovers(value?: string | null) {
return String(value ?? '')
.split(MULTIVALUE_DELIMITER)
.map((item) => item.trim())
.filter(Boolean)
}
export function formatWorkflowApprovers(value?: string | null) {
return splitWorkflowApprovers(value).join(', ')
}
export function targetTitle(criteria: WorkflowCriteriaDto[], id?: string | null) {
if (!id) return '-'
const item = criteria.find((candidate) => candidate.id === id)
return item ? `${item.id} - ${item.title}` : id
}
export function statusClass(status?: string) {
if (status === 'Onay Bekliyor') return 'pending'
if (status === 'Bitti') return 'done'
if (status === 'Bilgilendirildi') return 'info'
return ''
}
export function formatMoney(value?: number | string | null) {
return new Intl.NumberFormat('tr-TR', {
style: 'currency',
currency: 'TRY',
}).format(Number(value || 0))
}