Grid Approval birden fazla kişi seçilebilir.

This commit is contained in:
Sedat ÖZTÜRK 2026-07-08 10:29:45 +03:00
parent 04e88fc969
commit 17e4716ee8
9 changed files with 194 additions and 38 deletions

View file

@ -100,13 +100,11 @@ public class FileManagementAppService : ApplicationService, IFileManagementAppSe
private string EncodePathAsId(string path)
{
// Path'deki '/' karakterlerini '|' ile değiştir URL-safe hale getirmek için
return path.Replace("/", "|");
}
private string DecodeIdAsPath(string id)
{
// ID'deki '|' karakterlerini '/' ile geri değiştir
return id.Replace("|", "/");
}

View file

@ -357,6 +357,10 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
{
throw new UserFriendlyException("Seçili kayıt bu onay adımında beklemiyor.");
}
if (!IsCurrentUserWorkflowApprover(context, current))
{
throw new UserFriendlyException("Bu kayıt için onay yetkiniz yok.");
}
var update = new Dictionary<string, object>();
if (!context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace())
@ -548,7 +552,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
private async Task SendInformEmailAsync(WorkflowRunContext context, ListFormWorkflow node)
{
var recipientEmail = await ResolveApproverEmailAsync(node.Approver);
var recipientEmails = await ResolveApproverEmailsAsync(node.Approver);
var senderName = await settingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromDisplayName);
var senderEmail = await settingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromAddress);
KeyValuePair<string, string>? sender = null;
@ -557,22 +561,42 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
sender = new KeyValuePair<string, string>(senderName, senderEmail);
}
var result = await erpEmailSender.SendEmailAsync(
recipientEmail,
sender,
new { },
BuildInformEmailBody(context, node, await BuildPreviousWorkflowNotesHtmlAsync(context)),
$"Workflow Bilgilendirme: {node.Title}",
null,
true);
if (!result.Success)
var body = BuildInformEmailBody(context, node, await BuildPreviousWorkflowNotesHtmlAsync(context));
foreach (var recipientEmail in recipientEmails)
{
throw new UserFriendlyException($"Bilgilendirme maili gonderilemedi: {result.ErrorMessage}");
var result = await erpEmailSender.SendEmailAsync(
recipientEmail,
sender,
new { },
body,
$"Workflow Bilgilendirme: {node.Title}",
null,
true);
if (!result.Success)
{
throw new UserFriendlyException($"Bilgilendirme maili gonderilemedi: {result.ErrorMessage}");
}
}
}
private async Task<List<string>> ResolveApproverEmailsAsync(string approver)
{
var emails = new List<string>();
foreach (var item in SplitWorkflowApprovers(approver))
{
emails.Add(await ResolveApproverEmailAsync(item));
}
if (emails.Count == 0)
{
throw new UserFriendlyException("Bilgilendirme dugumu icin approver tanimli degil.");
}
return emails;
}
private async Task<string> ResolveApproverEmailAsync(string approver)
{
if (approver.IsNullOrWhiteSpace())
@ -594,6 +618,45 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
return user.Email;
}
private bool IsCurrentUserWorkflowApprover(WorkflowRunContext context, ListFormWorkflow current)
{
if (context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace())
{
return true;
}
var approverValue = GetRowValue(context.Row, context.Workflow.ApprovalUserFieldName)?.ToString();
if (approverValue.IsNullOrWhiteSpace())
{
approverValue = current.Approver;
}
var approvers = SplitWorkflowApprovers(approverValue)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (approvers.Count == 0)
{
return true;
}
return GetCurrentUserWorkflowIdentities().Any(approvers.Contains);
}
private IEnumerable<string> GetCurrentUserWorkflowIdentities()
{
return new[] { CurrentUser.UserName, CurrentUser.Email, CurrentUser.Name }
.Where(identity => !identity.IsNullOrWhiteSpace())
.Select(identity => identity.Trim());
}
private static IEnumerable<string> SplitWorkflowApprovers(string value)
{
return (value ?? string.Empty)
.Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries)
.Select(item => item.Trim())
.Where(item => !item.IsNullOrWhiteSpace());
}
private async Task<string> BuildPreviousWorkflowNotesHtmlAsync(WorkflowRunContext context)
{
var key = context.Keys?.FirstOrDefault()?.ToString();
@ -633,7 +696,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
var keyText = string.Join(", ", context.Keys.Select(key => WebUtility.HtmlEncode(key?.ToString() ?? string.Empty)));
var listFormCode = WebUtility.HtmlEncode(context.ListFormCode ?? string.Empty);
var nodeTitle = WebUtility.HtmlEncode(node.Title ?? string.Empty);
var recipient = WebUtility.HtmlEncode(node.Approver ?? string.Empty);
var recipient = WebUtility.HtmlEncode(FormatWorkflowApprovers(node.Approver));
var processRows = BuildWorkflowNoteContent(context.WorkflowNoteRows);
var previousNotesSection = previousWorkflowNotesHtml.IsNullOrWhiteSpace()
? string.Empty
@ -697,7 +760,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
if (!node.Approver.IsNullOrWhiteSpace())
{
context.WorkflowNoteRows.Add((node.Kind == "Inform" ? "Inform: " : "Approver: ", node.Approver));
context.WorkflowNoteRows.Add((node.Kind == "Inform" ? "Inform: " : "Approver: ", FormatWorkflowApprovers(node.Approver)));
}
}
@ -790,7 +853,12 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
{
return userName.IsNullOrWhiteSpace()
? localizer["ListForms.ListForm.Workflow.UndefinedUser"].Value
: userName;
: FormatWorkflowApprovers(userName);
}
private static string FormatWorkflowApprovers(string value)
{
return string.Join(", ", SplitWorkflowApprovers(value));
}
private async Task InsertWorkflowNoteAsync(

View file

@ -20,9 +20,9 @@ public static class AbpPermissionsExtensions
// 🔹 Gereksiz boşlukları temizle
var cleaned = string.Join(
'|',
"|",
menuGroup
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct()
);
@ -44,7 +44,7 @@ public static class AbpPermissionsExtensions
// 🔹 "Erp|Kurs" -> ["Erp", "Kurs"]
return value
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct()
.ToArray();
}

