Calude güncellemesi Grid, Tree, Rapor ve diğerleri

This commit is contained in:
Sedat ÖZTÜRK 2026-08-07 01:54:50 +03:00
parent ac4ccd76d6
commit 0224f4b3e1
55 changed files with 4815 additions and 6528 deletions

View file

@ -1,11 +1,14 @@
namespace Sozsoft.Platform.Enums;
namespace Sozsoft.Platform.Enums;
public static class ListFormTabTypeEnum
{
public const string List = "List";
public const string Card = "Card";
public const string Tree = "Tree";
public const string Gantt = "Gantt";
public const string Scheduler = "Scheduler";
public const string Todo = "Todo";
public const string Form = "Form";
public const string Chart = "Chart";
public const string Pivot = "Pivot";
}

View file

@ -30,6 +30,7 @@ public class DynamicFormReport : XtraReport
private const string QueryName = "DynamicForm";
private const int HorizontalMargin = 50;
private const int LandscapeColumnThreshold = 4;
private const int VerticalMargin = 35;
private const float RowHeight = 24;
private const float ColumnGap = 10;
private static readonly System.Drawing.Color FieldBorderColor = System.Drawing.Color.FromArgb(226, 232, 240);
@ -106,7 +107,7 @@ public class DynamicFormReport : XtraReport
await selectQueryManager.PrepareQueriesAsync(listForm, fields, dataSourceType, customizations, queryParams);
var reportFields = GetReportFields(listForm, fields, selectQueryManager, defaultValueHelper);
var reportFields = GetReportFields(fields, selectQueryManager, defaultValueHelper, localizer);
var selectQuery = DynamicReportImageHelper.StripReportPaging(DynamicReportImageHelper.ApplyQueryParameters(
selectQueryManager.SelectQuery,
selectQueryManager.SelectQueryParameters,
@ -133,23 +134,12 @@ public class DynamicFormReport : XtraReport
private void ConfigurePage(bool landscape)
{
Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35);
Margins = DynamicReportLayoutHelper.CreateMargins(HorizontalMargin, VerticalMargin);
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4;
Landscape = landscape;
Font = DynamicReportFontHelper.CreateDataFont();
}
#pragma warning disable CA1416
private static System.Drawing.Printing.Margins CreateReportMargins(
int left,
int right,
int top,
int bottom)
{
return new System.Drawing.Printing.Margins(left, right, top, bottom);
}
#pragma warning restore CA1416
private void ConfigureDataSource(string selectQuery, string connectionString, DataSourceTypeEnum dataSourceType)
{
var dataSource = new SqlDataSource(new CustomStringConnectionParameters(
@ -175,7 +165,8 @@ public class DynamicFormReport : XtraReport
var topMargin = new TopMarginBand { HeightF = Margins.Top };
var bottomMargin = new BottomMarginBand { HeightF = 32 };
var reportHeader = new ReportHeaderBand { HeightF = 38 };
var detail = new DetailBand { HeightF = CalculateDetailHeight(formGroups, subReports.Count) };
// Nihai yükseklik yerleşim tamamlandıktan sonra hesaplanıp atanır.
var detail = new DetailBand { HeightF = RowHeight };
Bands.AddRange([topMargin, reportHeader, detail, bottomMargin]);
@ -195,13 +186,7 @@ public class DynamicFormReport : XtraReport
DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth);
DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth);
bottomMargin.Controls.Add(new XRPageInfo
{
TextFormatString = "Sayfa {0} / {1}",
BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18),
Font = DynamicReportFontHelper.CreateValueFont(8F),
TextAlignment = TextAlignment.MiddleRight
});
DynamicReportLayoutHelper.AddPageInfoToFooter(bottomMargin.Controls, pageWidth);
var y = 0F;
foreach (var group in formGroups)
@ -451,25 +436,6 @@ public class DynamicFormReport : XtraReport
return true;
}
private static float CalculateDetailHeight(
IReadOnlyList<FormGroup> groups,
int subFormCount)
{
var groupHeight = groups
.Sum(group => (string.IsNullOrWhiteSpace(group.Caption) ? 0 : 26) +
CalculateGroupFieldsHeight(group) + 8);
var subFormHeight = subFormCount * 32;
return Math.Max(groupHeight + subFormHeight, RowHeight);
}
private static float CalculateGroupFieldsHeight(FormGroup group)
{
var columnCount = GetSafeColumnCount(group.ColumnCount);
return CreateLayoutRows(group.Fields, columnCount)
.Sum(row => row.Max(x => GetFieldRowHeight(x.Field)) + 3);
}
private static float GetCellWidth(float pageWidth, int columnCount)
{
return (pageWidth - ColumnGap * (columnCount - 1)) / columnCount;
@ -532,10 +498,10 @@ public class DynamicFormReport : XtraReport
}
private static List<FormReportField> GetReportFields(
ListForm listForm,
List<ListFormField> fields,
ISelectQueryManager selectQueryManager,
DefaultValueHelper defaultValueHelper)
DefaultValueHelper defaultValueHelper,
IStringLocalizer<PlatformResource> localizer)
{
var fieldsByName = fields
.Where(x => !string.IsNullOrWhiteSpace(x.FieldName) && x.FieldName != "undefined")
@ -565,7 +531,13 @@ public class DynamicFormReport : XtraReport
.Select(x =>
{
var selectField = selectFieldsByName[x.Field.FieldName];
return CreateFormReportField(x.Field, selectField, x.EditorType, x.ColumnSpan, defaultValueHelper);
return CreateFormReportField(
x.Field,
selectField,
x.EditorType,
x.ColumnSpan,
defaultValueHelper,
localizer);
})
.Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined")
.ToList();
@ -573,7 +545,6 @@ public class DynamicFormReport : XtraReport
private static IEnumerable<FormGroup> GetFormGroups(ListForm listForm, IReadOnlyList<FormReportField> fields)
{
var fieldsByName = fields.ToDictionary(x => x.Field.FieldName, StringComparer.OrdinalIgnoreCase);
var groups = GetEditingFormGroups(listForm).ToList();
if (groups.Count == 0)
{
@ -645,7 +616,7 @@ public class DynamicFormReport : XtraReport
yield break;
}
foreach (var subForm in subForms.Where(x => string.Equals(x.TabType, ListFormTypeEnum.List, StringComparison.OrdinalIgnoreCase)))
foreach (var subForm in subForms.Where(x => string.Equals(x.TabType, ListFormTabTypeEnum.List, StringComparison.OrdinalIgnoreCase)))
{
var relation = subForm.Relation?
.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.ParentFieldName) &&
@ -679,7 +650,8 @@ public class DynamicFormReport : XtraReport
SelectField selectField,
string editorType,
int columnSpan,
DefaultValueHelper defaultValueHelper)
DefaultValueHelper defaultValueHelper,
IStringLocalizer<PlatformResource> localizer)
{
var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField);
var lookup = DynamicReportImageHelper.GetLookup(field);
@ -697,7 +669,10 @@ public class DynamicFormReport : XtraReport
if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData)
{
var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper);
var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(
lookup,
defaultValueHelper,
localizer);
return new FormReportField(
field,
staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName,

View file

@ -29,8 +29,8 @@ public class DynamicGridReport : XtraReport
private const int A4PortraitWidth = 827;
private const int HorizontalMargin = 50;
private const int LandscapeColumnThreshold = 5;
private const int VerticalMargin = 35;
private const float GroupIndentWidth = 18F;
private static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240);
private static IServiceScopeFactory _scopeFactory;
public bool RequiresLandscapeLayout { get; private set; }
@ -149,12 +149,14 @@ public class DynamicGridReport : XtraReport
groupedFields,
selectFieldsByName,
editorTypesByField,
defaultValueHelper);
defaultValueHelper,
localizer);
var reportColumns = CreateReportColumns(
reportFields,
selectFieldsByName,
editorTypesByField,
defaultValueHelper);
defaultValueHelper,
localizer);
var allColumns = groupColumns
.Concat(reportColumns)
@ -188,40 +190,38 @@ public class DynamicGridReport : XtraReport
RequestParameters = false;
}
private static List<ReportColumn> CreateReportColumns(
private static List<DynamicReportColumn> CreateReportColumns(
IEnumerable<ListFormField> fields,
IReadOnlyDictionary<string, SelectField> selectFieldsByName,
IReadOnlyDictionary<string, string> editorTypesByField,
DefaultValueHelper defaultValueHelper)
DefaultValueHelper defaultValueHelper,
IStringLocalizer<PlatformResource> localizer)
{
return fields
.Select(field =>
{
var selectField = selectFieldsByName[field.FieldName];
editorTypesByField.TryGetValue(field.FieldName, out var editorType);
return CreateReportColumn(
return DynamicReportColumnFactory.Create(
field,
selectField,
selectFieldsByName[field.FieldName],
defaultValueHelper,
editorType,
DynamicReportImageHelper.IsImageEditor(editorType),
editorType == PlatformConsts.EditorTypes.dxHtmlEditor);
localizer);
})
.Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined")
.Where(x => x.HasBindingName)
.ToList();
}
#pragma warning disable CA1416
private void ConfigurePage(
IReadOnlyCollection<ReportColumn> visibleFields,
IReadOnlyCollection<DynamicReportColumn> visibleFields,
IStringLocalizer<PlatformResource> localizer,
IReadOnlyDictionary<string, float> stateWidths)
{
Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35);
Margins = DynamicReportLayoutHelper.CreateMargins(HorizontalMargin, VerticalMargin);
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4;
var portraitContentWidth = A4PortraitWidth - Margins.Left - Margins.Right;
var requestedContentWidth = visibleFields.Sum(x => x.IsImage
? GetImageColumnWidth(x)
? x.ImageWidth
: DynamicReportGridStateHelper.GetPreferredColumnWidth(x.Field, localizer, stateWidths));
RequiresLandscapeLayout = visibleFields.Count >= LandscapeColumnThreshold ||
requestedContentWidth > portraitContentWidth;
@ -229,16 +229,6 @@ public class DynamicGridReport : XtraReport
Font = DynamicReportFontHelper.CreateDataFont();
}
private static System.Drawing.Printing.Margins CreateReportMargins(
int left,
int right,
int top,
int bottom)
{
return new System.Drawing.Printing.Margins(left, right, top, bottom);
}
#pragma warning restore CA1416
private void ConfigureDataSource(string selectQuery, string connectionString, DataSourceTypeEnum dataSourceType)
{
var dataSource = new SqlDataSource(new CustomStringConnectionParameters(
@ -249,8 +239,8 @@ public class DynamicGridReport : XtraReport
DataMember = QueryName;
}
private static async Task<List<ReportColumn>> ApplyImageCountsAsync(
IReadOnlyList<ReportColumn> columns,
private static async Task<List<DynamicReportColumn>> ApplyImageCountsAsync(
IReadOnlyList<DynamicReportColumn> columns,
IDynamicDataRepository dynamicDataRepository,
string selectQuery,
string connectionString,
@ -286,7 +276,7 @@ public class DynamicGridReport : XtraReport
.ToList();
}
private static object GetImageRawValue(IDictionary<string, object> row, ReportColumn column)
private static object GetImageRawValue(IDictionary<string, object> row, DynamicReportColumn column)
{
if (DynamicReportImageHelper.TryGetRowValue(row, column.BindingName, out var value) &&
DynamicReportImageHelper.HasValue(value))
@ -299,8 +289,8 @@ public class DynamicGridReport : XtraReport
private void BuildLayout(
ListForm listForm,
IReadOnlyList<ReportColumn> fields,
IReadOnlyList<ReportColumn> groupFields,
IReadOnlyList<DynamicReportColumn> fields,
IReadOnlyList<DynamicReportColumn> groupFields,
IStringLocalizer<PlatformResource> localizer,
string imageBaseUrl,
string currentUserName,
@ -317,14 +307,11 @@ public class DynamicGridReport : XtraReport
{
HeightF = !isSubReport && !string.IsNullOrWhiteSpace(criteriaText) ? 50 : 32
};
var pageHeader = new PageHeaderBand { HeightF = 28 };
var detailHeight = fields.Any(x => x.IsImage)
? DynamicReportImageHelper.GetGridImageHeight()
: fields.Any(x => x.IsHtml)
? 48
: 24;
var pageHeader = new PageHeaderBand { HeightF = DynamicReportLayoutHelper.HeaderRowHeight };
var detailHeight = DynamicReportLayoutHelper.GetDetailHeight(fields);
var detail = new DetailBand { HeightF = detailHeight };
var reportFooter = new ReportFooterBand { HeightF = HasAnySummary(fields) ? 28 : 0 };
var hasSummary = DynamicReportLayoutHelper.HasAnySummary(fields);
var reportFooter = new ReportFooterBand { HeightF = hasSummary ? 28 : 0 };
var groupHeaderBands = CreateGroupHeaderBands(groupFields, localizer, pageWidth);
Bands.AddRange([topMargin, reportHeader, pageHeader]);
@ -344,34 +331,23 @@ public class DynamicGridReport : XtraReport
if (!isSubReport)
{
DynamicReportImageHelper.AddGeneratedAtToHeader(reportHeader.Controls, pageWidth);
}
if (!isSubReport)
{
DynamicReportImageHelper.AddReportCriteriaToHeader(reportHeader.Controls, criteriaText, pageWidth);
}
DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth);
DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth);
DynamicReportLayoutHelper.AddPageInfoToFooter(bottomMargin.Controls, pageWidth);
var pageInfo = new XRPageInfo
{
TextFormatString = "Sayfa {0} / {1}",
BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18),
Font = DynamicReportFontHelper.CreateValueFont(8F),
TextAlignment = TextAlignment.MiddleRight
};
bottomMargin.Controls.Add(pageInfo);
var detailIndent = GetGroupDetailIndent(groupFields.Count, pageWidth);
var detailIndent = GetGroupIndent(groupFields.Count, pageWidth);
var tableWidth = pageWidth - detailIndent;
var detailWidths = DynamicReportGridStateHelper.CalculateWidths(fields, tableWidth, localizer, stateWidths);
var headerTable = CreateTable(pageWidth, 28);
var headerRow = new XRTableRow { HeightF = 28 };
var detailTable = CreateTable(tableWidth, detailHeight, detailIndent);
var headerTable = DynamicReportLayoutHelper.CreateTable(pageWidth, DynamicReportLayoutHelper.HeaderRowHeight);
var headerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.HeaderRowHeight };
var detailTable = DynamicReportLayoutHelper.CreateTable(tableWidth, detailHeight, detailIndent);
detailTable.CanGrow = true;
var detailRow = new XRTableRow { HeightF = detailHeight, CanGrow = true };
var footerTable = CreateTable(tableWidth, 24, detailIndent);
var footerRow = new XRTableRow { HeightF = 24 };
var footerTable = DynamicReportLayoutHelper.CreateTable(tableWidth, DynamicReportLayoutHelper.FooterRowHeight, detailIndent);
var footerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.FooterRowHeight };
if (detailIndent > 0)
{
@ -381,9 +357,9 @@ public class DynamicGridReport : XtraReport
for (var i = 0; i < fields.Count; i++)
{
var field = fields[i];
headerRow.Cells.Add(CreateHeaderCell(field, detailWidths[i], localizer));
detailRow.Cells.Add(CreateDetailCell(field, detailWidths[i], imageBaseUrl, detailHeight));
footerRow.Cells.Add(CreateFooterCell(field, detailWidths[i]));
headerRow.Cells.Add(DynamicReportLayoutHelper.CreateHeaderCell(field, detailWidths[i], localizer));
detailRow.Cells.Add(DynamicReportLayoutHelper.CreateDetailCell(field, detailWidths[i], imageBaseUrl, detailHeight));
footerRow.Cells.Add(DynamicReportLayoutHelper.CreateFooterCell(field, detailWidths[i]));
}
headerTable.Rows.Add(headerRow);
@ -392,14 +368,14 @@ public class DynamicGridReport : XtraReport
pageHeader.Controls.Add(headerTable);
detail.Controls.Add(detailTable);
if (HasAnySummary(fields))
if (hasSummary)
{
reportFooter.Controls.Add(footerTable);
}
}
private static List<GroupHeaderBand> CreateGroupHeaderBands(
IReadOnlyList<ReportColumn> groupFields,
IReadOnlyList<DynamicReportColumn> groupFields,
IStringLocalizer<PlatformResource> localizer,
float pageWidth)
{
@ -418,7 +394,7 @@ public class DynamicGridReport : XtraReport
};
band.GroupFields.Add(new GroupField(groupField.BindingName, XRColumnSortOrder.Ascending));
var indent = GetGroupHeaderIndent(i, pageWidth);
var indent = GetGroupIndent(i, pageWidth);
var caption = DynamicReportImageHelper.GetLocalizedFieldCaption(groupField.Field, localizer);
var availableWidth = pageWidth - indent;
var captionWidth = Math.Clamp(caption.Length * 6F + 28F, 90F, Math.Min(220F, availableWidth / 2));
@ -432,7 +408,7 @@ public class DynamicGridReport : XtraReport
TextAlignment = TextAlignment.MiddleLeft,
BackColor = System.Drawing.Color.FromArgb(226, 232, 240),
Borders = BorderSide.Left | BorderSide.Top | BorderSide.Bottom,
BorderColor = TableBorderColor,
BorderColor = DynamicReportLayoutHelper.TableBorderColor,
BorderWidth = 0.25F
};
var valueLabel = new XRLabel
@ -443,7 +419,7 @@ public class DynamicGridReport : XtraReport
TextAlignment = TextAlignment.MiddleLeft,
BackColor = System.Drawing.Color.FromArgb(241, 245, 249),
Borders = BorderSide.All,
BorderColor = TableBorderColor,
BorderColor = DynamicReportLayoutHelper.TableBorderColor,
BorderWidth = 0.25F,
CanGrow = true,
Multiline = true
@ -463,25 +439,9 @@ public class DynamicGridReport : XtraReport
return bands;
}
private static float GetGroupHeaderIndent(int groupIndex, float pageWidth)
private static float GetGroupIndent(int level, float pageWidth)
{
return Math.Min(groupIndex * GroupIndentWidth, pageWidth * 0.25F);
}
private static float GetGroupDetailIndent(int groupCount, float pageWidth)
{
return Math.Min(groupCount * GroupIndentWidth, pageWidth * 0.25F);
}
private static XRTable CreateTable(float width, float height, float left = 0)
{
return new XRTable
{
BoundsF = new System.Drawing.RectangleF(left, 0, width, height),
Borders = BorderSide.All,
BorderColor = TableBorderColor,
BorderWidth = 0.25F
};
return Math.Min(level * GroupIndentWidth, pageWidth * 0.25F);
}
private static XRTableCell CreateIndentHeaderCell(float width)
@ -495,244 +455,6 @@ public class DynamicGridReport : XtraReport
};
}
private static XRTableCell CreateHeaderCell(
ReportColumn column,
float width,
IStringLocalizer<PlatformResource> localizer)
{
var field = column.Field;
return new XRTableCell
{
Text = DynamicReportImageHelper.GetLocalizedFieldCaption(field, localizer),
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateHeaderFont(),
BackColor = System.Drawing.Color.FromArgb(238, 242, 247),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = TextAlignment.MiddleCenter,
Multiline = true
};
}
private static XRTableCell CreateDetailCell(
ReportColumn column,
float width,
string imageBaseUrl,
float height)
{
var field = column.Field;
if (column.IsImage)
{
return DynamicReportImageHelper.CreateGridImageCell(
column.BindingName,
column.Field.FieldName,
width,
imageBaseUrl,
column.ImageCount);
}
if (column.IsHtml)
{
return CreateHtmlDetailCell(column, width, height);
}
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateDataFont(),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = DynamicReportImageHelper.ToTextAlignment(field.Alignment, field.SourceDbType),
Multiline = true,
CanGrow = true
};
var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression($"[{column.BindingName}]", field, column.EditorType);
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Text",
valueExpression));
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Font.Name",
DynamicReportImageHelper.CreateFontFamilyExpression(valueExpression)));
return cell;
}
private static XRTableCell CreateHtmlDetailCell(
ReportColumn column,
float width,
float height)
{
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Padding = new PaddingInfo(0, 0, 0, 0),
CanGrow = true
};
var richText = new XRRichText
{
BoundsF = new System.Drawing.RectangleF(0, 0, width, height),
Font = DynamicReportFontHelper.CreateDataFont(),
Padding = new PaddingInfo(4, 4, 2, 2),
CanGrow = true
};
richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]"));
cell.Controls.Add(richText);
return cell;
}
private static XRTableCell CreateFooterCell(ReportColumn column, float width)
{
var field = column.Field;
var summaryType = GetSummaryType(field.TotalSummaryJson);
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateDataFont(DXFontStyle.Bold),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = DynamicReportImageHelper.ToTextAlignment(field.Alignment, field.SourceDbType)
};
if (summaryType == null)
{
return cell;
}
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Text",
DynamicReportImageHelper.CreateFormatValueExpression(
CreateSummaryExpression(summaryType.Value, column.BindingName),
field,
column.EditorType)));
cell.Summary = new XRSummary
{
Running = SummaryRunning.Report,
Func = summaryType.Value,
IgnoreNullValues = true
};
return cell;
}
private static string CreateSummaryExpression(SummaryFunc summaryType, string bindingName)
{
return summaryType switch
{
SummaryFunc.Sum => $"sumSum([{bindingName}])",
SummaryFunc.Avg => $"sumAvg([{bindingName}])",
SummaryFunc.Min => $"sumMin([{bindingName}])",
SummaryFunc.Max => $"sumMax([{bindingName}])",
SummaryFunc.Count => "sumCount()",
_ => $"sumSum([{bindingName}])"
};
}
private static bool HasAnySummary(IEnumerable<ReportColumn> fields)
{
return fields.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null);
}
private static float GetImageColumnWidth(ReportColumn column)
{
return DynamicReportImageHelper.GetGridImageColumnWidth(column.ImageCount);
}
private static ReportColumn CreateReportColumn(
ListFormField field,
SelectField selectField,
DefaultValueHelper defaultValueHelper,
string editorType,
bool isImage,
bool isHtml)
{
var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField);
var lookup = DynamicReportImageHelper.GetLookup(field);
var isMultiValue = IsMultiValueLookupEditor(editorType);
if (lookup == null || selectField?.JoinOptions != null)
{
return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType);
}
if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup))
{
return new ReportColumn(field, DynamicReportSqlBuilder.GetLookupBindingName(field), lookup, null, isMultiValue, isImage, isHtml, editorType);
}
if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData)
{
var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper);
return new ReportColumn(
field,
staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName,
lookup,
staticLookupValues,
isMultiValue,
isImage,
isHtml,
editorType);
}
return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType);
}
private static bool IsMultiValueLookupEditor(string editorType)
{
return editorType is PlatformConsts.EditorTypes.dxTagBox or "dxGridBox";
}
private sealed record ReportColumn(
ListFormField Field,
string BindingName,
LookupDto Lookup,
Dictionary<string, string> StaticLookupValues,
bool IsMultiValue,
bool IsImage,
bool IsHtml,
string EditorType,
int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn, DynamicReportGridStateHelper.IReportColumnWidth
{
public float ImageWidth => GetImageColumnWidth(this);
}
private static SummaryFunc? GetSummaryType(string totalSummaryJson)
{
if (string.IsNullOrWhiteSpace(totalSummaryJson))
{
return null;
}
var value = totalSummaryJson.ToLowerInvariant();
if (value.Contains("\"summarytype\":\"sum\""))
{
return SummaryFunc.Sum;
}
if (value.Contains("\"summarytype\":\"avg\""))
{
return SummaryFunc.Avg;
}
if (value.Contains("\"summarytype\":\"min\""))
{
return SummaryFunc.Min;
}
if (value.Contains("\"summarytype\":\"max\""))
{
return SummaryFunc.Max;
}
if (value.Contains("\"summarytype\":\"count\""))
{
return SummaryFunc.Count;
}
return null;
}
private static string GetParameter(IDictionary<string, string> parameters, string name) =>
DynamicReportImageHelper.GetParameter(parameters, name);
}

View file

@ -111,10 +111,9 @@ public partial class DynamicReport : XtraReport
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
var language = dbContext.Set<Language>()
.FirstOrDefault(l => l.DisplayName == "Türkçe");
var language = GetCurrentLanguage(dbContext);
if (language != null)
{
SetParameter("id", language.Id.ToString());
@ -129,6 +128,24 @@ public partial class DynamicReport : XtraReport
}
}
/// <summary>
/// Rapor isteğiyle gelen kültüre karşılık gelen dil kaydını döner.
/// Eşleşme yoksa etkin dillerden ilki kullanılır.
/// </summary>
private static Language GetCurrentLanguage(PlatformDbContext dbContext)
{
var cultureName = System.Globalization.CultureInfo.CurrentUICulture.Name;
var twoLetterName = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
var languages = dbContext.Set<Language>().ToList();
return languages.FirstOrDefault(l =>
string.Equals(l.CultureName, cultureName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(l.UiCultureName, cultureName, StringComparison.OrdinalIgnoreCase)) ??
languages.FirstOrDefault(l =>
string.Equals(l.TwoLetterISOLanguageName, twoLetterName, StringComparison.OrdinalIgnoreCase)) ??
languages.FirstOrDefault(l => l.IsEnabled);
}
private void XrRichText1_BeforePrint(object sender, CancelEventArgs e)
{
var rt = (XRRichText)sender;

View file

@ -0,0 +1,89 @@
using System.Collections.Generic;
using Microsoft.Extensions.Localization;
using Sozsoft.Platform;
using Sozsoft.Platform.Localization;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.ListForms;
using Sozsoft.Platform.Queries;
namespace Sozsoft.Reports.PredefinedReports;
/// <summary>
/// Grid ve Tree raporlarının ortak kolon tanımı.
/// </summary>
internal sealed record DynamicReportColumn(
ListFormField Field,
string BindingName,
LookupDto Lookup,
Dictionary<string, string> StaticLookupValues,
bool IsMultiValue,
bool IsImage,
bool IsHtml,
string EditorType,
int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn, DynamicReportGridStateHelper.IReportColumnWidth
{
public float ImageWidth => DynamicReportImageHelper.GetGridImageColumnWidth(ImageCount);
public bool HasBindingName =>
!string.IsNullOrWhiteSpace(BindingName) && BindingName != "undefined";
}
internal static class DynamicReportColumnFactory
{
public static DynamicReportColumn Create(
ListFormField field,
SelectField selectField,
DefaultValueHelper defaultValueHelper,
string editorType,
IStringLocalizer<PlatformResource> localizer)
{
var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField);
var lookup = DynamicReportImageHelper.GetLookup(field);
var isMultiValue = IsMultiValueLookupEditor(editorType);
var isImage = DynamicReportImageHelper.IsImageEditor(editorType);
var isHtml = editorType == PlatformConsts.EditorTypes.dxHtmlEditor;
if (lookup == null || selectField?.JoinOptions != null)
{
return new DynamicReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType);
}
if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup))
{
return new DynamicReportColumn(
field,
DynamicReportSqlBuilder.GetLookupBindingName(field),
lookup,
null,
isMultiValue,
isImage,
isHtml,
editorType);
}
if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData)
{
var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(
lookup,
defaultValueHelper,
localizer);
return new DynamicReportColumn(
field,
staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName,
lookup,
staticLookupValues,
isMultiValue,
isImage,
isHtml,
editorType);
}
return new DynamicReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType);
}
public static bool IsMultiValueLookupEditor(string editorType)
{
return editorType is PlatformConsts.EditorTypes.dxTagBox or "dxGridBox";
}
}

View file

@ -136,12 +136,6 @@ internal static class DynamicReportGridStateHelper
IStringLocalizer<PlatformResource> localizer = null,
IReadOnlyDictionary<string, float> stateWidths = null)
{
var caption = localizer == null
? (string.IsNullOrWhiteSpace(field.CaptionName) ? field.FieldName : field.CaptionName)
: DynamicReportImageHelper.GetLocalizedFieldCaption(field, localizer);
var name = field.FieldName ?? string.Empty;
var calculatedWidth = CalculateTextColumnWidth(field, caption, name);
if (!string.IsNullOrWhiteSpace(field.FieldName) &&
stateWidths?.TryGetValue(field.FieldName, out var stateWidth) == true &&
stateWidth > 0)
@ -154,7 +148,11 @@ internal static class DynamicReportGridStateHelper
return Math.Max(field.Width.Value, 40F);
}
return calculatedWidth;
var caption = localizer == null
? (string.IsNullOrWhiteSpace(field.CaptionName) ? field.FieldName : field.CaptionName)
: DynamicReportImageHelper.GetLocalizedFieldCaption(field, localizer);
return CalculateTextColumnWidth(field, caption, field.FieldName ?? string.Empty);
}
private static string GetStateStorageKey(ListForm listForm)

View file

@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
@ -51,6 +52,11 @@ internal static class DynamicReportImageHelper
Timeout = TimeSpan.FromSeconds(5)
};
// Aynı görsel bir raporda çok sayıda satırda ve hem ImageUrl hem ImageSource ifadelerinde
// kullanılabildiği için indirilen içerik süreç boyunca önbelleklenir.
private const int MaxCachedRemoteImages = 256;
private static readonly ConcurrentDictionary<string, byte[]> RemoteImageCache = new(StringComparer.Ordinal);
static DynamicReportImageHelper()
{
RegisterImageFunction(new ReportImageUrlFunction());
@ -209,9 +215,15 @@ internal static class DynamicReportImageHelper
}
}
/// <summary>
/// Statik lookup tanımlarını okur. Görüntü metinleri arayüzdeki gibi
/// (<c>translate('::' + name)</c>) yerelleştirme anahtarı olabildiği için
/// <paramref name="localizer"/> verildiğinde çevrilerek döner.
/// </summary>
public static Dictionary<string, string> GetStaticLookupValues(
LookupDto lookup,
DefaultValueHelper defaultValueHelper)
DefaultValueHelper defaultValueHelper,
IStringLocalizer<PlatformResource> localizer = null)
{
if (string.IsNullOrWhiteSpace(lookup.LookupQuery))
{
@ -244,7 +256,8 @@ internal static class DynamicReportImageHelper
continue;
}
values[value] = GetJsonScalar(displayElement) ?? value;
var display = GetJsonScalar(displayElement) ?? value;
values[value] = localizer == null ? display : Localize(localizer, display, display);
}
return values.Count == 0 ? null : values;
@ -440,18 +453,6 @@ internal static class DynamicReportImageHelper
return editorType is PlatformConsts.EditorTypes.dxImageUpload or PlatformConsts.EditorTypes.dxImageViewer;
}
public static bool HasImageEditor(IEnumerable<ListFormField> fields, string fieldName)
{
if (string.IsNullOrWhiteSpace(fieldName))
{
return false;
}
return (fields ?? []).Any(x =>
string.Equals(x.FieldName, fieldName, StringComparison.OrdinalIgnoreCase) &&
IsImageEditor(x.EditorType2));
}
public static float GetGridImageHeight() => DefaultGridImageHeight;
public static float GetFormImageHeight() => DefaultFormImageHeight;
@ -496,46 +497,6 @@ internal static class DynamicReportImageHelper
return safeImageCount * DefaultGridImageWidth + (safeImageCount - 1) * 4F + 8F;
}
private static object GetCurrentValue(XRControl control, string bindingName, string fieldName)
{
if (HasImageValue(control.Tag))
{
return control.Tag;
}
var report = control.Report;
if (report == null)
{
return null;
}
var value = GetCurrentColumnValue(report, bindingName);
return HasImageValue(value) || string.Equals(bindingName, fieldName, StringComparison.OrdinalIgnoreCase)
? value
: GetCurrentColumnValue(report, fieldName);
}
private static object GetCurrentColumnValue(XtraReportBase report, string fieldName)
{
if (string.IsNullOrWhiteSpace(fieldName))
{
return null;
}
try
{
return report.GetCurrentColumnValue(fieldName);
}
catch (InvalidOperationException)
{
return null;
}
catch (ArgumentException)
{
return null;
}
}
public static bool HasValue(object value)
{
return value != null &&
@ -1070,8 +1031,8 @@ internal static class DynamicReportImageHelper
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.GetRawText(),
JsonValueKind.True => "Evet",
JsonValueKind.False => "Hayır",
JsonValueKind.True => FormatBoolean(true),
JsonValueKind.False => FormatBoolean(false),
JsonValueKind.Null => string.Empty,
JsonValueKind.Array => string.Join(", ", value.EnumerateArray().Select(FormatJsonValue)),
_ => value.ToString()
@ -1107,20 +1068,6 @@ internal static class DynamicReportImageHelper
dbType == DbType.Boolean;
}
private static string GetCustomFormat(string format)
{
if (string.IsNullOrWhiteSpace(format))
{
return null;
}
var customFormat = TryGetFormatOption(format, "displayFormat") ??
TryGetFormatOption(format, "format") ??
format.Trim();
return NormalizeDateTimeFormat(customFormat);
}
private static string GetEffectiveFormat(ListFormField field)
{
return TryGetFormatOption(field?.EditorOptions, "displayFormat") ??
@ -1355,11 +1302,6 @@ internal static class DynamicReportImageHelper
}
}
private static bool HasImageValue(object value)
{
return HasValue(value) && NormalizeImageValue(value).Any(IsPotentialImageSource);
}
private static bool IsPotentialImageSource(string value)
{
if (string.IsNullOrWhiteSpace(value) ||
@ -1436,28 +1378,6 @@ internal static class DynamicReportImageHelper
}
}
private static void ArrangePictureBoxes(
IReadOnlyList<XRPictureBox> pictureBoxes,
float width,
float height,
int visibleCount)
{
var safeCount = Math.Max(visibleCount, 1);
var gap = 4F;
var boxWidth = safeCount == 1
? width
: Math.Max((width - gap * (safeCount - 1)) / safeCount, 1);
for (var i = 0; i < pictureBoxes.Count; i++)
{
pictureBoxes[i].BoundsF = new System.Drawing.RectangleF(
i * (boxWidth + gap),
0,
boxWidth,
height);
}
}
private static string GetImageSourceAt(object rawValue, string baseUrl, int index)
{
if (index < 0)
@ -1600,25 +1520,44 @@ internal static class DynamicReportImageHelper
return false;
}
bytes = GetRemoteImageBytes(uri);
return bytes?.Length > 0;
}
private static byte[] GetRemoteImageBytes(Uri uri)
{
var cacheKey = uri.AbsoluteUri;
if (RemoteImageCache.TryGetValue(cacheKey, out var cached))
{
return cached;
}
var bytes = DownloadImageBytes(uri);
if (RemoteImageCache.Count < MaxCachedRemoteImages)
{
RemoteImageCache.TryAdd(cacheKey, bytes);
}
return bytes;
}
private static byte[] DownloadImageBytes(Uri uri)
{
try
{
bytes = HttpClient.GetByteArrayAsync(uri).GetAwaiter().GetResult();
return bytes?.Length > 0;
return HttpClient.GetByteArrayAsync(uri).GetAwaiter().GetResult();
}
catch (HttpRequestException)
{
bytes = null;
return false;
return null;
}
catch (TaskCanceledException)
{
bytes = null;
return false;
return null;
}
catch (InvalidOperationException)
{
bytes = null;
return false;
return null;
}
}

