Calude güncellemesi Grid, Tree, Rapor ve diğerleri
This commit is contained in:
parent
ac4ccd76d6
commit
0224f4b3e1
55 changed files with 4815 additions and 6528 deletions
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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ı açı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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
149
ui/src/views/form/shared/FormPageShell.tsx
Normal file
149
ui/src/views/form/shared/FormPageShell.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
126
ui/src/views/form/shared/formItems.ts
Normal file
126
ui/src/views/form/shared/formItems.ts
Normal 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,
|
||||
)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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ı açı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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
124
ui/src/views/list/shared/cascade.ts
Normal file
124
ui/src/views/list/shared/cascade.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Lookup cascade (bağımlı açı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 açı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
|
||||
}
|
||||
}
|
||||
39
ui/src/views/list/shared/cellStyles.ts
Normal file
39
ui/src/views/list/shared/cellStyles.ts
Normal 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ç iş 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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
31
ui/src/views/list/shared/columnFormatters.ts
Normal file
31
ui/src/views/list/shared/columnFormatters.ts
Normal 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`'ı çağı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)
|
||||
}
|
||||
16
ui/src/views/list/shared/columns.ts
Normal file
16
ui/src/views/list/shared/columns.ts
Normal 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)'
|
||||
123
ui/src/views/list/shared/editPopup.ts
Normal file
123
ui/src/views/list/shared/editPopup.ts
Normal 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),
|
||||
})
|
||||
569
ui/src/views/list/shared/editingForm.ts
Normal file
569
ui/src/views/list/shared/editingForm.ts
Normal 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` açı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
|
||||
}
|
||||
221
ui/src/views/list/shared/editingFormScripts.ts
Normal file
221
ui/src/views/list/shared/editingFormScripts.ts
Normal 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
|
||||
})
|
||||
}
|
||||
103
ui/src/views/list/shared/exportListView.ts
Normal file
103
ui/src/views/list/shared/exportListView.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* DataGrid / PivotGrid dışa aktarma işlemleri.
|
||||
* Ağı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)
|
||||
}
|
||||
}
|
||||
185
ui/src/views/list/shared/hooks.ts
Normal file
185
ui/src/views/list/shared/hooks.ts
Normal 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 açı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 }
|
||||
}
|
||||
115
ui/src/views/list/shared/imageValue.ts
Normal file
115
ui/src/views/list/shared/imageValue.ts
Normal 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) ?? {}
|
||||
}
|
||||
49
ui/src/views/list/shared/workflowRows.tsx
Normal file
49
ui/src/views/list/shared/workflowRows.tsx
Normal 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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
148
ui/src/views/report/shared/reportShell.ts
Normal file
148
ui/src/views/report/shared/reportShell.ts
Normal 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])
|
||||
}
|
||||
46
ui/src/views/shared/HtmlEditorToolbarItems.tsx
Normal file
46
ui/src/views/shared/HtmlEditorToolbarItems.tsx
Normal 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>
|
||||
)
|
||||
67
ui/src/views/shared/lookupCache.ts
Normal file
67
ui/src/views/shared/lookupCache.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue