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 & { 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() 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() 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 = { Start: 0, Compare: 1, Approval: 2, Inform: 3, End: 4, } return priority[item.kind] ?? 2 } function compareLayoutNodes( a: WorkflowCriteriaDto, b: WorkflowCriteriaDto, rankById = new Map(), ) { 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() const outgoing = new Map(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() 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 = { 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>, 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>, 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 & Partial & { conditions?: Partial[] }, ): 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 & Partial & { conditions?: Partial[] }, ): 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 & Partial, ) { 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)) }