View file

@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DevExpress.Drawing;
using DevExpress.XtraPrinting;
using DevExpress.XtraReports.UI;
using Microsoft.Extensions.Localization;
using Sozsoft.Platform.Localization;
namespace Sozsoft.Reports.PredefinedReports;
/// <summary>
/// Grid ve Tree raporlarının ortak tablo/hücre/özet üretimi.
/// </summary>
internal static class DynamicReportLayoutHelper
{
public const float HeaderRowHeight = 28F;
public const float FooterRowHeight = 24F;
public const float DefaultDetailHeight = 24F;
public const float HtmlDetailHeight = 48F;
public static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240);
private static readonly System.Drawing.Color HeaderBackColor = System.Drawing.Color.FromArgb(238, 242, 247);
#pragma warning disable CA1416
public static System.Drawing.Printing.Margins CreateMargins(int horizontal, int vertical)
{
return new System.Drawing.Printing.Margins(horizontal, horizontal, vertical, vertical);
}
#pragma warning restore CA1416
public static float GetDetailHeight(IEnumerable<DynamicReportColumn> columns)
{
var materialized = columns as IReadOnlyCollection<DynamicReportColumn> ?? columns.ToList();
if (materialized.Any(x => x.IsImage))
{
return DynamicReportImageHelper.GetGridImageHeight();
}
return materialized.Any(x => x.IsHtml) ? HtmlDetailHeight : DefaultDetailHeight;
}
public static XRTable CreateTable(float width, float height, float left = 0)
{
return new XRTable
{
BoundsF = new System.Drawing.RectangleF(left, 0, width, height),
Borders = BorderSide.All,
BorderColor = TableBorderColor,
BorderWidth = 0.25F
};
}
public static XRTableCell CreateHeaderCell(
DynamicReportColumn column,
float width,
IStringLocalizer<PlatformResource> localizer)
{
return new XRTableCell
{
Text = DynamicReportImageHelper.GetLocalizedFieldCaption(column.Field, localizer),
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateHeaderFont(),
BackColor = HeaderBackColor,
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = TextAlignment.MiddleCenter,
Multiline = true
};
}
/// <summary>
/// Detay hücresi üretir. <paramref name="prefixColumnName"/> verildiğinde metnin başına
/// ilgili kolonun değeri eklenir (Tree raporundaki girinti için kullanılır).
/// </summary>
public static XRTableCell CreateDetailCell(
DynamicReportColumn column,
float width,
string imageBaseUrl,
float height,
string prefixColumnName = null,
string boldColumnName = null)
{
if (column.IsImage)
{
return DynamicReportImageHelper.CreateGridImageCell(
column.BindingName,
column.Field.FieldName,
width,
imageBaseUrl,
column.ImageCount);
}
if (column.IsHtml)
{
return CreateHtmlDetailCell(column, width, height);
}
var hasPrefix = !string.IsNullOrWhiteSpace(prefixColumnName);
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateDataFont(),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = hasPrefix
? TextAlignment.MiddleLeft
: DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType),
Multiline = true,
CanGrow = true
};
var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression(
$"[{column.BindingName}]",
column.Field,
column.EditorType);
var textExpression = hasPrefix
? $"Concat([{prefixColumnName}], {valueExpression})"
: valueExpression;
cell.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Text", textExpression));
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Font.Name",
DynamicReportImageHelper.CreateFontFamilyExpression(textExpression)));
if (!string.IsNullOrWhiteSpace(boldColumnName))
{
cell.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Font.Bold", $"[{boldColumnName}]"));
}
return cell;
}
public static XRTableCell CreateHtmlDetailCell(DynamicReportColumn column, float width, float height)
{
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Padding = new PaddingInfo(0, 0, 0, 0),
CanGrow = true
};
var richText = new XRRichText
{
BoundsF = new System.Drawing.RectangleF(0, 0, width, height),
Font = DynamicReportFontHelper.CreateDataFont(),
Padding = new PaddingInfo(4, 4, 2, 2),
CanGrow = true
};
richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]"));
cell.Controls.Add(richText);
return cell;
}
public static XRTableCell CreateFooterCell(DynamicReportColumn column, float width)
{
var summaryType = GetSummaryType(column.Field.TotalSummaryJson);
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateDataFont(DXFontStyle.Bold),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType)
};
if (summaryType == null)
{
return cell;
}
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Text",
DynamicReportImageHelper.CreateFormatValueExpression(
CreateSummaryExpression(summaryType.Value, column.BindingName),
column.Field,
column.EditorType)));
cell.Summary = new XRSummary
{
Running = SummaryRunning.Report,
Func = summaryType.Value,
IgnoreNullValues = true
};
return cell;
}
public static bool HasAnySummary(IEnumerable<DynamicReportColumn> columns)
{
return columns.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null);
}
public static void AddPageInfoToFooter(XRControlCollection controls, float pageWidth)
{
controls.Add(new XRPageInfo
{
TextFormatString = "Sayfa {0} / {1}",
BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18),
Font = DynamicReportFontHelper.CreateValueFont(8F),
TextAlignment = TextAlignment.MiddleRight
});
}
public static string CreateSummaryExpression(SummaryFunc summaryType, string bindingName)
{
return summaryType switch
{
SummaryFunc.Avg => $"sumAvg([{bindingName}])",
SummaryFunc.Min => $"sumMin([{bindingName}])",
SummaryFunc.Max => $"sumMax([{bindingName}])",
SummaryFunc.Count => "sumCount()",
_ => $"sumSum([{bindingName}])"
};
}
/// <summary>
/// ListForm alanındaki toplam özeti tanımını okur. JSON tek bir nesne veya dizi olabilir.
/// </summary>
public static SummaryFunc? GetSummaryType(string totalSummaryJson)
{
if (string.IsNullOrWhiteSpace(totalSummaryJson))
{
return null;
}
try
{
using var document = JsonDocument.Parse(totalSummaryJson);
return GetSummaryType(document.RootElement);
}
catch (JsonException)
{
return null;
}
}
private static SummaryFunc? GetSummaryType(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Array)
{
return element.EnumerateArray()
.Select(GetSummaryType)
.FirstOrDefault(x => x != null);
}
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var property in element.EnumerateObject())
{
if (!string.Equals(property.Name, "summaryType", StringComparison.OrdinalIgnoreCase) ||
property.Value.ValueKind != JsonValueKind.String)
{
continue;
}
return property.Value.GetString()?.ToLowerInvariant() switch
{
"sum" => SummaryFunc.Sum,
"avg" => SummaryFunc.Avg,
"min" => SummaryFunc.Min,
"max" => SummaryFunc.Max,
"count" => SummaryFunc.Count,
_ => null
};
}
return null;
}
}

View file

@ -13,9 +13,6 @@ internal static class DynamicReportSqlBuilder
{
private const string SourceAlias = "ReportSource";
private const string LookupAlias = "LookupSource";
private static readonly Regex OrderByRegex = new(
@"(?<![A-Za-z0-9_])ORDER\s+BY\b[\s\S]*$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
private static readonly Regex LookupParameterRegex = new(
@"@\s*param\d+",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
@ -66,11 +63,17 @@ internal static class DynamicReportSqlBuilder
var orderBy = ExtractOrderBy(normalizedSql);
if (!string.IsNullOrWhiteSpace(orderBy))
{
normalizedSql = OrderByRegex.Replace(normalizedSql, string.Empty).Trim();
normalizedSql = RemoveOrderBy(normalizedSql);
}
// A derived table cannot contain its own WITH clause, so the source CTEs are
// promoted to the outer query together with the lookup CTEs.
var (sourceCommonTableExpression, sourceSelectQuery) = SplitCommonTableExpression(normalizedSql);
normalizedSql = sourceSelectQuery;
var commonTableExpressions = lookupSelectParts
.Select(part => part.CommonTableExpression)
.Prepend(sourceCommonTableExpression)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
@ -292,8 +295,7 @@ internal static class DynamicReportSqlBuilder
return null;
}
var query = lookupQuery.Trim().TrimEnd(';');
query = OrderByRegex.Replace(query, string.Empty);
var query = RemoveOrderBy(NormalizeSql(lookupQuery));
return LookupParameterRegex.Replace(query, "NULL");
}
@ -305,8 +307,83 @@ internal static class DynamicReportSqlBuilder
private static string ExtractOrderBy(string sql)
{
var match = OrderByRegex.Match(sql);
return match.Success ? " " + match.Value.Trim() : string.Empty;
var index = FindTrailingOrderByIndex(sql);
return index < 0 ? string.Empty : " " + sql[index..].Trim();
}
private static string RemoveOrderBy(string sql)
{
var index = FindTrailingOrderByIndex(sql);
return index < 0 ? sql : sql[..index].TrimEnd();
}
/// <summary>
/// Sorgunun sonundaki ORDER BY ifadesini bulur. Alt sorgular ve OVER (ORDER BY ...)
/// gibi parantez içindeki sıralamalar dikkate alınmaz.
/// </summary>
private static int FindTrailingOrderByIndex(string sql)
{
var depth = 0;
var quote = '\0';
var result = -1;
for (var index = 0; index < sql.Length; index++)
{
var current = sql[index];
if (quote != '\0')
{
if (current == quote)
{
if (index + 1 < sql.Length && sql[index + 1] == quote)
{
index++;
}
else
{
quote = '\0';
}
}
continue;
}
if (current is '\'' or '"')
{
quote = current;
continue;
}
if (current == '(')
{
depth++;
continue;
}
if (current == ')')
{
depth--;
continue;
}
if (depth != 0 || !IsKeywordAt(sql, index, "ORDER"))
{
continue;
}
var next = index + "ORDER".Length;
while (next < sql.Length && char.IsWhiteSpace(sql[next]))
{
next++;
}
if (IsKeywordAt(sql, next, "BY"))
{
result = index;
index = next + 1;
}
}
return result;
}
private static string QuoteSqlIdentifier(string identifier)

View file

@ -29,11 +29,11 @@ public class DynamicTreeReport : XtraReport
private const int A4PortraitWidth = 827;
private const int HorizontalMargin = 50;
private const int LandscapeColumnThreshold = 5;
private const int VerticalMargin = 35;
private const string TreeLevelColumn = "__TreeLevel";
private const string TreeHasChildrenColumn = "__TreeHasChildren";
private const string TreePrefixColumn = "__TreePrefix";
private const string TreeDataSourceName = "DynamicTree";
private static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240);
private static IServiceScopeFactory _scopeFactory;
public static void SetScopeFactory(IServiceScopeFactory scopeFactory)
@ -139,17 +139,15 @@ public class DynamicTreeReport : XtraReport
var reportColumns = visibleFields
.Select(field =>
{
var selectField = selectFieldsByName[field.FieldName];
editorTypesByField.TryGetValue(field.FieldName, out var editorType);
return CreateReportColumn(
return DynamicReportColumnFactory.Create(
field,
selectField,
selectFieldsByName[field.FieldName],
defaultValueHelper,
editorType,
DynamicReportImageHelper.IsImageEditor(editorType),
editorType == PlatformConsts.EditorTypes.dxHtmlEditor);
localizer);
})
.Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined")
.Where(x => x.HasBindingName)
.ToList();
var selectQuery = DynamicReportImageHelper.StripReportPaging(DynamicReportImageHelper.ApplyQueryParameters(
@ -178,32 +176,21 @@ public class DynamicTreeReport : XtraReport
RequestParameters = false;
}
#pragma warning disable CA1416
private void ConfigurePage(
IReadOnlyCollection<ReportColumn> visibleFields,
IReadOnlyCollection<DynamicReportColumn> visibleFields,
IStringLocalizer<PlatformResource> localizer,
IReadOnlyDictionary<string, float> stateWidths)
{
Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35);
Margins = DynamicReportLayoutHelper.CreateMargins(HorizontalMargin, VerticalMargin);
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4;
var portraitContentWidth = A4PortraitWidth - Margins.Left - Margins.Right;
var requestedContentWidth = visibleFields.Sum(x => x.IsImage
? GetImageColumnWidth(x)
? x.ImageWidth
: DynamicReportGridStateHelper.GetPreferredColumnWidth(x.Field, localizer, stateWidths));
Landscape = visibleFields.Count >= LandscapeColumnThreshold || requestedContentWidth > portraitContentWidth;
Font = DynamicReportFontHelper.CreateDataFont();
}
private static System.Drawing.Printing.Margins CreateReportMargins(
int left,
int right,
int top,
int bottom)
{
return new System.Drawing.Printing.Margins(left, right, top, bottom);
}
#pragma warning restore CA1416
private static async Task<List<Dictionary<string, object>>> ReadRowsAsync(
IDynamicDataRepository dynamicDataRepository,
string selectQuery,
@ -312,7 +299,7 @@ public class DynamicTreeReport : XtraReport
private void ConfigureDataSource(
IEnumerable<Dictionary<string, object>> rows,
IReadOnlyList<ReportColumn> columns)
IReadOnlyList<DynamicReportColumn> columns)
{
var normalizedRows = rows
.Select(row => NormalizeJsonRow(row, columns))
@ -334,7 +321,7 @@ public class DynamicTreeReport : XtraReport
private static Dictionary<string, object> NormalizeJsonRow(
IDictionary<string, object> row,
IReadOnlyList<ReportColumn> columns)
IReadOnlyList<DynamicReportColumn> columns)
{
var normalized = row.ToDictionary(
x => x.Key,
@ -382,8 +369,8 @@ public class DynamicTreeReport : XtraReport
return value;
}
private static List<ReportColumn> ApplyImageCounts(
IReadOnlyList<ReportColumn> columns,
private static List<DynamicReportColumn> ApplyImageCounts(
IReadOnlyList<DynamicReportColumn> columns,
IReadOnlyList<Dictionary<string, object>> rows,
string imageBaseUrl)
{
@ -413,7 +400,7 @@ public class DynamicTreeReport : XtraReport
.ToList();
}
private static object GetImageRawValue(IDictionary<string, object> row, ReportColumn column)
private static object GetImageRawValue(IDictionary<string, object> row, DynamicReportColumn column)
{
if (DynamicReportImageHelper.TryGetRowValue(row, column.BindingName, out var value) &&
DynamicReportImageHelper.HasValue(value))
@ -426,7 +413,7 @@ public class DynamicTreeReport : XtraReport
private void BuildLayout(
ListForm listForm,
IReadOnlyList<ReportColumn> fields,
IReadOnlyList<DynamicReportColumn> fields,
IStringLocalizer<PlatformResource> localizer,
string imageBaseUrl,
string currentUserName,
@ -439,14 +426,11 @@ public class DynamicTreeReport : XtraReport
var topMargin = new TopMarginBand { HeightF = Margins.Top };
var bottomMargin = new BottomMarginBand { HeightF = 32 };
var reportHeader = new ReportHeaderBand { HeightF = string.IsNullOrWhiteSpace(criteriaText) ? 32 : 50 };
var pageHeader = new PageHeaderBand { HeightF = 28 };
var detailHeight = fields.Any(x => x.IsImage)
? DynamicReportImageHelper.GetGridImageHeight()
: fields.Any(x => x.IsHtml)
? 48
: 24;
var pageHeader = new PageHeaderBand { HeightF = DynamicReportLayoutHelper.HeaderRowHeight };
var detailHeight = DynamicReportLayoutHelper.GetDetailHeight(fields);
var detail = new DetailBand { HeightF = detailHeight };
var reportFooter = new ReportFooterBand { HeightF = HasAnySummary(fields) ? 28 : 0 };
var hasSummary = DynamicReportLayoutHelper.HasAnySummary(fields);
var reportFooter = new ReportFooterBand { HeightF = hasSummary ? 28 : 0 };
Bands.AddRange([topMargin, reportHeader, pageHeader, detail, reportFooter, bottomMargin]);
@ -467,29 +451,30 @@ public class DynamicTreeReport : XtraReport
DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth);
DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth);
bottomMargin.Controls.Add(new XRPageInfo
{
TextFormatString = "Sayfa {0} / {1}",
BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18),
Font = DynamicReportFontHelper.CreateValueFont(8F),
TextAlignment = TextAlignment.MiddleRight
});
DynamicReportLayoutHelper.AddPageInfoToFooter(bottomMargin.Controls, pageWidth);
var widths = DynamicReportGridStateHelper.CalculateWidths(fields, pageWidth, localizer, stateWidths);
var headerTable = CreateTable(pageWidth, 28);
var headerRow = new XRTableRow { HeightF = 28 };
var detailTable = CreateTable(pageWidth, detailHeight);
var headerTable = DynamicReportLayoutHelper.CreateTable(pageWidth, DynamicReportLayoutHelper.HeaderRowHeight);
var headerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.HeaderRowHeight };
var detailTable = DynamicReportLayoutHelper.CreateTable(pageWidth, detailHeight);
detailTable.CanGrow = true;
var detailRow = new XRTableRow { HeightF = detailHeight, CanGrow = true };
var footerTable = CreateTable(pageWidth, 24);
var footerRow = new XRTableRow { HeightF = 24 };
var footerTable = DynamicReportLayoutHelper.CreateTable(pageWidth, DynamicReportLayoutHelper.FooterRowHeight);
var footerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.FooterRowHeight };
for (var i = 0; i < fields.Count; i++)
{
var field = fields[i];
headerRow.Cells.Add(CreateHeaderCell(field, widths[i], localizer));
detailRow.Cells.Add(CreateDetailCell(field, widths[i], imageBaseUrl, detailHeight, i == 0));
footerRow.Cells.Add(CreateFooterCell(field, widths[i]));
var isTreeColumn = i == 0;
headerRow.Cells.Add(DynamicReportLayoutHelper.CreateHeaderCell(field, widths[i], localizer));
detailRow.Cells.Add(DynamicReportLayoutHelper.CreateDetailCell(
field,
widths[i],
imageBaseUrl,
detailHeight,
isTreeColumn ? TreePrefixColumn : null,
isTreeColumn ? TreeHasChildrenColumn : null));
footerRow.Cells.Add(DynamicReportLayoutHelper.CreateFooterCell(field, widths[i]));
}
headerTable.Rows.Add(headerRow);
@ -498,211 +483,12 @@ public class DynamicTreeReport : XtraReport
pageHeader.Controls.Add(headerTable);
detail.Controls.Add(detailTable);
if (HasAnySummary(fields))
if (hasSummary)
{
reportFooter.Controls.Add(footerTable);
}
}
private static XRTable CreateTable(float width, float height)
{
return new XRTable
{
BoundsF = new System.Drawing.RectangleF(0, 0, width, height),
Borders = BorderSide.All,
BorderColor = TableBorderColor,
BorderWidth = 0.25F
};
}
private static XRTableCell CreateHeaderCell(
ReportColumn column,
float width,
IStringLocalizer<PlatformResource> localizer)
{
return new XRTableCell
{
Text = DynamicReportImageHelper.GetLocalizedFieldCaption(column.Field, localizer),
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateHeaderFont(),
BackColor = System.Drawing.Color.FromArgb(238, 242, 247),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = TextAlignment.MiddleCenter,
Multiline = true
};
}
private static XRTableCell CreateDetailCell(
ReportColumn column,
float width,
string imageBaseUrl,
float height,
bool isTreeColumn)
{
if (column.IsImage)
{
return DynamicReportImageHelper.CreateGridImageCell(
column.BindingName,
column.Field.FieldName,
width,
imageBaseUrl,
column.ImageCount);
}
if (column.IsHtml)
{
return CreateHtmlDetailCell(column, width, height);
}
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateDataFont(),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = isTreeColumn
? TextAlignment.MiddleLeft
: DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType),
Multiline = true,
CanGrow = true
};
var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression($"[{column.BindingName}]", column.Field, column.EditorType);
var textExpression = isTreeColumn
? $"Concat([{TreePrefixColumn}], {valueExpression})"
: valueExpression;
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Text",
textExpression));
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Font.Name",
DynamicReportImageHelper.CreateFontFamilyExpression(textExpression)));
if (isTreeColumn)
{
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Font.Bold",
$"[{TreeHasChildrenColumn}]"));
}
return cell;
}
private static XRTableCell CreateHtmlDetailCell(
ReportColumn column,
float width,
float height)
{
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Padding = new PaddingInfo(0, 0, 0, 0),
CanGrow = true
};
var richText = new XRRichText
{
BoundsF = new System.Drawing.RectangleF(0, 0, width, height),
Font = DynamicReportFontHelper.CreateDataFont(),
Padding = new PaddingInfo(4, 4, 2, 2),
CanGrow = true
};
richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]"));
cell.Controls.Add(richText);
return cell;
}
private static XRTableCell CreateFooterCell(ReportColumn column, float width)
{
var summaryType = GetSummaryType(column.Field.TotalSummaryJson);
var cell = new XRTableCell
{
WidthF = width,
Weight = width,
Font = DynamicReportFontHelper.CreateDataFont(DXFontStyle.Bold),
Padding = new PaddingInfo(4, 4, 2, 2),
TextAlignment = DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType)
};
if (summaryType == null)
{
return cell;
}
cell.ExpressionBindings.Add(new ExpressionBinding(
"BeforePrint",
"Text",
DynamicReportImageHelper.CreateFormatValueExpression($"sumSum([{column.BindingName}])", column.Field, column.EditorType)));
cell.Summary = new XRSummary
{
Running = SummaryRunning.Report,
Func = summaryType.Value,
IgnoreNullValues = true
};
return cell;
}
private static bool HasAnySummary(IEnumerable<ReportColumn> fields)
{
return fields.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null);
}
private static float GetImageColumnWidth(ReportColumn column)
{
return DynamicReportImageHelper.GetGridImageColumnWidth(column.ImageCount);
}
private static ReportColumn CreateReportColumn(
ListFormField field,
SelectField selectField,
DefaultValueHelper defaultValueHelper,
string editorType,
bool isImage,
bool isHtml)
{
var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField);
var lookup = DynamicReportImageHelper.GetLookup(field);
var isMultiValue = IsMultiValueLookupEditor(editorType);
if (lookup == null || selectField?.JoinOptions != null)
{
return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType);
}
if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup))
{
return new ReportColumn(field, DynamicReportSqlBuilder.GetLookupBindingName(field), lookup, null, isMultiValue, isImage, isHtml, editorType);
}
if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData)
{
var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper);
return new ReportColumn(
field,
staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName,
lookup,
staticLookupValues,
isMultiValue,
isImage,
isHtml,
editorType);
}
return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType);
}
private static bool IsMultiValueLookupEditor(string editorType)
{
return editorType is PlatformConsts.EditorTypes.dxTagBox or "dxGridBox";
}
private static TreeOptionDto GetTreeOptions(ListForm listForm)
{
if (string.IsNullOrWhiteSpace(listForm.TreeOptionJson))
@ -723,52 +509,6 @@ public class DynamicTreeReport : XtraReport
}
}
private sealed record ReportColumn(
ListFormField Field,
string BindingName,
LookupDto Lookup,
Dictionary<string, string> StaticLookupValues,
bool IsMultiValue,
bool IsImage,
bool IsHtml,
string EditorType,
int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn, DynamicReportGridStateHelper.IReportColumnWidth
{
public float ImageWidth => GetImageColumnWidth(this);
}
private static SummaryFunc? GetSummaryType(string totalSummaryJson)
{
if (string.IsNullOrWhiteSpace(totalSummaryJson))
{
return null;
}
var value = totalSummaryJson.ToLowerInvariant();
if (value.Contains("\"summarytype\":\"sum\""))
{
return SummaryFunc.Sum;
}
if (value.Contains("\"summarytype\":\"avg\""))
{
return SummaryFunc.Avg;
}
if (value.Contains("\"summarytype\":\"min\""))
{
return SummaryFunc.Min;
}
if (value.Contains("\"summarytype\":\"max\""))
{
return SummaryFunc.Max;
}
if (value.Contains("\"summarytype\":\"count\""))
{
return SummaryFunc.Count;
}
return null;
}
private static string GetParameter(IDictionary<string, string> parameters, string name) =>
DynamicReportImageHelper.GetParameter(parameters, name);
}

View file