View file

@ -479,11 +479,7 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager
// hem de ApprovalUserFieldName alanı CurrentUserName'e eşit olan kayıtları getirmek istiyoruz,
// Boş olanları getirmemizin sebebi workflow start edebilmektir.
// İlk kayıt eklenince onaylayacak kişi atanmaz, böylece o kayıt onaysız olarak kalmaz ve workflow başlatılabilir olur.
whereParts.Add(
$"(\"{workflow.ApprovalUserFieldName}\" = '{CurrentUser.UserName}' " +
$"OR \"{workflow.ApprovalUserFieldName}\" IS NULL " +
$"OR \"{workflow.ApprovalUserFieldName}\" = '')"
);
whereParts.Add(GetWorkflowApproverFilter(workflow.ApprovalUserFieldName));
}
}
@ -495,6 +491,38 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager
return whereParts;
}
private string GetWorkflowApproverFilter(string approvalUserFieldName)
{
if (approvalUserFieldName.IsNullOrWhiteSpace())
{
return "1 = 1";
}
var field = $"\"{approvalUserFieldName}\"";
var identities = new[] { CurrentUser.UserName, CurrentUser.Email, CurrentUser.Name }
.Where(identity => !identity.IsNullOrWhiteSpace())
.Select(EscapeSqlLiteral)
.Distinct()
.ToList();
if (identities.Count == 0)
{
return $"({field} IS NULL OR {field} = '')";
}
var identityList = string.Join(",", identities.Select(identity => $"'{identity}'"));
var approverMatch = DataSourceType == DataSourceTypeEnum.Postgresql
? $"EXISTS (SELECT 1 FROM unnest(string_to_array(CAST({field} AS TEXT), '{PlatformConsts.MultiValueDelimiter}')) AS split(value) WHERE btrim(split.value) IN ({identityList}))"
: $"EXISTS (SELECT 1 FROM STRING_SPLIT(CAST({field} AS NVARCHAR(MAX)), N'{PlatformConsts.MultiValueDelimiter}') AS split WHERE LTRIM(RTRIM(split.value)) IN ({identityList}))";
return $"({approverMatch} OR {field} IS NULL OR {field} = '')";
}
private static string EscapeSqlLiteral(string value)
{
return (value ?? string.Empty).Replace("'", "''");
}
/// <summary> FieldName operator Id seklinde olan filtreleri ayirir
/// </summary>
/// <param name="filter"></param>
@ -829,8 +857,7 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager
}
else
{
// Birden fazla → '|' ile birleştir
argumentExpression = string.Join(" + '|' + ", argumentFields.Select(f => $"[{f}]"));
argumentExpression = string.Join(PlatformConsts.MultiValueDelimiter, argumentFields.Select(f => $"[{f}]"));
}
// Select sütunlarını oluştur

View file

@ -1854,7 +1854,7 @@ internal static class DynamicReportImageHelper
private static bool IsImageDelimiter(string text, int index)
{
return text[index] == '|' ||
return text[index] == PlatformConsts.MultiValueDelimiter ||
text[index] == '\n' ||
text[index] == '\r' ||
(text[index] == ',' && !IsDataImageComma(text, index));
@ -1868,7 +1868,7 @@ internal static class DynamicReportImageHelper
return false;
}
var previousDelimiterIndex = text.LastIndexOfAny(['|', '\r', '\n'], index);
var previousDelimiterIndex = text.LastIndexOfAny([PlatformConsts.MultiValueDelimiter, '\r', '\n'], index);
return previousDelimiterIndex < dataImageIndex &&
text.IndexOf(";base64,", dataImageIndex, index - dataImageIndex + 1, StringComparison.OrdinalIgnoreCase) >= 0;
}

