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) private string EncodePathAsId(string path)
{ {
// Path'deki '/' karakterlerini '|' ile değiştir URL-safe hale getirmek için
return path.Replace("/", "|"); return path.Replace("/", "|");
} }
private string DecodeIdAsPath(string id) private string DecodeIdAsPath(string id)
{ {
// ID'deki '|' karakterlerini '/' ile geri değiştir
return id.Replace("|", "/"); 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."); 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>(); var update = new Dictionary<string, object>();
if (!context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace()) if (!context.Workflow.ApprovalUserFieldName.IsNullOrWhiteSpace())
@ -548,7 +552,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
private async Task SendInformEmailAsync(WorkflowRunContext context, ListFormWorkflow node) 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 senderName = await settingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromDisplayName);
var senderEmail = await settingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromAddress); var senderEmail = await settingProvider.GetOrNullAsync(SeedConsts.AbpSettings.Mailing.Default.DefaultFromAddress);
KeyValuePair<string, string>? sender = null; KeyValuePair<string, string>? sender = null;
@ -557,11 +561,14 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
sender = new KeyValuePair<string, string>(senderName, senderEmail); sender = new KeyValuePair<string, string>(senderName, senderEmail);
} }
var body = BuildInformEmailBody(context, node, await BuildPreviousWorkflowNotesHtmlAsync(context));
foreach (var recipientEmail in recipientEmails)
{
var result = await erpEmailSender.SendEmailAsync( var result = await erpEmailSender.SendEmailAsync(
recipientEmail, recipientEmail,
sender, sender,
new { }, new { },
BuildInformEmailBody(context, node, await BuildPreviousWorkflowNotesHtmlAsync(context)), body,
$"Workflow Bilgilendirme: {node.Title}", $"Workflow Bilgilendirme: {node.Title}",
null, null,
true); true);
@ -570,9 +577,26 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
{ {
throw new UserFriendlyException($"Bilgilendirme maili gonderilemedi: {result.ErrorMessage}"); 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) private async Task<string> ResolveApproverEmailAsync(string approver)
{ {
if (approver.IsNullOrWhiteSpace()) if (approver.IsNullOrWhiteSpace())
@ -594,6 +618,45 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
return user.Email; 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) private async Task<string> BuildPreviousWorkflowNotesHtmlAsync(WorkflowRunContext context)
{ {
var key = context.Keys?.FirstOrDefault()?.ToString(); 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 keyText = string.Join(", ", context.Keys.Select(key => WebUtility.HtmlEncode(key?.ToString() ?? string.Empty)));
var listFormCode = WebUtility.HtmlEncode(context.ListFormCode ?? string.Empty); var listFormCode = WebUtility.HtmlEncode(context.ListFormCode ?? string.Empty);
var nodeTitle = WebUtility.HtmlEncode(node.Title ?? 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 processRows = BuildWorkflowNoteContent(context.WorkflowNoteRows);
var previousNotesSection = previousWorkflowNotesHtml.IsNullOrWhiteSpace() var previousNotesSection = previousWorkflowNotesHtml.IsNullOrWhiteSpace()
? string.Empty ? string.Empty
@ -697,7 +760,7 @@ public class ListFormWorkflowAppService : PlatformAppService, IListFormWorkflowA
if (!node.Approver.IsNullOrWhiteSpace()) 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() return userName.IsNullOrWhiteSpace()
? localizer["ListForms.ListForm.Workflow.UndefinedUser"].Value ? localizer["ListForms.ListForm.Workflow.UndefinedUser"].Value
: userName; : FormatWorkflowApprovers(userName);
}
private static string FormatWorkflowApprovers(string value)
{
return string.Join(", ", SplitWorkflowApprovers(value));
} }
private async Task InsertWorkflowNoteAsync( private async Task InsertWorkflowNoteAsync(

View file

@ -20,9 +20,9 @@ public static class AbpPermissionsExtensions
// 🔹 Gereksiz boşlukları temizle // 🔹 Gereksiz boşlukları temizle
var cleaned = string.Join( var cleaned = string.Join(
'|', "|",
menuGroup menuGroup
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct() .Distinct()
); );
@ -44,7 +44,7 @@ public static class AbpPermissionsExtensions
// 🔹 "Erp|Kurs" -> ["Erp", "Kurs"] // 🔹 "Erp|Kurs" -> ["Erp", "Kurs"]
return value return value
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct() .Distinct()
.ToArray(); .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, // hem de ApprovalUserFieldName alanı CurrentUserName'e eşit olan kayıtları getirmek istiyoruz,
// Boş olanları getirmemizin sebebi workflow start edebilmektir. // 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. // İ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( whereParts.Add(GetWorkflowApproverFilter(workflow.ApprovalUserFieldName));
$"(\"{workflow.ApprovalUserFieldName}\" = '{CurrentUser.UserName}' " +
$"OR \"{workflow.ApprovalUserFieldName}\" IS NULL " +
$"OR \"{workflow.ApprovalUserFieldName}\" = '')"
);
} }
} }
@ -495,6 +491,38 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager
return whereParts; 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> FieldName operator Id seklinde olan filtreleri ayirir
/// </summary> /// </summary>
/// <param name="filter"></param> /// <param name="filter"></param>
@ -829,8 +857,7 @@ public class SelectQueryManager : PlatformDomainService, ISelectQueryManager
} }
else else
{ {
// Birden fazla → '|' ile birleştir argumentExpression = string.Join(PlatformConsts.MultiValueDelimiter, argumentFields.Select(f => $"[{f}]"));
argumentExpression = string.Join(" + '|' + ", argumentFields.Select(f => $"[{f}]"));
} }
// Select sütunlarını oluştur // Select sütunlarını oluştur

View file

@ -1854,7 +1854,7 @@ internal static class DynamicReportImageHelper
private static bool IsImageDelimiter(string text, int index) private static bool IsImageDelimiter(string text, int index)
{ {
return text[index] == '|' || return text[index] == PlatformConsts.MultiValueDelimiter ||
text[index] == '\n' || text[index] == '\n' ||
text[index] == '\r' || text[index] == '\r' ||
(text[index] == ',' && !IsDataImageComma(text, index)); (text[index] == ',' && !IsDataImageComma(text, index));
@ -1868,7 +1868,7 @@ internal static class DynamicReportImageHelper
return false; return false;
} }
var previousDelimiterIndex = text.LastIndexOfAny(['|', '\r', '\n'], index); var previousDelimiterIndex = text.LastIndexOfAny([PlatformConsts.MultiValueDelimiter, '\r', '\n'], index);
return previousDelimiterIndex < dataImageIndex && return previousDelimiterIndex < dataImageIndex &&
text.IndexOf(";base64,", dataImageIndex, index - dataImageIndex + 1, StringComparison.OrdinalIgnoreCase) >= 0; 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 { getNodeHeight, nodeSize } from './workflowConstants'
import type { import type {
CompareOutcomeDto, CompareOutcomeDto,
@ -179,10 +179,10 @@ export function collectLinks(criteria: WorkflowCriteriaDto[]) {
field: 'nextOnReject', 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 endpointGroups = new Map<string, Endpoint[]>()
const addEndpoint = (nodeId: string, side: string, endpoint: Endpoint) => { const addEndpoint = (nodeId: string, side: string, endpoint: Endpoint) => {
const key = `${nodeId}:${side}` const key = `${nodeId}:${side}`
@ -588,11 +588,22 @@ export function criteriaSummary(item: WorkflowCriteriaDto) {
) )
} }
if (item.kind === 'Approval' || item.kind === 'Inform') { 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 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) { export function targetTitle(criteria: WorkflowCriteriaDto[], id?: string | null) {
if (!id) return '-' if (!id) return '-'
const item = criteria.find((candidate) => candidate.id === id) const item = criteria.find((candidate) => candidate.id === id)

View file

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

View file

@ -12,12 +12,14 @@ import {
compareOutcomeRuleText, compareOutcomeRuleText,
criteriaSummary, criteriaSummary,
emptyCompareOutcome1, emptyCompareOutcome1,
splitWorkflowApprovers,
targetTitle, targetTitle,
} from '@/utils/workflow/workflowHelpers' } from '@/utils/workflow/workflowHelpers'
import type { CompareOutcomeDto, WorkflowCriteriaDto } from '@/services/workflow.service' import type { CompareOutcomeDto, WorkflowCriteriaDto } from '@/services/workflow.service'
import { SelectBoxOption } from '@/types/shared' import { SelectBoxOption } from '@/types/shared'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models' import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import { MULTIVALUE_DELIMITER } from '@/constants/app.constant'
type WorkflowCriteriaProps = { type WorkflowCriteriaProps = {
criteria: WorkflowCriteriaDto[] criteria: WorkflowCriteriaDto[]
@ -265,7 +267,7 @@ export function WorkflowCriteria({
asterisk asterisk
label={translate('::App.Listform.ListformField.Approver')} label={translate('::App.Listform.ListformField.Approver')}
> >
<SelectField <MultiSelectField
required required
options={userList} options={userList}
value={formValues.approver} 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) { function criteriaSummaryContent(item: WorkflowCriteriaDto) {
if (item.kind === 'Compare') { if (item.kind === 'Compare') {
const outcomes = item.compareOutcomes || [] const outcomes = item.compareOutcomes || []

View file

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