@ -839,9 +839,11 @@ export enum ColumnRowTypeListOptions {
export enum SubFormTabTypeEnum {
List = 'List',
Card = 'Card',
Tree = 'Tree',
Gantt = 'Gantt',
Scheduler = 'Scheduler',
Todo = 'Todo',
Form = 'Form',
Chart = 'Chart',
Pivot = 'Pivot',

View file

@ -8,7 +8,7 @@ import {
SimpleItem as SimpleItemDx,
} from 'devextreme-react/form'
import type { FieldDataChangedEvent, GroupItem } from 'devextreme/ui/form'
import { Dispatch, RefObject, useEffect, useRef, useState } from 'react'
import { Dispatch, ReactNode, RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
@ -16,135 +16,21 @@ import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
import { RowMode, SimpleItemWithColData } from './types'
import { PlatformEditorTypes } from '@/proxy/form/models'
import { useLocalization } from '@/utils/hooks/useLocalization'
import {
getValueByField,
setFormEditorReadOnly,
shouldRunEditorScriptOnContentReady,
} from '../list/shared/editingForm'
import { isTouchLikeDevice } from '../list/shared/editPopup'
import { flattenFormGroups, getCascadeParentFields } from './shared/formItems'
const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] =>
items.flatMap((item) => [
...(item?.dataField ? [item] : []),
...flattenFormItems(item?.items || []),
...(item?.tabs || []).flatMap((tab: any) => flattenFormItems(tab?.items || [])),
])
const updateReadOnlyInFormItems = (items: any[] = [], field: string, readOnly: boolean) => {
let changed = false
const expected = String(field || '').toLowerCase()
const nextItems = items.map((item) => {
const key = item?.dataField || item?.name
let nextItem = item
if (key && String(key).toLowerCase() === expected) {
const editorOptions = nextItem.editorOptions || {}
if (editorOptions.readOnly !== readOnly) {
changed = true
nextItem = {
...nextItem,
editorOptions: {
...editorOptions,
readOnly,
},
}
}
}
if (nextItem?.items?.length) {
const childResult = updateReadOnlyInFormItems(nextItem.items, field, readOnly)
if (childResult.changed) {
changed = true
nextItem = { ...nextItem, items: childResult.items }
}
}
if (nextItem?.tabs?.length) {
const tabs = nextItem.tabs.map((tab: any) => {
const tabResult = updateReadOnlyInFormItems(tab.items, field, readOnly)
if (tabResult.changed) {
changed = true
return { ...tab, items: tabResult.items }
}
return tab
})
nextItem = tabs === nextItem.tabs ? nextItem : { ...nextItem, tabs }
}
return nextItem
})
return { items: nextItems, changed }
}
const findFormFieldKey = (items: any[] = [], field: string): string => {
const expected = String(field || '').toLowerCase()
for (const item of items || []) {
const key = item?.dataField || item?.name
if (key && String(key).toLowerCase() === expected) {
return key
}
const childKey = findFormFieldKey(item?.items || [], field)
if (childKey) {
return childKey
}
for (const tab of item?.tabs || []) {
const tabKey = findFormFieldKey(tab?.items || [], field)
if (tabKey) {
return tabKey
}
}
}
return field
}
const setFormEditorReadOnly = (form: any, field: string, readOnly: boolean) => {
if (!form?.option) return false
const apply = () => {
const formItems = form.option('items') || []
const resolvedField = findFormFieldKey(formItems, field)
const editor = form.getEditor?.(resolvedField) ?? form.getEditor?.(field)
const result = updateReadOnlyInFormItems(formItems, resolvedField, readOnly)
if (result.changed) {
try {
const item = form.itemOption?.(resolvedField) ?? form.itemOption?.(field)
if (item) {
form.itemOption?.(resolvedField, 'editorOptions', {
...(item.editorOptions || {}),
readOnly,
})
} else {
form.option('items', result.items)
}
} catch {
form.option('items', result.items)
}
}
const activeEditor = editor ?? form.getEditor?.(resolvedField) ?? form.getEditor?.(field)
if (activeEditor?.option?.('readOnly') !== readOnly) {
activeEditor?.option?.('readOnly', readOnly)
}
}
apply()
return true
}
const getValueByField = (data: Record<string, any> = {}, field?: string) => {
if (!field) return undefined
if (Object.prototype.hasOwnProperty.call(data, field)) return data[field]
const key = Object.keys(data).find(
(itemKey) => itemKey.toLowerCase() === String(field).toLowerCase(),
)
return key ? data[key] : undefined
}
const shouldRunEditorScriptOnContentReady = (script?: string) =>
Boolean(
script && (script.includes('setEditorReadOnly') || script.includes('runtimeSetEditorReadOnly')),
)
/** Kendi render'ını yapan platform editörleri. */
const CUSTOM_EDITOR_TYPES = [
PlatformEditorTypes.dxTagBox,
PlatformEditorTypes.dxGridBox,
PlatformEditorTypes.dxImageUpload,
PlatformEditorTypes.dxImageViewer,
] as const
const FormDevExpress = (props: {
listFormCode: string
@ -166,18 +52,8 @@ const FormDevExpress = (props: {
const [runtimeReadOnlyFields, setRuntimeReadOnlyFields] = useState<Record<string, boolean>>({})
const runtimeReadOnlyFieldsRef = useRef<Record<string, boolean>>({})
const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches ||
window.matchMedia?.('(hover: none)').matches)
useEffect(() => {
formDataRef.current = formData
}, [formData])
useEffect(() => {
formItemsRef.current = formItems
}, [formItems])
formDataRef.current = formData
formItemsRef.current = formItems
useEffect(() => {
runtimeReadOnlyFieldsRef.current = runtimeReadOnlyFields
@ -187,24 +63,21 @@ const FormDevExpress = (props: {
didAutoFocusRef.current = false
}, [listFormCode, mode])
const getFieldKey = (formItem: SimpleItemWithColData) =>
String(formItem.dataField || formItem.name || '').toLowerCase()
const setRuntimeEditorReadOnly = (field: string, readOnly: boolean) => {
const resolvedField = findFormFieldKey(formItemsRef.current, field)
const key = String(resolvedField || field || '').toLowerCase()
const key = String(field || '').toLowerCase()
if (!key || runtimeReadOnlyFieldsRef.current[key] === readOnly) {
return
}
runtimeReadOnlyFieldsRef.current = {
...runtimeReadOnlyFieldsRef.current,
[key]: readOnly,
}
runtimeReadOnlyFieldsRef.current = { ...runtimeReadOnlyFieldsRef.current, [key]: readOnly }
setRuntimeReadOnlyFields(runtimeReadOnlyFieldsRef.current)
}
const getRuntimeEditorReadOnly = (formItem: SimpleItemWithColData) => {
const field = formItem.dataField || formItem.name
const resolvedField = findFormFieldKey(formItemsRef.current, field || '')
const key = String(resolvedField || field || '').toLowerCase()
const key = getFieldKey(formItem)
return Object.prototype.hasOwnProperty.call(runtimeReadOnlyFields, key)
? runtimeReadOnlyFields[key]
: undefined
@ -212,32 +85,31 @@ const FormDevExpress = (props: {
const applyEditorReadOnly = (form: any, field: string, readOnly: boolean) => {
setRuntimeEditorReadOnly(field, readOnly)
setFormEditorReadOnly(form, field, readOnly)
setTimeout(() => setFormEditorReadOnly(formInstanceRef.current ?? form, field, readOnly), 0)
setFormEditorReadOnly(formInstanceRef.current ?? form, field, readOnly)
}
const applyEditorScriptFormData = (form: any, newData: any) => {
const nextFormData = {
...(formDataRef.current || {}),
...(newData || {}),
}
/** Editör script'i / özel editör değişikliklerini forma ve state'e yazar. */
const commitFormData = useCallback(
(patch: Record<string, any>, form?: any) => {
const nextFormData = { ...(formDataRef.current || {}), ...patch }
formDataRef.current = nextFormData
formDataRef.current = nextFormData
form?.option?.('formData', nextFormData)
const targetForm = form ?? formInstanceRef.current
targetForm?.option?.('formData', nextFormData)
Object.keys(patch).forEach((field) => {
targetForm?.getEditor?.(field)?.option?.('value', patch[field])
})
Object.keys(newData || {}).forEach((field) => {
form?.getEditor?.(field)?.option?.('value', newData[field])
})
setFormData(nextFormData)
return nextFormData
},
[setFormData],
)
setFormData(nextFormData)
}
const runEditorScript = (formItem: SimpleItemWithColData, eventValue: any) => {
if (!formItem?.editorScript) return
const runEditorScript = (formItem: SimpleItemWithColData, eventValue: any, component?: any) => {
if (!formItem?.editorScript) {
return
}
const form = formInstanceRef.current ?? component
const form = formInstanceRef.current
const dataField = formItem.dataField
const nextFormData = {
...(formDataRef.current || {}),
@ -247,46 +119,38 @@ const FormDevExpress = (props: {
formDataRef.current = nextFormData
try {
const editor = {
dataField,
component: form,
}
const formData = nextFormData
const e = {
component: form,
dataField,
scriptEvent: 'valueChanged',
value: eventValue,
}
const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) =>
applyEditorReadOnly(form, field, readOnly)
executeEditorScript(formItem.editorScript, {
formData,
e,
editor,
runtimeSetEditorReadOnly,
setFormData: (newData: any) => applyEditorScriptFormData(form, newData),
formData: nextFormData,
e: { component: form, dataField, scriptEvent: 'valueChanged', value: eventValue },
editor: { dataField, component: form },
runtimeSetEditorReadOnly: (field: string, readOnly: boolean) =>
applyEditorReadOnly(form, field, readOnly),
setFormData: (newData: any) => commitFormData(newData || {}, form),
})
} catch (err) {
console.error('Script execution failed for', formItem.name, err)
}
}
/** Özel editörlerin ortak değer değişimi akışı. */
const handleCustomEditorChange = (formItem: SimpleItemWithColData, value: any) => {
if (!formItem.dataField) return
formDataRef.current = { ...formDataRef.current, [formItem.dataField]: value }
setFormData(formDataRef.current)
runEditorScript(formItem, value)
}
const getEditorOptions = (formItem: SimpleItemWithColData, index?: number) => {
const runtimeReadOnly = getRuntimeEditorReadOnly(formItem)
const prevOnValueChanged = formItem.editorOptions?.onValueChanged
const previousOnValueChanged = formItem.editorOptions?.onValueChanged
const configuredButtons = formItem.editorOptions?.buttons
return {
...(index !== undefined && mode !== 'view' && !isTouchLikeDevice()
? { autoFocus: index === 1 }
: {}),
...(formItem.editorType === 'dxDateBox'
? {
useMaskBehavior: true,
openOnFieldClick: true,
showClearButton: true,
}
? { useMaskBehavior: true, openOnFieldClick: true, showClearButton: true }
: {}),
...(formItem.colData?.placeHolder
? { placeholder: translate('::' + formItem.colData.placeHolder) }
@ -297,350 +161,303 @@ const FormDevExpress = (props: {
...(formItem.editorScript
? {
onValueChanged: (e: any) => {
if (typeof prevOnValueChanged === 'function') {
prevOnValueChanged(e)
}
previousOnValueChanged?.(e)
if (formItem.dataField) {
const nextFormData = {
...(formDataRef.current || {}),
...(formInstanceRef.current?.option?.('formData') || {}),
[formItem.dataField]: e?.value,
}
formDataRef.current = nextFormData
formInstanceRef.current?.option?.('formData', nextFormData)
setFormData(nextFormData)
commitFormData({ [formItem.dataField]: e?.value })
}
runEditorScript(formItem, e?.value, formInstanceRef.current)
runEditorScript(formItem, e?.value)
},
}
: {}),
buttons: (formItem.editorOptions?.buttons || []).map((btn: any) => {
if (btn?.options?.onClick && typeof btn.options.onClick === 'string') {
const origClick = eval(`(${btn.options.onClick})`)
btn.options.onClick = (e: any) => {
origClick({
...e,
formData: formDataRef.current,
fieldName: formItem.dataField,
mode,
})
...(configuredButtons?.length
? {
buttons: configuredButtons.map((button: any) => {
// Yapılandırmadan string olarak gelen onClick bir kez fonksiyona çevrilir.
if (typeof button?.options?.onClick !== 'string') return button
const originalClick = eval(`(${button.options.onClick})`)
return {
...button,
options: {
...button.options,
onClick: (e: any) =>
originalClick({
...e,
formData: formDataRef.current,
fieldName: formItem.dataField,
mode,
}),
},
}
}),
}
}
return btn
}),
: {}),
}
}
const getFormItemKey = (formItem: SimpleItemWithColData, index: number) => {
const runtimeReadOnly = getRuntimeEditorReadOnly(formItem)
return `formItem-${formItem.dataField || formItem.name || index}-${String(runtimeReadOnly)}`
}
const getFormItemKey = (formItem: SimpleItemWithColData, index: number) =>
`formItem-${formItem.dataField || formItem.name || index}-${String(
getRuntimeEditorReadOnly(formItem),
)}`
// formItems değiştiğinde (özellikle cascading alanlar için) editörlerin dataSource'larını güncelle
// Cascading alanlarda formItems yenilendiğinde editör dataSource'larını güncelle.
useEffect(() => {
if (!refForm.current?.instance()) return
const allItems = formItems.flatMap((group) => flattenFormItems([group]))
allItems.forEach((item) => {
if (item.colData?.lookupDto?.dataSourceType && item.editorOptions?.dataSource) {
try {
const editor = refForm.current?.instance().getEditor(item.dataField!)
if (editor) {
editor.option('dataSource', item.editorOptions.dataSource)
}
} catch (err) {
// Editor henüz oluşmamış olabilir, sessizce devam et
console.debug('Editor update skipped for', item.dataField, err)
}
}
})
}, [formItems])
// Cascade fieldlerin disabled durumunu güncelle
const updateCascadeDisabledStates = () => {
if (!refForm.current?.instance()) return
const allItems = formItemsRef.current.flatMap((group) => flattenFormItems([group]))
allItems.forEach((item) => {
const cascadeParentFields = item.colData?.lookupDto?.cascadeParentFields
if (cascadeParentFields) {
const parentFields = cascadeParentFields.split(',').map((f: string) => f.trim())
try {
const editor = refForm.current?.instance().getEditor(item.dataField!)
if (editor && mode !== 'view') {
if (item.editorOptions?.disabled === true) {
editor.option('disabled', true)
return
}
// Parent fieldlerden en az biri boşsa disabled olmalı
const shouldDisable = parentFields.some((parentField: string) => {
return !formDataRef.current || !formDataRef.current[parentField]
})
editor.option('disabled', shouldDisable)
}
} catch (err) {
console.debug('Cascade disabled update skipped for', item.dataField, err)
}
}
})
}
// formData değiştiğinde cascade disabled durumlarını güncelle
useEffect(() => {
updateCascadeDisabledStates()
}, [formData, mode])
const runReadOnlyScripts = (form: any) => {
const form = refForm.current?.instance()
if (!form) return
const currentFormData = {
...(formDataRef.current || {}),
...(form?.option?.('formData') || {}),
}
formDataRef.current = currentFormData
flattenFormGroups(formItems).forEach((item) => {
if (!item.colData?.lookupDto?.dataSourceType || !item.editorOptions?.dataSource) return
formItemsRef.current
.flatMap((group) => flattenFormItems([group]))
.filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript))
.forEach((formItem) => {
try {
const editor = {
dataField: formItem.dataField,
component: form,
}
const formData = currentFormData
const e = {
component: form,
dataField: formItem.dataField,
scriptEvent: 'contentReady',
value: getValueByField(currentFormData, formItem.dataField),
}
const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) =>
applyEditorReadOnly(form, field, readOnly)
try {
form.getEditor(item.dataField!)?.option('dataSource', item.editorOptions.dataSource)
} catch (err) {
// Editör henüz oluşmamış olabilir.
console.debug('Editor update skipped for', item.dataField, err)
}
})
}, [formItems, refForm])
executeEditorScript(formItem.editorScript!, {
formData,
e,
editor,
runtimeSetEditorReadOnly,
setFormData: undefined,
})
} catch (err) {
console.error('Script execution failed on contentReady for', formItem.name, err)
}
})
}
/** Parent alanı boş olan cascade alanlarını devre dışı bırakır. */
const updateCascadeDisabledStates = useCallback(() => {
const form = refForm.current?.instance()
if (!form || mode === 'view') return
flattenFormGroups(formItemsRef.current).forEach((item) => {
const parentFields = getCascadeParentFields(item)
if (!parentFields.length) return
try {
const editor = form.getEditor(item.dataField!)
if (!editor) return
editor.option(
'disabled',
item.editorOptions?.disabled === true ||
parentFields.some((parentField) => !formDataRef.current?.[parentField]),
)
} catch (err) {
console.debug('Cascade disabled update skipped for', item.dataField, err)
}
})
}, [mode, refForm])
useEffect(() => {
updateCascadeDisabledStates()
}, [formData, updateCascadeDisabledStates])
/** contentReady anında salt-okunur davranışını belirleyen script'leri çalıştırır. */
const runReadOnlyScripts = useCallback(
(form: any) => {
if (!form) return
const currentFormData = {
...(formDataRef.current || {}),
...(form?.option?.('formData') || {}),
}
formDataRef.current = currentFormData
flattenFormGroups(formItemsRef.current)
.filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript))
.forEach((formItem) => {
try {
executeEditorScript(formItem.editorScript!, {
formData: currentFormData,
e: {
component: form,
dataField: formItem.dataField,
scriptEvent: 'contentReady',
value: getValueByField(currentFormData, formItem.dataField),
},
editor: { dataField: formItem.dataField, component: form },
runtimeSetEditorReadOnly: (field: string, readOnly: boolean) =>
applyEditorReadOnly(form, field, readOnly),
setFormData: undefined,
})
} catch (err) {
console.error('Script execution failed on contentReady for', formItem.name, err)
}
})
},
// applyEditorReadOnly ref tabanlıdır; her render'da yeniden oluşması sorun değil.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
useEffect(() => {
const form = formInstanceRef.current
if (!form || mode === 'view' || !formData || !formItems?.length) {
return
}
if (!form || mode === 'view' || !formData || !formItems?.length) return
const scriptFields = formItemsRef.current
.flatMap((group) => flattenFormItems([group]))
const scriptFields = flattenFormGroups(formItemsRef.current)
.filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript))
.map((formItem) => formItem.dataField)
.join(MULTIVALUE_DELIMITER)
if (!scriptFields) {
return
}
if (!scriptFields) return
const scriptKey = `${mode}|${scriptFields}|${JSON.stringify(formData)}`
if (lastContentReadyScriptKeyRef.current === scriptKey) {
return
}
if (lastContentReadyScriptKeyRef.current === scriptKey) return
lastContentReadyScriptKeyRef.current = scriptKey
setTimeout(() => runReadOnlyScripts(form), 0)
}, [formData, formItems, mode])
}, [formData, formItems, mode, runReadOnlyScripts])
/** Platform editörünün render fonksiyonunu döner. */
const renderCustomEditor = (formItem: SimpleItemWithColData): ReactNode => {
const value = formData[formItem.dataField!]
const editorOptions = getEditorOptions(formItem)
const onChange = (next: any) => handleCustomEditorChange(formItem, next)
switch (formItem.editorType2) {
case PlatformEditorTypes.dxTagBox:
return (
<TagBoxEditorComponent
value={value || []}
setDefaultValue={false}
values={formData}
options={formItem.tagBoxOptions}
col={formItem.colData}
onValueChanged={onChange}
editorOptions={editorOptions}
/>
)
case PlatformEditorTypes.dxGridBox:
return (
<GridBoxEditorComponent
value={value || []}
values={formData}
options={formItem.gridBoxOptions}
col={formItem.colData}
onValueChanged={onChange}
editorOptions={editorOptions}
/>
)
case PlatformEditorTypes.dxImageUpload:
return (
<ImageUploadEditorComponent
value={value}
options={formItem.imageUploadOptions}
onValueChanged={onChange}
editorOptions={editorOptions}
/>
)
case PlatformEditorTypes.dxImageViewer:
return (
<ImageViewerEditorComponent
value={value}
options={formItem.imageUploadOptions}
editorOptions={editorOptions}
/>
)
default:
return null
}
}
const renderFormItem = (formItem: SimpleItemWithColData, index: number) => {
const key = getFormItemKey(formItem, index)
const label = {
text: translate('::' + formItem.colData?.captionName),
className: 'font-semibold',
}
if (!CUSTOM_EDITOR_TYPES.includes(formItem.editorType2 as any)) {
return (
<SimpleItemDx
cssClass="font-semibold"
key={key}
{...formItem}
editorOptions={getEditorOptions(formItem, index)}
label={{ text: label.text }}
/>
)
}
// Görsel editörleri DevExtreme'in bilmediği alan tiplerini yaymamak için
// yalnızca gerekli alanlarla oluşturulur.
const isImageEditor =
formItem.editorType2 === PlatformEditorTypes.dxImageUpload ||
formItem.editorType2 === PlatformEditorTypes.dxImageViewer
const itemProps = isImageEditor
? {
dataField: formItem.dataField,
name: formItem.name,
colSpan: formItem.colSpan,
isRequired: formItem.isRequired,
}
: formItem
return (
<SimpleItemDx
cssClass="font-semibold"
key={key}
{...itemProps}
render={() => renderCustomEditor(formItem)}
label={label}
/>
)
}
return (
<FormDx
ref={refForm}
className={`${DX_CLASSNAMES} ${!isSubForm ? 'px-2' : ''} pb-2`}
formData={formData}
onFieldDataChanged={async (e: FieldDataChangedEvent) => {
if (!e.dataField) {
return
}
onFieldDataChanged={(e: FieldDataChangedEvent) => {
if (!e.dataField) return
const newFormData = { ...formData, [e.dataField]: e.value }
let hasChanges = !Object.is(formData?.[e.dataField], e.value)
// Cascading child field'leri temizle (parent field değiştiğinde)
const allItems = formItemsRef.current.flatMap((group) => flattenFormItems([group]))
const cascadingChildren = allItems.filter((item) => {
const parentFields = item.colData?.lookupDto?.cascadeParentFields?.split(',') || []
return parentFields.some((field) => field.trim() === e.dataField)
})
// Parent field değiştiğinde child field'leri temizle
cascadingChildren.forEach((child) => {
if (!Object.is(newFormData[child.dataField!], null)) {
newFormData[child.dataField!] = null
hasChanges = true
}
})
// Parent alan değiştiğinde bağımlı (cascade) alanları temizle.
flattenFormGroups(formItemsRef.current)
.filter((item) => getCascadeParentFields(item).includes(e.dataField!))
.forEach((child) => {
if (!Object.is(newFormData[child.dataField!], null)) {
newFormData[child.dataField!] = null
hasChanges = true
}
})
if (hasChanges) {
formDataRef.current = newFormData
setFormData(newFormData)
}
// Cascade disabled durumlarını güncelle (setTimeout ile editor güncellemesinden sonra çalışsın)
setTimeout(() => {
updateCascadeDisabledStates()
}, 0)
// Editör güncellemesi tamamlandıktan sonra devre dışı durumlarını yenile.
setTimeout(updateCascadeDisabledStates, 0)
}}
onContentReady={(e) => {
formInstanceRef.current = e.component
runReadOnlyScripts(e.component)
const form = e.component
runReadOnlyScripts(form)
const groupItems = e.component.option('items') as any[]
const firstItem = groupItems?.[0]?.items?.[0]
if (!didAutoFocusRef.current && firstItem?.dataField && !isTouchLikeDevice()) {
const firstItem = (e.component.option('items') as any[])?.[0]?.items?.[0]
if (
!didAutoFocusRef.current &&
firstItem?.dataField &&
mode !== 'view' &&
!isTouchLikeDevice()
) {
didAutoFocusRef.current = true
const editor = e.component.getEditor(firstItem.dataField)
if (mode !== 'view') editor?.focus()
e.component.getEditor(firstItem.dataField)?.focus()
}
}}
>
{formItems.map((formGroupItem, i) => {
return (
<GroupItemDx
key={'formGroupItem-' + i}
colCount={formGroupItem.colCount}
colSpan={formGroupItem.colSpan}
caption={formGroupItem.caption}
>
{(formGroupItem.items as SimpleItemWithColData[])
?.filter((formItem) => {
if (mode === 'edit') return formItem.allowEditing !== false
if (mode === 'new') return formItem.allowAdding !== false
return true
})
.map((formItem, i) => {
return formItem.editorType2 === PlatformEditorTypes.dxTagBox ? (
<SimpleItemDx
cssClass="font-semibold"
key={getFormItemKey(formItem, i)}
{...formItem}
render={() => (
<TagBoxEditorComponent
value={formData[formItem.dataField!] || []}
setDefaultValue={false}
values={formData}
options={formItem.tagBoxOptions}
col={formItem.colData}
onValueChanged={(e: any) => {
const newData = { ...formDataRef.current, [formItem.dataField!]: e }
formDataRef.current = newData
setFormData(newData)
runEditorScript(formItem, e, formInstanceRef.current)
}}
editorOptions={getEditorOptions(formItem)}
></TagBoxEditorComponent>
)}
label={{
text: translate('::' + formItem.colData?.captionName),
className: 'font-semibold',
}}
></SimpleItemDx>
) : formItem.editorType2 === PlatformEditorTypes.dxGridBox ? (
<SimpleItemDx
cssClass="font-semibold"
key={getFormItemKey(formItem, i)}
{...formItem}
render={() => (
<GridBoxEditorComponent
value={formData[formItem.dataField!] || []}
values={formData}
options={formItem.gridBoxOptions}
col={formItem.colData}
onValueChanged={(e: any) => {
const newData = { ...formDataRef.current, [formItem.dataField!]: e }
formDataRef.current = newData
setFormData(newData)
runEditorScript(formItem, e, formInstanceRef.current)
}}
editorOptions={getEditorOptions(formItem)}
></GridBoxEditorComponent>
)}
label={{
text: translate('::' + formItem.colData?.captionName),
className: 'font-semibold',
}}
></SimpleItemDx>
) : formItem.editorType2 === PlatformEditorTypes.dxImageUpload ? (
<SimpleItemDx
cssClass="font-semibold"
key={getFormItemKey(formItem, i)}
dataField={formItem.dataField}
name={formItem.name}
colSpan={formItem.colSpan}
isRequired={formItem.isRequired}
render={() => (
<ImageUploadEditorComponent
value={formData[formItem.dataField!]}
options={formItem.imageUploadOptions}
onValueChanged={(val: any) => {
const newData = { ...formDataRef.current, [formItem.dataField!]: val }
formDataRef.current = newData
setFormData(newData)
runEditorScript(formItem, val, formInstanceRef.current)
}}
editorOptions={getEditorOptions(formItem)}
/>
)}
label={{
text: translate('::' + formItem.colData?.captionName),
className: 'font-semibold',
}}
></SimpleItemDx>
) : formItem.editorType2 === PlatformEditorTypes.dxImageViewer ? (
<SimpleItemDx
cssClass="font-semibold"
key={getFormItemKey(formItem, i)}
dataField={formItem.dataField}
name={formItem.name}
colSpan={formItem.colSpan}
isRequired={formItem.isRequired}
render={() => (
<ImageViewerEditorComponent
value={formData[formItem.dataField!]}
options={formItem.imageUploadOptions}
editorOptions={getEditorOptions(formItem)}
/>
)}
label={{
text: translate('::' + formItem.colData?.captionName),
className: 'font-semibold',
}}
></SimpleItemDx>
) : (
<SimpleItemDx
cssClass="font-semibold"
key={getFormItemKey(formItem, i)}
{...formItem}
editorOptions={getEditorOptions(formItem, i)}
label={{ text: translate('::' + formItem.colData?.captionName) }}
/>
)
})}
</GroupItemDx>
)
})}
{formItems.map((formGroupItem, groupIndex) => (
<GroupItemDx
key={'formGroupItem-' + groupIndex}
colCount={formGroupItem.colCount}
colSpan={formGroupItem.colSpan}
caption={formGroupItem.caption}
>
{(formGroupItem.items as SimpleItemWithColData[])
?.filter((formItem) => {
if (mode === 'edit') return formItem.allowEditing !== false
if (mode === 'new') return formItem.allowAdding !== false
return true
})
.map(renderFormItem)}
</GroupItemDx>
))}
</FormDx>
)
}

View file

@ -1,46 +1,16 @@
import { Container, Loading } from '@/components/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Helmet } from 'react-helmet'
import { useParams } from 'react-router-dom'
import FormButtons from './FormButtons'
import FormDevExpress from './FormDevExpress'
import SubForms from './SubForms'
import { FormProps } from './types'
import { useGridData } from './useFormData'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { Badge } from '@/components/ui'
import { useState } from 'react'
import { NotePanel } from './notes/NotePanel'
import { usePermission } from '@/utils/hooks/usePermission'
import { APP_NAME } from '@/constants/app.constant'
import { FormPageShell } from './shared/FormPageShell'
const FormEdit = (
props: FormProps = {
isSubForm: false,
onActionView: () => {},
onActionNew: () => {},
},
) => {
const FormEdit = (props: FormProps = { isSubForm: false }) => {
const mode = 'edit'
const { isSubForm, level } = props
const { isSubForm, level, onActionView, onActionNew } = props
const params = useParams()
const listFormCode = props?.listFormCode ?? params?.listFormCode ?? ''
const id = props?.id ?? params?.id ?? ''
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const { checkPermission } = usePermission()
const [isNotePanelVisible, setIsNotePanelVisible] = useState(false)
const isNotePanel = () => {
return (
listFormCode &&
id &&
!isSubForm &&
gridDto?.gridOptions?.showNote &&
checkPermission(gridDto?.gridOptions.permissionDto.n)
)
}
const { translate } = useLocalization()
const {
fetchData,
setFormData,
@ -55,97 +25,49 @@ const FormEdit = (
refForm,
permissionResults,
} = useGridData({
mode: 'edit',
mode,
listFormCode,
id,
level,
isSubForm,
onSubmitAction: props?.onActionView,
onSubmitAction: onActionView,
})
if (!listFormCode) {
return <></>
}
if (loading) {
return <Loading type="default" loading={loading}></Loading>
}
if (!formData) {
return <>{translate('::App.NoResults')}</>
}
return (
<>
<Container
className={`${isNotePanelVisible && !isSubForm ? 'lg:mr-[375px]' : ''} transition-all duration-300`}
>
{!isSubForm && (
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle={APP_NAME}
></Helmet>
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
)}
</div>
{permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
listFormCode={listFormCode}
id={formData?.Id}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={handleSubmit}
refreshData={fetchData}
getSelectedRowKeys={() => [id]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionView={props?.onActionView}
onActionNew={props?.onActionNew}
/>
)}
</div>
<div className="px-2">
<FormDevExpress
<FormPageShell
mode={mode}
listFormCode={listFormCode}
isSubForm={isSubForm}
level={level}
loading={loading}
gridDto={gridDto}
formData={formData}
formItems={formItems}
refForm={refForm}
setFormData={setFormData}
noteEntityId={id}
actions={
permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={setFormData}
listFormCode={listFormCode}
id={formData?.Id}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={handleSubmit}
refreshData={fetchData}
getSelectedRowKeys={() => [id]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionView={onActionView}
onActionNew={onActionNew}
/>
</div>
<SubForms gridDto={gridDto!} formData={formData} level={level ?? 0} />
</Container>
{/* Note Panel - sadece ana formda göster */}
{isNotePanel() && (
<NotePanel
entityName={listFormCode}
entityId={id}
isVisible={isNotePanelVisible}
onToggle={() => setIsNotePanelVisible(!isNotePanelVisible)}
/>
)}
</>
)
}
/>
)
}

View file

@ -1,35 +1,23 @@
import { Container, Loading } from '@/components/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Loading } from '@/components/shared'
import { lazy, useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import type { DataType } from 'devextreme/common'
import type { SimpleItem } from 'devextreme/ui/form'
import { lazy, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet'
import { useParams, useSearchParams } from 'react-router-dom'
import FormButtons from './FormButtons'
import FormDevExpress from './FormDevExpress'
import { FormProps } from './types'
import { useGridData } from './useFormData'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { Badge } from '@/components/ui'
import { APP_NAME } from '@/constants/app.constant'
import { FieldCustomValueTypeEnum } from '@/proxy/form/models'
import { getList, getNextSequenceValue } from '@/services/form.service'
import { autoNumber } from '../list/Utils'
import { getList } from '@/services/form.service'
import { applyNewRowDefaults, castValueByDataType } from '../list/shared/editingForm'
import { FormPageShell } from './shared/FormPageShell'
const FormView = lazy(() => import('./FormView'))
export const FormNewContent = (
props: FormProps = {
isSubForm: false,
},
) => {
export const FormNewContent = (props: FormProps = { isSubForm: false }) => {
const mode = 'new'
const { isSubForm, level, sParams } = props
const { isSubForm, level, sParams, onActionView } = props
const params = useParams()
const listFormCode = props?.listFormCode ?? params?.listFormCode ?? ''
const [searchParams] = useSearchParams()
const { translate } = useLocalization()
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const initializedListFormCodeRef = useRef<string>()
const {
@ -48,220 +36,107 @@ export const FormNewContent = (
listFormCode,
level,
isSubForm,
onSubmitAction: (props?.onActionView),
onSubmitAction: onActionView,
})
const [newParams, setNewParams] = useState<URLSearchParams>(new URLSearchParams())
useEffect(() => {
setNewParams(
// URL parametreleri + üst formdan gelen parametreler.
const newParams = useMemo(
() =>
new URLSearchParams({
...Object.fromEntries(searchParams),
...Object.fromEntries(sParams ?? []),
}),
)
}, [searchParams, sParams])
[searchParams, sParams],
)
useEffect(() => {
initializedListFormCodeRef.current = undefined
}, [listFormCode])
/** Form ilk açıldığında varsayılan değerleri kurar. */
useEffect(() => {
if (!formItems?.length || !gridDto?.columnFormats) {
return
}
if (initializedListFormCodeRef.current === listFormCode) {
return
}
if (!formItems?.length || !gridDto?.columnFormats) return
if (initializedListFormCodeRef.current === listFormCode) return
initializedListFormCodeRef.current = listFormCode
let isActive = true
const initializeFormData = async () => {
const initialFormData = await createInitialFormData()
if (isActive) {
setFormData(initialFormData)
}
}
// Tanımlı tüm alanlar önce boşaltılır, ardından kolon varsayılanları uygulanır.
const initialData: Record<string, any> = {}
formItems.forEach((group) =>
(group.items as SimpleItem[] | undefined)?.forEach((item) => {
if (item.dataField) initialData[item.dataField] = undefined
}),
)
initializeFormData()
applyNewRowDefaults(gridDto, initialData).then((data) => {
if (isActive) setFormData(data)
})
return () => {
isActive = false
}
}, [listFormCode, formItems?.length, gridDto?.columnFormats])
// newParams değiştiğinde sadece newParams'tan gelen alanları güncelle
/** URL parametrelerinden gelen alanları forma yazar. */
useEffect(() => {
if (!formItems || !formData || !gridDto?.columnFormats) {
return
}
if (!formItems?.length || !formData || !gridDto?.columnFormats) return
const updatedData = { ...formData }
let hasChanges = false
for (const colFormat of gridDto.columnFormats) {
if (!colFormat.fieldName || !newParams.has(colFormat.fieldName)) {
continue
}
const fieldName = colFormat.fieldName
if (!fieldName || !newParams.has(fieldName)) continue
const dType = colFormat.dataType as DataType
let newValue: any
switch (dType) {
case 'date':
case 'datetime':
newValue = new Date(newParams.get(colFormat.fieldName)!)
break
case 'number':
newValue = Number(newParams.get(colFormat.fieldName))
break
case 'boolean':
if (newParams.get(colFormat.fieldName) === 'true') {
newValue = true
} else if (newParams.get(colFormat.fieldName) === 'false') {
newValue = false
}
break
case 'object':
try {
newValue = JSON.parse(newParams.get(colFormat.fieldName) as string)
} catch {
newValue = updatedData[colFormat.fieldName]
}
break
default:
newValue = newParams.get(colFormat.fieldName)
break
}
if (updatedData[colFormat.fieldName] !== newValue) {
updatedData[colFormat.fieldName] = newValue
const nextValue = castValueByDataType(
newParams.get(fieldName),
colFormat.dataType as DataType,
)
if (updatedData[fieldName] !== nextValue) {
updatedData[fieldName] = nextValue
hasChanges = true
}
}
if (hasChanges) {
setFormData(updatedData)
}
}, [newParams, gridDto?.columnFormats])
if (hasChanges) setFormData(updatedData)
}, [newParams, formData, gridDto?.columnFormats])
// newParams'tan gelen alanları readonly yap
/** URL'den gelen alanlar kullanıcı tarafından değiştirilemez. */
useEffect(() => {
if (!formItems || !gridDto?.columnFormats || !newParams) {
return
}
if (!formItems?.length || !gridDto?.columnFormats) return
for (const colFormat of gridDto.columnFormats) {
if (!colFormat.fieldName || !newParams.has(colFormat.fieldName)) {
continue
}
const lockedFields = new Set(
gridDto.columnFormats
.map((colFormat) => colFormat.fieldName)
.filter((fieldName): fieldName is string => !!fieldName && newParams.has(fieldName)),
)
if (!lockedFields.size) return
// Eğer URL'den değer geliyorsa, bu editörü readonly yapıyoruz
formItems.forEach((form) => {
form?.items?.forEach((item: SimpleItem) => {
if (item.dataField === colFormat.fieldName) {
item.editorOptions = { ...item.editorOptions, readOnly: true }
}
})
})
}
formItems.forEach((group) =>
(group.items as SimpleItem[] | undefined)?.forEach((item) => {
if (item.dataField && lockedFields.has(item.dataField)) {
item.editorOptions = { ...item.editorOptions, readOnly: true }
}
}),
)
}, [formItems, newParams, gridDto?.columnFormats])
async function createInitialFormData() {
const data: any = {}
if (!formItems?.length) {
return data
}
// Default tüm alanlar undefined olsun
for (const formGroupItem of formItems) {
if (formGroupItem.items) {
//SimpleItem
for (const formItem of formGroupItem.items) {
const f = formItem as SimpleItem
data[f.dataField!] = undefined
}
}
}
if (!gridDto?.columnFormats) {
return data
}
// Grid'den gelen columnFormat'ları kullanarak default değerleri set et
for (const colFormat of gridDto?.columnFormats || []) {
if (!colFormat.fieldName) {
continue
}
if (colFormat.defaultValue !== null && colFormat.defaultValue !== undefined) {
if (
typeof colFormat.defaultValue === 'string' &&
colFormat.defaultValue === '@AUTONUMBER'
) {
data[colFormat.fieldName] = autoNumber()
continue
}
if (colFormat.defaultValueType === FieldCustomValueTypeEnum.Sequence) {
try {
const response = await getNextSequenceValue(String(colFormat.defaultValue))
data[colFormat.fieldName] = response.data
} catch (error) {
console.error('Sequence default value alınamadı:', {
fieldName: colFormat.fieldName,
defaultValue: colFormat.defaultValue,
error,
})
}
continue
}
data[colFormat.fieldName] = colFormat.defaultValue
}
}
return data
}
if (!listFormCode) {
return <></>
}
if (loading) {
return <Loading type="default" loading={loading}></Loading>
}
if (!formData) {
return <>{translate('::App.NoResults')}</>
}
return (
<Container>
{!isSubForm && (
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle={APP_NAME}
></Helmet>
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
)}
</div>
{permissionResults && (
<FormPageShell
mode={mode}
listFormCode={listFormCode}
isSubForm={isSubForm}
level={level}
loading={loading}
gridDto={gridDto}
formData={formData}
formItems={formItems}
refForm={refForm}
setFormData={setFormData}
showSubForms={false}
actions={
permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
@ -276,57 +151,42 @@ export const FormNewContent = (
getSelectedRowKeys={() => []}
getSelectedRowsData={() => [formData]}
getFilter={() => []}
onActionView={props?.onActionView}
onActionView={onActionView}
/>
)}
</div>
<div className="px-2">
<FormDevExpress
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={setFormData}
listFormCode={listFormCode}
/>
</div>
</Container>
)
}
/>
)
}
const FormNew = (
props: FormProps = {
isSubForm: false,
},
) => {
/**
* `listFormType === 'Form'` olan liste formlarında "yeni kayıt" ekranı yerine
* doğrudan görüntüleme ekranı ılır (tekil kayıtlı formlar).
*/
const FormNew = (props: FormProps = { isSubForm: false }) => {
const params = useParams()
const listFormCode = props.listFormCode ?? params.listFormCode ?? ''
const [listFormType, setListFormType] = useState<string>()
const [loading, setLoading] = useState(true)
useEffect(() => {
let isActive = true
const resolveFormType = async () => {
setLoading(true)
try {
const response = await getList({ listFormCode })
if (isActive) {
setListFormType(response?.data?.gridOptions?.listFormType)
}
} finally {
if (isActive) {
setLoading(false)
}
}
}
if (listFormCode && !props.isSubForm) {
resolveFormType()
} else {
if (!listFormCode || props.isSubForm) {
setLoading(false)
return
}
let isActive = true
setLoading(true)
getList({ listFormCode })
.then((response: any) => {
if (isActive) setListFormType(response?.data?.gridOptions?.listFormType)
})
.catch((error: unknown) => console.error('ListForm tipi alınamadı:', error))
.finally(() => {
if (isActive) setLoading(false)
})
return () => {
isActive = false
}

View file

@ -1,45 +1,15 @@
import { Container, Loading } from '@/components/shared'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Helmet } from 'react-helmet'
import { useParams } from 'react-router-dom'
import FormButtons from './FormButtons'
import FormDevExpress from './FormDevExpress'
import SubForms from './SubForms'
import { FormProps } from './types'
import { useGridData } from './useFormData'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { Badge } from '@/components/ui'
import { useState } from 'react'
import { NotePanel } from './notes/NotePanel'
import { usePermission } from '@/utils/hooks/usePermission'
import { APP_NAME } from '@/constants/app.constant'
import { FormPageShell } from './shared/FormPageShell'
const FormView = (
props: FormProps = {
isSubForm: false,
onActionEdit: () => {},
onActionNew: () => {},
},
) => {
const FormView = (props: FormProps = { isSubForm: false }) => {
const mode = 'view'
const { isSubForm, level } = props
const { isSubForm, level, onActionEdit, onActionNew } = props
const params = useParams()
const listFormCode = props?.listFormCode ?? params?.listFormCode ?? ''
const id = props?.id ?? params?.id ?? ''
const { translate } = useLocalization()
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const { checkPermission } = usePermission()
const [isNotePanelVisible, setIsNotePanelVisible] = useState(false)
const isNotePanel = () => {
return (
listFormCode &&
formData &&
!isSubForm &&
gridDto?.gridOptions?.showNote &&
checkPermission(gridDto?.gridOptions.permissionDto.n)
)
}
const {
loading,
@ -52,102 +22,49 @@ const FormView = (
refForm,
permissionResults,
fetchData,
} = useGridData({
mode,
listFormCode,
id,
isSubForm,
})
} = useGridData({ mode, listFormCode, id, isSubForm })
if (!listFormCode) {
return <></>
}
if (loading) {
return <Loading type="default" loading={loading}></Loading>
}
if (!formData) {
return <>{translate('::App.NoResults')}</>
}
const recordId = id || formData[gridDto?.gridOptions.keyFieldName ?? 'Id']
// Form tipi liste formları ID olmadan da açılabildiği için kayıt anahtarı
// veriden okunur.
const recordId = id || formData?.[gridDto?.gridOptions.keyFieldName ?? 'Id']
return (
<>
<Container
className={`${isNotePanelVisible && !isSubForm ? 'lg:mr-[375px]' : ''} transition-all duration-300`}
>
{!isSubForm && (
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle={APP_NAME}
></Helmet>
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
)}
</div>
{permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
listFormCode={listFormCode}
id={recordId}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={() => {}}
refreshData={fetchData}
getSelectedRowKeys={() => [recordId]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionEdit={props.onActionEdit}
onActionNew={props.onActionNew}
/>
)}
</div>
<div className={`${isSubForm ? 'px-2' : ''}`}>
<FormDevExpress
<FormPageShell
mode={mode}
listFormCode={listFormCode}
isSubForm={isSubForm}
level={level}
loading={loading}
gridDto={gridDto}
formData={formData}
formItems={formItems}
refForm={refForm}
setFormData={() => {}}
noteEntityId={recordId}
refreshData={fetchData}
contentClassName={isSubForm ? 'px-2' : ''}
actions={
permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={() => {}}
listFormCode={listFormCode}
id={recordId}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={() => {}}
refreshData={fetchData}
getSelectedRowKeys={() => [recordId]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionEdit={onActionEdit}
onActionNew={onActionNew}
/>
</div>
<SubForms
gridDto={gridDto!}
formData={formData}
level={level ?? 0}
refreshData={fetchData}
/>
</Container>
{/* Note Panel - sadece ana formda göster */}
{isNotePanel() && (
<NotePanel
entityName={listFormCode}
entityId={recordId}
isVisible={isNotePanelVisible}
onToggle={() => setIsNotePanelVisible(!isNotePanelVisible)}
/>
)}
</>
)
}
/>
)
}

View file

@ -2,11 +2,26 @@ import { Tabs } from '@/components/ui'
import TabContent from '@/components/ui/Tabs/TabContent'
import TabList from '@/components/ui/Tabs/TabList'
import TabNav from '@/components/ui/Tabs/TabNav'
import { useEffect, useState } from 'react'
import { FaChartBar, FaList } from 'react-icons/fa'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import {
FaCalendarAlt,
FaChartArea,
FaChartBar,
FaList,
FaProjectDiagram,
FaSitemap,
FaTable,
FaTasks,
FaThLarge,
FaWpforms,
} from 'react-icons/fa'
import { useLocation, useNavigate } from 'react-router-dom'
import Grid from '../list/Grid'
import CardView from '../list/CardView'
import Chart from '../list/Chart'
import Pivot from '../list/Pivot'
import TodoBoard from '../list/TodoBoard'
import { GridDto, SubFormDto, SubFormTabTypeEnum } from '@/proxy/form/models'
import FormEdit from './FormEdit'
import { FormNewContent as FormNew } from './FormNew'
@ -16,6 +31,74 @@ import Tree from '../list/Tree'
import GanttView from '../list/GanttView'
import SchedulerView from '../list/SchedulerView'
/**
* Aynı props sözleşmesini paylaşan liste tipi alt form görünümleri.
*
* DİKKAT: Bu eşleme modül seviyesinde sabit olarak tutulamaz. Grid/CardView/Tree/
* GanttView/SchedulerView/TodoBoard/Pivot bu dosyayı geri import ettiği için
* döngüsel bağımlılık vardır ve modül değerlendirme anında bileşenlere erişmek
* "Cannot access 'Grid' before initialization" hatası verir. Bu yüzden çözüm
* render anında yapılır.
*/
const resolveListView = (tabType: SubFormTabTypeEnum) => {
switch (tabType) {
case SubFormTabTypeEnum.List:
return Grid
case SubFormTabTypeEnum.Card:
return CardView
case SubFormTabTypeEnum.Tree:
return Tree
case SubFormTabTypeEnum.Gantt:
return GanttView
case SubFormTabTypeEnum.Scheduler:
return SchedulerView
case SubFormTabTypeEnum.Todo:
return TodoBoard
case SubFormTabTypeEnum.Pivot:
return Pivot
default:
return undefined
}
}
/** Sekme başlığındaki ikonlar. */
const TAB_ICONS: Partial<Record<SubFormTabTypeEnum, ReactNode>> = {
[SubFormTabTypeEnum.List]: <FaList />,
[SubFormTabTypeEnum.Card]: <FaThLarge />,
[SubFormTabTypeEnum.Tree]: <FaSitemap />,
[SubFormTabTypeEnum.Gantt]: <FaProjectDiagram />,
[SubFormTabTypeEnum.Scheduler]: <FaCalendarAlt />,
[SubFormTabTypeEnum.Todo]: <FaTasks />,
[SubFormTabTypeEnum.Form]: <FaWpforms />,
[SubFormTabTypeEnum.Chart]: <FaChartArea />,
[SubFormTabTypeEnum.Pivot]: <FaTable />,
}
type SubFormTabMode = 'view' | 'edit' | 'new'
/**
* Alt form ile üst formun ilişkili alanlarından filtre ve kayıt anahtarı üretir.
* (Üst kayıt seçili değilse alt form filtresiz kalır.)
*/
const withParentRelation = (subForm: SubFormDto, formData: any): SubFormDto => {
const relation = subForm.relation?.find((item) => formData?.[item.parentFieldName])
if (!relation) {
return subForm
}
const filter: [string, string, string] = [
relation.childFieldName,
'=',
formData[relation.parentFieldName],
]
return {
...subForm,
searchParams: new URLSearchParams({ filter: JSON.stringify(filter) }),
id: formData[relation.parentFieldName],
}
}
const SubForms = (props: {
gridDto: GridDto
formData: any
@ -25,206 +108,137 @@ const SubForms = (props: {
}) => {
const { gridDto, formData, level, refreshData, refreshGridDto } = props
const { hash } = useLocation()
const [currentTab, setCurrentTab] = useState<string>()
const [subForms, setSubForms] = useState<SubFormDto[]>()
const navigate = useNavigate()
const { translate } = useLocalization()
const [currentTab, setCurrentTab] = useState<string>()
// Form tipi sekmelerin görüntüle/düzenle/yeni modu; kod bazında tutulur.
const [tabModes, setTabModes] = useState<Record<string, SubFormTabMode>>({})
const subForms = useMemo(
() => (gridDto?.gridOptions?.subFormsDto ?? []).map((sub) => withParentRelation(sub, formData)),
[gridDto, formData],
)
useEffect(() => {
if (hash && level === 0) {
setCurrentTab(hash.replace('#', ''))
} else if (gridDto?.gridOptions?.subFormsDto?.length) {
setCurrentTab(gridDto?.gridOptions?.subFormsDto[0].code)
} else {
setCurrentTab(undefined)
setCurrentTab(subForms[0]?.code)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, gridDto])
useEffect(() => {
if (!gridDto?.gridOptions?.subFormsDto?.length) {
return
const setTabMode = useCallback(
(code: string, tabMode: SubFormTabMode) =>
setTabModes((current) => ({ ...current, [code]: tabMode })),
[],
)
if (!formData || !subForms.length) {
return null
}
const renderTabContent = (subForm: SubFormDto) => {
const ListView = resolveListView(subForm.tabType)
if (ListView) {
return (
<ListView
listFormCode={subForm.code}
searchParams={subForm.searchParams}
isSubForm
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
/>
)
}
for (const subForm of gridDto.gridOptions.subFormsDto) {
if (formData && subForm.relation?.length) {
// sadece ilk eşleşmeyi al
const rel = subForm.relation.find((a) => formData?.[a.parentFieldName])
if (rel) {
const filter: [string, string, string] = [
rel.childFieldName,
'=',
formData[rel.parentFieldName],
]
subForm.searchParams = new URLSearchParams({
filter: JSON.stringify(filter),
})
subForm.id = formData[rel.parentFieldName]
}
}
if (subForm.tabType === SubFormTabTypeEnum.Chart) {
return (
<Chart
id={gridDto?.gridOptions.id as string}
listFormCode={subForm.code}
isSubForm
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
gridDto={gridDto}
refreshGridDto={refreshGridDto}
/>
)
}
setSubForms([...gridDto.gridOptions.subFormsDto])
}, [gridDto, formData])
if (subForm.tabType !== SubFormTabTypeEnum.Form) {
return null
}
if (!formData || !subForms?.length) {
return <></>
const tabMode = tabModes[subForm.code] ?? (subForm.tabMode as SubFormTabMode) ?? 'view'
const commonProps = {
listFormCode: subForm.code,
level: level + 1,
isSubForm: true,
}
if (tabMode === 'edit') {
return (
<FormEdit
{...commonProps}
id={subForm.id}
onActionNew={() => setTabMode(subForm.code, 'new')}
onActionView={() => setTabMode(subForm.code, 'view')}
/>
)
}
if (tabMode === 'new') {
return (
<FormNew
{...commonProps}
sParams={subForm.searchParams}
onActionView={() => setTabMode(subForm.code, 'view')}
/>
)
}
return (
<FormView
{...commonProps}
id={subForm.id}
onActionNew={() => setTabMode(subForm.code, 'new')}
onActionEdit={() => setTabMode(subForm.code, 'edit')}
/>
)
}
return (
<Tabs
value={currentTab}
onChange={(val: string) => {
if (subForms.some((a) => a.code === val)) {
setCurrentTab(val)
if (level === 0) {
navigate(`#${val}`)
}
onChange={(value: string) => {
if (!subForms.some((subForm) => subForm.code === value)) return
setCurrentTab(value)
if (level === 0) {
navigate(`#${value}`)
}
}}
className="mt-2"
>
<TabList>
{subForms.map((subForm) => {
return (
<TabNav
key={subForm.code}
value={subForm.code}
icon={subForm.tabType == SubFormTabTypeEnum.List ? <FaList /> : <FaChartBar />}
>
{translate('::' + subForm.tabTitle)}
</TabNav>
)
})}
{subForms.map((subForm) => (
<TabNav
key={subForm.code}
value={subForm.code}
icon={TAB_ICONS[subForm.tabType] ?? <FaChartBar />}
>
{translate('::' + subForm.tabTitle)}
</TabNav>
))}
</TabList>
{subForms.map((subForm) => {
return (
<TabContent key={subForm.code} value={subForm.code} className="pt-1">
{subForm.tabType == SubFormTabTypeEnum.List && (
<Grid
listFormCode={subForm.code}
searchParams={subForm.searchParams}
isSubForm={true}
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
/>
)}
{subForm.tabType == SubFormTabTypeEnum.Tree && (
<Tree
listFormCode={subForm.code}
searchParams={subForm.searchParams}
isSubForm={true}
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
/>
)}
{subForm.tabType == SubFormTabTypeEnum.Gantt && (
<GanttView
listFormCode={subForm.code}
searchParams={subForm.searchParams}
isSubForm={true}
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
/>
)}
{subForm.tabType == SubFormTabTypeEnum.Scheduler && (
<SchedulerView
listFormCode={subForm.code}
searchParams={subForm.searchParams}
isSubForm={true}
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
/>
)}
{subForm.tabType == SubFormTabTypeEnum.Form &&
(subForm.tabMode == 'edit' ? (
<FormEdit
onActionNew={() => {
setSubForms(
subForms.map((a) => {
if (a.code == subForm.code) {
a.tabMode = 'new'
}
return a
}),
)
}}
onActionView={() => {
setSubForms(
subForms.map((a) => {
if (a.code == subForm.code) {
a.tabMode = 'view'
}
return a
}),
)
}}
listFormCode={subForm.code}
id={subForm.id}
level={level + 1}
isSubForm={true}
/>
) : subForm.tabMode == 'new' ? (
<FormNew
listFormCode={subForm.code}
sParams={subForm.searchParams}
level={level + 1}
isSubForm={true}
onActionView={() => {
setSubForms(
subForms.map((a) => {
if (a.code == subForm.code) {
a.tabMode = 'view'
}
return a
}),
)
}}
/>
) : (
<FormView
onActionNew={() => {
setSubForms(
subForms.map((a) => {
if (a.code == subForm.code) {
a.tabMode = 'new'
}
return a
}),
)
}}
onActionEdit={() => {
setSubForms(
subForms.map((a) => {
if (a.code == subForm.code) {
a.tabMode = 'edit'
}
return a
}),
)
}}
listFormCode={subForm.code}
id={subForm.id}
level={level + 1}
isSubForm={true}
/>
))}
{subForm.tabType == SubFormTabTypeEnum.Chart && (
<Chart
id={gridDto?.gridOptions.id as string}
listFormCode={subForm.code}
isSubForm={true}
level={level + 1}
refreshData={subForm.isRefresh ? refreshData : undefined}
gridDto={gridDto}
refreshGridDto={refreshGridDto}
/>
)}
</TabContent>
)
})}
{subForms.map((subForm) => (
<TabContent key={subForm.code} value={subForm.code} className="pt-1">
{renderTabContent(subForm)}
</TabContent>
))}
</Tabs>
)
}

View file

@ -1,5 +1,5 @@
import { ImageUploadOptionsDto } from '@/proxy/form/models'
import { ReactElement, useRef, useState } from 'react'
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
import { FaSpinner, FaUpload } from 'react-icons/fa'
import { Button } from '@/components/ui'
import {
@ -7,6 +7,7 @@ import {
openImageInNewTab,
showImageHoverPreview,
} from './imageHoverPreview'
import { normalizeImageSize } from '../../list/shared/imageValue'
const ImageUploadEditorComponent = ({
value,
@ -28,19 +29,24 @@ const ImageUploadEditorComponent = ({
const isMultiple: boolean =
resolvedOptions.multiple === true || (resolvedOptions.multiple as any) === 'true'
const thumbW: number = resolvedOptions.width ?? 40
const thumbH: number = resolvedOptions.height ?? 40
const thumbW = normalizeImageSize(resolvedOptions.width, 40)
const thumbH = normalizeImageSize(resolvedOptions.height, 40)
const initialUrls: string[] = value
? Array.isArray(value)
? (value as string[]).filter(Boolean)
: [value as string].filter(Boolean)
: []
const valueUrls = useMemo<string[]>(
() => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[],
[value],
)
const [urls, setUrls] = useState<string[]>(initialUrls)
const [urls, setUrls] = useState<string[]>(valueUrls)
const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Değer dışarıdan (editör script'i, veri yenileme…) değiştiğinde önizlemeler
// güncellenmiyordu; state yalnızca ilk render'da kuruluyordu.
useEffect(() => {
setUrls(valueUrls)
}, [valueUrls])
const removeImage = (index: number) => {
const newUrls = urls.filter((_, i) => i !== index)
setUrls(newUrls)
@ -76,8 +82,7 @@ const ImageUploadEditorComponent = ({
const res = await fetch(resolvedOptions.uploadUrl, { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json()
const url: string = data?.url ?? data?.fileUrl ?? data?.path ?? data
uploadedUrls.push(url)
uploadedUrls.push(String(data?.url ?? data?.fileUrl ?? data?.path ?? data ?? '').trim())
} else {
// uploadUrl yoksa base64 olarak sakla
const base64 = await toBase64(file)

View file

@ -6,91 +6,12 @@ import {
openImageInNewTab,
showImageHoverPreview,
} from './imageHoverPreview'
const parseJsonObject = (value: unknown) => {
if (!value) return undefined
if (typeof value === 'object') return value as Record<string, any>
if (typeof value !== 'string') return undefined
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: undefined
} catch {
return undefined
}
}
const normalizeImageSize = (value: unknown, fallback: number) => {
const size = Number(value)
return Number.isFinite(size) && size > 0 ? size : fallback
}
const getImageSource = (value: unknown) => {
if (!value) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'object') {
const item = value as Record<string, unknown>
return String(item.url ?? item.src ?? item.fileUrl ?? item.path ?? item.value ?? '').trim()
}
return String(value).trim()
}
const isProbablyBase64Image = (value: string) =>
value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
const toImageSource = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed
return `data:image/jpeg;base64,${trimmed}`
}
const splitImageString = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('data:image/')) return [trimmed]
return trimmed
.split(/\r?\n|\|\s*/)
.flatMap((part) => {
const text = part.trim()
if (!text || text.startsWith('data:image/')) return text ? [text] : []
return text.split(',').map((item) => item.trim())
})
.map(toImageSource)
.filter(Boolean)
}
const normalizeImageValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeImageValue(item))
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.flatMap((item) => normalizeImageValue(item))
}
} catch {
return [toImageSource(trimmed)]
}
}
return splitImageString(trimmed)
}
const source = getImageSource(value)
return source ? [toImageSource(source)] : []
}
import {
NO_IMAGE,
normalizeImageSize,
normalizeImageValue,
parseJsonObject,
} from '../../list/shared/imageValue'
const ImageViewerEditorComponent = ({
value,
@ -137,7 +58,7 @@ const ImageViewerEditorComponent = ({
}}
onError={({ currentTarget }) => {
currentTarget.onerror = null
currentTarget.src = '/img/others/no-image.png'
currentTarget.src = NO_IMAGE
}}
/>
</Button>

View file

@ -1,7 +1,12 @@
import React from 'react'
const NO_IMAGE = '/img/others/no-image.png'
/** Hem React SyntheticEvent hem de native MouseEvent ile çalışabilmesi için minimum sözleşme. */
type HoverPreviewEvent = {
clientX: number
clientY: number
currentTarget: EventTarget | null
}
let previewElement: HTMLDivElement | null = null
let previewTarget: HTMLElement | null = null
let previewListenersBound = false
@ -71,9 +76,9 @@ const getPreviewElement = () => {
return previewElement
}
export const showImageHoverPreview = (src: string, event: React.MouseEvent<HTMLElement>) => {
export const showImageHoverPreview = (src: string, event: HoverPreviewEvent) => {
const element = getPreviewElement()
previewTarget = event.currentTarget
previewTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null
const image = element.querySelector('img') as HTMLImageElement
image.onerror = () => {
image.onerror = null

View file

@ -6,15 +6,8 @@ import { FaFileAlt, FaFileUpload, FaPlus, FaTrash } from 'react-icons/fa'
import { Field, FieldProps, Form, Formik } from 'formik'
import * as Yup from 'yup'
import { noteService } from '@/services/note.service'
import {
sizeValues,
fontSizeOptions,
fontValues,
fontFamilyOptions,
headerValues,
headerOptions,
} from '@/proxy/reports/data'
import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextreme-react/html-editor'
import { HtmlEditor, ImageUpload, MediaResizing } from 'devextreme-react/html-editor'
import { HtmlEditorToolbar } from '@/views/shared/HtmlEditorToolbarItems'
import { useLocalization } from '@/utils/hooks/useLocalization'
const validationSchema = Yup.object({
@ -161,36 +154,7 @@ function NoteModalContent({
>
<MediaResizing enabled={true} />
<ImageUpload fileUploadMode="base64" />
<Toolbar multiline>
<Item name="undo" />
<Item name="redo" />
<Item name="separator" />
<Item name="size" acceptedValues={sizeValues} options={fontSizeOptions} />
<Item name="font" acceptedValues={fontValues} options={fontFamilyOptions} />
<Item name="separator" />
<Item name="bold" />
<Item name="italic" />
<Item name="underline" />
<Item name="strike" />
<Item name="separator" />
<Item name="orderedList" />
<Item name="bulletList" />
<Item name="separator" />
<Item name="header" acceptedValues={headerValues} options={headerOptions} />
<Item name="separator" />
<Item name="color" />
<Item name="background" />
<Item name="separator" />
<Item name="alignLeft" />
<Item name="alignCenter" />
<Item name="alignRight" />
<Item name="alignJustify" />
<Item name="separator" />
<Item name="link" />
<Item name="image" />
<Item name="separator" />
<Item name="clear" />
</Toolbar>
<HtmlEditorToolbar />
</HtmlEditor>
)}
</Field>

View file

@ -0,0 +1,149 @@
/**
* FormView / FormEdit / FormNew ekranlarının ortak sayfa iskeleti.
*
* Üç bileşen de aynı düzeni (Helmet + başlık + FormButtons + FormDevExpress +
* SubForms + NotePanel) satır satır kopyalıyordu. Tek noktadan yönetilir.
*/
import type { ReactNode, RefObject } from 'react'
import { useState } from 'react'
import { Container, Loading } from '@/components/shared'
import { Badge } from '@/components/ui'
import { APP_NAME } from '@/constants/app.constant'
import type { GridDto } from '@/proxy/form/models'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import type { FormRef } from 'devextreme-react/form'
import type { GroupItem } from 'devextreme/ui/form'
import { Helmet } from 'react-helmet'
import FormDevExpress from '../FormDevExpress'
import SubForms from '../SubForms'
import { NotePanel } from '../notes/NotePanel'
import type { RowMode } from '../types'
type FormPageShellProps = {
mode: RowMode
listFormCode: string
isSubForm?: boolean
level?: number
loading: boolean
gridDto?: GridDto
formData: any
formItems: GroupItem[]
refForm: RefObject<FormRef>
setFormData: (data: any) => void
/** Başlığın sağındaki aksiyon butonları (FormButtons). */
actions?: ReactNode
/** Not paneli kimliği; verilmezse panel gösterilmez. */
noteEntityId?: string
/** Alt form sekmelerini yenileme geri çağrısı. */
refreshData?: () => Promise<void>
/** FormNew'de alt form gösterilmez. */
showSubForms?: boolean
/** Form gövdesinin sarmalayıcı sınıfı (FormView farklı dolgu kullanır). */
contentClassName?: string
}
export const FormPageShell = ({
mode,
listFormCode,
isSubForm,
level,
loading,
gridDto,
formData,
formItems,
refForm,
setFormData,
actions,
noteEntityId,
refreshData,
showSubForms = true,
contentClassName = 'px-2',
}: FormPageShellProps) => {
const { translate } = useLocalization()
const { checkPermission } = usePermission()
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const [isNotePanelVisible, setIsNotePanelVisible] = useState(false)
const showNotePanel = Boolean(
listFormCode &&
noteEntityId &&
!isSubForm &&
gridDto?.gridOptions?.showNote &&
checkPermission(gridDto?.gridOptions.permissionDto.n),
)
if (!listFormCode) {
return null
}
if (loading) {
return <Loading type="default" loading />
}
if (!formData) {
return <>{translate('::App.NoResults')}</>
}
const title = translate('::' + gridDto?.gridOptions.title)
return (
<>
<Container
className={`${
isNotePanelVisible && !isSubForm ? 'lg:mr-[375px]' : ''
} transition-all duration-300`}
>
{!isSubForm && (
<Helmet titleTemplate={`%s | ${APP_NAME}`} title={title} defaultTitle={APP_NAME} />
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">{title}</h4>
<Badge content={mode} />
</>
)}
</div>
{actions}
</div>
<div className={contentClassName}>
<FormDevExpress
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={setFormData}
listFormCode={listFormCode}
/>
</div>
{showSubForms && gridDto && (
<SubForms
gridDto={gridDto}
formData={formData}
level={level ?? 0}
refreshData={refreshData}
/>
)}
</Container>
{/* Not paneli yalnızca ana formda gösterilir. */}
{showNotePanel && (
<NotePanel
entityName={listFormCode}
entityId={noteEntityId!}
isVisible={isNotePanelVisible}
onToggle={() => setIsNotePanelVisible((visible) => !visible)}
/>
)}
</>
)
}

View file

@ -0,0 +1,126 @@
/**
* Form (FormNew/FormEdit/FormView) ekranlarının item üretimi ve gezinme
* yardımcıları. Daha önce `useFormData.tsx` ve `FormDevExpress.tsx` içinde
* ayrı ayrı tanımlıydı.
*/
import { captionize } from 'devextreme/core/utils/inflector'
import type { GroupItem } from 'devextreme/ui/form'
import type { ColumnFormatDto, GridDto } from '@/proxy/form/models'
import { getEditingFormGroups } from '../../list/Utils'
import { isFormItemVisible, resolveEditorType } from '../../list/shared/editingForm'
import type { GridColumnData } from '../../list/GridColumnData'
import type { RowMode, SimpleItemWithColData } from '../types'
/** Grup / tab ağacındaki tüm veri alanlarını düz listeye indirger. */
export const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] =>
items.flatMap((item) => [
...(item?.dataField ? [item] : []),
...flattenFormItems(item?.items || []),
...(item?.tabs || []).flatMap((tab: any) => flattenFormItems(tab?.items || [])),
])
/** Bir grup listesindeki tüm alanları tek seferde düzleştirir. */
export const flattenFormGroups = (groups: GroupItem[] = []): SimpleItemWithColData[] =>
groups.flatMap((group) => flattenFormItems([group]))
/** Bir alanın cascade parent alan adlarını döner. */
export const getCascadeParentFields = (item: SimpleItemWithColData): string[] =>
item.colData?.lookupDto?.cascadeParentFields
?.split(',')
.map((field: string) => field.trim())
.filter(Boolean) ?? []
type BuildFormItemsParams = {
gridDto: GridDto
mode: RowMode
columns?: GridColumnData[]
/** Lookup alanları için DevExtreme dataSource üretici. */
getLookupDataSource: (options: any, colData: ColumnFormatDto, formData: any) => any
formData: any
}
/** Tek bir ColumnFormatDto'yu DevExtreme form item'ına çevirir. */
const toFormItem = (
colFormat: ColumnFormatDto,
{ mode, columns, getLookupDataSource, formData }: Omit<BuildFormItemsParams, 'gridDto'>,
): SimpleItemWithColData => {
let parsedEditorOptions: Record<string, any> = {}
try {
parsedEditorOptions = colFormat.editorOptions ? JSON.parse(colFormat.editorOptions) : {}
} catch (err) {
console.error('EditorOptions parse error:', colFormat.fieldName, err)
}
const lookupEditorOptions = colFormat.lookupDto?.dataSourceType
? {
dataSource: getLookupDataSource(colFormat.editorOptions, colFormat, formData),
valueExpr: colFormat.lookupDto?.valueExpr?.toLowerCase(),
displayExpr: colFormat.lookupDto?.displayExpr?.toLowerCase(),
}
: {}
const item: SimpleItemWithColData = {
canRead: colFormat.canRead ?? false,
canUpdate: colFormat.canUpdate ?? false,
canCreate: colFormat.canCreate ?? false,
canExport: colFormat.canExport ?? false,
allowEditing: colFormat.allowEditing ?? true,
allowAdding: colFormat.allowAdding ?? true,
dataField: colFormat.fieldName,
name: colFormat.fieldName,
editorType2: colFormat.editorType2,
editorType: resolveEditorType(colFormat.editorType2),
colSpan: colFormat.colSpan,
editorOptions: { ...lookupEditorOptions, ...parsedEditorOptions },
colData: colFormat,
tagBoxOptions: colFormat.tagBoxOptions,
gridBoxOptions: colFormat.gridBoxOptions,
imageUploadOptions: colFormat.imageUploadOptions,
editorScript: colFormat.editorScript,
}
// "Options:TemplateName" biçimindeki JSON alt alanları için etiket üret.
if (colFormat.fieldName?.includes(':')) {
item.label = { text: captionize(colFormat.fieldName.split(':')[1]) }
}
if ((mode === 'edit' && !item.canUpdate) || (mode === 'new' && !item.canCreate)) {
item.editorOptions = { ...item.editorOptions, readOnly: true }
}
item.isRequired =
columns?.some(
(column) =>
column.dataField === colFormat.fieldName &&
colFormat.validationRuleDto?.some((rule) => rule.type === 'required'),
) ?? false
return item
}
/**
* EditingFormDto gruplarını DevExtreme `GroupItem` listesine çevirir.
* Alanlar `editOrderNo`'ya göre sıralanır ve moda göre yetki filtresinden geçer.
*/
export const buildFormGroupItems = ({
gridDto,
mode,
columns,
getLookupDataSource,
formData,
}: BuildFormItemsParams): GroupItem[] =>
getEditingFormGroups(gridDto).map(
(group: any) =>
({
itemType: group.itemType,
colCount: group.colCount,
colSpan: group.colSpan,
caption: group.caption,
items: [...(group.items ?? [])]
.sort((left, right) => (left.editOrderNo ?? 0) - (right.editOrderNo ?? 0))
.map((colFormat: ColumnFormatDto) =>
toFormItem(colFormat, { mode, columns, getLookupDataSource, formData }),
)
.filter((item) => isFormItemVisible(item, mode)),
}) as GroupItem,
)

View file

@ -3,28 +3,25 @@ import { getList } from '@/services/form.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import type { FormRef } from 'devextreme-react/form'
import { captionize } from 'devextreme/core/utils/inflector'
import CustomStore from 'devextreme/data/custom_store'
import type { GroupItem } from 'devextreme/ui/form'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { GridColumnData } from '../list/GridColumnData'
import { addCss, addJs, getEditingFormGroups } from '../list/Utils'
import { PermissionResults, RowMode, SimpleItemWithColData } from './types'
import { ColumnFormatDto, GridDto, PlatformEditorTypes } from '@/proxy/form/models'
import { GridDto } from '@/proxy/form/models'
import { getAccessDeniedPath } from '@/utils/routing'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLookupDataSource } from './useLookupDataSource'
import { layoutTypes } from '../admin/listForm/edit/types'
import { useListFormCustomDataSource } from '../list/useListFormCustomDataSource'
import { useListFormColumns } from '../list/useListFormColumns'
const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] =>
items.flatMap((item) => [
...(item?.dataField ? [item] : []),
...flattenFormItems(item?.items || []),
...(item?.tabs || []).flatMap((tab: any) => flattenFormItems(tab?.items || [])),
])
import { useListFormCustomSources } from '../list/shared/hooks'
import {
buildFormGroupItems,
flattenFormGroups,
getCascadeParentFields,
} from './shared/formItems'
const useGridData = (props: {
mode: RowMode
@ -63,21 +60,15 @@ const useGridData = (props: {
const { createSelectDataSource } = useListFormCustomDataSource({} as any)
const { getLookupDataSource } = useLookupDataSource({ listFormCode, isSubForm })
useListFormCustomSources(gridDto)
const fetchData = async () => {
setLoading(true)
try {
const response: any = await dataSource?.load({
filter,
skip: 0,
take: 1,
})
if (response?.data?.length) {
setFormData(response.data[0])
setFormDataOld({ ...response.data[0] })
} else {
setFormData(undefined)
setFormDataOld(undefined)
}
const response: any = await dataSource?.load({ filter, skip: 0, take: 1 })
const record = response?.data?.[0]
setFormData(record)
setFormDataOld(record ? { ...record } : undefined)
} catch (error: any) {
toast.push(<Notification title={error.message} type="danger" />, {
placement: 'bottom-end',
@ -87,77 +78,71 @@ const useGridData = (props: {
}
}
const handleSubmit = async (e: any) => {
e.preventDefault()
if (!dataSource) {
return
const showSaveSuccess = () =>
toast.push(
<Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>,
{ placement: 'bottom-end' },
)
/** Kaydetme sonrası yönlendirme; alt formda üst bileşene haber verilir. */
const finishSubmit = (viewPath: string) => {
if (isSubForm) {
props.onSubmitAction?.()
} else {
navigate(viewPath)
}
showSaveSuccess()
}
/** Güncelleme isteğinin gövdesini hazırlar. */
const buildUpdateData = (formValues: Record<string, any>) => {
const sendOnlyChanged = gridDto?.gridOptions.editingOptionDto?.sendOnlyChangedFormValuesUpdate
const data =
sendOnlyChanged && formDataOld
? Object.fromEntries(
Object.keys(formValues)
.filter((key) => !Object.is(formValues[key], formDataOld[key]))
.map((key) => [key, formValues[key]]),
)
: { ...formValues }
if (gridDto?.gridOptions.keyFieldName) {
delete data[gridDto.gridOptions.keyFieldName]
}
const validationResult = refForm.current?.instance().validate()
if (!validationResult?.isValid) {
return data
}
const handleSubmit = async (e: any) => {
e.preventDefault()
if (!dataSource) return
if (!refForm.current?.instance().validate()?.isValid) {
return
}
setLoading(true)
try {
const formValues = { ...formData }
if (mode === 'new') {
const result = await dataSource.insert(formValues)
if (result.data) {
if (!isSubForm) {
navigate(result.data)
} else if (props.onSubmitAction) {
props.onSubmitAction()
}
toast.push(
<Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>,
{
placement: 'bottom-end',
},
)
} else {
if (!result?.data) {
throw new Error(translate('::ListForms.FormBilgileriKaydedilemedi'))
}
finishSubmit(result.data)
} else if (mode === 'edit') {
let data: any = {}
if (gridDto?.gridOptions.editingOptionDto?.sendOnlyChangedFormValuesUpdate) {
Object.keys(formValues).forEach((key) => {
if (formValues[key] !== formDataOld[key]) {
data[key] = formValues[key]
}
})
} else {
data = { ...formValues }
}
if (gridDto?.gridOptions.keyFieldName) {
delete data[gridDto?.gridOptions.keyFieldName]
}
var result = await dataSource.update(id, data)
if (result.data > 0) {
if (!isSubForm) {
navigate(
ROUTES_ENUM.protected.admin.formView
.replace(':listFormCode', encodeURIComponent(listFormCode))
.replace(':id', encodeURIComponent(id!)),
)
} else if (props.onSubmitAction) {
props.onSubmitAction()
}
toast.push(
<Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>,
{
placement: 'bottom-end',
},
)
} else {
const result = await dataSource.update(id, buildUpdateData(formValues))
if (!(result?.data > 0)) {
throw new Error(translate('::ListForms.FormBilgileriKaydedilemedi'))
}
finishSubmit(
ROUTES_ENUM.protected.admin.formView
.replace(':listFormCode', encodeURIComponent(listFormCode))
.replace(':id', encodeURIComponent(id!)),
)
}
} catch (error: any) {
toast.push(<Notification title={error.message} type="danger" />, {
@ -170,142 +155,64 @@ const useGridData = (props: {
useEffect(() => {
setGridReady(false)
const initializeGrid = async () => {
const response = await getList({ listFormCode })
setGridDto(response.data)
}
initializeGrid()
let isActive = true
getList({ listFormCode })
.then((response: any) => {
if (isActive) setGridDto(response.data)
})
.catch((error: unknown) => console.error('GridDto load error:', error))
return () => {
isActive = false
}
}, [listFormCode])
useEffect(() => {
setGridReady(false)
if (!gridDto) {
return
}
if (!gridDto) return
const { editingOptionDto, permissionDto } = gridDto.gridOptions
setPermissionResults({
c:
gridDto?.gridOptions.editingOptionDto.allowAdding === true &&
checkPermission(gridDto?.gridOptions.permissionDto.c),
r: checkPermission(gridDto?.gridOptions.permissionDto.r),
u:
gridDto?.gridOptions.editingOptionDto.allowUpdating === true &&
checkPermission(gridDto?.gridOptions.permissionDto.u),
d:
gridDto?.gridOptions.editingOptionDto.allowDeleting === true &&
checkPermission(gridDto?.gridOptions.permissionDto.d),
e: checkPermission(gridDto?.gridOptions.permissionDto.e),
i: checkPermission(gridDto?.gridOptions.permissionDto.i),
c: editingOptionDto.allowAdding === true && checkPermission(permissionDto.c),
r: checkPermission(permissionDto.r),
u: editingOptionDto.allowUpdating === true && checkPermission(permissionDto.u),
d: editingOptionDto.allowDeleting === true && checkPermission(permissionDto.d),
e: checkPermission(permissionDto.e),
i: checkPermission(permissionDto.i),
})
// Set js and css
const grdOpt = gridDto.gridOptions
grdOpt.customJsSources.forEach(addJs)
grdOpt.customStyleSources.forEach(addCss)
const columns = getBandedColumns()
setCommandColumnData(columns?.find((column) => column.type === 'buttons'))
// Set columns
const cols = getBandedColumns()
setCommandColumnData(cols?.find((a) => a.type == 'buttons'))
// Set data source
const dataSource: CustomStore<any, any> = createSelectDataSource(
gridDto.gridOptions,
listFormCode,
searchParams,
layoutTypes.grid,
cols,
setDataSource(
createSelectDataSource(
gridDto.gridOptions,
listFormCode,
searchParams,
layoutTypes.grid,
columns,
),
)
setDataSource(dataSource)
const items = getEditingFormGroups(gridDto)
.map((e: any) => {
return {
itemType: e.itemType,
colCount: e.colCount,
colSpan: e.colSpan,
caption: e.caption,
items: e.items
?.sort((a: any, b: any) => {
return (a.editOrderNo ?? 0) >= (b.editOrderNo ?? 0) ? 1 : -1
})
.map((i: ColumnFormatDto) => {
let editorOptions: Record<string, any> = {}
let parsedEditorOptions: Record<string, any> = {}
const colData = i
parsedEditorOptions = i.editorOptions ? JSON.parse(i.editorOptions) : {}
const lookupEditorOptions = colData?.lookupDto?.dataSourceType
? {
dataSource: getLookupDataSource(colData?.editorOptions, colData, formData),
valueExpr: colData?.lookupDto?.valueExpr?.toLowerCase(),
displayExpr: colData?.lookupDto?.displayExpr?.toLowerCase(),
}
: {}
editorOptions = {
...lookupEditorOptions,
...parsedEditorOptions,
}
const item: SimpleItemWithColData = {
canRead: i.canRead ?? false,
canUpdate: i.canUpdate ?? false,
canCreate: i.canCreate ?? false,
canExport: i.canExport ?? false,
allowEditing: i.allowEditing ?? true,
allowAdding: i.allowAdding ?? true,
dataField: i.fieldName,
name: i.fieldName,
editorType2: i.editorType2,
editorType:
i.editorType2 == PlatformEditorTypes.dxGridBox
? 'dxDropDownBox'
: i.editorType2 == PlatformEditorTypes.dxImageUpload ||
i.editorType2 == PlatformEditorTypes.dxImageViewer
? undefined
: i.editorType2,
colSpan: i.colSpan,
editorOptions,
colData,
tagBoxOptions: i.tagBoxOptions,
gridBoxOptions: i.gridBoxOptions,
imageUploadOptions: i.imageUploadOptions,
editorScript: i.editorScript,
}
if (i.fieldName?.indexOf(':') >= 0) {
item.label = { text: captionize(i.fieldName.split(':')[1]) }
}
if ((mode == 'edit' && !item.canUpdate) || (mode == 'new' && !item.canCreate)) {
item.editorOptions = {
...item.editorOptions,
readOnly: true,
}
}
item.isRequired =
cols?.some(
(c) =>
c.dataField === item.colData?.fieldName &&
item.colData?.validationRuleDto?.some((a) => a.type === 'required'),
) ?? false
return item
})
.filter((a: any) => {
if (mode === 'view') {
return a.canRead
} else if (mode === 'new') {
return a.canCreate && a.allowAdding
} else if (mode === 'edit') {
return a.canUpdate && a.allowEditing
} else {
return false
}
}),
} as GroupItem
})
setFormItems(items)
setFormItems(
buildFormGroupItems({
gridDto,
mode,
columns,
getLookupDataSource,
formData,
}),
)
setGridReady(true)
// getBandedColumns/getLookupDataSource her render'da yeniden oluşabilir.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridDto])
// formData değiştiğinde sadece etkilenen cascading lookup datasource'ları güncelle
/**
* formData değiştiğinde yalnızca etkilenen cascading lookup dataSource'larını
* yeniden üretir; tüm form item ağacını gereksiz yere yenilemez.
*/
useEffect(() => {
if (!gridDto || !formItems.length) {
previousFormDataRef.current = formData
@ -319,23 +226,26 @@ const useGridData = (props: {
)
: []
const shouldRefreshLookup = (item: SimpleItemWithColData) => {
const cascadeParentFields = item.colData?.lookupDto?.cascadeParentFields
?.split(',')
.map((field: string) => field.trim())
.filter(Boolean)
const shouldRefreshLookup = (item: SimpleItemWithColData) =>
!previousFormData ||
getCascadeParentFields(item).some((field) => changedFields.includes(field))
return (
!previousFormData ||
cascadeParentFields?.some((field: string) => changedFields.includes(field))
)
const hasAffectedLookup = flattenFormGroups(formItems).some(
(item) => item.colData?.lookupDto?.dataSourceType && shouldRefreshLookup(item),
)
if (!hasAffectedLookup) {
previousFormDataRef.current = formData
return
}
const updateItems = (items: any[] = []): any[] =>
items.map((item) => {
const colData = item.colData
if (colData?.lookupDto?.dataSourceType && shouldRefreshLookup(item)) {
const currentDataSource = item.editorOptions?.dataSource
// Kullanıcı tarafından elle verilmiş (statik dizi vb.) dataSource korunur.
const keepCustomDataSource =
currentDataSource !== undefined && typeof currentDataSource?.load !== 'function'
@ -343,7 +253,6 @@ const useGridData = (props: {
...item,
editorOptions: {
...item.editorOptions,
// formData null bile olsa getLookupDataSource çağrılmalı (null parametrelerle API çağrısı yapılacak)
dataSource: keepCustomDataSource
? currentDataSource
: getLookupDataSource(colData?.editorOptions, colData, formData || null),
@ -356,82 +265,66 @@ const useGridData = (props: {
}
if (item?.items?.length) {
return {
...item,
items: updateItems(item.items),
}
return { ...item, items: updateItems(item.items) }
}
if (item?.tabs?.length) {
return {
...item,
tabs: item.tabs.map((tab: any) => ({
...tab,
items: updateItems(tab.items),
})),
tabs: item.tabs.map((tab: any) => ({ ...tab, items: updateItems(tab.items) })),
}
}
return item
})
const hasAffectedLookup =
!previousFormData ||
formItems
.flatMap((group) => flattenFormItems([group]))
.some((item) => item.colData?.lookupDto?.dataSourceType && shouldRefreshLookup(item))
if (!hasAffectedLookup) {
previousFormDataRef.current = formData
return
}
const updatedItems = formItems.map((groupItem) => ({
...groupItem,
items: updateItems(groupItem.items as any[]),
tabs: (groupItem as any).tabs?.map((tab: any) => ({
...tab,
items: updateItems(tab.items),
})),
}))
previousFormDataRef.current = formData
setFormItems(updatedItems)
setFormItems((currentItems) =>
currentItems.map((groupItem) => ({
...groupItem,
items: updateItems(groupItem.items as any[]),
tabs: (groupItem as any).tabs?.map((tab: any) => ({
...tab,
items: updateItems(tab.items),
})),
})),
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, gridDto])
useEffect(() => {
if (!gridReady) {
return
}
if (!gridReady || mode === 'new') return
if (mode !== 'new') {
if (id) {
setFilter([gridDto?.gridOptions.keyFieldName ?? 'Id', '=', id])
} else {
// Form tipi listformlar ID olmadan açılabilir. Bu durumda kayıt seçimini
// sunucudaki DefaultFilter'a bırakıyoruz.
fetchData()
}
if (id) {
setFilter([gridDto?.gridOptions.keyFieldName ?? 'Id', '=', id])
} else {
// Form tipi liste formlar ID olmadan açılabilir; kayıt seçimi sunucudaki
// DefaultFilter'a bırakılır.
fetchData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, gridReady])
useEffect(() => {
if (filter?.length) {
fetchData()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter])
// Auth check
// Yetki kontrolü
useEffect(() => {
if (!permissionResults) return
const noCreate = mode === 'new' && !permissionResults.c
const noUpdate = mode === 'edit' && !permissionResults.u
const noRead = mode === 'view' && !permissionResults.r
const denied =
(mode === 'new' && !permissionResults.c) ||
(mode === 'edit' && !permissionResults.u) ||
(mode === 'view' && !permissionResults.r)
if (noCreate || noUpdate || noRead) {
if (denied) {
navigate(getAccessDeniedPath(location.pathname), { replace: true, state: { from: location } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissionResults])
return {

View file

@ -3,21 +3,16 @@ import { UiLookupDataSourceTypeEnum } from '@/proxy/form/models'
import CustomStore from 'devextreme/data/custom_store'
import { useCallback } from 'react'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { cachedLookupLoad } from '../shared/lookupCache'
const __lookupCache = new Map<string, Promise<any[]>>()
export { clearLookupCache } from '../shared/lookupCache'
const cachedLoader = (key: string, loader: () => Promise<any[]>) => {
if (__lookupCache.has(key)) return __lookupCache.get(key)!
const p = Promise.resolve()
.then(() => loader())
.then((res) => res ?? [])
.catch((err) => {
__lookupCache.delete(key)
throw err
})
__lookupCache.set(key, p)
return p
}
/**
* Lookup sorgusu yalnızca ilgili liste formu ekranı ıkken çalışmalı.
* (SubForm'larda bu kontrol uygulanmaz.)
*/
const isLookupAllowed = (listFormCode?: string, isSubForm?: boolean) =>
isSubForm || !listFormCode || window.location.pathname.includes(listFormCode)
const createLookupStaticDataSource = (
load: () => any,
@ -28,13 +23,12 @@ const createLookupStaticDataSource = (
store: new CustomStore({
key,
loadMode: 'raw',
load: async () => {
const cacheKey = `static:${key}`
return cachedLoader(cacheKey, async () => {
const res = await Promise.resolve(load())
return Array.isArray(res) ? res : [res]
})
},
load: () =>
cachedLookupLoad(`static:${key}`, async () => {
const result = await Promise.resolve(load())
if (result === undefined || result === null) return []
return Array.isArray(result) ? result : [result]
}),
}),
sort,
filter,
@ -45,31 +39,38 @@ const createLookupQueryDataSource = (
listFormFieldName?: string,
filters?: any[],
isSubForm?: boolean,
) => {
return new CustomStore({
) =>
new CustomStore({
loadMode: 'raw',
load: async () => {
if (!isSubForm && listFormCode && !window.location.pathname.includes(listFormCode)) {
return
if (!isLookupAllowed(listFormCode, isSubForm)) {
return []
}
const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}`
return cachedLoader(cacheKey, async () => {
const response = await dynamicFetch('list-form-select/lookup', 'POST', null, {
listFormCode,
listFormFieldName,
filters,
try {
const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}`
return await cachedLookupLoad(cacheKey, async () => {
const response = await dynamicFetch('list-form-select/lookup', 'POST', null, {
listFormCode,
listFormFieldName,
filters,
})
const data = response?.data ?? []
return (Array.isArray(data) ? data : [data]).map((item: any) => ({
key: item.Key,
name: item.Name,
group: item.Group,
...item,
}))
})
const data = response?.data ?? []
return (Array.isArray(data) ? data : [data]).map((a: any) => ({
key: a.Key,
name: a.Name,
group: a.Group,
}))
}).catch(() => null as any)
} catch (error) {
console.error('Lookup query data could not be loaded:', error)
return []
}
},
})
}
const createLookupApiDataSource = (
listFormCode?: string,
@ -77,19 +78,15 @@ const createLookupApiDataSource = (
filters?: any[],
keyName?: string,
isSubForm?: boolean,
) => {
return new CustomStore({
) =>
new CustomStore({
key: keyName,
loadMode: 'raw',
load: async () => {
if (!isSubForm && listFormCode && !window.location.pathname.includes(listFormCode)) {
return
}
if (!lookupQuery) {
return
if (!isLookupAllowed(listFormCode, isSubForm) || !lookupQuery) {
return []
}
const parts = lookupQuery.split(';')
const [
method = 'GET',
url = '',
@ -97,30 +94,40 @@ const createLookupApiDataSource = (
keySelector = 'a=>a.id',
nameSelector = 'a=>a.name',
groupSelector = 'a=>a.group',
] = parts
] = lookupQuery.split(';')
// Gövdedeki @param0, @param1… yer tutucularını cascade filtreleriyle doldur.
let resolvedBody = bodyTemplate ?? ''
if (filters?.length) {
for (let i = 0; i < filters.length; i++) {
resolvedBody = resolvedBody.replace(new RegExp(`@param${i}`, 'g'), String(filters[i]))
}
}
filters?.forEach((filterValue, index) => {
resolvedBody = resolvedBody.replace(new RegExp(`@param${index}`, 'g'), String(filterValue))
})
const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}`
return cachedLoader(cacheKey, async () => {
const response = await dynamicFetch(url, method, null, resolvedBody)
let { data } = response
if (!data) return []
if (!Array.isArray(data)) data = [data]
return data.map(() => ({
key: eval(keySelector),
name: eval(nameSelector),
group: eval(groupSelector),
}))
}).catch(() => [] as any)
try {
const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}`
return await cachedLookupLoad(cacheKey, async () => {
const response = await dynamicFetch(url, method, null, resolvedBody)
let { data } = response
if (!data) return []
if (!Array.isArray(data)) data = [data]
// NOT: Selector'lar `a=>a.id` biçiminde yapılandırmadan gelir; bu yüzden
// map parametresi `a` olarak adlandırılmalıdır. Önceden parametresiz
// `() => ...` kullanıldığı için eval fonksiyonun kendisini döndürüyor ve
// key/name/group alanları bozuk geliyordu.
return data.map((a: any) => ({
key: eval(keySelector),
name: eval(nameSelector),
group: eval(groupSelector),
...a,
}))
})
} catch (error) {
console.error('Lookup API data could not be loaded:', error)
return []
}
},
})
}
export const useLookupDataSource = ({
listFormCode,
@ -136,7 +143,7 @@ export const useLookupDataSource = ({
const { lookupDto } = colData
const filters: any[] = []
// Cascading field'ler için - data yoksa veya parent field değeri yoksa null parametreler ile API çağrısı yap
// Cascading alanlar: parent değeri yoksa null gönderilir, istek yine de yapılır.
if (lookupDto.cascadeParentFields) {
if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) {
filters.push([
@ -146,34 +153,34 @@ export const useLookupDataSource = ({
])
} else {
for (const cascadeParentField of lookupDto.cascadeParentFields.split(',')) {
filters.push(data?.[cascadeParentField] ?? null)
filters.push(data?.[cascadeParentField.trim()] ?? null)
}
}
}
if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) {
const staticKey = `static:${cultureName}:${listFormCode}:${colData.fieldName}`
return createLookupStaticDataSource(
() =>
JSON.parse(lookupDto?.lookupQuery).map((item: any) => ({
...item,
name: typeof item.name === 'string' ? translate(`::${item.name}`) : item.name,
})),
filters.length ? filters : null,
staticKey,
)
} else if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.Query) {
return createLookupQueryDataSource(listFormCode, colData.fieldName, filters, isSubForm)
} else if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.WebService) {
return createLookupApiDataSource(
listFormCode,
lookupDto?.lookupQuery,
filters,
colData.lookupDto?.valueExpr?.toLowerCase(),
isSubForm,
)
} else {
return { store: [] }
switch (lookupDto.dataSourceType) {
case UiLookupDataSourceTypeEnum.StaticData:
return createLookupStaticDataSource(
() =>
JSON.parse(lookupDto?.lookupQuery).map((item: any) => ({
...item,
name: typeof item.name === 'string' ? translate(`::${item.name}`) : item.name,
})),
filters.length ? filters : null,
`static:${cultureName}:${listFormCode}:${colData.fieldName}`,
)
case UiLookupDataSourceTypeEnum.Query:
return createLookupQueryDataSource(listFormCode, colData.fieldName, filters, isSubForm)
case UiLookupDataSourceTypeEnum.WebService:
return createLookupApiDataSource(
listFormCode,
lookupDto?.lookupQuery,
filters,
colData.lookupDto?.valueExpr?.toLowerCase(),
isSubForm,
)
default:
return { store: [] }
}
},
[listFormCode, isSubForm, translate, cultureName],
@ -181,9 +188,3 @@ export const useLookupDataSource = ({
return { getLookupDataSource }
}
/** Opsiyonel: dışarıdan cache temizlemek istersen export et */
export const clearLookupCache = (key?: string) => {
if (!key) __lookupCache.clear()
else __lookupCache.delete(key)
}

View file

@ -50,6 +50,16 @@ import {
GridExtraFilterState,
safeJsonParse,
} from './Utils'
import {
buildEditPopupToolbarItems,
getEditPopupHeight,
getEditPopupMaxHeight,
getMobilePopupPosition,
shouldUseMobileEditPopup,
} from './shared/editPopup'
import { getValueByField, resolveEditorType } from './shared/editingForm'
import { flattenColumns } from './shared/columns'
import { getInitialExtraFilters, mergeFilterTriplets } from './shared/hooks'
import { useFilters } from './useFilters'
import { useListFormColumns } from './useListFormColumns'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
@ -71,28 +81,6 @@ interface CardViewProps {
const cardColumnCountOptions = [1, 2, 3, 4, 5] as const
const defaultCardColumnCount = 3
const flattenColumns = (columns: any[] = []): any[] =>
columns.flatMap((column) => (column?.columns?.length ? flattenColumns(column.columns) : [column]))
const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches)
const isMobileViewport = () =>
typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
const getPopupHeight = (useMobilePopup: boolean, fullScreen: boolean) =>
useMobilePopup && fullScreen ? '100%' : 'auto'
const getPopupMaxHeight = (
useMobilePopup: boolean,
fullScreen: boolean,
configuredHeight?: number,
) => {
if (useMobilePopup && fullScreen) return '100%'
return configuredHeight && configuredHeight > 0 ? configuredHeight : '90vh'
}
const toCardColumn = (
column: any,
lookupDisplayValuesRef: { current: Record<string, Map<string, string>> },
@ -171,13 +159,6 @@ const toCardColumn = (
) as CardViewTypes.ColumnProperties
}
const getValueByField = (data: Record<string, any>, field?: string | null) => {
if (!field) return undefined
if (Object.prototype.hasOwnProperty.call(data, field)) return data[field]
const key = Object.keys(data).find((item) => item.toLowerCase() === field.toLowerCase())
return key ? data[key] : undefined
}
const getEditingFieldName = (dataField?: string | null) =>
String(dataField ?? '')
.split(':')[0]
@ -202,14 +183,6 @@ const lookupDisplayValuesEqual = (
const isGridDtoForList = (gridDto: GridDto | undefined, listFormCode: string) =>
gridDto?.gridOptions.listFormCode?.toLowerCase() === listFormCode.toLowerCase()
const getInitialExtraFilters = (gridDto?: GridDto): GridExtraFilterState[] =>
(gridDto?.gridOptions.extraFilterDto ?? []).map((filter) => ({
fieldName: filter.fieldName,
operator: filter.operator,
controlType: filter.controlType,
value: filter.defaultValue ?? '',
}))
const loadCascadeLookupDisplayValues = async (
columns: any[],
rows: Record<string, any>[],
@ -384,7 +357,7 @@ const CardView = (props: CardViewProps) => {
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localizationTexts = useStoreState((state) => state.abpConfig.texts)
const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice()
const useMobileEditPopup = shouldUseMobileEditPopup()
const cardViewRef = useRef<CardViewRef>()
const searchParamsRef = useRef(searchParams)
const defaultSearchFilterRef = useRef(searchParams?.get('filter') ?? null)
@ -418,31 +391,20 @@ const CardView = (props: CardViewProps) => {
),
)
const mergedExtraFilter = useMemo(() => {
const base = safeJsonParse(defaultSearchFilterRef.current, null, 'Search filter parse error:')
const triplets = [
...extractSearchParamsFields(base),
...extraFilters
.filter(
(filter) => filter.value !== undefined && filter.value !== null && filter.value !== '',
)
.map(
(filter) =>
[filter.fieldName, filter.operator, filter.value] as [string, string, unknown],
const mergedExtraFilter = useMemo(
() =>
mergeFilterTriplets([
...extractSearchParamsFields(
safeJsonParse(defaultSearchFilterRef.current, null, 'Search filter parse error:'),
),
]
const unique = triplets.reduce(
(result, current) => {
const index = result.findIndex((item) => item[0] === current[0] && item[1] === current[1])
if (index >= 0) result[index] = current
else result.push(current)
return result
},
[] as [string, string, unknown][],
)
return unique.reduce<any>((result, filter) => (result ? [result, 'and', filter] : filter), null)
}, [extraFilters])
...extraFilters
.filter((filter) => !!filter.value)
.map(
(filter) => [filter.fieldName, filter.operator, filter.value] as [string, string, any],
),
]),
[extraFilters],
)
if (!dataSourceSearchParamsRef.current) {
dataSourceSearchParamsRef.current = new URLSearchParams(searchParams?.toString())
@ -552,7 +514,7 @@ const CardView = (props: CardViewProps) => {
listColumnsCacheRef.current = {
gridDto,
localizationRevision,
columns: flattenColumns(getBandedColumns()),
columns: flattenColumns(getBandedColumns() ?? []),
}
}
const listColumns = listColumnsCacheRef.current.columns
@ -869,13 +831,7 @@ const CardView = (props: CardViewProps) => {
? item.fieldName!.split(':')[1]
: translate('::' + (column.captionName || column.fieldName)),
},
editorType:
item.editorType2 === PlatformEditorTypes.dxGridBox
? 'dxDropDownBox'
: item.editorType2 === PlatformEditorTypes.dxImageUpload ||
item.editorType2 === PlatformEditorTypes.dxImageViewer
? undefined
: item.editorType2,
editorType: resolveEditorType(item.editorType2),
editorOptions,
template:
item.editorType2 === PlatformEditorTypes.dxImageUpload
@ -1219,57 +1175,24 @@ const CardView = (props: CardViewProps) => {
? editingOptions?.popup?.width
: 'auto',
maxWidth: useMobileEditPopup ? '100%' : '95vw',
height: getPopupHeight(useMobileEditPopup, isPopupFullScreen),
maxHeight: getPopupMaxHeight(
height: getEditPopupHeight(useMobileEditPopup, isPopupFullScreen),
maxHeight: getEditPopupMaxHeight(
useMobileEditPopup,
isPopupFullScreen,
editingOptions?.popup?.height,
),
fullScreen: isPopupFullScreen,
position: useMobileEditPopup
? {
my: 'top center',
at: 'top center',
of: typeof window !== 'undefined' ? window : undefined,
}
? getMobilePopupPosition()
: editingOptions?.popup?.position,
resizeEnabled: editingOptions?.popup?.resizeEnabled,
dragEnabled: editingOptions?.popup?.dragEnabled,
restorePosition: editingOptions?.popup?.restorePosition,
toolbarItems: [
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: translate('::Save'),
type: 'default',
onClick: () => cardViewRef.current?.instance().saveEditData(),
},
},
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: translate('::Cancel'),
onClick: () => cardViewRef.current?.instance().cancelEditData(),
},
},
{
widget: 'dxButton',
toolbar: 'top',
location: 'after',
options: {
icon: isPopupFullScreen ? 'collapse' : 'fullscreen',
hint: isPopupFullScreen
? translate('::Normal Boyut')
: translate('::Tam Ekran'),
stylingMode: 'text',
onClick: () => setIsPopupFullScreen((current) => !current),
},
},
],
toolbarItems: buildEditPopupToolbarItems(translate, isPopupFullScreen, {
onSave: () => cardViewRef.current?.instance().saveEditData(),
onCancel: () => cardViewRef.current?.instance().cancelEditData(),
onToggleFullScreen: () => setIsPopupFullScreen((current) => !current),
}),
}}
/>
<Toolbar visible>

View file

@ -17,9 +17,7 @@ import Gantt, {
} from 'devextreme-react/gantt'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet'
import { getList } from '@/services/form.service'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { addCss, addJs } from './Utils'
import { layoutTypes } from '../admin/listForm/edit/types'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
import { ROUTES_ENUM } from '@/routes/route.constant'
@ -34,6 +32,11 @@ import { useListFormStateStoring } from './useListFormStateStoring'
import TreeList from 'devextreme/ui/tree_list'
import { postListFormCustomization } from '@/services/list-form-customization.service'
import { useFilters } from './useFilters'
import {
useListFormCustomSources,
useListFormGridDto,
useWidgetGroupHeight,
} from './shared/hooks'
import GridFilterDialogs from './GridFilterDialogs'
type GanttInstance = ReturnType<GanttRef['instance']>
@ -68,20 +71,21 @@ const GanttView = (props: GanttViewProps) => {
const isPwaMode = usePWA()
const gridRef = useRef<GanttRef>()
const refListFormCode = useRef('')
const widgetGroupRef = useRef<HTMLDivElement>(null)
const { checkPermission } = usePermission()
const [ganttDataSource, setGanttDataSource] = useState<CustomStore<any, any>>()
const [columnData, setColumnData] = useState<GridColumnData[]>()
const [gridDto, setGridDto] = useState<GridDto>()
const [widgetGroupHeight, setWidgetGroupHeight] = useState(0)
const gridDto = useListFormGridDto(listFormCode, extGridDto)
const [scaleType, setScaleType] = useState<GanttScaleType>('weeks')
const [taskListWidth, setTaskListWidth] = useState(500)
const layout = layoutTypes.gantt || 'gantt'
const pendingStateRef = useRef<Record<string, any>>()
const loadedStateKeyRef = useRef('')
const widgetGroupHeight = useWidgetGroupHeight(widgetGroupRef, gridDto?.widgets)
useListFormCustomSources(gridDto)
const { customSaveState, customLoadState, storageKey } = useListFormStateStoring({
listFormCode,
storageKey: gridDto?.gridOptions.stateStoringDto?.storageKey,
@ -103,31 +107,12 @@ const GanttView = (props: GanttViewProps) => {
)
useEffect(() => {
const initializeGantt = async () => {
const response = await getList({ listFormCode })
setGridDto(response.data)
}
if (extGridDto === undefined) {
initializeGantt()
} else {
setGridDto(extGridDto)
}
setScaleType(extGridDto?.gridOptions.ganttOptionDto?.scaleType || 'weeks')
}, [listFormCode, extGridDto])
setScaleType(gridDto?.gridOptions.ganttOptionDto?.scaleType || 'weeks')
}, [gridDto])
// listFormCode değişiminde eski veri kaynağını temizle.
useEffect(() => {
if (gridRef?.current) {
const instance = gridRef?.current?.instance()
if (instance) {
instance.option('dataSource', undefined)
}
}
if (refListFormCode.current !== listFormCode) {
// Reset state if needed
}
gridRef.current?.instance()?.option('dataSource', undefined)
}, [listFormCode])
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef })
@ -137,24 +122,6 @@ const GanttView = (props: GanttViewProps) => {
isSubForm,
gridRef,
})
useEffect(() => {
if (!gridDto) {
return
}
// Set js and css
const grdOpt = gridDto.gridOptions
if (grdOpt.customJsSources.length) {
for (const js of grdOpt.customJsSources) {
addJs(js)
}
}
if (grdOpt.customStyleSources.length) {
for (const css of grdOpt.customStyleSources) {
addCss(css)
}
}
}, [gridDto])
useEffect(() => {
if (!gridDto) return
@ -173,31 +140,6 @@ const GanttView = (props: GanttViewProps) => {
setGanttDataSource(dataSource)
}, [createSelectDataSource, getBandedColumns, gridDto, layout, listFormCode, searchParams])
useEffect(() => {
refListFormCode.current = listFormCode
}, [listFormCode])
// WidgetGroup yüksekliğini hesapla
useEffect(() => {
const calculateWidgetHeight = () => {
if (widgetGroupRef.current) {
const height = widgetGroupRef.current.offsetHeight
setWidgetGroupHeight(height)
}
}
calculateWidgetHeight()
const resizeObserver = new ResizeObserver(calculateWidgetHeight)
if (widgetGroupRef.current) {
resizeObserver.observe(widgetGroupRef.current)
}
return () => {
resizeObserver.disconnect()
}
}, [gridDto?.widgets])
const settingButtonClick = useCallback(() => {
window.open(
ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode),

File diff suppressed because it is too large Load diff

View file

@ -11,18 +11,21 @@ export function GridExtraFilterToolbar({
extraFilters: GridExtraFilterState[]
setExtraFilters: React.Dispatch<React.SetStateAction<GridExtraFilterState[]>>
}) {
// input için geçici state tutuyoruz
// Metin girişleri yazarken değil, Enter/blur anında uygulanır.
const [inputValues, setInputValues] = useState<Record<string, string>>({})
const { translate } = useLocalization()
return (
<div className="flex gap-4">
{filters.map((fs) => {
const current = extraFilters.find((f) => f.fieldName === fs.fieldName)
const handleSave = (value: string) => {
// Değer değişmediyse gereksiz yeniden yükleme tetikleme.
if ((current?.value ?? '') === value) return
setExtraFilters((prev) => {
const exists = prev.find((f) => f.fieldName === fs.fieldName)
const exists = prev.some((f) => f.fieldName === fs.fieldName)
if (exists) {
return prev.map((f) => (f.fieldName === fs.fieldName ? { ...f, value } : f))
}

View file

@ -25,14 +25,11 @@ import type { Field } from 'devextreme/ui/pivot_grid/data_source'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet'
import { GridColumnData } from './GridColumnData'
import {
addCss,
addJs,
controlStyleCondition,
pivotFieldConvertDataType,
setGridPanelColor,
} from './Utils'
import WidgetGroup from '@/components/common/WidgetGroup'
import { pivotFieldConvertDataType, setGridPanelColor } from './Utils'
import { createConditionalCellStyleHandler } from './shared/cellStyles'
import { useListFormCustomSources, useListFormGridDto } from './shared/hooks'
import { exportPivotView } from './shared/exportListView'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
import { Notification, toast } from '@/components/ui'
import { usePermission } from '@/utils/hooks/usePermission'
import { ROUTES_ENUM } from '@/routes/route.constant'
@ -52,20 +49,21 @@ interface PivotProps {
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
refreshGridDto: () => Promise<void>
refreshGridDto?: () => Promise<void>
}
type LookupDisplayValues = Record<string, Map<string, string>>
const Pivot = (props: PivotProps) => {
const { listFormCode, searchParams, isSubForm, gridDto } = props
const { listFormCode, searchParams, isSubForm, gridDto: extGridDto } = props
// Alt form olarak açıldığında tanım dışarıdan gelmez; kendisi yükler.
const gridDto = useListFormGridDto(listFormCode, extGridDto)
const { translate } = useLocalization()
const { checkPermission } = usePermission()
const isPwaMode = usePWA()
const gridRef = useRef<PivotGridRef>()
const chartRef = useRef<ChartRef>(null)
const refListFormCode = useRef('')
const widgetGroupRef = useRef<HTMLDivElement>(null)
const config = useStoreState((state) => state.abpConfig.config)
@ -81,6 +79,8 @@ const Pivot = (props: PivotProps) => {
showSaveToast: false,
})
useListFormCustomSources(gridDto)
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef })
const { getBandedColumns, loadLookupDisplayValues } = useListFormColumns({
gridDto,
@ -89,45 +89,7 @@ const Pivot = (props: PivotProps) => {
gridRef,
})
const onCellPrepared = useCallback(
(e: any) => {
const columnFormats = gridDto?.columnFormats
if (!columnFormats) {
return
}
// satir, hucre yada header vb. kisimlara conditional style uygulamak icin
for (let indxCol = 0; indxCol < columnFormats.length; indxCol++) {
const colFormat = columnFormats[indxCol]
for (let indxStyl = 0; indxStyl < colFormat.columnStylingDto.length; indxStyl++) {
const colStyle = colFormat.columnStylingDto[indxStyl] // uygulanacak style
if (e.rowType == colStyle.rowType) {
// header, filter, data, group, summaries ..her birisine style uygulanabilir
// style bütün satıra uygulansın olarak seçili ise yada sadece ilgili field üzerinde ise
if (colStyle.useRow || e.column?.dataField == colFormat.fieldName) {
if (
!colStyle.conditionValue ||
controlStyleCondition(e.data, colFormat.fieldName, colStyle)
) {
// css sınıf ismi var ise uygula
if (colStyle.cssClassName) {
e.cellElement.addClass(colStyle.cssClassName)
}
// css inline style var ise uygula
if (colStyle.cssStyles) {
e.cellElement.attr(
'style',
e.cellElement.attr('style') + ';' + colStyle.cssStyles,
)
}
}
}
}
}
}
},
[gridDto],
)
const onCellPrepared = useMemo(() => createConditionalCellStyleHandler(gridDto), [gridDto])
const clearPivotFilters = useCallback(() => {
const grid = gridRef.current?.instance()
@ -175,42 +137,23 @@ const Pivot = (props: PivotProps) => {
}, [listFormCode, storageKey, clearPivotFilters, translate])
const onExporting = useCallback(
async (e: PivotGridTypes.ExportingEvent) => {
(e: PivotGridTypes.ExportingEvent) => {
e.cancel = true
const pivot = gridRef?.current?.instance()
const pivot = gridRef.current?.instance()
if (!pivot) return
try {
// PivotGrid sadece Excel export destekliyor
const [{ Workbook }, { saveAs }, { exportPivotGrid }] = await Promise.all([
import('devextreme-exceljs-fork'),
import('file-saver'),
import('devextreme/excel_exporter'),
])
const workbook = new Workbook()
const worksheet = workbook.addWorksheet(`${listFormCode}_pivot`)
await exportPivotGrid({
component: pivot as any,
worksheet,
})
const buffer = await workbook.xlsx.writeBuffer()
saveAs(
new Blob([buffer], { type: 'application/octet-stream' }),
`${listFormCode}_pivot_export.xlsx`,
)
} catch (err) {
console.error('Pivot export error:', err)
toast.push(
<Notification type="danger" duration={2500}>
{translate('::App.Common.ExportError') ?? 'Dışa aktarma sırasında hata oluştu.'}
</Notification>,
{ placement: 'bottom-end' },
)
}
void exportPivotView({
component: pivot,
fileName: listFormCode,
onError: () =>
toast.push(
<Notification type="danger" duration={2500}>
{translate('::App.Common.ExportError') ?? 'Dışa aktarma sırasında hata oluştu.'}
</Notification>,
{ placement: 'bottom-end' },
),
})
},
[listFormCode, translate],
)
@ -253,30 +196,9 @@ const Pivot = (props: PivotProps) => {
)
}, [isPwaMode, listFormCode])
// Grafiğin başlangıç görünürlüğü
useEffect(() => {
refListFormCode.current = listFormCode
}, [listFormCode])
useEffect(() => {
if (!gridDto) {
return
}
// Set js and css
const grdOpt = gridDto.gridOptions
if (grdOpt.customJsSources.length) {
for (const js of grdOpt.customJsSources) {
addJs(js)
}
}
if (grdOpt.customStyleSources.length) {
for (const css of grdOpt.customStyleSources) {
addCss(css)
}
}
// Set initial chart visibility
setShowChart(gridDto.gridOptions.pivotOptionDto.showChart ?? false)
setShowChart(gridDto?.gridOptions.pivotOptionDto.showChart ?? false)
}, [gridDto])
// Kolonları memoize et

View file

@ -6,7 +6,6 @@ import {
DbTypeEnum,
FieldCustomValueTypeEnum,
GridDto,
PlatformEditorTypes,
} from '@/proxy/form/models'
import { useLocalization } from '@/utils/hooks/useLocalization'
import Scheduler, {
@ -19,9 +18,9 @@ import Scheduler, {
} from 'devextreme-react/scheduler'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet'
import { getList, getNextSequenceValue } from '@/services/form.service'
import { getNextSequenceValue } from '@/services/form.service'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { addCss, addJs, autoNumber, getEditingFormGroups } from './Utils'
import { autoNumber, getEditingFormGroups } from './Utils'
import { layoutTypes } from '../admin/listForm/edit/types'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
import { ROUTES_ENUM } from '@/routes/route.constant'
@ -38,7 +37,19 @@ import { FaAlignLeft, FaClock, FaHeading } from 'react-icons/fa'
import { Avatar } from '@/components/ui'
import { getUsers } from '@/services/identity.service'
import { useListFormColumns } from './useListFormColumns'
import type { GridColumnData } from './GridColumnData'
import {
getEditPopupHeight,
getEditPopupMaxHeight,
getMobilePopupPosition,
shouldUseMobileEditPopup,
} from './shared/editPopup'
import { getValueByField, resolveEditorType } from './shared/editingForm'
import { flattenGridColumns } from './shared/columns'
import {
useListFormCustomSources,
useListFormGridDto,
useWidgetGroupHeight,
} from './shared/hooks'
interface SchedulerViewProps {
listFormCode: string
@ -49,63 +60,25 @@ interface SchedulerViewProps {
gridDto?: GridDto
}
const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches)
const isMobileViewport = () =>
typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
const getEditPopupHeight = (useMobileEditPopup: boolean, isPopupFullScreen: boolean) =>
useMobileEditPopup && isPopupFullScreen ? '100%' : 'auto'
const getEditPopupMaxHeight = (
useMobileEditPopup: boolean,
isPopupFullScreen: boolean,
configuredHeight?: number,
) => {
if (useMobileEditPopup && isPopupFullScreen) {
return '100%'
}
return configuredHeight && configuredHeight > 0 ? configuredHeight : '90vh'
}
const getValueByField = (data: Record<string, any>, field?: string) => {
if (!field) {
return undefined
}
const resolvedField = Object.keys(data).find(
(dataField) => dataField.toLowerCase() === field.toLowerCase(),
)
return resolvedField ? data[resolvedField] : undefined
}
const flattenColumns = (columns: GridColumnData[] = []): GridColumnData[] =>
columns.flatMap((column) =>
column.columns?.length ? flattenColumns(column.columns as GridColumnData[]) : [column],
)
const SchedulerView = (props: SchedulerViewProps) => {
const { listFormCode, searchParams, isSubForm, level, gridDto: extGridDto } = props
const { translate } = useLocalization()
const isPwaMode = usePWA()
const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice()
const useMobileEditPopup = shouldUseMobileEditPopup()
const schedulerRef = useRef<SchedulerRef>()
const refListFormCode = useRef('')
const widgetGroupRef = useRef<HTMLDivElement>(null)
const { checkPermission } = usePermission()
const [schedulerDataSource, setSchedulerDataSource] = useState<CustomStore<any, any>>()
const [gridDto, setGridDto] = useState<GridDto>()
const [widgetGroupHeight, setWidgetGroupHeight] = useState(0)
const gridDto = useListFormGridDto(listFormCode, extGridDto)
const [currentView, setCurrentView] = useState<string>('week')
const [isPopupFullScreen, setIsPopupFullScreen] = useState(false)
const [userAvatarUrls, setUserAvatarUrls] = useState<Record<string, string>>({})
const layout = layoutTypes.scheduler || 'scheduler'
const widgetGroupHeight = useWidgetGroupHeight(widgetGroupRef, gridDto?.widgets)
useListFormCustomSources(gridDto)
useEffect(() => {
let cancelled = false
@ -136,31 +109,12 @@ const SchedulerView = (props: SchedulerViewProps) => {
}, [])
useEffect(() => {
const initializeScheduler = async () => {
const response = await getList({ listFormCode })
setGridDto(response.data)
}
if (extGridDto === undefined) {
initializeScheduler()
} else {
setGridDto(extGridDto)
}
setCurrentView(extGridDto?.gridOptions.schedulerOptionDto?.defaultView || 'week')
}, [listFormCode, extGridDto])
setCurrentView(gridDto?.gridOptions.schedulerOptionDto?.defaultView || 'week')
}, [gridDto])
// listFormCode değişiminde eski veri kaynağını temizle.
useEffect(() => {
if (schedulerRef?.current) {
const instance = schedulerRef?.current?.instance()
if (instance) {
instance.option('dataSource', undefined)
}
}
if (refListFormCode.current !== listFormCode) {
// Reset state if needed
}
schedulerRef.current?.instance()?.option('dataSource', undefined)
}, [listFormCode])
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef: schedulerRef })
@ -170,26 +124,7 @@ const SchedulerView = (props: SchedulerViewProps) => {
isSubForm,
gridRef: schedulerRef,
})
const listFormColumns = useMemo(() => flattenColumns(getBandedColumns()), [getBandedColumns])
useEffect(() => {
if (!gridDto) {
return
}
// Set js and css
const grdOpt = gridDto.gridOptions
if (grdOpt.customJsSources.length) {
for (const js of grdOpt.customJsSources) {
addJs(js)
}
}
if (grdOpt.customStyleSources.length) {
for (const css of grdOpt.customStyleSources) {
addCss(css)
}
}
}, [gridDto])
const listFormColumns = useMemo(() => flattenGridColumns(getBandedColumns()), [getBandedColumns])
useEffect(() => {
if (!gridDto) return
@ -205,31 +140,6 @@ const SchedulerView = (props: SchedulerViewProps) => {
setSchedulerDataSource(dataSource)
}, [gridDto, searchParams, createSelectDataSource])
useEffect(() => {
refListFormCode.current = listFormCode
}, [listFormCode])
// WidgetGroup yüksekliğini hesapla
useEffect(() => {
const calculateWidgetHeight = () => {
if (widgetGroupRef.current) {
const height = widgetGroupRef.current.offsetHeight
setWidgetGroupHeight(height)
}
}
calculateWidgetHeight()
const resizeObserver = new ResizeObserver(calculateWidgetHeight)
if (widgetGroupRef.current) {
resizeObserver.observe(widgetGroupRef.current)
}
return () => {
resizeObserver.disconnect()
}
}, [gridDto?.widgets])
const settingButtonClick = useCallback(() => {
window.open(
ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode),
@ -522,13 +432,7 @@ const SchedulerView = (props: SchedulerViewProps) => {
hideOnOutsideClick: popupOptions?.hideOnOutsideClick,
maxHeight: getEditPopupMaxHeight(useMobileEditPopup, popupFullScreen, popupOptions?.height),
maxWidth: useMobileEditPopup ? '100%' : popupOptions?.width,
position: useMobileEditPopup
? {
my: 'top center',
at: 'top center',
of: typeof window !== 'undefined' ? window : undefined,
}
: popupOptions?.position,
position: useMobileEditPopup ? getMobilePopupPosition() : popupOptions?.position,
resizeEnabled: popupOptions?.resizeEnabled,
restorePosition: popupOptions?.restorePosition,
showTitle: popupOptions?.showTitle,
@ -685,14 +589,6 @@ const SchedulerView = (props: SchedulerViewProps) => {
}
}
// EditorType belirleme
let editorType: any = i.editorType2
if (i.editorType2 === PlatformEditorTypes.dxGridBox) {
editorType = 'dxDropDownBox'
} else if (i.editorType2) {
editorType = i.editorType2
}
const lookup = listFormColumn?.lookup
if (lookup) {
editorOptions.dataSource =
@ -713,13 +609,7 @@ const SchedulerView = (props: SchedulerViewProps) => {
dataField: i.fieldName,
name: i.fieldName,
editorType2: i.editorType2,
editorType:
i.editorType2 == PlatformEditorTypes.dxGridBox
? 'dxDropDownBox'
: i.editorType2 == PlatformEditorTypes.dxImageUpload ||
i.editorType2 == PlatformEditorTypes.dxImageViewer
? undefined
: i.editorType2,
editorType: resolveEditorType(i.editorType2),
colSpan: i.colSpan,
editorOptions,
editorScript: i.editorScript,
@ -827,11 +717,7 @@ const SchedulerView = (props: SchedulerViewProps) => {
),
maxWidth: schedulerPopupWidth,
position: useMobileEditPopup
? {
my: 'top center',
at: 'top center',
of: typeof window !== 'undefined' ? window : undefined,
}
? getMobilePopupPosition()
: configuredEditPopup.position,
resizeEnabled: configuredEditPopup.resizeEnabled,
restorePosition: configuredEditPopup.restorePosition,

View file

@ -2,25 +2,12 @@ import type { GridDto, TodoOptionDto } from '@/proxy/form/models'
import type { IdentityUserDto } from '@/proxy/admin/models'
import type { DataGridRef } from 'devextreme-react/data-grid'
import type CustomStore from 'devextreme/data/custom_store'
import {
HtmlEditor,
ImageUpload,
Item as HtmlEditorItem,
MediaResizing,
Toolbar as HtmlEditorToolbar,
} from 'devextreme-react/html-editor'
import { HtmlEditor, ImageUpload, MediaResizing } from 'devextreme-react/html-editor'
import { HtmlEditorToolbar } from '@/views/shared/HtmlEditorToolbarItems'
import Toolbar, { Item as ToolbarItem } from 'devextreme-react/toolbar'
import TagBox from 'devextreme-react/tag-box'
import { AVATAR_URL } from '@/constants/app.constant'
import { ROUTES_ENUM } from '@/routes/route.constant'
import {
fontFamilyOptions,
fontSizeOptions,
fontValues,
headerOptions,
headerValues,
sizeValues,
} from '@/proxy/reports/data'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -39,6 +26,7 @@ import {
} from 'react-icons/fa'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { useListFormStateStoring } from './useListFormStateStoring'
import { useListFormGridDto } from './shared/hooks'
import { Avatar, Rate } from '@/components/ui'
import { getUsers } from '@/services/identity.service'
import { getTenants } from '@/services/tenant.service'
@ -47,6 +35,7 @@ import { usePermission } from '@/utils/hooks/usePermission'
import { usePWA } from '@/utils/hooks/usePWA'
import { useListFormColumns } from './useListFormColumns'
import type { GridColumnData } from './GridColumnData'
import { flattenGridColumns } from './shared/columns'
import { useStoreState } from '@/store/store'
dayjs.extend(relativeTime)
@ -55,7 +44,9 @@ interface TodoBoardProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
gridDto: GridDto
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
}
type TodoRow = Record<string, any>
@ -92,11 +83,6 @@ const TODO_STATUS_LANGUAGE_KEYS: Record<string, string> = {
staging: 'App.StaticLookup.Staging',
}
const flattenColumns = (columns: GridColumnData[] = []): GridColumnData[] =>
columns.flatMap((column) =>
column.columns?.length ? flattenColumns(column.columns as GridColumnData[]) : [column],
)
const fieldValue = (row: TodoRow, field?: string) => (field ? row[field] : undefined)
const tenantIdValue = (row: TodoRow) => row.TenantId ?? row.tenantId
@ -176,7 +162,22 @@ const priorityLabel = (
: translate('::ListForms.TodoBoard.NoPriority')
}
const TodoBoard = ({ listFormCode, searchParams, isSubForm, gridDto }: TodoBoardProps) => {
const TodoBoard = (props: TodoBoardProps) => {
const { listFormCode, searchParams, isSubForm, gridDto: extGridDto } = props
// Alt form olarak açıldığında tanım dışarıdan gelmez; kendisi yükler.
const gridDto = useListFormGridDto(listFormCode, extGridDto)
return gridDto ? (
<TodoBoardContent {...props} gridDto={gridDto} />
) : null
}
const TodoBoardContent = ({
listFormCode,
searchParams,
isSubForm,
gridDto,
}: TodoBoardProps & { gridDto: GridDto }) => {
const options = gridDto.gridOptions.todoOptionDto as TodoOptionDto
const editingOptions = gridDto.gridOptions.editingOptionDto
const canAdd =
@ -216,7 +217,7 @@ const TodoBoard = ({ listFormCode, searchParams, isSubForm, gridDto }: TodoBoard
listFormColumnsCacheRef.current = {
gridDto,
listFormCode,
columns: flattenColumns(getBandedColumnsRef.current()),
columns: flattenGridColumns(getBandedColumnsRef.current()),
}
}
const listFormColumns = listFormColumnsCacheRef.current.columns
@ -904,48 +905,7 @@ const TodoBoard = ({ listFormCode, searchParams, isSubForm, gridDto }: TodoBoard
>
<MediaResizing enabled={true} />
<ImageUpload fileUploadMode="base64" />
<HtmlEditorToolbar multiline>
<HtmlEditorItem name="undo" />
<HtmlEditorItem name="redo" />
<HtmlEditorItem name="separator" />
<HtmlEditorItem
name="size"
acceptedValues={sizeValues}
options={fontSizeOptions}
/>
<HtmlEditorItem
name="font"
acceptedValues={fontValues}
options={fontFamilyOptions}
/>
<HtmlEditorItem name="separator" />
<HtmlEditorItem name="bold" />
<HtmlEditorItem name="italic" />
<HtmlEditorItem name="underline" />
<HtmlEditorItem name="strike" />
<HtmlEditorItem name="separator" />
<HtmlEditorItem name="orderedList" />
<HtmlEditorItem name="bulletList" />
<HtmlEditorItem name="separator" />
<HtmlEditorItem
name="header"
acceptedValues={headerValues}
options={headerOptions}
/>
<HtmlEditorItem name="separator" />
<HtmlEditorItem name="color" />
<HtmlEditorItem name="background" />
<HtmlEditorItem name="separator" />
<HtmlEditorItem name="alignLeft" />
<HtmlEditorItem name="alignCenter" />
<HtmlEditorItem name="alignRight" />
<HtmlEditorItem name="alignJustify" />
<HtmlEditorItem name="separator" />
<HtmlEditorItem name="link" />
<HtmlEditorItem name="image" />
<HtmlEditorItem name="separator" />
<HtmlEditorItem name="clear" />
</HtmlEditorToolbar>
<HtmlEditorToolbar />
</HtmlEditor>
</div>
)}

File diff suppressed because it is too large Load diff

View file

@ -332,16 +332,14 @@ export function buildSeriesDto(seriesList: ChartSeriesDto[]) {
export function autoNumber() {
const now = new Date()
const pad = (n: number, width: number) => n.toString().padStart(width, '0')
const pad = (value: number, width: number) => value.toString().padStart(width, '0')
const shortYear = pad(now.getFullYear() % 100, 2); // 2025 → 25
return (
`${shortYear}` +
`${pad(now.getMonth() + 1, 2)}` +
`${pad(now.getDate(), 2)}` +
`${pad(now.getHours(), 2)}` +
`${pad(now.getMinutes(), 2)}` +
`${pad(now.getSeconds(), 2)}`
)
return [
pad(now.getFullYear() % 100, 2), // 2025 -> 25
pad(now.getMonth() + 1, 2),
pad(now.getDate(), 2),
pad(now.getHours(), 2),
pad(now.getMinutes(), 2),
pad(now.getSeconds(), 2),
].join('')
}

View file

@ -7,6 +7,7 @@ import {
openImageInNewTab,
showImageHoverPreview,
} from '../../form/editors/imageHoverPreview'
import { normalizeImageSize, parseJsonObject } from '../shared/imageValue'
type ImageUploadOptions = {
accept?: string
@ -17,24 +18,14 @@ type ImageUploadOptions = {
width?: number | string
}
const parseJsonObject = (value: unknown) => {
if (!value) return undefined
if (typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
if (typeof value !== 'string') return undefined
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
const normalizeImageValue = (value: unknown): string[] => {
/**
* Yüklenen değerin ham biçimini korur; ayraçlara göre bölmez.
* (Görüntüleme tarafındaki `normalizeImageValue` ile bilinçli olarak farklıdır:
* burada kullanıcı tarafından yazılmış virgüllü URL'ler bozulmamalıdır.)
*/
const normalizeUploadValue = (value: unknown): string[] => {
if (!value) return []
if (Array.isArray(value)) return value.flatMap(normalizeImageValue)
if (Array.isArray(value)) return value.flatMap(normalizeUploadValue)
if (typeof value === 'string') {
const trimmed = value.trim()
@ -43,7 +34,7 @@ const normalizeImageValue = (value: unknown): string[] => {
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed.flatMap(normalizeImageValue)
if (Array.isArray(parsed)) return parsed.flatMap(normalizeUploadValue)
} catch {
return [trimmed]
}
@ -55,11 +46,6 @@ const normalizeImageValue = (value: unknown): string[] => {
return [String(value)].filter(Boolean)
}
const normalizeSize = (value: unknown, fallback: number) => {
const size = Number(value)
return Number.isFinite(size) && size > 0 ? size : fallback
}
const resolveOptions = (column: any): ImageUploadOptions => {
const configuredOptions = column?.extras?.imageUploadOptions
if (configuredOptions && Object.keys(configuredOptions).length > 0) {
@ -90,10 +76,10 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const [urls, setUrls] = useState<string[]>(() => normalizeImageValue(cellElement.value))
const [urls, setUrls] = useState<string[]>(() => normalizeUploadValue(cellElement.value))
useEffect(() => {
setUrls(normalizeImageValue(cellElement.value))
setUrls(normalizeUploadValue(cellElement.value))
}, [cellElement.value])
const options = useMemo(() => resolveOptions(col), [col])
@ -105,8 +91,8 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
// JSON'dan string olarak gelebilir: "true" / "false"
const isMultiple: boolean = options.multiple === true || options.multiple === 'true'
const thumbW = normalizeSize(options.width, 40)
const thumbH = normalizeSize(options.height, 40)
const thumbW = normalizeImageSize(options.width, 40)
const thumbH = normalizeImageSize(options.height, 40)
const removeImage = (index: number) => {
const newUrls = urls.filter((_, i) => i !== index)

View file

@ -5,91 +5,12 @@ import {
openImageInNewTab,
showImageHoverPreview,
} from '../../form/editors/imageHoverPreview'
const parseJsonObject = (value: unknown) => {
if (!value) return undefined
if (typeof value === 'object') return value as Record<string, any>
if (typeof value !== 'string') return undefined
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: undefined
} catch {
return undefined
}
}
const normalizeImageSize = (value: unknown, fallback: number) => {
const size = Number(value)
return Number.isFinite(size) && size > 0 ? size : fallback
}
const getImageSource = (value: unknown) => {
if (!value) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'object') {
const item = value as Record<string, unknown>
return String(item.url ?? item.src ?? item.fileUrl ?? item.path ?? item.value ?? '').trim()
}
return String(value).trim()
}
const isProbablyBase64Image = (value: string) =>
value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
const toImageSource = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed
return `data:image/jpeg;base64,${trimmed}`
}
const splitImageString = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('data:image/')) return [trimmed]
return trimmed
.split(/\r?\n|\|\s*/)
.flatMap((part) => {
const text = part.trim()
if (!text || text.startsWith('data:image/')) return text ? [text] : []
return text.split(',').map((item) => item.trim())
})
.map(toImageSource)
.filter(Boolean)
}
const normalizeImageValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeImageValue(item))
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.flatMap((item) => normalizeImageValue(item))
}
} catch {
return [toImageSource(trimmed)]
}
}
return splitImageString(trimmed)
}
const source = getImageSource(value)
return source ? [toImageSource(source)] : []
}
import {
NO_IMAGE,
normalizeImageSize,
normalizeImageValue,
parseJsonObject,
} from '../shared/imageValue'
const resolveTemplateValue = (templateData: any) => {
const dataField =
@ -181,7 +102,7 @@ const ImageViewerEditorComponent = (templateData: any): ReactElement => {
}}
onError={({ currentTarget }) => {
currentTarget.onerror = null
currentTarget.src = '/img/others/no-image.png'
currentTarget.src = NO_IMAGE
}}
/>
</Button>

View file

@ -0,0 +1,124 @@
/**
* Lookup cascade (bağımlı ılır liste) davranışı.
* Grid ve Tree aynı kuralları paylaşır.
*/
import type { GridDto } from '@/proxy/form/models'
export type CascadeInfo = {
parentFields: string[]
childFields?: string[]
}
export type CascadeMaps = {
/** fieldName -> cascade bilgisi */
cascadeFieldsMap: Map<string, CascadeInfo>
/** parentField -> etkilediği child alan adları */
parentToChildrenMap: Map<string, Set<string>>
}
const splitFields = (value?: string | null) =>
(value ?? '')
.split(',')
.map((field) => field.trim())
.filter(Boolean)
export const createCascadeMaps = (gridDto?: GridDto): CascadeMaps => {
const cascadeFieldsMap = new Map<string, CascadeInfo>()
const parentToChildrenMap = new Map<string, Set<string>>()
gridDto?.columnFormats.forEach((col) => {
if (!col.fieldName || !col.lookupDto?.cascadeParentFields) return
const parentFields = splitFields(col.lookupDto.cascadeParentFields)
const childFields = col.lookupDto.cascadeEmptyFields
? splitFields(col.lookupDto.cascadeEmptyFields)
: undefined
cascadeFieldsMap.set(col.fieldName, { parentFields, childFields })
parentFields.forEach((parentField) => {
const children = parentToChildrenMap.get(parentField) ?? new Set<string>()
children.add(col.fieldName!)
parentToChildrenMap.set(parentField, children)
})
})
return { cascadeFieldsMap, parentToChildrenMap }
}
type AttachCascadeParams = {
editor: any
gridDto: GridDto
cascadeInfo: CascadeInfo
maps: CascadeMaps
}
/**
* `onEditorPreparing` içinde cascade davranışını editöre bağlar:
* - parent değiştiğinde child alanları temizler,
* - etkilenen child editörlerin `disabled` durumunu günceller,
* - form ilk ıldığında parent'ı boş olan alanı devre dışı bırakır.
*/
export const attachCascadeBehavior = ({
editor,
gridDto,
cascadeInfo,
maps,
}: AttachCascadeParams) => {
const { cascadeFieldsMap, parentToChildrenMap } = maps
const affectedChildren = parentToChildrenMap.get(editor.dataField)
const previousHandler = editor.editorOptions.onValueChanged
editor.editorOptions.onValueChanged = (e: any) => {
previousHandler?.(e)
const grid = editor.component
const rowKey = grid.option('editing.editRowKey')
const rowIndex = grid.getRowIndexByKey(rowKey)
// Parent değiştiğinde bağımlı alanları temizle.
if (cascadeInfo.childFields && rowIndex >= 0) {
cascadeInfo.childFields.forEach((childField) => grid.cellValue(rowIndex, childField, null))
}
if (!affectedChildren?.size) return
const formInstance = grid.option('editing.form') as any
if (!formInstance?.getEditor) return
const rowData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data
if (!rowData) return
affectedChildren.forEach((childFieldName) => {
const childInfo = cascadeFieldsMap.get(childFieldName)
if (!childInfo) return
try {
const childColumn = gridDto.columnFormats.find(
(column) => column.fieldName === childFieldName,
)
const childEditorOptions = childColumn?.editorOptions
? JSON.parse(childColumn.editorOptions)
: {}
const childEditor = formInstance.getEditor(childFieldName)
childEditor?.option(
'disabled',
childEditorOptions?.disabled === true ||
childInfo.parentFields.some((parentField) => !rowData[parentField]),
)
} catch (err) {
console.error(`Cascade child editor güncellenemedi: "${childFieldName}"`, err)
}
})
}
// İlk açılışta parent'ı dolu değilse editörü kilitle.
const grid = editor.component
const rowKey = grid.option('editing.editRowKey')
const rowData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data
if (rowData && cascadeInfo.parentFields.some((parentField) => !rowData[parentField])) {
editor.editorOptions.disabled = true
}
}

View file

@ -0,0 +1,39 @@
/**
* ColumnStylingDto tabanlı koşullu hücre/satır stilleri.
* Grid, Tree ve Pivot aynı handler'ı kullanır.
*/
import type { GridDto } from '@/proxy/form/models'
import { controlStyleCondition } from '../Utils'
/**
* `onCellPrepared` handler'ı üretir. Yapılandırılmış stil bulunmayan
* liste formlarında hiç yapmaması için önceden filtreleme yapılır.
*/
export const createConditionalCellStyleHandler = (gridDto?: GridDto) => {
const styledColumns = (gridDto?.columnFormats ?? [])
.filter((colFormat) => colFormat.columnStylingDto?.length)
.map((colFormat) => ({ fieldName: colFormat.fieldName, styles: colFormat.columnStylingDto }))
if (!styledColumns.length) {
return undefined
}
return (e: any) => {
styledColumns.forEach(({ fieldName, styles }) => {
styles.forEach((colStyle) => {
// header, filter, data, group, summaries… her satır tipine stil verilebilir.
if (e.rowType !== colStyle.rowType) return
// Stil tüm satıra mı yoksa yalnızca ilgili kolona mı uygulanacak?
if (!colStyle.useRow && e.column?.dataField !== fieldName) return
if (colStyle.conditionValue && !controlStyleCondition(e.data, fieldName, colStyle)) return
if (colStyle.cssClassName) {
e.cellElement.addClass(colStyle.cssClassName)
}
if (colStyle.cssStyles) {
e.cellElement.attr('style', `${e.cellElement.attr('style')};${colStyle.cssStyles}`)
}
})
})
}
}

View file

@ -0,0 +1,31 @@
/**
* Kolon düzeyinde özel görüntüleme biçimleri.
*/
/**
* 10 haneli yerel numarayı `(XXX) XXX-XXXX` biçiminde gösterir.
* Ülke kodu (+90 / 0090 / 0) ön ekleri temizlenir; 10 hane değilse boş döner.
*/
export const formatGlobalPhone = (value: unknown) => {
if (!value) return ''
let digits = String(value).replace(/\D/g, '')
if (digits.length > 10) digits = digits.slice(-10)
if (digits.length !== 10) return ''
const match = digits.match(/^(\d{3})(\d{3})(\d{4})$/)
return match ? `(${match[1]}) ${match[2]}-${match[3]}` : digits
}
/**
* `editorOptions.format === 'phoneGlobal'` olan kolonlara telefon biçimini uygular.
* DevExtreme bazı durumlarda `format.formatter`'ı çırmadığı için
* `customizeText` de yedek olarak eklenir.
*/
export const applyPhoneGlobalFormat = (column: any) => {
if (column?.editorOptions?.format !== 'phoneGlobal') return
column.dataType = 'string'
column.format = { formatter: formatGlobalPhone }
column.customizeText = (cellInfo: any) => formatGlobalPhone(cellInfo?.value)
}

View file

@ -0,0 +1,16 @@
/**
* Kolon yapısıyla ilgili paylaşılan yardımcılar.
*/
import type { GridColumnData } from '../GridColumnData'
/** Banded (iç içe) kolon ağacını düz listeye indirger. */
export const flattenColumns = <T extends { columns?: any[] }>(columns: T[] = []): T[] =>
columns.flatMap((column) => (column?.columns?.length ? flattenColumns(column.columns) : [column]))
export const flattenGridColumns = (columns: GridColumnData[] = []): GridColumnData[] =>
flattenColumns(columns as { columns?: any[] }[]) as GridColumnData[]
/** Kullanıcı filtresi uygulanmış grid panelinin rengi. */
export const FILTERED_GRID_PANEL_COLOR = 'rgba(10, 200, 10, 0.5)'
/** Kayıtlı grid state'i yüklenmiş panelin rengi. */
export const STATED_GRID_PANEL_COLOR = 'rgba(50, 200, 200, 0.5)'

View file

@ -0,0 +1,123 @@
/**
* Liste görünümlerinin düzenleme popup'ı için ortak cihaz tespiti ve popup
* yapılandırması. Grid, Tree, CardView ve SchedulerView aynı kuralları kullanır.
*/
import type { ToolbarItem as PopupToolbarItem } from 'devextreme/ui/popup'
import type { RowMode } from '../../form/types'
export const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches)
export const isMobileViewport = () =>
typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
/** Popup'ın mobil davranış kurallarıyla açılıp açılmayacağını belirler. */
export const shouldUseMobileEditPopup = () => isMobileViewport() || isTouchLikeDevice()
export const getEditPopupHeight = (useMobileEditPopup: boolean, isPopupFullScreen: boolean) =>
useMobileEditPopup && isPopupFullScreen ? '100%' : 'auto'
export const getEditPopupMaxHeight = (
useMobileEditPopup: boolean,
isPopupFullScreen: boolean,
configuredHeight?: number,
) => {
if (useMobileEditPopup && isPopupFullScreen) {
return '100%'
}
return configuredHeight && configuredHeight > 0 ? configuredHeight : '90vh'
}
export const getMobilePopupPosition = () => ({
my: 'top center',
at: 'top center',
of: typeof window !== 'undefined' ? window : undefined,
})
type EditPopupToolbarHandlers = {
onSave: () => void
onCancel: () => void
onToggleFullScreen: () => void
}
/** Kaydet / İptal / Tam ekran butonlarını üretir. */
export const buildEditPopupToolbarItems = (
translate: (key: string) => string,
isPopupFullScreen: boolean,
{ onSave, onCancel, onToggleFullScreen }: EditPopupToolbarHandlers,
): PopupToolbarItem[] => [
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: translate('::Save'),
type: 'default',
onClick: onSave,
},
},
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: translate('::Cancel'),
onClick: onCancel,
},
},
{
widget: 'dxButton',
toolbar: 'top',
location: 'after',
options: {
icon: isPopupFullScreen ? 'collapse' : 'fullscreen',
hint: isPopupFullScreen ? translate('::Normal Boyut') : translate('::Tam Ekran'),
stylingMode: 'text',
onClick: onToggleFullScreen,
},
},
]
type BuildEditPopupParams = {
/** gridOptions.editingOptionDto.popup */
popupOptions: any
mode: RowMode
isPopupFullScreen: boolean
useMobileEditPopup: boolean
translate: (key: string) => string
handlers: EditPopupToolbarHandlers
/** Scheduler gibi ek sınıf gerektiren yerler için. */
mobileWrapperClass?: string
}
/**
* Grid / Tree / CardView düzenleme popup'ının tek noktadan üretimi.
* Daha önce her bir bileşende ~70 satır olarak tekrarlanıyordu.
*/
export const buildEditPopupOptions = ({
popupOptions,
mode,
isPopupFullScreen,
useMobileEditPopup,
translate,
handlers,
mobileWrapperClass = 'mobile-edit-popup',
}: BuildEditPopupParams) => ({
animation: {},
deferRendering: true,
wrapperAttr: useMobileEditPopup ? { class: mobileWrapperClass } : undefined,
title: (mode === 'new' ? '✚ ' : '🖊️ ') + translate('::' + popupOptions?.title),
showTitle: popupOptions?.showTitle,
hideOnOutsideClick: popupOptions?.hideOnOutsideClick,
width: useMobileEditPopup ? '100%' : popupOptions?.width,
height: getEditPopupHeight(useMobileEditPopup, isPopupFullScreen),
maxHeight: getEditPopupMaxHeight(useMobileEditPopup, isPopupFullScreen, popupOptions?.height),
position: useMobileEditPopup ? getMobilePopupPosition() : popupOptions?.position,
resizeEnabled: popupOptions?.resizeEnabled,
fullScreen: isPopupFullScreen,
dragEnabled: popupOptions?.dragEnabled,
restorePosition: popupOptions?.restorePosition,
toolbarItems: buildEditPopupToolbarItems(translate, isPopupFullScreen, handlers),
})

View file

@ -0,0 +1,569 @@
/**
* Grid / Tree / CardView düzenleme formlarının ortak altyapısı.
*
* Bu dosyadan önce aynı fonksiyonlar Grid.tsx ve Tree.tsx içinde satır satır
* kopyalanmış durumdaydı. Tek noktadan yönetim için buraya taşındı.
*/
import { captionize } from 'devextreme/core/utils/inflector'
import type { DataType } from 'devextreme/common'
import {
ColumnFormatDto,
DbTypeEnum,
FieldCustomValueTypeEnum,
GridDto,
PlatformEditorTypes,
} from '@/proxy/form/models'
import { getNextSequenceValue } from '@/services/form.service'
import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types'
import {
autoNumber,
extractSearchParamsFields,
getEditingFormGroups,
GridExtraFilterState,
safeJsonParse,
setFormEditingExtraItemValues,
} from '../Utils'
export type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any>
/* ------------------------------------------------------------------ *
* Satır anahtarı yardımcıları
* ------------------------------------------------------------------ */
const isTemporaryDxKey = (key: unknown) => typeof key === 'string' && key.startsWith('_DX_KEY_')
/** DevExtreme'in geçici `_DX_KEY_` değerlerini eleyerek gerçek insert anahtarını bulur. */
export const getPersistedInsertedKey = (e: any, keyFieldName?: string) => {
const dataKey = keyFieldName ? e?.data?.[keyFieldName] : undefined
if (dataKey !== undefined && dataKey !== null && !isTemporaryDxKey(dataKey)) {
return dataKey
}
if (e?.key !== undefined && e?.key !== null && !isTemporaryDxKey(e.key)) {
return e.key
}
return undefined
}
/* ------------------------------------------------------------------ *
* Alan erişim yardımcıları
* ------------------------------------------------------------------ */
/** Alan adını önce birebir, bulunamazsa büyük/küçük harf duyarsız arar. */
export const getValueByField = (data: Record<string, any> = {}, field?: string | null) => {
if (!field) return undefined
if (Object.prototype.hasOwnProperty.call(data, field)) return data[field]
const key = Object.keys(data).find(
(itemKey) => itemKey.toLowerCase() === String(field).toLowerCase(),
)
return key ? data[key] : undefined
}
const findFormFieldKey = (items: any[] = [], field: string): string => {
const expected = String(field || '').toLowerCase()
for (const item of items || []) {
const key = item?.dataField || item?.name
if (key && String(key).toLowerCase() === expected) {
return key
}
const childKey = findFormFieldKey(item?.items || [], field)
if (childKey) {
return childKey
}
for (const tab of item?.tabs || []) {
const tabKey = findFormFieldKey(tab?.items || [], field)
if (tabKey) {
return tabKey
}
}
}
return field
}
const updateReadOnlyInFormItems = (items: any[] = [], field: string, readOnly: boolean) => {
let changed = false
const expected = String(field || '').toLowerCase()
const nextItems = items.map((item) => {
const key = item?.dataField || item?.name
let nextItem = item
if (key && String(key).toLowerCase() === expected) {
const editorOptions = nextItem.editorOptions || {}
if (editorOptions.readOnly !== readOnly) {
changed = true
nextItem = { ...nextItem, editorOptions: { ...editorOptions, readOnly } }
}
}
if (nextItem?.items?.length) {
const childResult = updateReadOnlyInFormItems(nextItem.items, field, readOnly)
if (childResult.changed) {
changed = true
nextItem = { ...nextItem, items: childResult.items }
}
}
if (nextItem?.tabs?.length) {
let tabsChanged = false
const tabs = nextItem.tabs.map((tab: any) => {
const tabResult = updateReadOnlyInFormItems(tab.items, field, readOnly)
if (tabResult.changed) {
changed = true
tabsChanged = true
return { ...tab, items: tabResult.items }
}
return tab
})
if (tabsChanged) {
nextItem = { ...nextItem, tabs }
}
}
return nextItem
})
return { items: nextItems, changed }
}
/** Editör script'lerinin `setEditorReadOnly` çağrılarını çalışma anında uygular. */
export const setFormEditorReadOnly = (form: any, field: string, readOnly: boolean) => {
if (!form?.option) return false
const apply = () => {
const formItems = form.option('items') || []
const resolvedField = findFormFieldKey(formItems, field)
const editor = form.getEditor?.(resolvedField) ?? form.getEditor?.(field)
const result = updateReadOnlyInFormItems(formItems, resolvedField, readOnly)
if (result.changed) {
try {
const item = form.itemOption?.(resolvedField) ?? form.itemOption?.(field)
if (item) {
form.itemOption?.(resolvedField, 'editorOptions', {
...(item.editorOptions || {}),
readOnly,
})
} else {
form.option('items', result.items)
}
} catch {
form.option('items', result.items)
}
}
const activeEditor = editor ?? form.getEditor?.(resolvedField) ?? form.getEditor?.(field)
if (activeEditor?.option?.('readOnly') !== readOnly) {
activeEditor?.option?.('readOnly', readOnly)
}
}
apply()
// DevExtreme editörü ilk apply anında henüz oluşmamış olabilir.
setTimeout(apply, 0)
return true
}
export const getActiveEditingForm = (component: any) => {
const editForm = component?.getView?.('editingView')?._editForm
return editForm?.getEditor || editForm?.itemOption ? editForm : undefined
}
export const shouldRunEditorScriptOnContentReady = (script?: string) =>
Boolean(
script && (script.includes('setEditorReadOnly') || script.includes('runtimeSetEditorReadOnly')),
)
/* ------------------------------------------------------------------ *
* Yeni satır varsayılan değerleri
* ------------------------------------------------------------------ */
/** URL/filtre üzerinden gelen ham değeri kolonun veri tipine göre dönüştürür. */
export const castValueByDataType = (value: any, dataType?: DataType) => {
switch (dataType) {
case 'date':
case 'datetime':
return new Date(value)
case 'number':
return Number(value)
case 'boolean':
return value === true || value === 'true'
case 'object':
return safeJsonParse(value, value)
default:
return value
}
}
/**
* Yeni kayıt için varsayılan değerleri uygular:
* 1) kolon defaultValue (@AUTONUMBER / Sequence / sabit)
* 2) URL veya prop üzerinden gelen filtre değerleri (üzerine yazar)
*
* ExtraFilter ile yönetilen alanlar atlanır.
*/
export const applyNewRowDefaults = async (
gridDto: GridDto,
data: Record<string, any>,
rawFilter?: string | null,
extraFilters: GridExtraFilterState[] = [],
) => {
const filters = rawFilter
? extractSearchParamsFields(safeJsonParse<any>(rawFilter, null, 'Filter parse edilemedi:'))
: []
const extraFilterFields = new Set(extraFilters.map((filter) => filter.fieldName))
for (const colFormat of gridDto.columnFormats ?? []) {
const fieldName = colFormat.fieldName
if (!fieldName || extraFilterFields.has(fieldName)) {
continue
}
if (colFormat.defaultValue !== null && colFormat.defaultValue !== undefined) {
if (typeof colFormat.defaultValue === 'string' && colFormat.defaultValue === '@AUTONUMBER') {
data[fieldName] = autoNumber()
} else if (colFormat.defaultValueType === FieldCustomValueTypeEnum.Sequence) {
try {
const response = await getNextSequenceValue(String(colFormat.defaultValue))
data[fieldName] = response.data
} catch (error) {
console.error('Sequence default value alınamadı:', {
fieldName,
defaultValue: colFormat.defaultValue,
error,
})
}
} else {
data[fieldName] = colFormat.defaultValue
}
}
const fieldMatch = filters.find(([field]) => field === fieldName)
if (fieldMatch) {
data[fieldName] = castValueByDataType(fieldMatch[2], colFormat.dataType as DataType)
}
}
return data
}
/* ------------------------------------------------------------------ *
* Insert / Update payload üretimi
* ------------------------------------------------------------------ */
/** Sadece eklemeye izin verilen alanları gönderir (yetki filtresi). */
export const buildInsertPayload = (gridDto: GridDto | undefined, data: Record<string, any>) => {
if (!gridDto?.columnFormats) {
return setFormEditingExtraItemValues(data)
}
const allowedFields = new Set(
gridDto.columnFormats.filter((field) => field.allowAdding).map((field) => field.fieldName),
)
const filtered: Record<string, any> = {}
for (const key of Object.keys(data)) {
// "Options:TemplateName" gibi JSON alt alanları isim kökü üzerinden kontrol edilir.
if (allowedFields.has(key) || allowedFields.has(key.split(':')[0])) {
filtered[key] = data[key]
}
}
return setFormEditingExtraItemValues(filtered)
}
/**
* Güncelleme payload'ı. `sendOnlyChangedFormValuesUpdate` ıksa yalnızca
* değişen alanlar, kapalıysa eski+yeni birleşimi gönderilir.
*/
export const buildUpdatePayload = (
gridDto: GridDto | undefined,
oldData: Record<string, any>,
newData: Record<string, any>,
) => {
const keyFieldName = gridDto?.gridOptions.keyFieldName
if (gridDto?.gridOptions.editingOptionDto?.sendOnlyChangedFormValuesUpdate) {
const payload = { ...newData }
if (Object.keys(payload).some((key) => key.includes(':'))) {
Object.keys(oldData).forEach((key) => {
if (key.includes(':')) {
payload[key] = payload[key] ?? oldData[key]
}
})
}
const result = setFormEditingExtraItemValues(payload)
if (keyFieldName) delete result[keyFieldName]
return result
}
const allowedFields = gridDto?.columnFormats
? new Set(
gridDto.columnFormats.filter((field) => field.allowEditing).map((field) => field.fieldName),
)
: undefined
const merged: Record<string, any> = { ...oldData, ...newData }
if (allowedFields) {
Object.keys(merged).forEach((key) => {
if (!allowedFields.has(key) && !allowedFields.has(key.split(':')[0])) {
delete merged[key]
}
})
}
const result = setFormEditingExtraItemValues(merged)
Object.keys(result).forEach((key) => {
if (key.includes(':')) delete result[key]
})
if (keyFieldName) delete result[keyFieldName]
return result
}
/**
* "Options:TemplateName" biçimindeki sanal alanları, JSON kolonundan
* okuyup düzenleme formuna doldurur.
*/
export const expandJsonFormFields = (columns: { dataField?: string }[] = [], data: any) => {
columns.forEach((col) => {
if (!col.dataField?.includes(':')) return
const [jsonField, jsonProperty] = col.dataField.split(':')
const rawValue = data?.[jsonField]
if (!rawValue) return
const parsed = safeJsonParse<Record<string, any> | null>(
rawValue,
null,
`JSON alanı çözümlenemedi: ${jsonField}`,
)
if (parsed) {
data[col.dataField] = parsed[jsonProperty]
}
})
}
/* ------------------------------------------------------------------ *
* Form item üretimi
* ------------------------------------------------------------------ */
const dateEditorDefaults = {
type: 'date',
dateSerializationFormat: 'yyyy-MM-dd',
displayFormat: 'shortDate',
}
const dateTimeEditorDefaults = {
type: 'datetime',
dateSerializationFormat: 'yyyy-MM-ddTHH:mm:ss',
displayFormat: 'shortDateShortTime',
}
/** Kolonun DB tipine göre tarih editörü varsayılanlarını döner. */
const getDateEditorDefaults = (sourceDbType?: DbTypeEnum) => {
if (sourceDbType === DbTypeEnum.Date) return dateEditorDefaults
if (
sourceDbType === DbTypeEnum.DateTime ||
sourceDbType === DbTypeEnum.DateTime2 ||
sourceDbType === DbTypeEnum.DateTimeOffset
) {
return dateTimeEditorDefaults
}
return undefined
}
/** PlatformEditorTypes -> DevExtreme editorType eşlemesi. */
export const resolveEditorType = (editorType2?: EditorType2) => {
if (editorType2 === PlatformEditorTypes.dxGridBox) return 'dxDropDownBox'
if (
editorType2 === PlatformEditorTypes.dxImageUpload ||
editorType2 === PlatformEditorTypes.dxImageViewer
) {
return undefined
}
return editorType2
}
type FormItemMapperParams = {
mode: RowMode
translate: (key: string) => string
/** URL/prop filtresinin ham JSON değeri. */
rawFilter?: string | null
extraFilters?: GridExtraFilterState[]
/** ImageViewer editörünün değerini okuyabilmesi için aktif form verisi. */
getEditingFormData?: () => Record<string, any>
}
/**
* ColumnFormatDto -> DevExtreme form item dönüşümü.
* Grid ve Tree bu tek uygulamayı paylaşır.
*/
export const createFormItemMapper = ({
mode,
translate,
rawFilter,
extraFilters = [],
getEditingFormData,
}: FormItemMapperParams) => {
// Filtreyi item başına değil, bir kez çözümle.
const filteredFields = new Set(
rawFilter
? extractSearchParamsFields(
safeJsonParse<any>(rawFilter, null, 'EditorOptions filter parse error:'),
).map(([field]) => field)
: [],
)
const overriddenByExtraFilter = new Set(
extraFilters.filter((filter) => !!filter.value).map((filter) => filter.fieldName),
)
return (colFormat: ColumnFormatDto): SimpleItemWithColData => {
const fieldName = colFormat.fieldName
let parsedEditorOptions: EditorOptionsWithButtons = {}
try {
parsedEditorOptions = colFormat.editorOptions ? JSON.parse(colFormat.editorOptions) : {}
} catch (err) {
console.error('EditorOptions parse error:', fieldName, err)
}
const defaultEditorOptions: EditorOptionsWithButtons = {
...(getDateEditorDefaults(colFormat.sourceDbType) ?? {}),
}
if (colFormat.placeHolder) {
defaultEditorOptions.placeholder = translate('::' + colFormat.placeHolder)
}
if (typeof colFormat.defaultValue === 'string' && colFormat.defaultValue === '@AUTONUMBER' && mode === 'new') {
defaultEditorOptions.value = autoNumber()
}
// URL filtresi ile sabitlenmiş alanlar (extra filter ile değiştirilemiyorsa) salt okunur.
const forcedEditorOptions: EditorOptionsWithButtons =
fieldName && filteredFields.has(fieldName) && !overriddenByExtraFilter.has(fieldName)
? { readOnly: true }
: {}
const editorOptions: EditorOptionsWithButtons = {
...defaultEditorOptions,
...parsedEditorOptions,
...forcedEditorOptions,
}
if (editorOptions.buttons) {
editorOptions.buttons = editorOptions.buttons.map((btn: any) => {
if (typeof btn?.options?.onClick === 'string') {
// Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
btn.options.onClick = eval(`(${btn.options.onClick})`)
}
return btn
})
}
const item: SimpleItemWithColData = {
canRead: colFormat.canRead ?? false,
canUpdate: colFormat.canUpdate ?? false,
canCreate: colFormat.canCreate ?? false,
canExport: colFormat.canExport ?? false,
allowEditing: colFormat.allowEditing ?? true,
allowAdding: colFormat.allowAdding ?? true,
dataField: fieldName,
name: fieldName,
editorType2: colFormat.editorType2,
editorType: resolveEditorType(colFormat.editorType2),
colSpan: colFormat.colSpan,
editorOptions,
editorScript: colFormat.editorScript,
}
if (colFormat.editorType2 === PlatformEditorTypes.dxImageViewer) {
item.template = 'cellEditImageViewer'
item.editorOptions = {
...item.editorOptions,
value: getValueByField(getEditingFormData?.() ?? {}, fieldName),
}
}
if (fieldName && fieldName.includes(':')) {
item.label = { text: captionize(fieldName.split(':')[1]) }
}
if ((mode === 'edit' && !item.canUpdate) || (mode === 'new' && !item.canCreate)) {
item.editorOptions = { ...item.editorOptions, readOnly: true }
}
return item
}
}
/** Aktif moda göre form alanının görünür olup olmayacağını belirler. */
export const isFormItemVisible = (item: SimpleItemWithColData, mode: RowMode) => {
if (mode === 'view') return item.canRead
if (mode === 'new') return item.canCreate && item.allowAdding
if (mode === 'edit') return item.canUpdate && item.allowEditing
return false
}
/**
* EditingFormDto gruplarını DevExtreme form item ağacına çevirir.
* Tüm `tabbed` gruplar tek bir `tabbed` item altında toplanır.
*/
export const buildEditingFormItems = (
gridDto: GridDto | undefined,
mapFormItem: (colFormat: ColumnFormatDto) => SimpleItemWithColData,
mode: RowMode,
{ singleColumn = false }: { singleColumn?: boolean } = {},
) => {
if (!gridDto) return undefined
const groups = getEditingFormGroups(gridDto)
if (!groups.length) return undefined
const tabbedGroups = groups.filter((group: any) => group.itemType === 'tabbed')
const result: any[] = []
const mapGroupItems = (items: ColumnFormatDto[] = []) =>
[...items]
.sort((left, right) => (left.editOrderNo ?? 0) - (right.editOrderNo ?? 0))
.map(mapFormItem)
.filter((item) => isFormItemVisible(item, mode))
const colCountOf = (group: any) => (singleColumn ? 1 : group.colCount || 1)
groups.forEach((group: any) => {
if (group.itemType !== 'tabbed') {
result.push({
itemType: group.itemType,
colCount: colCountOf(group),
colSpan: group.colSpan || 1,
caption: group.caption,
items: mapGroupItems(group.items),
})
return
}
// Tabbed gruplar yalnızca ilk tabbed grup üzerinden tek seferde eklenir.
if (group !== tabbedGroups[0]) return
result.push({
itemType: 'tabbed',
colCount: 1,
colSpan: 1,
// Tabbed item'da caption kullanılmaz; başlıklar tabs içindeki title'lardır.
tabs: tabbedGroups.map((tabbedGroup: any) => ({
title: tabbedGroup.caption,
colCount: colCountOf(tabbedGroup),
items: mapGroupItems(tabbedGroup.items),
})),
})
})
return result
}

View file

@ -0,0 +1,221 @@
/**
* Düzenleme formu (DataGrid/TreeList `editing.form`) olay yönetimi.
*
* `onContentReady` ve `onFieldDataChanged` mantığı Grid.tsx ve Tree.tsx içinde
* birebir tekrar ediyordu; buradan tek noktadan sağlanır.
*/
import type { MutableRefObject } from 'react'
import { executeEditorScript } from '@/utils/editorScriptRuntime'
import type { GridDto } from '@/proxy/form/models'
import { getEditingFormFields } from '../Utils'
import {
getValueByField,
setFormEditorReadOnly,
shouldRunEditorScriptOnContentReady,
} from './editingForm'
type EditingFormHandlerParams = {
getGridDto: () => GridDto | undefined
/** DataGrid / TreeList instance'ı. */
getGridInstance: () => any
editingFormDataRef: MutableRefObject<Record<string, any>>
editingFormInstanceRef: MutableRefObject<any>
}
const getEditingRowIndex = (grid: any) => {
const rowKey = grid?.option?.('editing.editRowKey')
return {
rowKey,
rowIndex: rowKey !== undefined ? (grid?.getRowIndexByKey?.(rowKey) ?? -1) : -1,
}
}
/** Grid hücrelerine script tarafından yazılan değerleri uygular. */
const createGridCellWriter = (grid: any, rowIndex: number) => (newData: Record<string, any>) => {
if (!grid || rowIndex < 0) return
Object.keys(newData).forEach((field) => grid.cellValue(rowIndex, field, newData[field]))
}
export const createEditingFormHandlers = ({
getGridDto,
getGridInstance,
editingFormDataRef,
editingFormInstanceRef,
}: EditingFormHandlerParams) => {
/**
* Form ilk render olduğunda salt-okunur davranışını belirleyen editör
* script'lerini çalıştırır.
*/
const onContentReady = (e: any) => {
editingFormInstanceRef.current = e.component
const gridDto = getGridDto()
if (!gridDto) return
const form = e.component
const grid = getGridInstance()
const formFields = getEditingFormFields(gridDto)
const scriptFields = formFields.filter((field) =>
shouldRunEditorScriptOnContentReady(field.editorScript),
)
if (!scriptFields.length) return
const editorValues = formFields.reduce<Record<string, any>>((values, field) => {
const editorInstance = form?.getEditor?.(field.fieldName)
if (editorInstance?.option) {
values[field.fieldName!] = editorInstance.option('value')
}
return values
}, {})
const formData = {
...editingFormDataRef.current,
...(form?.option?.('formData') || {}),
...editorValues,
}
editingFormDataRef.current = { ...formData }
const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) =>
setFormEditorReadOnly(form, field, readOnly)
scriptFields.forEach((field) => {
try {
const editorValue =
form?.getEditor?.(field.fieldName)?.option?.('value') ??
getValueByField(formData, field.fieldName)
executeEditorScript(field.editorScript!, {
formData,
e: {
component: form,
dataField: field.fieldName,
scriptEvent: 'contentReady',
value: editorValue,
},
editor: { dataField: field.fieldName, component: grid },
runtimeSetEditorReadOnly,
setFormData: undefined,
})
} catch (err) {
console.error('Script exec error on contentReady', field.fieldName, err)
}
})
}
/** Bir alan değiştiğinde ilgili editör script'ini çalıştırır. */
const onFieldDataChanged = (e: any) => {
if (!e.dataField) return
editingFormDataRef.current = { ...(e.component?.option?.('formData') || {}) }
const gridDto = getGridDto()
if (!gridDto) return
const formField = getEditingFormFields(gridDto).find(
(field) =>
String(field.fieldName || '').toLowerCase() === String(e.dataField || '').toLowerCase(),
)
if (!formField?.editorScript) return
try {
const grid = getGridInstance()
const { rowIndex } = getEditingRowIndex(grid)
const formData = {
...(e.component?.option?.('formData') || {}),
[e.dataField]: e.value,
}
editingFormDataRef.current = { ...formData }
const writeToGrid = createGridCellWriter(grid, rowIndex)
executeEditorScript(formField.editorScript, {
formData,
e: { ...e, scriptEvent: 'valueChanged' },
editor: { dataField: e.dataField, component: grid },
runtimeSetEditorReadOnly: (field: string, readOnly: boolean) =>
setFormEditorReadOnly(e.component, field, readOnly),
setFormData: (newData: any) => {
e.component?.option?.('formData', newData)
editingFormDataRef.current = { ...newData }
writeToGrid(newData)
},
})
} catch (err) {
console.error('Script exec error', formField.fieldName, err)
}
}
return { onContentReady, onFieldDataChanged }
}
type EditorValueChangedScriptParams = {
editor: any
editorScript: string
fieldName?: string
editingFormInstanceRef: MutableRefObject<any>
getActiveEditingForm: (component: any) => any
}
/**
* `onEditorPreparing` içinde editör script'ini `onValueChanged` zincirine ekler.
*/
export const attachEditorValueChangedScript = ({
editor,
editorScript,
fieldName,
editingFormInstanceRef,
getActiveEditingForm,
}: EditorValueChangedScriptParams) => {
const previousHandler = editor.editorOptions.onValueChanged
const editorDataField = editor.dataField
editor.editorOptions.onValueChanged = (e: any) => {
previousHandler?.(e)
try {
const grid = editor.component
const { rowKey, rowIndex } = getEditingRowIndex(grid)
const formData = {
...(grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data || {}),
[editorDataField]: e.value,
}
executeEditorScript(editorScript, {
formData,
e: { ...e, scriptEvent: 'valueChanged' },
editor,
runtimeSetEditorReadOnly: (field: string, readOnly: boolean) =>
setFormEditorReadOnly(
editingFormInstanceRef.current ?? getActiveEditingForm(grid),
field,
readOnly,
),
setFormData: createGridCellWriter(grid, rowIndex),
})
} catch (err) {
console.error('Script exec error', fieldName, err)
}
}
}
/**
* Editör butonlarının `onClick` çağrılarına satır bağlamını (formData, rowKey) ekler.
*/
export const attachEditorButtonContext = (editor: any) => {
if (!editor.editorOptions?.buttons) return
editor.editorOptions.buttons = editor.editorOptions.buttons.map((btn: any) => {
if (typeof btn?.options?.onClick !== 'function') return btn
const originalClick = btn.options.onClick
btn.options.onClick = (e: any) => {
const grid = editor.component
const { rowKey, rowIndex } = getEditingRowIndex(grid)
const formData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data || {}
originalClick({ ...e, formData, fieldName: editor.dataField, rowKey, rowIndex })
}
return btn
})
}

View file

@ -0,0 +1,103 @@
/**
* DataGrid / PivotGrid dışa aktarma işlemleri.
* ır kütüphaneler (exceljs, jspdf, file-saver) yalnızca ihtiyaç anında yüklenir.
*/
type ExportFormat = 'xlsx' | 'csv' | 'pdf' | string
type ExportParams = {
component: any
format: ExportFormat
fileName: string
onError?: (error: unknown) => void
}
const createWorkbook = async (fileName: string, sheetSuffix: string) => {
const [{ Workbook }, { saveAs }] = await Promise.all([
import('devextreme-exceljs-fork'),
import('file-saver'),
])
const workbook = new Workbook()
const worksheet = workbook.addWorksheet(`${fileName}_${sheetSuffix}`)
return { workbook, worksheet, saveAs }
}
const exportXlsx = async (component: any, fileName: string) => {
const { workbook, worksheet, saveAs } = await createWorkbook(fileName, 'sheet')
const { exportDataGrid } = await import('devextreme/excel_exporter')
await exportDataGrid({ component, worksheet, autoFilterEnabled: true })
const buffer = await workbook.xlsx.writeBuffer()
saveAs(new Blob([buffer], { type: 'application/octet-stream' }), `${fileName}_export.xlsx`)
}
const exportCsv = async (component: any, fileName: string) => {
const { workbook, worksheet, saveAs } = await createWorkbook(fileName, 'sheet')
const items = component.getDataSource()?.items() ?? []
const columns = component.getVisibleColumns().filter((column: any) => column.dataField)
worksheet.addRow(columns.map((column: any) => column.caption || column.dataField))
items.forEach((item: any) => {
worksheet.addRow(columns.map((column: any) => item[column.dataField]))
})
const buffer = await workbook.csv.writeBuffer()
saveAs(new Blob([buffer], { type: 'text/csv' }), `${fileName}_export.csv`)
}
const exportPdf = async (component: any, fileName: string) => {
const [jspdfModule, { exportDataGrid }] = await Promise.all([
import('jspdf'),
import('devextreme/pdf_exporter'),
])
// jsPDF bazı paketlemelerde default, bazılarında named export olarak gelir.
const JsPDFCtor = (jspdfModule as any).default ?? (jspdfModule as any).jsPDF
const doc = new JsPDFCtor({})
await exportDataGrid({ jsPDFDocument: doc, component, indent: 5 })
doc.save(`${fileName}_export.pdf`)
}
/** DataGrid tabanlı görünümlerin dışa aktarımı. */
export const exportListView = async ({ component, format, fileName, onError }: ExportParams) => {
try {
if (format === 'xlsx') {
await exportXlsx(component, fileName)
} else if (format === 'csv') {
await exportCsv(component, fileName)
} else if (format === 'pdf') {
await exportPdf(component, fileName)
}
} catch (error) {
console.error('Export error:', error)
onError?.(error)
}
}
/** PivotGrid yalnızca Excel dışa aktarımı destekler. */
export const exportPivotView = async ({
component,
fileName,
onError,
}: Omit<ExportParams, 'format'>) => {
try {
const { workbook, worksheet, saveAs } = await createWorkbook(fileName, 'pivot')
const { exportPivotGrid } = await import('devextreme/excel_exporter')
await exportPivotGrid({ component, worksheet })
const buffer = await workbook.xlsx.writeBuffer()
saveAs(
new Blob([buffer], { type: 'application/octet-stream' }),
`${fileName}_pivot_export.xlsx`,
)
} catch (error) {
console.error('Pivot export error:', error)
onError?.(error)
}
}

View file

@ -0,0 +1,185 @@
/**
* Liste görünümlerinin paylaştığı küçük React hook'ları.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { RefObject } from 'react'
import type { GridDto } from '@/proxy/form/models'
import { getList } from '@/services/form.service'
import {
addCss,
addJs,
extractSearchParamsFields,
GridExtraFilterState,
safeJsonParse,
} from '../Utils'
/**
* Liste formu tanımını çözer: dışarıdan verilmişse onu kullanır, verilmemişse
* (örn. alt form olarak ıldığında) servisten yükler.
*
* Grid, Tree, Gantt, Scheduler, Pivot ve TodoBoard aynı akışı kopyalıyordu.
*/
export const useListFormGridDto = (listFormCode: string, externalGridDto?: GridDto) => {
const [gridDto, setGridDto] = useState<GridDto | undefined>(externalGridDto)
useEffect(() => {
if (externalGridDto !== undefined) {
setGridDto(externalGridDto)
return
}
if (!listFormCode) {
setGridDto(undefined)
return
}
let isActive = true
getList({ listFormCode })
.then((response: any) => {
if (isActive) setGridDto(response.data)
})
.catch((error: unknown) => console.error('GridDto load error:', error))
return () => {
isActive = false
}
}, [listFormCode, externalGridDto])
return gridDto
}
/**
* WidgetGroup alanının yüksekliğini izler; `fullHeight` grid'lerinin
* `calc(100vh - …)` hesabında kullanılır.
*/
export const useWidgetGroupHeight = (
widgetGroupRef: RefObject<HTMLElement>,
widgets: unknown,
) => {
const [widgetGroupHeight, setWidgetGroupHeight] = useState(0)
useEffect(() => {
const element = widgetGroupRef.current
if (!element) {
setWidgetGroupHeight(0)
return
}
const calculateHeight = () => setWidgetGroupHeight(element.offsetHeight)
calculateHeight()
const resizeObserver = new ResizeObserver(calculateHeight)
resizeObserver.observe(element)
return () => resizeObserver.disconnect()
}, [widgetGroupRef, widgets])
return widgetGroupHeight
}
/** Liste formuna tanımlı özel JS/CSS kaynaklarını sayfaya ekler. */
export const useListFormCustomSources = (gridDto?: GridDto) => {
const customJsSources = gridDto?.gridOptions.customJsSources
const customStyleSources = gridDto?.gridOptions.customStyleSources
useEffect(() => {
customJsSources?.forEach(addJs)
}, [customJsSources])
useEffect(() => {
customStyleSources?.forEach(addCss)
}, [customStyleSources])
}
export const getInitialExtraFilters = (gridDto?: GridDto): GridExtraFilterState[] =>
(gridDto?.gridOptions.extraFilterDto ?? []).map((filter) => ({
fieldName: filter.fieldName,
operator: filter.operator,
controlType: filter.controlType,
value: filter.defaultValue ?? '',
}))
/** [field, operator, value] üçlülerini `and` ile zincirlenmiş DevExtreme filtresine çevirir. */
export const mergeFilterTriplets = (triplets: [string, string, any][]) => {
const unique = triplets.reduce<[string, string, any][]>((result, current) => {
const index = result.findIndex((item) => item[0] === current[0] && item[1] === current[1])
if (index >= 0) {
result[index] = current
} else {
result.push(current)
}
return result
}, [])
return unique.reduce<any>((result, filter) => (result ? [result, 'and', filter] : filter), null)
}
type UseExtraFiltersParams = {
gridDto?: GridDto
listFormCode: string
searchParams?: URLSearchParams
/** Filtre değişince görünümü yenilemek için. */
onFilterChanged: () => void
}
/**
* Toolbar'daki ek filtre alanlarının state'i, URL filtresiyle birleştirilmesi
* ve `searchParams` senkronizasyonu. Grid ve Tree bu hook'u paylaşır.
*/
export const useExtraFilters = ({
gridDto,
listFormCode,
searchParams,
onFilterChanged,
}: UseExtraFiltersParams) => {
const [extraFilters, setExtraFilters] = useState<GridExtraFilterState[]>([])
// İlk render'daki URL filtresi, ek filtrelerin birleştirileceği temeldir.
const defaultFilterRef = useRef<string | null>(null)
const isMountedRef = useRef(false)
if (defaultFilterRef.current === null) {
defaultFilterRef.current = searchParams?.get('filter') ?? ''
}
useEffect(() => {
setExtraFilters([])
isMountedRef.current = false
}, [listFormCode])
useEffect(() => {
setExtraFilters(getInitialExtraFilters(gridDto))
}, [gridDto])
const mergedFilter = useMemo(() => {
const base = safeJsonParse<any>(defaultFilterRef.current, null, 'Search filter parse error:')
return mergeFilterTriplets([
...extractSearchParamsFields(base),
...extraFilters
.filter((filter) => !!filter.value)
.map(
(filter) => [filter.fieldName, filter.operator, filter.value] as [string, string, any],
),
])
}, [extraFilters])
useEffect(() => {
// İlk render'da URL zaten doğru; gereksiz refresh yapmamak için atlanır.
if (!isMountedRef.current) {
isMountedRef.current = true
return
}
if (mergedFilter) {
searchParams?.set('filter', JSON.stringify(mergedFilter))
} else {
searchParams?.delete('filter')
}
onFilterChanged()
// onFilterChanged her render'da yeniden oluşabileceği için bağımlılığa alınmaz.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mergedFilter])
return { extraFilters, setExtraFilters, mergedFilter }
}

View file

@ -0,0 +1,115 @@
/**
* Liste görünümlerinde (grid hücresi, image upload/viewer editörleri) kullanılan
* ortak görsel değer normalizasyon yardımcıları.
*
* Daha önce useListFormColumns.ts, ImageViewerEditorComponent.tsx ve
* ImageUploadEditorComponent.tsx içinde birebir tekrarlanıyordu.
*/
export const NO_IMAGE = '/img/others/no-image.png'
/** String ya da obje olabilen bir değeri düz objeye çevirir; dizi/geçersiz JSON için undefined döner. */
export const parseJsonObject = (value: unknown): Record<string, any> | undefined => {
if (!value) return undefined
if (typeof value === 'object') {
return Array.isArray(value) ? undefined : (value as Record<string, any>)
}
if (typeof value !== 'string') return undefined
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: undefined
} catch {
return undefined
}
}
/** Pozitif sayıya çevirir, aksi halde varsayılanı döner. */
export const normalizeImageSize = (value: unknown, fallback: number) => {
const size = Number(value)
return Number.isFinite(size) && size > 0 ? size : fallback
}
/** Obje formatındaki görsel değerinden (url/src/fileUrl/path/value) kaynağı çıkarır. */
export const getImageSource = (value: unknown) => {
if (!value) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'object') {
const item = value as Record<string, unknown>
return String(item.url ?? item.src ?? item.fileUrl ?? item.path ?? item.value ?? '').trim()
}
return String(value).trim()
}
const isProbablyBase64Image = (value: string) =>
value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
/** Ham base64 içeriğini data URL'e çevirir; zaten URL ise dokunmaz. */
export const toImageSource = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed
return `data:image/jpeg;base64,${trimmed}`
}
const splitImageString = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('data:image/')) return [trimmed]
return trimmed
.split(/\r?\n|\|\s*/)
.flatMap((part) => {
const text = part.trim()
if (!text || text.startsWith('data:image/')) return text ? [text] : []
return text.split(',').map((item) => item.trim())
})
.map(toImageSource)
.filter(Boolean)
}
/**
* Tek görsel, çoklu görsel, JSON dizi ya da ayraçlı string olabilen değeri
* gösterilebilir URL listesine dönüştürür.
*/
export const normalizeImageValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeImageValue(item))
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.flatMap((item) => normalizeImageValue(item))
}
} catch {
return [toImageSource(trimmed)]
}
}
return splitImageString(trimmed)
}
const source = getImageSource(value)
return source ? [toImageSource(source)] : []
}
/** Kolon yapılandırmasından image upload/viewer seçeneklerini çözer. */
export const resolveImageColumnOptions = (column: unknown): Record<string, any> => {
const col = column as any
const configured = col?.extras?.imageUploadOptions
if (configured && Object.keys(configured).length > 0) {
return configured
}
return parseJsonObject(col?.extras?.editorOptions) ?? parseJsonObject(col?.editorOptions) ?? {}
}

View file

@ -0,0 +1,49 @@
/**
* Yeni eklenen kayıt için workflow başlatma.
* Grid ve Tree `onRowInserted` içinde aynı akışı kullanır.
*/
import { Notification, toast } from '@/components/ui'
import type { GridDto } from '@/proxy/form/models'
import { workflowService } from '@/services/workflow.service'
import { getPersistedInsertedKey } from './editingForm'
export const showWorkflowMessages = (messages: string[] = []) => {
if (!messages.length) return
toast.push(
<Notification type="info" duration={7000}>
{messages.map((message, messageIndex) => (
<div key={messageIndex} className={messageIndex > 0 ? 'mt-2 border-t pt-2' : undefined}>
{message.split('\n').map((line, lineIndex) => (
<div key={lineIndex}>{line}</div>
))}
</div>
))}
</Notification>,
{ placement: 'bottom-end' },
)
}
/**
* Liste formunda workflow onay alanı tanımlıysa eklenen kayıt için
* workflow'u başlatır ve görünümü yeniler.
*/
export const startWorkflowForInsertedRow = (
event: any,
gridDto: GridDto,
listFormCode: string,
refresh: () => void,
) => {
if (!gridDto.gridOptions.workflowDto?.approvalStatusFieldName) return
const insertedKey = getPersistedInsertedKey(event, gridDto.gridOptions.keyFieldName)
if (insertedKey === undefined) return
workflowService
.startWorkflow(listFormCode, [insertedKey])
.then((result) => {
showWorkflowMessages(result.toastMessages ?? [])
refresh()
})
.catch((error) => console.error('Workflow başlatılamadı:', error))
}

View file

@ -17,6 +17,7 @@ import dxGantt from 'devextreme/ui/gantt'
import dxCardView from 'devextreme/ui/card_view'
import { Dispatch, MutableRefObject, SetStateAction, useEffect, useState } from 'react'
import { setGridPanelColor } from './Utils'
import { FILTERED_GRID_PANEL_COLOR } from './shared/columns'
import { usePermission } from '@/utils/hooks/usePermission'
import { usePWA } from '@/utils/hooks/usePWA'
import { ROUTES_ENUM } from '@/routes/route.constant'
@ -305,9 +306,6 @@ const useFilters = ({
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const [isPermissionUsersModalOpen, setIsPermissionUsersModalOpen] = useState(false)
const filteredGridPanelColor = 'rgba(10, 200, 10, 0.5)' // kullanici tanimli filtre ile filtrelenmis gridin paneline ait renk
//const statedGridPanelColor = 'rgba(50, 200, 200, 0.5)' // kullanici tanimli gridState ile islem gormus gridin paneline ait renk
const grdOpt = gridDto?.gridOptions
const config = useStoreState((state) => state.abpConfig.config)
const toToolbarText = (text: string) => text
@ -558,7 +556,7 @@ const useFilters = ({
if (filter) {
setFilterValue(grid, JSON.parse(filter.customizationData))
setFilterPanelVisible(grid, true)
setGridPanelColor(filteredGridPanelColor)
setGridPanelColor(FILTERED_GRID_PANEL_COLOR)
} else {
clearGridFilter(grid)
setFilterPanelVisible(grid, false)

View file

@ -17,8 +17,18 @@ import {
UiLookupDataSourceTypeEnum,
} from '@/proxy/form/models'
import { addCss, getEditingFormFields } from './Utils'
const NO_IMAGE = '/img/others/no-image.png'
import { cachedLookupLoad, clearLookupCache } from '../shared/lookupCache'
import {
NO_IMAGE,
normalizeImageSize,
normalizeImageValue,
resolveImageColumnOptions,
} from './shared/imageValue'
import {
hideImageHoverPreview,
openImageInNewTab,
showImageHoverPreview,
} from '../form/editors/imageHoverPreview'
const cellTemplateMultiValue = (
cellElement: HTMLElement,
@ -65,260 +75,41 @@ const cellTemplateMultiValue = (
}
}
// Hover preview overlay — singleton, tüm grid hücreleri tarafından paylaşılır
let __imgPreviewEl: HTMLDivElement | null = null
let __imgPreviewTarget: HTMLElement | null = null
let __imgPreviewListenersBound = false
function hideImgPreview() {
__imgPreviewTarget = null
if (__imgPreviewEl) {
__imgPreviewEl.style.opacity = '0'
__imgPreviewEl.style.display = 'none'
}
}
function bindImgPreviewDismissListeners() {
if (__imgPreviewListenersBound) return
__imgPreviewListenersBound = true
document.addEventListener(
'pointermove',
(event) => {
const target = event.target as Node | null
if (
__imgPreviewTarget &&
(!__imgPreviewTarget.isConnected || !target || !__imgPreviewTarget.contains(target))
) {
hideImgPreview()
}
},
{ passive: true },
)
document.addEventListener('scroll', hideImgPreview, true)
window.addEventListener('blur', hideImgPreview)
document.addEventListener('visibilitychange', () => {
if (document.hidden) hideImgPreview()
})
}
function getImgPreview(): HTMLDivElement {
if (!__imgPreviewEl) {
const el = document.createElement('div')
el.id = '__cellImgPreview'
el.style.cssText = [
'position:fixed',
'z-index:99999',
'display:none',
'pointer-events:none',
'background:#fff',
'border:1px solid #d1d5db',
'border-radius:8px',
'box-shadow:0 8px 32px rgba(0,0,0,0.22)',
'padding:4px',
'max-width:320px',
'max-height:320px',
'overflow:hidden',
'transition:opacity 0.15s ease',
'opacity:0',
].join(';')
const img = document.createElement('img')
img.onerror = null
img.src = NO_IMAGE
img.style.cssText =
'display:block;max-width:312px;max-height:312px;object-fit:contain;border-radius:4px;'
el.appendChild(img)
document.body.appendChild(el)
__imgPreviewEl = el
bindImgPreviewDismissListeners()
}
return __imgPreviewEl
}
function showImgPreview(src: string, e: MouseEvent) {
const el = getImgPreview()
__imgPreviewTarget = e.currentTarget instanceof HTMLElement ? e.currentTarget : null
const imgEl = el.querySelector('img') as HTMLImageElement
imgEl.onerror = () => {
imgEl.onerror = null
imgEl.src = NO_IMAGE
}
if (imgEl.src !== src) imgEl.src = src
const GAP = 12
const vw = window.innerWidth
const vh = window.innerHeight
el.style.opacity = '0'
el.style.display = 'block'
const pw = el.offsetWidth || 320
const ph = el.offsetHeight || 320
let left = e.clientX + GAP
let top = e.clientY + GAP
if (left + pw > vw - 8) left = e.clientX - pw - GAP
if (top + ph > vh - 8) top = e.clientY - ph - GAP
if (left < 8) left = 8
if (top < 8) top = 8
el.style.left = `${left}px`
el.style.top = `${top}px`
el.style.opacity = '1'
}
// Görsel hücre şablonu. Normalizasyon ve hover önizleme ortak modüllerden gelir.
const cellTemplateImage = (
cellElement: HTMLElement,
cellInfo: DataGridTypes.ColumnCellTemplateData<any, any>,
) => {
if (cellInfo?.value) {
const urls = normalizeImageCellValue(cellInfo.value)
const imgOptions = getImageColumnOptions(cellInfo.column)
const w = normalizeImageSize(imgOptions.width, 40)
const h = normalizeImageSize(imgOptions.height, 40)
if (!cellInfo?.value) return
cellElement.style.cssText += 'display:flex;flex-wrap:wrap;align-items:center;gap:4px;'
cellElement.innerHTML = ''
//cellElement.title = urls.join(', ')
const urls = normalizeImageValue(cellInfo.value)
const imageOptions = resolveImageColumnOptions(cellInfo.column)
const width = normalizeImageSize(imageOptions.width, 40)
const height = normalizeImageSize(imageOptions.height, 40)
urls.forEach((url) => {
const img = document.createElement('img')
img.onerror = () => {
img.onerror = null
img.src = NO_IMAGE
}
img.src = url
img.alt = ''
img.style.cssText = `width:${w}px;height:${h}px;object-fit:cover;border-radius:4px;margin:2px;vertical-align:middle;display:inline-block;cursor:zoom-in;`
cellElement.style.cssText += 'display:flex;flex-wrap:wrap;align-items:center;gap:4px;'
cellElement.innerHTML = ''
img.addEventListener('mouseenter', (e) => showImgPreview(url, e as MouseEvent))
img.addEventListener('mousemove', (e) => showImgPreview(url, e as MouseEvent))
img.addEventListener('mouseleave', hideImgPreview)
img.addEventListener('click', () => {
hideImgPreview()
openImageSource(url)
})
cellElement.appendChild(img)
})
}
}
const normalizeImageSize = (value: unknown, fallback: number) => {
const size = Number(value)
return Number.isFinite(size) && size > 0 ? size : fallback
}
const parseJsonObject = (value: unknown) => {
if (!value) return undefined
if (typeof value === 'object') return value as Record<string, any>
if (typeof value !== 'string') return undefined
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, any>)
: undefined
} catch {
return undefined
}
}
const getImageColumnOptions = (column: unknown) => {
const col = column as any
return (
col?.extras?.imageUploadOptions ??
parseJsonObject(col?.extras?.editorOptions) ??
parseJsonObject(col?.editorOptions) ??
{}
)
}
const getImageSource = (value: unknown) => {
if (!value) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'object') {
const item = value as Record<string, unknown>
return String(item.url ?? item.src ?? item.fileUrl ?? item.path ?? item.value ?? '').trim()
}
return String(value).trim()
}
const isProbablyBase64Image = (value: string) =>
value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
const toImageSource = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed
return `data:image/jpeg;base64,${trimmed}`
}
const openImageSource = (url: string) => {
if (!url.startsWith('data:image/')) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
try {
const match = url.match(/^data:(image\/[^;]+);base64,(.*)$/)
if (!match) return
const binary = atob(match[2])
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0))
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: match[1] }))
window.open(blobUrl, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60000)
} catch {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
const splitImageString = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('data:image/')) return [trimmed]
return trimmed
.split(/\r?\n|\|\s*/)
.flatMap((part) => {
const text = part.trim()
if (!text || text.startsWith('data:image/')) return text ? [text] : []
return text.split(',').map((item) => item.trim())
})
.map(toImageSource)
.filter(Boolean)
}
const normalizeImageCellValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeImageCellValue(item))
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) {
return parsed.flatMap((item) => normalizeImageCellValue(item))
}
} catch {
return [toImageSource(trimmed)]
}
urls.forEach((url) => {
const img = document.createElement('img')
img.onerror = () => {
img.onerror = null
img.src = NO_IMAGE
}
img.src = url
img.alt = ''
img.style.cssText = `width:${width}px;height:${height}px;object-fit:cover;border-radius:4px;margin:2px;vertical-align:middle;display:inline-block;cursor:zoom-in;`
return splitImageString(trimmed)
}
img.addEventListener('mouseenter', (event) => showImageHoverPreview(url, event))
img.addEventListener('mousemove', (event) => showImageHoverPreview(url, event))
img.addEventListener('mouseleave', hideImageHoverPreview)
img.addEventListener('click', () => openImageInNewTab(url))
const source = getImageSource(value)
return source ? [toImageSource(source)] : []
cellElement.appendChild(img)
})
}
function calculateFilterExpressionMultiValue(
this: DataGridTypes.Column,
filterValue: any,
@ -360,40 +151,6 @@ function calculateFilterExpressionMultiValue(
}
}
// lookup cache (module scope) - cache süresini ve boyutunu yönet
const __lookupCache = new Map<string, { promise: Promise<any[]>; timestamp: number }>()
const CACHE_DURATION = 5 * 60 * 1000 // 5 dakika
const MAX_CACHE_SIZE = 100 // Maksimum cache entry sayısı
const cachedLoader = (key: string, loader: () => Promise<any[]>) => {
const now = Date.now()
const cached = __lookupCache.get(key)
// Cache'de var ve süresi dolmamışsa kullan
if (cached && now - cached.timestamp < CACHE_DURATION) {
return cached.promise
}
// Cache boyutu limitini aşarsa en eskiyi temizle
if (__lookupCache.size >= MAX_CACHE_SIZE) {
const oldestKey = Array.from(__lookupCache.entries()).sort(
(a, b) => a[1].timestamp - b[1].timestamp,
)[0][0]
__lookupCache.delete(oldestKey)
}
const p = Promise.resolve()
.then(() => loader())
.then((res) => res ?? [])
.catch((err) => {
__lookupCache.delete(key) // hata olursa tekrar denenebilsin
throw err
})
__lookupCache.set(key, { promise: p, timestamp: now })
return p
}
const useListFormColumns = ({
gridDto,
listFormCode,
@ -414,7 +171,7 @@ const useListFormColumns = ({
useEffect(() => {
// listFormCode değişince lookup cache temizlensin (farklı form farklı lookuplar)
__lookupCache.clear()
clearLookupCache()
}, [listFormCode])
const lookupDataSource = useCallback(
@ -523,7 +280,7 @@ const useListFormColumns = ({
loadMode: 'raw',
load: async () => {
// load fonksiyonu sync sonuç döndürüyor olabilir, o yüzden Promise.resolve ile sar
return cachedLoader(`static:${key}`, async () => {
return cachedLookupLoad(`static:${key}`, async () => {
const result = await Promise.resolve(load())
if (result === undefined || result === null) return []
return Array.isArray(result) ? result : [result]
@ -548,7 +305,7 @@ const useListFormColumns = ({
try {
const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}`
return cachedLoader(cacheKey, async () => {
return cachedLookupLoad(cacheKey, async () => {
const response = await dynamicFetch('list-form-select/lookup', 'POST', null, {
listFormCode,
listFormFieldName,
@ -600,7 +357,7 @@ const useListFormColumns = ({
try {
const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}`
return cachedLoader(cacheKey, async () => {
return cachedLookupLoad(cacheKey, async () => {
const response = await dynamicFetch(url, method, null, resolvedBody)
let { data } = response
if (!data) return []
@ -1056,12 +813,14 @@ const useListFormColumns = ({
}
for (const col of gridDto.columnFormats) {
// NOT: Buradaki atlamalar `return` değil `continue` olmalı; aksi halde
// band'e dahil ilk tekrar eden kolonda tüm kolon listesi kayboluyordu.
if (!col.fieldName) {
return
continue
}
if (insertedColumns.some((a) => a === col.fieldName)) {
if (insertedColumns.includes(col.fieldName)) {
// kolon zaten eklenmis ise islem yapma
return
continue
}
if (col.bandName) {

View file

@ -8,12 +8,33 @@ import { GridOptionsDto } from '@/proxy/form/models'
import { GridColumnData } from './GridColumnData'
import { dynamicFetch } from '@/services/form.service'
import { MULTIVALUE_DELIMITER } from '@/constants/app.constant'
import { FILTERED_GRID_PANEL_COLOR } from './shared/columns'
import type { TreeListRef } from 'devextreme-react/cjs/tree-list'
import type { GanttRef } from 'devextreme-react/cjs/gantt'
import type { SchedulerRef } from 'devextreme-react/cjs/scheduler'
import type { CardViewRef, CardViewTypes } from 'devextreme-react/cjs/card-view'
const filteredGridPanelColor = 'rgba(10, 200, 10, 0.5)' // kullanici tanimli filtre ile filtrelenmis gridin paneline ait renk
/** URL'den gelen varsayılan filtre ile grid filtresini `and` ile birleştirir. */
const mergeWithDefaultFilter = (gridFilter: any, searchParams?: URLSearchParams) => {
const defaultFilter = safeJsonParse(
searchParams?.get('filter'),
null,
'Search filter parse error:',
)
if (defaultFilter && gridFilter) return [defaultFilter, 'and', gridFilter]
return defaultFilter ?? gridFilter
}
/** Filtre doluysa serialize eder, boşsa parametreyi hiç göndermez. */
const applyCombinedFilter = (parameters: any, combinedFilter: any) => {
if (combinedFilter && combinedFilter.length > 0) {
parameters.filter = JSON.stringify(combinedFilter)
} else {
delete parameters.filter
}
}
const toInsertedRowData = (values: any, responseData: any, keyFieldName?: string | null) => {
if (!keyFieldName) {
@ -120,21 +141,6 @@ const useListFormCustomDataSource = ({
Object.assign(parameters, chartParameters)
}
// 1. Default filter'ı al
const defaultFilter = safeJsonParse(
searchParams?.get('filter'),
null,
'Search filter parse error:',
)
let combinedFilter: any = parameters.filter
// 2. Eğer hem default hem de grid filter varsa merge et
if (defaultFilter && combinedFilter) {
combinedFilter = [defaultFilter, 'and', combinedFilter]
} else if (defaultFilter) {
combinedFilter = defaultFilter
}
//editing asamasinda her bir field de yapilan degisiklik load istegi olarak buraya dusuyor.
//TODO: bu bug halen devam ediyor!!
//Bunu engellemek icin eklendi.
@ -176,13 +182,13 @@ const useListFormCustomDataSource = ({
}
}
if (combinedFilter && combinedFilter.length > 0) {
parameters.filter = JSON.stringify(combinedFilter)
} else {
delete parameters.filter // hiç göndermesin
}
// NOT: Birleştirme, yukarıdaki kolon bazlı URL filtreleri parameters.filter'a
// eklendikten SONRA yapılmalı; aksi halde bu filtreler sessizce kayboluyordu.
applyCombinedFilter(
parameters,
mergeWithDefaultFilter(parameters.filter, searchParams),
)
//parameters.filter = JSON.stringify(parameters.filter)
const response = await dynamicFetch('list-form-select/select', 'GET', parameters)
// Chart için grouped data'yı chart formatına çevir
@ -276,7 +282,7 @@ const useListFormCustomDataSource = ({
// gride filtre uygulanmis ise renklendir
setGridPanelColor(
response.data.queryInfos?.isAppliedGridFilter
? filteredGridPanelColor
? FILTERED_GRID_PANEL_COLOR
: 'transparent',
)
@ -315,27 +321,9 @@ const useListFormCustomDataSource = ({
group: '',
})
// 1. Default filter'ı al
const defaultFilter = safeJsonParse(
searchParams?.get('filter'),
null,
'Search filter parse error:',
)
const combinedFilter = mergeWithDefaultFilter(parameters.filter, searchParams)
let combinedFilter: any = parameters.filter
// 2. Eğer hem default hem de grid filter varsa merge et
if (defaultFilter && combinedFilter) {
combinedFilter = [defaultFilter, 'and', combinedFilter]
} else if (defaultFilter) {
combinedFilter = defaultFilter
}
if (combinedFilter && combinedFilter.length > 0) {
parameters.filter = JSON.stringify(combinedFilter)
} else {
delete parameters.filter // hiç göndermesin
}
applyCombinedFilter(parameters, combinedFilter)
try {
const response = await dynamicFetch('list-form-select/select', 'GET', parameters)

View file

@ -7,8 +7,8 @@ import {
import { useLocalization } from '@/utils/hooks/useLocalization'
import { MutableRefObject, useCallback, useEffect, useMemo, useRef } from 'react'
import { setGridPanelColor } from './Utils'
import { STATED_GRID_PANEL_COLOR } from './shared/columns'
const statedGridPanelColor = 'rgba(50, 200, 200, 0.5)'
const defaultSaveDebounceMs = 500
type PendingSave = {
@ -139,7 +139,7 @@ export const useListFormStateStoring = ({
customizationData,
})
.then(() => {
setGridPanelColor(statedGridPanelColor)
setGridPanelColor(STATED_GRID_PANEL_COLOR)
showSaveSuccessToast()
pendingSave?.resolve()
})
@ -178,7 +178,7 @@ export const useListFormStateStoring = ({
try {
const state = JSON.parse(customizationData)
setGridPanelColor(statedGridPanelColor)
setGridPanelColor(STATED_GRID_PANEL_COLOR)
return state
} catch (err) {
console.error('Grid state parse error:', err)

View file

@ -1,111 +1,52 @@
import React, { lazy, Suspense, useEffect } from 'react'
import { lazy, Suspense } from 'react'
import type { FC } from 'react'
import { Container } from '@/components/shared'
import { Helmet } from 'react-helmet'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useLocation, useParams } from 'react-router-dom'
import { RequestOptions } from 'devexpress-reporting-react/dx-report-designer'
import { APP_NAME } from '@/constants/app.constant'
import { createReportUrl } from './reportRouteParams'
import { MODE_DARK } from '@/constants/theme.constant'
import { useStoreState } from '@/store/store'
import { useReportRequestSetup, useReportShellTheme, useReportUrl } from './shared/reportShell'
const ReportDesigner = lazy(() => import('devexpress-reporting-react/dx-report-designer'))
const VIEWER_THEME_LINK_ID = 'devexpress-report-viewer-theme'
const REPORT_DESIGNER_CSS_ID = 'devexpress-report-designer-css'
const getViewerClassName = (isDarkMode: boolean) =>
[
'dx-viewport',
'dx-device-desktop',
'dx-device-generic',
'dx-theme-generic',
'dx-theme-generic-typography',
isDarkMode ? 'dx-color-scheme-dark' : 'dx-color-scheme-light',
'report-viewer-shell',
isDarkMode ? 'report-viewer-shell-dark' : '',
]
.filter(Boolean)
.join(' ')
const loadViewerCss = (isDarkMode: boolean) => {
const styles = [
new URL('@devexpress/analytics-core/dist/css/dx-analytics.common.css', import.meta.url).href,
new URL('devexpress-reporting/dist/css/dx-webdocumentviewer.css', import.meta.url).href,
]
styles.forEach((href) => {
if (document.querySelector(`link[href="${href}"]`)) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
document.head.appendChild(link)
})
const themeHref = isDarkMode
? new URL('@devexpress/analytics-core/dist/css/dx-analytics.dark.css', import.meta.url).href
: new URL('@devexpress/analytics-core/dist/css/dx-analytics.light.css', import.meta.url).href
let themeLink = document.getElementById(VIEWER_THEME_LINK_ID) as HTMLLinkElement | null
if (!themeLink) {
themeLink = document.createElement('link')
themeLink.id = VIEWER_THEME_LINK_ID
themeLink.rel = 'stylesheet'
document.head.appendChild(themeLink)
}
if (themeLink.href !== themeHref) {
themeLink.href = themeHref
}
if (!document.getElementById(REPORT_DESIGNER_CSS_ID)) {
const designerLink = document.createElement('link')
designerLink.id = REPORT_DESIGNER_CSS_ID
designerLink.rel = 'stylesheet'
designerLink.href = new URL(
'devexpress-reporting/dist/css/dx-reportdesigner.css',
import.meta.url,
).href
document.head.appendChild(designerLink)
}
}
const DevexpressReportDesigner: React.FC = () => {
const DevexpressReportDesigner: FC = () => {
const { translate } = useLocalization()
const { report, id, listFormCode } = useParams<{
report: string
id: string
listFormCode: string
}>()
const themeMode = useStoreState((state) => state.theme.mode)
const isDarkMode = themeMode === MODE_DARK
const location = useLocation()
const reportUrlWithParams = createReportUrl(
const { shellClassName } = useReportShellTheme(true)
const { cultureName, isRequestConfigured } = useReportRequestSetup()
const reportUrlWithParams = useReportUrl(
{ report, id, listFormCode },
location.search,
cultureName,
'design',
)
useEffect(() => {
loadViewerCss(isDarkMode)
}, [isDarkMode])
if (!reportUrlWithParams) return null
return (
<Container className={getViewerClassName(isDarkMode)}>
<Container className={shellClassName}>
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::App.Reports')}
defaultTitle={APP_NAME}
/>
<Suspense fallback={<div>{translate('::App.Loading')}</div>}>
<ReportDesigner reportUrl={reportUrlWithParams}>
<RequestOptions
host={`${import.meta.env.VITE_API_URL}/`}
getDesignerModelAction="DXXRD/GetDesignerModel"
/>
</ReportDesigner>
</Suspense>
{isRequestConfigured && (
<Suspense fallback={<div>{translate('::App.Loading')}</div>}>
<ReportDesigner key={reportUrlWithParams} reportUrl={reportUrlWithParams}>
<RequestOptions
host={`${import.meta.env.VITE_API_URL}/`}
getDesignerModelAction="DXXRD/GetDesignerModel"
/>
</ReportDesigner>
</Suspense>
)}
</Container>
)
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useRef } from 'react'
import type { FC } from 'react'
import { Container } from '@/components/shared'
import { Helmet } from 'react-helmet'
@ -8,14 +8,10 @@ import ReportViewer, {
Callbacks,
RequestOptions,
} from 'devexpress-reporting-react/dx-report-viewer'
import { ajaxSetup } from '@devexpress/analytics-core/analytics-utils-native'
import { ZoomAutoBy } from 'devexpress-reporting/viewer/constants'
import { APP_NAME } from '@/constants/app.constant'
import { MODE_DARK } from '@/constants/theme.constant'
import { useStoreState } from '@/store'
import { createReportQueryString, createReportUrl } from './reportRouteParams'
import { useReportRequestSetup, useReportShellTheme, useReportUrl } from './shared/reportShell'
const VIEWER_THEME_LINK_ID = 'devexpress-report-viewer-theme'
const DEFAULT_REPORT_ZOOM = 1
interface ReportPreviewModel {
@ -34,50 +30,6 @@ interface ReportPreviewContainer {
}
}
const getViewerClassName = (isDarkMode: boolean) =>
[
'dx-viewport',
'dx-device-desktop',
'dx-device-generic',
'dx-theme-generic',
'dx-theme-generic-typography',
isDarkMode ? 'dx-color-scheme-dark' : 'dx-color-scheme-light',
'report-viewer-shell',
isDarkMode ? 'report-viewer-shell-dark' : '',
]
.filter(Boolean)
.join(' ')
const loadViewerCss = (isDarkMode: boolean) => {
const styles = [
new URL('@devexpress/analytics-core/dist/css/dx-analytics.common.css', import.meta.url).href,
new URL('devexpress-reporting/dist/css/dx-webdocumentviewer.css', import.meta.url).href,
]
styles.forEach((href) => {
if (document.querySelector(`link[href="${href}"]`)) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
document.head.appendChild(link)
})
const themeHref = isDarkMode
? new URL('@devexpress/analytics-core/dist/css/dx-analytics.dark.css', import.meta.url).href
: new URL('@devexpress/analytics-core/dist/css/dx-analytics.light.css', import.meta.url).href
let themeLink = document.getElementById(VIEWER_THEME_LINK_ID) as HTMLLinkElement | null
if (!themeLink) {
themeLink = document.createElement('link')
themeLink.id = VIEWER_THEME_LINK_ID
themeLink.rel = 'stylesheet'
document.head.appendChild(themeLink)
}
if (themeLink.href !== themeHref) {
themeLink.href = themeHref
}
}
const DevexpressReportViewer: FC = () => {
const { translate } = useLocalization()
const { report, listFormCode, id } = useParams<{
@ -86,17 +38,14 @@ const DevexpressReportViewer: FC = () => {
id: string
}>()
const location = useLocation()
const token = useStoreState((state) => state.auth.session.token)
const themeMode = useStoreState((state) => state.theme.mode)
const cultureName = useStoreState((state) => state.locale.currentLang)
const configCultureName = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const [configuredRequestKey, setConfiguredRequestKey] = useState<string>()
const previewModelRef = useRef<ReportPreviewModel>()
const isDarkMode = themeMode === MODE_DARK
const reportCultureName = cultureName || configCultureName
const requestConfigKey = `${token ?? ''}\u0000${reportCultureName ?? ''}`
const { shellClassName } = useReportShellTheme()
const { cultureName, isRequestConfigured } = useReportRequestSetup()
const reportUrlWithParams = useReportUrl(
{ report, id, listFormCode },
location.search,
cultureName,
)
const getReportPreview = useCallback(
(eventOrModel?: ReportPreviewContainer | ReportPreviewModel) => {
@ -139,44 +88,17 @@ const DevexpressReportViewer: FC = () => {
[getReportPreview],
)
useEffect(() => {
loadViewerCss(isDarkMode)
}, [isDarkMode])
useEffect(() => {
const nextHeaders = { ...(ajaxSetup.ajaxSettings.headers ?? {}) }
delete nextHeaders.Authorization
delete nextHeaders['Accept-Language']
ajaxSetup.ajaxSettings.headers = {
...nextHeaders,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(reportCultureName ? { 'Accept-Language': reportCultureName } : {}),
}
setConfiguredRequestKey(requestConfigKey)
}, [token, reportCultureName, requestConfigKey])
const reportUrlWithParams = useMemo(() => {
const searchParams = new URLSearchParams(location.search)
if (reportCultureName) {
searchParams.set('cultureName', reportCultureName)
}
const search = createReportQueryString(searchParams)
return createReportUrl({ report, id, listFormCode }, search ? `?${search}` : '')
}, [report, id, listFormCode, location.search, reportCultureName])
if (!reportUrlWithParams) return null
return (
<Container className={getViewerClassName(isDarkMode)}>
<Container className={shellClassName}>
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::App.Reports')}
defaultTitle={APP_NAME}
/>
{configuredRequestKey === requestConfigKey && (
{isRequestConfigured && (
<ReportViewer key={reportUrlWithParams} reportUrl={reportUrlWithParams}>
<Callbacks BeforeRender={setDefaultZoom} DocumentReady={setDefaultZoom} />
<RequestOptions host={import.meta.env.VITE_API_URL} invokeAction="/DXXRDV" />

View file

@ -0,0 +1,148 @@
import { useEffect, useMemo, useState } from 'react'
import { ajaxSetup } from '@devexpress/analytics-core/analytics-utils-native'
import { MODE_DARK } from '@/constants/theme.constant'
import { useStoreState } from '@/store'
import {
createReportQueryString,
createReportUrl,
type ReportRouteParams,
} from '../reportRouteParams'
const REPORT_THEME_LINK_ID = 'devexpress-report-viewer-theme'
const REPORT_DESIGNER_CSS_ID = 'devexpress-report-designer-css'
const COMMON_STYLE_HREFS = [
new URL('@devexpress/analytics-core/dist/css/dx-analytics.common.css', import.meta.url).href,
new URL('devexpress-reporting/dist/css/dx-webdocumentviewer.css', import.meta.url).href,
]
const DARK_THEME_HREF = new URL(
'@devexpress/analytics-core/dist/css/dx-analytics.dark.css',
import.meta.url,
).href
const LIGHT_THEME_HREF = new URL(
'@devexpress/analytics-core/dist/css/dx-analytics.light.css',
import.meta.url,
).href
const DESIGNER_STYLE_HREF = new URL(
'devexpress-reporting/dist/css/dx-reportdesigner.css',
import.meta.url,
).href
const appendStylesheet = (href: string, id?: string) => {
if (id ? document.getElementById(id) : document.querySelector(`link[href="${href}"]`)) return
const link = document.createElement('link')
if (id) link.id = id
link.rel = 'stylesheet'
link.href = href
document.head.appendChild(link)
}
const applyThemeStylesheet = (isDarkMode: boolean) => {
const themeHref = isDarkMode ? DARK_THEME_HREF : LIGHT_THEME_HREF
let themeLink = document.getElementById(REPORT_THEME_LINK_ID) as HTMLLinkElement | null
if (!themeLink) {
themeLink = document.createElement('link')
themeLink.id = REPORT_THEME_LINK_ID
themeLink.rel = 'stylesheet'
document.head.appendChild(themeLink)
}
if (themeLink.href !== themeHref) {
themeLink.href = themeHref
}
}
export const getReportShellClassName = (isDarkMode: boolean) =>
[
'dx-viewport',
'dx-device-desktop',
'dx-device-generic',
'dx-theme-generic',
'dx-theme-generic-typography',
isDarkMode ? 'dx-color-scheme-dark' : 'dx-color-scheme-light',
'report-viewer-shell',
isDarkMode ? 'report-viewer-shell-dark' : '',
]
.filter(Boolean)
.join(' ')
/**
* DevExpress rapor bileşenlerinin stillerini yükler ve aktif temaya göre kabuk sınıfını döner.
*/
export const useReportShellTheme = (includeDesignerStyles = false) => {
const themeMode = useStoreState((state) => state.theme.mode)
const isDarkMode = themeMode === MODE_DARK
useEffect(() => {
COMMON_STYLE_HREFS.forEach((href) => appendStylesheet(href))
applyThemeStylesheet(isDarkMode)
if (includeDesignerStyles) {
appendStylesheet(DESIGNER_STYLE_HREF, REPORT_DESIGNER_CSS_ID)
}
}, [isDarkMode, includeDesignerStyles])
return { isDarkMode, shellClassName: getReportShellClassName(isDarkMode) }
}
export const useReportCultureName = () => {
const cultureName = useStoreState((state) => state.locale.currentLang)
const configCultureName = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
return cultureName || configCultureName
}
/**
* DevExpress istekleri global ajax ayarları üzerinden gittiği için oturum ve dil
* başlıkları bileşen render edilmeden önce yazılır. `isRequestConfigured` false iken
* rapor bileşeni render edilmemelidir, aksi hâlde ilk istek yetkisiz gider.
*/
export const useReportRequestSetup = () => {
const token = useStoreState((state) => state.auth.session.token)
const cultureName = useReportCultureName()
const requestConfigKey = `${token ?? ''}|${cultureName ?? ''}`
const [configuredRequestKey, setConfiguredRequestKey] = useState<string>()
useEffect(() => {
const nextHeaders = { ...(ajaxSetup.ajaxSettings.headers ?? {}) }
delete nextHeaders.Authorization
delete nextHeaders['Accept-Language']
ajaxSetup.ajaxSettings.headers = {
...nextHeaders,
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(cultureName ? { 'Accept-Language': cultureName } : {}),
}
setConfiguredRequestKey(requestConfigKey)
}, [token, cultureName, requestConfigKey])
return {
cultureName,
isRequestConfigured: configuredRequestKey === requestConfigKey,
}
}
export const useReportUrl = (
params: ReportRouteParams,
search: string,
cultureName?: string,
action?: string,
) => {
const { report, id, listFormCode } = params
return useMemo(() => {
const searchParams = new URLSearchParams(search)
if (cultureName) {
searchParams.set('cultureName', cultureName)
}
const queryString = createReportQueryString(searchParams)
return createReportUrl(
{ report, id, listFormCode },
queryString ? `?${queryString}` : '',
action,
)
}, [report, id, listFormCode, search, cultureName, action])
}

View file

@ -0,0 +1,46 @@
/**
* DevExtreme HtmlEditor için standart araç çubuğu.
* NoteModal ve TodoBoard içinde birebir aynı ~40 satırlık tanım tekrar ediyordu.
*/
import { Item, Toolbar } from 'devextreme-react/html-editor'
import {
fontFamilyOptions,
fontSizeOptions,
fontValues,
headerOptions,
headerValues,
sizeValues,
} from '@/proxy/reports/data'
export const HtmlEditorToolbar = () => (
<Toolbar multiline>
<Item name="undo" />
<Item name="redo" />
<Item name="separator" />
<Item name="size" acceptedValues={sizeValues} options={fontSizeOptions} />
<Item name="font" acceptedValues={fontValues} options={fontFamilyOptions} />
<Item name="separator" />
<Item name="bold" />
<Item name="italic" />
<Item name="underline" />
<Item name="strike" />
<Item name="separator" />
<Item name="orderedList" />
<Item name="bulletList" />
<Item name="separator" />
<Item name="header" acceptedValues={headerValues} options={headerOptions} />
<Item name="separator" />
<Item name="color" />
<Item name="background" />
<Item name="separator" />
<Item name="alignLeft" />
<Item name="alignCenter" />
<Item name="alignRight" />
<Item name="alignJustify" />
<Item name="separator" />
<Item name="link" />
<Item name="image" />
<Item name="separator" />
<Item name="clear" />
</Toolbar>
)

View file

@ -0,0 +1,67 @@
/**
* Lookup veri kaynakları için ortak, TTL'li in-memory cache.
*
* Daha önce `views/list/useListFormColumns.ts` (TTL + boyut limitli) ve
* `views/form/useLookupDataSource.ts` (limitsiz, hiç boşalmayan) içinde iki
* ayrı uygulama vardı; ikinci sürüm bellek sızdırıyor ve dil/tenant değişiminde
* bayat veri döndürüyordu. Tek noktadan yönetilmesi için buraya taşındı.
*/
const CACHE_DURATION_MS = 5 * 60 * 1000
const MAX_CACHE_SIZE = 100
const lookupCache = new Map<string, { promise: Promise<any[]>; timestamp: number }>()
/** Cache'in en eski girdilerini atarak boyut limitini korur. */
const evictOldestEntry = () => {
let oldestKey: string | undefined
let oldestTimestamp = Number.POSITIVE_INFINITY
lookupCache.forEach((entry, key) => {
if (entry.timestamp < oldestTimestamp) {
oldestTimestamp = entry.timestamp
oldestKey = key
}
})
if (oldestKey !== undefined) {
lookupCache.delete(oldestKey)
}
}
/**
* Aynı anahtarla yapılan lookup yüklemelerini tek bir isteğe indirger.
* Hata durumunda girdi silinir, böylece sonraki çağrı yeniden dener.
*/
export const cachedLookupLoad = (key: string, loader: () => Promise<any[]>): Promise<any[]> => {
const now = Date.now()
const cached = lookupCache.get(key)
if (cached && now - cached.timestamp < CACHE_DURATION_MS) {
return cached.promise
}
if (lookupCache.size >= MAX_CACHE_SIZE) {
evictOldestEntry()
}
const promise = Promise.resolve()
.then(loader)
.then((result) => result ?? [])
.catch((error) => {
lookupCache.delete(key)
throw error
})
lookupCache.set(key, { promise, timestamp: now })
return promise
}
/** Tek bir anahtarı ya da anahtar verilmezse tüm cache'i temizler. */
export const clearLookupCache = (key?: string) => {
if (key === undefined) {
lookupCache.clear()
} else {
lookupCache.delete(key)
}
}