View file

@ -1,4 +1,4 @@
import { useLocalization } from '../hooks/useLocalization'
import { MULTIVALUE_DELIMITER } from '@/constants/app.constant'
import { getNodeHeight, nodeSize } from './workflowConstants'
import type {
CompareOutcomeDto,
@ -179,10 +179,10 @@ export function collectLinks(criteria: WorkflowCriteriaDto[]) {
field: 'nextOnReject',
})
})
return assignLinkSlots(links, criteria)
return assignLinkSlots(links)
}
export function assignLinkSlots(links: WorkflowLink[], criteria: WorkflowCriteriaDto[]) {
export function assignLinkSlots(links: WorkflowLink[]) {
const endpointGroups = new Map<string, Endpoint[]>()
const addEndpoint = (nodeId: string, side: string, endpoint: Endpoint) => {
const key = `${nodeId}:${side}`
@ -588,11 +588,22 @@ export function criteriaSummary(item: WorkflowCriteriaDto) {
)
}
if (item.kind === 'Approval' || item.kind === 'Inform') {
return `${item.title} ${item.approver ? `- ${item.approver}` : ''}`
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)

View file

@ -20,6 +20,7 @@ import {
WidgetEditDto,
WorkflowDto,
} from '@/proxy/form/models'
import { formatWorkflowApprovers } from '@/utils/workflow/workflowHelpers'
// ─── Types ────────────────────────────────────────────────────────────────────
@ -559,7 +560,7 @@ const WizardStep7 = ({
{(criteria.kind === 'Approval' || criteria.kind === 'Inform') &&
criteria.approver && (
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Approver: {criteria.approver}
Approver: {formatWorkflowApprovers(criteria.approver)}
</div>
)}
</div>

View file

@ -12,12 +12,14 @@ import {
compareOutcomeRuleText,
criteriaSummary,
emptyCompareOutcome1,
splitWorkflowApprovers,
targetTitle,
} from '@/utils/workflow/workflowHelpers'
import type { CompareOutcomeDto, WorkflowCriteriaDto } from '@/services/workflow.service'
import { SelectBoxOption } from '@/types/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import { MULTIVALUE_DELIMITER } from '@/constants/app.constant'
type WorkflowCriteriaProps = {
criteria: WorkflowCriteriaDto[]
@ -265,7 +267,7 @@ export function WorkflowCriteria({
asterisk
label={translate('::App.Listform.ListformField.Approver')}
>
<SelectField
<MultiSelectField
required
options={userList}
value={formValues.approver}
@ -527,6 +529,48 @@ function SelectField({
)
}
function MultiSelectField({
options,
value,
onChange,
required = false,
}: {
options: SelectBoxOption[]
value: string
onChange: (value: string) => void
required?: boolean
}) {
const values = splitWorkflowApprovers(value)
const selectedOptions = options.filter((option) => values.includes(String(option.value)))
return (
<>
<Select
isMulti
options={options}
value={selectedOptions}
onChange={(selected: any) =>
onChange(
(Array.isArray(selected) ? selected : [])
.map((option) => option?.value)
.filter(Boolean)
.join(MULTIVALUE_DELIMITER),
)
}
/>
{required && (
<input
required
tabIndex={-1}
className="pointer-events-none h-0 w-0 opacity-0"
value={value}
onChange={() => undefined}
/>
)}
</>
)
}
function criteriaSummaryContent(item: WorkflowCriteriaDto) {
if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes || []

View file

@ -6,16 +6,18 @@ import { usePermission } from '@/utils/hooks/usePermission'
import { DataGridTypes } from 'devextreme-react/data-grid'
import { ToolbarItem } from 'devextreme/ui/data_grid_types'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { useDialogContext } from '../shared/DialogContext'
import { usePWA } from '@/utils/hooks/usePWA'
import { layoutTypes, ListViewLayoutType } from '../admin/listForm/edit/types'
import { useStoreState } from '@/store'
import { workflowService } from '@/services/workflow.service'
import type { WorkflowRunResultDto } from '@/services/workflow.service'
import { splitWorkflowApprovers } from '@/utils/workflow/workflowHelpers'
type ToolbarModalData = {
open: boolean
content?: JSX.Element
content?: ReactNode
}
const showWorkflowToastMessages = (results: WorkflowRunResultDto | WorkflowRunResultDto[]) => {
@ -626,8 +628,13 @@ function isWorkflowApprovalCriteriaActive(
return true
}
const approver = normalizeWorkflowValue(row?.[workflowOptions.approvalUserFieldName])
return currentUserIdentities.some((identity) => normalizeWorkflowValue(identity) === approver)
const approvers = splitWorkflowApprovers(
String(row?.[workflowOptions.approvalUserFieldName] ?? ''),
).map(normalizeWorkflowValue)
return currentUserIdentities.some((identity) =>
approvers.includes(normalizeWorkflowValue(identity)),
)
}
function normalizeWorkflowValue(value: unknown) {