DevRequest Timesheet ve Subtask sütunları açıldı
This commit is contained in:
parent
0d0834efaf
commit
b1dac82d56
15 changed files with 753 additions and 117 deletions
|
|
@ -15,6 +15,8 @@ public class TodoOptionDto
|
||||||
public string PriorityExpr { get; set; }
|
public string PriorityExpr { get; set; }
|
||||||
public string CompletedExpr { get; set; }
|
public string CompletedExpr { get; set; }
|
||||||
public string OrderExpr { get; set; }
|
public string OrderExpr { get; set; }
|
||||||
|
public string TimesheetExpr { get; set; }
|
||||||
|
public string SubTaskExpr { get; set; }
|
||||||
public string StatusOrder { get; set; }
|
public string StatusOrder { get; set; }
|
||||||
public bool AllowDragging { get; set; } = true;
|
public bool AllowDragging { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26885,6 +26885,108 @@
|
||||||
"key": "App.CatalogForm.FormPermissionDescription",
|
"key": "App.CatalogForm.FormPermissionDescription",
|
||||||
"en": "Permission guarding this command. Empty leaves it open: the bound endpoint alone is enough for its button.",
|
"en": "Permission guarding this command. Empty leaves it open: the bound endpoint alone is enough for its button.",
|
||||||
"tr": "Bu komutu koruyan yetki. Boş bırakılırsa komut serbesttir: butonun görünmesi için bağlı endpoint yeterlidir."
|
"tr": "Bu komutu koruyan yetki. Boş bırakılırsa komut serbesttir: butonun görünmesi için bağlı endpoint yeterlidir."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormFieldEdit.TodoTimesheetExpr",
|
||||||
|
"en": "Timesheet field (JSON)",
|
||||||
|
"tr": "Çalışma çizelgesi alanı (JSON)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormFieldEdit.TodoSubTaskExpr",
|
||||||
|
"en": "Sub task field (JSON)",
|
||||||
|
"tr": "Alt görev alanı (JSON)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Listform.ListformField.Timesheets",
|
||||||
|
"en": "Timesheets",
|
||||||
|
"tr": "Çalışma Çizelgeleri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Listform.ListformField.SubTasks",
|
||||||
|
"en": "Sub Tasks",
|
||||||
|
"tr": "Alt Görevler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.Timesheets",
|
||||||
|
"en": "Timesheets",
|
||||||
|
"tr": "Çalışma Çizelgeleri"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.SubTasks",
|
||||||
|
"en": "Sub Tasks",
|
||||||
|
"tr": "Alt Görevler"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.TimesheetDate",
|
||||||
|
"en": "Date",
|
||||||
|
"tr": "Tarih"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.TimesheetEmployee",
|
||||||
|
"en": "Employee",
|
||||||
|
"tr": "Çalışan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.TimesheetDuration",
|
||||||
|
"en": "Spent Time",
|
||||||
|
"tr": "Harcanan Zaman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.TimesheetTotal",
|
||||||
|
"en": "Total",
|
||||||
|
"tr": "Toplam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.SubTaskTitle",
|
||||||
|
"en": "Sub task",
|
||||||
|
"tr": "Alt görev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.SubTaskAssignee",
|
||||||
|
"en": "Assignee",
|
||||||
|
"tr": "Atanan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.SubTaskDueDate",
|
||||||
|
"en": "Due date",
|
||||||
|
"tr": "Bitiş tarihi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.AddRow",
|
||||||
|
"en": "Add row",
|
||||||
|
"tr": "Satır ekle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.DeleteRow",
|
||||||
|
"en": "Delete row",
|
||||||
|
"tr": "Satırı sil"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.NoRows",
|
||||||
|
"en": "No records",
|
||||||
|
"tr": "Kayıt yok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ListFormTodoBoard.InvalidJson",
|
||||||
|
"en": "The stored value is not in a valid JSON format; it will be overwritten when saved.",
|
||||||
|
"tr": "Kayıtlı değer geçerli JSON biçiminde değil; kaydedildiğinde üzerine yazılacak."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,8 @@ public static class ListFormSeeder_DefaultJsons
|
||||||
string PriorityExpr = "Priority",
|
string PriorityExpr = "Priority",
|
||||||
string CompletedExpr = "Completed",
|
string CompletedExpr = "Completed",
|
||||||
string OrderExpr = "SortOrder",
|
string OrderExpr = "SortOrder",
|
||||||
|
string TimesheetExpr = "Timesheets",
|
||||||
|
string SubTaskExpr = "SubTasks",
|
||||||
string StatusOrder = "Backlog, Ready to Start, In Progress, Code Review, Testing, Staging",
|
string StatusOrder = "Backlog, Ready to Start, In Progress, Code Review, Testing, Staging",
|
||||||
bool AllowDragging = true
|
bool AllowDragging = true
|
||||||
) => JsonSerializer.Serialize(new TodoOptionDto
|
) => JsonSerializer.Serialize(new TodoOptionDto
|
||||||
|
|
@ -126,6 +128,8 @@ public static class ListFormSeeder_DefaultJsons
|
||||||
PriorityExpr = PriorityExpr,
|
PriorityExpr = PriorityExpr,
|
||||||
CompletedExpr = CompletedExpr,
|
CompletedExpr = CompletedExpr,
|
||||||
OrderExpr = OrderExpr,
|
OrderExpr = OrderExpr,
|
||||||
|
TimesheetExpr = TimesheetExpr,
|
||||||
|
SubTaskExpr = SubTaskExpr,
|
||||||
StatusOrder = StatusOrder,
|
StatusOrder = StatusOrder,
|
||||||
AllowDragging = AllowDragging
|
AllowDragging = AllowDragging
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9716,7 +9716,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
|
||||||
new() { FieldName = "RequestDate", FieldDbType = DbType.Date, Value = "@NOW", CustomValueType = FieldCustomValueTypeEnum.CustomKey },
|
new() { FieldName = "RequestDate", FieldDbType = DbType.Date, Value = "@NOW", CustomValueType = FieldCustomValueTypeEnum.CustomKey },
|
||||||
new() { FieldName = "Importance", FieldDbType = DbType.String, Value = "Low", CustomValueType = FieldCustomValueTypeEnum.Value },
|
new() { FieldName = "Importance", FieldDbType = DbType.String, Value = "Low", CustomValueType = FieldCustomValueTypeEnum.Value },
|
||||||
new() { FieldName = "Urgency", FieldDbType = DbType.String, Value = "Low", CustomValueType = FieldCustomValueTypeEnum.Value },
|
new() { FieldName = "Urgency", FieldDbType = DbType.String, Value = "Low", CustomValueType = FieldCustomValueTypeEnum.Value },
|
||||||
new() { FieldName = "Status", FieldDbType = DbType.String, Value = "backlog", CustomValueType = FieldCustomValueTypeEnum.Value },
|
new() { FieldName = "Status", FieldDbType = DbType.String, Value = "Backlog", CustomValueType = FieldCustomValueTypeEnum.Value },
|
||||||
}),
|
}),
|
||||||
TodoOptionJson = DefaultTodoOptionJson()
|
TodoOptionJson = DefaultTodoOptionJson()
|
||||||
});
|
});
|
||||||
|
|
@ -9818,12 +9818,12 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
|
||||||
DisplayExpr = "name",
|
DisplayExpr = "name",
|
||||||
ValueExpr = "key",
|
ValueExpr = "key",
|
||||||
LookupQuery = JsonSerializer.Serialize(new LookupDataDto[] {
|
LookupQuery = JsonSerializer.Serialize(new LookupDataDto[] {
|
||||||
new () { Key="backlog", Name="App.StaticLookup.Backlog" },
|
new () { Key="Backlog", Name="App.StaticLookup.Backlog" },
|
||||||
new () { Key="ready_to_start", Name="App.StaticLookup.ReadyToStart" },
|
new () { Key="Ready to Start", Name="App.StaticLookup.ReadyToStart" },
|
||||||
new () { Key="in_progress", Name="App.StaticLookup.InProgress" },
|
new () { Key="In Progress", Name="App.StaticLookup.InProgress" },
|
||||||
new () { Key="code_review", Name="App.StaticLookup.CodeReview" },
|
new () { Key="Code Review", Name="App.StaticLookup.CodeReview" },
|
||||||
new () { Key="testing", Name="App.StaticLookup.Testing" },
|
new () { Key="Testing", Name="App.StaticLookup.Testing" },
|
||||||
new () { Key="staging", Name="App.StaticLookup.Staging" }
|
new () { Key="Staging", Name="App.StaticLookup.Staging" }
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
ColSpan = 1,
|
ColSpan = 1,
|
||||||
|
|
@ -9976,6 +9976,34 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
|
||||||
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
|
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
|
||||||
PivotSettingsJson = DefaultPivotSettingsJson
|
PivotSettingsJson = DefaultPivotSettingsJson
|
||||||
},
|
},
|
||||||
|
new() {
|
||||||
|
ListFormCode = listForm.ListFormCode,
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
SourceDbType = DbType.String,
|
||||||
|
FieldName = "Timesheets",
|
||||||
|
CaptionName = "App.Listform.ListformField.Timesheets",
|
||||||
|
Width = 0,
|
||||||
|
ListOrderNo = 13,
|
||||||
|
Visible = false,
|
||||||
|
IsActive = true,
|
||||||
|
ColumnCustomizationJson = DefaultColumnCustomizationJson,
|
||||||
|
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
|
||||||
|
PivotSettingsJson = DefaultPivotSettingsJson
|
||||||
|
},
|
||||||
|
new() {
|
||||||
|
ListFormCode = listForm.ListFormCode,
|
||||||
|
CultureName = LanguageCodes.En,
|
||||||
|
SourceDbType = DbType.String,
|
||||||
|
FieldName = "SubTasks",
|
||||||
|
CaptionName = "App.Listform.ListformField.SubTasks",
|
||||||
|
Width = 0,
|
||||||
|
ListOrderNo = 14,
|
||||||
|
Visible = false,
|
||||||
|
IsActive = true,
|
||||||
|
ColumnCustomizationJson = DefaultColumnCustomizationJson,
|
||||||
|
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
|
||||||
|
PivotSettingsJson = DefaultPivotSettingsJson
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ public class DevRequest : FullAuditedEntity<Guid>
|
||||||
public string? Priority { get; set; }
|
public string? Priority { get; set; }
|
||||||
public bool? Completed { get; set; }
|
public bool? Completed { get; set; }
|
||||||
public int? SortOrder { get; set; }
|
public int? SortOrder { get; set; }
|
||||||
|
public string? Timesheets { get; set; }
|
||||||
|
public string? SubTasks { get; set; }
|
||||||
|
|
||||||
|
|
||||||
public DevRequest(Guid id)
|
public DevRequest(Guid id)
|
||||||
|
|
|
||||||
|
|
@ -1616,6 +1616,8 @@ public class PlatformDbContext :
|
||||||
b.Property(e => e.Priority).HasMaxLength(64);
|
b.Property(e => e.Priority).HasMaxLength(64);
|
||||||
b.Property(e => e.Completed).HasDefaultValue(false);
|
b.Property(e => e.Completed).HasDefaultValue(false);
|
||||||
b.Property(e => e.SortOrder).HasDefaultValue(0);
|
b.Property(e => e.SortOrder).HasDefaultValue(0);
|
||||||
|
b.Property(e => e.Timesheets).HasColumnType("nvarchar(max)");
|
||||||
|
b.Property(e => e.SubTasks).HasColumnType("nvarchar(max)");
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<Agenda>(b =>
|
builder.Entity<Agenda>(b =>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
|
||||||
namespace Sozsoft.Platform.Migrations
|
namespace Sozsoft.Platform.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(PlatformDbContext))]
|
[DbContext(typeof(PlatformDbContext))]
|
||||||
[Migration("20260821153543_Initial")]
|
[Migration("20260824093234_Initial")]
|
||||||
partial class Initial
|
partial class Initial
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -2423,6 +2423,9 @@ namespace Sozsoft.Platform.Migrations
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("SubTasks")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Tags")
|
b.Property<string>("Tags")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("nvarchar(256)");
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
@ -2430,6 +2433,9 @@ namespace Sozsoft.Platform.Migrations
|
||||||
b.Property<Guid?>("TenantId")
|
b.Property<Guid?>("TenantId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Timesheets")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
|
|
@ -1318,6 +1318,8 @@ namespace Sozsoft.Platform.Migrations
|
||||||
Priority = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
Priority = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
Completed = table.Column<bool>(type: "bit", nullable: true, defaultValue: false),
|
Completed = table.Column<bool>(type: "bit", nullable: true, defaultValue: false),
|
||||||
SortOrder = table.Column<int>(type: "int", nullable: true, defaultValue: 0),
|
SortOrder = table.Column<int>(type: "int", nullable: true, defaultValue: 0),
|
||||||
|
Timesheets = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
SubTasks = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
|
@ -2420,6 +2420,9 @@ namespace Sozsoft.Platform.Migrations
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("SubTasks")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Tags")
|
b.Property<string>("Tags")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("nvarchar(256)");
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
@ -2427,6 +2430,9 @@ namespace Sozsoft.Platform.Migrations
|
||||||
b.Property<Guid?>("TenantId")
|
b.Property<Guid?>("TenantId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Timesheets")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,13 @@
|
||||||
.input-suffix-end {
|
.input-suffix-end {
|
||||||
@apply absolute top-2/4 transform -translate-y-2/4 ltr:right-2.5 rtl:left-2.5 flex;
|
@apply absolute top-2/4 transform -translate-y-2/4 ltr:right-2.5 rtl:left-2.5 flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Native date/time inputs: keep the browser picker icon visible in dark mode */
|
||||||
|
.dark input[type='date']::-webkit-calendar-picker-indicator,
|
||||||
|
.dark input[type='time']::-webkit-calendar-picker-indicator,
|
||||||
|
.dark input[type='month']::-webkit-calendar-picker-indicator,
|
||||||
|
.dark input[type='week']::-webkit-calendar-picker-indicator,
|
||||||
|
.dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(1);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,8 @@ export interface TodoOptionDto {
|
||||||
priorityExpr?: string
|
priorityExpr?: string
|
||||||
completedExpr?: string
|
completedExpr?: string
|
||||||
orderExpr?: string
|
orderExpr?: string
|
||||||
|
timesheetExpr?: string
|
||||||
|
subTaskExpr?: string
|
||||||
statusOrder?: string
|
statusOrder?: string
|
||||||
allowDragging?: boolean
|
allowDragging?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ const mappings: Array<{
|
||||||
{ name: 'priorityExpr', labelKey: 'TodoPriorityExpr' },
|
{ name: 'priorityExpr', labelKey: 'TodoPriorityExpr' },
|
||||||
{ name: 'completedExpr', labelKey: 'TodoCompletedExpr' },
|
{ name: 'completedExpr', labelKey: 'TodoCompletedExpr' },
|
||||||
{ name: 'orderExpr', labelKey: 'TodoOrderExpr' },
|
{ name: 'orderExpr', labelKey: 'TodoOrderExpr' },
|
||||||
|
{ name: 'timesheetExpr', labelKey: 'TodoTimesheetExpr' },
|
||||||
|
{ name: 'subTaskExpr', labelKey: 'TodoSubTaskExpr' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const FormTabTodo = ({ onSubmit }: FormEditProps) => {
|
const FormTabTodo = ({ onSubmit }: FormEditProps) => {
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ const mappings: Array<{
|
||||||
translationKey: '::App.ListFormFieldEdit.TodoCompletedExpr',
|
translationKey: '::App.ListFormFieldEdit.TodoCompletedExpr',
|
||||||
},
|
},
|
||||||
{ name: 'orderExpr', translationKey: '::App.ListFormFieldEdit.TodoOrderExpr' },
|
{ name: 'orderExpr', translationKey: '::App.ListFormFieldEdit.TodoOrderExpr' },
|
||||||
|
{
|
||||||
|
name: 'timesheetExpr',
|
||||||
|
translationKey: '::App.ListFormFieldEdit.TodoTimesheetExpr',
|
||||||
|
},
|
||||||
|
{ name: 'subTaskExpr', translationKey: '::App.ListFormFieldEdit.TodoSubTaskExpr' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -389,6 +389,24 @@ const TODO_COLUMNS: ColumnDefinition[] = [
|
||||||
defaultValue: '0',
|
defaultValue: '0',
|
||||||
description: 'Todo order inside its board column',
|
description: 'Todo order inside its board column',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: '__TodoTimesheets',
|
||||||
|
columnName: 'Timesheets',
|
||||||
|
dataType: 'nvarchar(MAX)',
|
||||||
|
maxLength: '',
|
||||||
|
isNullable: true,
|
||||||
|
defaultValue: '',
|
||||||
|
description: 'Todo timesheet rows as JSON array',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '__TodoSubTasks',
|
||||||
|
columnName: 'SubTasks',
|
||||||
|
dataType: 'nvarchar(MAX)',
|
||||||
|
maxLength: '',
|
||||||
|
isNullable: true,
|
||||||
|
defaultValue: '',
|
||||||
|
description: 'Todo sub task rows as JSON array',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const GANTT_COLUMNS: ColumnDefinition[] = [
|
const GANTT_COLUMNS: ColumnDefinition[] = [
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
FaPencilAlt,
|
FaPencilAlt,
|
||||||
FaPlus,
|
FaPlus,
|
||||||
FaRegCircle,
|
FaRegCircle,
|
||||||
|
FaSave,
|
||||||
FaTag,
|
FaTag,
|
||||||
FaTimes,
|
FaTimes,
|
||||||
FaTrash,
|
FaTrash,
|
||||||
|
|
@ -76,6 +77,10 @@ interface UserOption {
|
||||||
|
|
||||||
type LookupDisplayValues = Record<string, Map<string, string>>
|
type LookupDisplayValues = Record<string, Map<string, string>>
|
||||||
|
|
||||||
|
type DetailTab = 'description' | 'timesheet' | 'subtask'
|
||||||
|
|
||||||
|
type JsonRow = Record<string, any>
|
||||||
|
|
||||||
const TODO_STATUS_LANGUAGE_KEYS: Record<string, string> = {
|
const TODO_STATUS_LANGUAGE_KEYS: Record<string, string> = {
|
||||||
backlog: 'App.StaticLookup.Backlog',
|
backlog: 'App.StaticLookup.Backlog',
|
||||||
'ready to start': 'App.StaticLookup.ReadyToStart',
|
'ready to start': 'App.StaticLookup.ReadyToStart',
|
||||||
|
|
@ -107,6 +112,42 @@ const textList = (value: unknown) =>
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const parseJsonRows = (value: unknown): JsonRow[] => {
|
||||||
|
if (Array.isArray(value)) return value.filter((item) => item && typeof item === 'object')
|
||||||
|
const text = String(value ?? '').trim()
|
||||||
|
if (!text) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((item) => item && typeof item === 'object') : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInvalidJson = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const text = String(value ?? '').trim()
|
||||||
|
if (!text) return false
|
||||||
|
try {
|
||||||
|
return !Array.isArray(JSON.parse(text))
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRowId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
|
const formatDuration = (value: unknown) => {
|
||||||
|
const minutes = Math.max(0, Math.round((Number(value) || 0) * 60))
|
||||||
|
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeToDuration = (value: string) => {
|
||||||
|
const [hours, minutes] = value.split(':')
|
||||||
|
const total = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
|
||||||
|
return Math.round(total) / 60
|
||||||
|
}
|
||||||
|
|
||||||
const isCompleted = (value: unknown) =>
|
const isCompleted = (value: unknown) =>
|
||||||
value === true ||
|
value === true ||
|
||||||
value === 1 ||
|
value === 1 ||
|
||||||
|
|
@ -248,6 +289,8 @@ const TodoBoardContent = ({
|
||||||
const [tenantNames, setTenantNames] = useState<Map<string, string>>(new Map())
|
const [tenantNames, setTenantNames] = useState<Map<string, string>>(new Map())
|
||||||
const [usersLoading, setUsersLoading] = useState(false)
|
const [usersLoading, setUsersLoading] = useState(false)
|
||||||
const [lookupDisplayValues, setLookupDisplayValues] = useState<LookupDisplayValues>({})
|
const [lookupDisplayValues, setLookupDisplayValues] = useState<LookupDisplayValues>({})
|
||||||
|
const [detailTab, setDetailTab] = useState<DetailTab>('description')
|
||||||
|
const focusRowRef = useRef<{ field?: string; index: number } | undefined>(undefined)
|
||||||
const columnConfigRef = useRef<StoredBoardColumns>({ columns: [], retired: [] })
|
const columnConfigRef = useRef<StoredBoardColumns>({ columns: [], retired: [] })
|
||||||
const dragActiveRef = useRef(false)
|
const dragActiveRef = useRef(false)
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
|
|
@ -280,11 +323,6 @@ const TodoBoardContent = ({
|
||||||
[lookupText, options.statusExpr, translate],
|
[lookupText, options.statusExpr, translate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const statusSelectOptions = statuses.map((status) => ({
|
|
||||||
value: status,
|
|
||||||
label: statusText(status),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const clearDragState = useCallback(() => {
|
const clearDragState = useCallback(() => {
|
||||||
dragActiveRef.current = false
|
dragActiveRef.current = false
|
||||||
setDraggedKey(undefined)
|
setDraggedKey(undefined)
|
||||||
|
|
@ -535,6 +573,39 @@ const TodoBoardContent = ({
|
||||||
[draft, options.tagExpr],
|
[draft, options.tagExpr],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const focusNewRow = useCallback(
|
||||||
|
(field: string | undefined, index: number) => (element: HTMLInputElement | null) => {
|
||||||
|
const target = focusRowRef.current
|
||||||
|
if (!element || !target || target.field !== field || target.index !== index) return
|
||||||
|
focusRowRef.current = undefined
|
||||||
|
element.focus()
|
||||||
|
element.scrollIntoView({ block: 'nearest' })
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setJsonRows = useCallback((field: string | undefined, rows: JsonRow[]) => {
|
||||||
|
if (!field) return
|
||||||
|
setDraft((current) => ({ ...current, [field]: rows.length ? JSON.stringify(rows) : null }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const appendJsonRow = useCallback(
|
||||||
|
(field: string | undefined, rows: JsonRow[], row: JsonRow) => {
|
||||||
|
focusRowRef.current = { field, index: rows.length }
|
||||||
|
setJsonRows(field, [...rows, row])
|
||||||
|
},
|
||||||
|
[setJsonRows],
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateJsonRow = useCallback(
|
||||||
|
(field: string | undefined, rows: JsonRow[], index: number, values: JsonRow) =>
|
||||||
|
setJsonRows(
|
||||||
|
field,
|
||||||
|
rows.map((row, item) => (item === index ? { ...row, ...values } : row)),
|
||||||
|
),
|
||||||
|
[setJsonRows],
|
||||||
|
)
|
||||||
|
|
||||||
const saveColumnConfig = useCallback(
|
const saveColumnConfig = useCallback(
|
||||||
(columns: string[], retired = columnConfigRef.current.retired) => {
|
(columns: string[], retired = columnConfigRef.current.retired) => {
|
||||||
const next = { columns, retired }
|
const next = { columns, retired }
|
||||||
|
|
@ -829,6 +900,7 @@ const TodoBoardContent = ({
|
||||||
const openTodo = (row: TodoRow) => {
|
const openTodo = (row: TodoRow) => {
|
||||||
setSelected(row)
|
setSelected(row)
|
||||||
setDraft({ ...row })
|
setDraft({ ...row })
|
||||||
|
setDetailTab('description')
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveTodo = async (exitAfterSave = false) => {
|
const saveTodo = async (exitAfterSave = false) => {
|
||||||
|
|
@ -843,6 +915,8 @@ const TodoBoardContent = ({
|
||||||
options.priorityExpr,
|
options.priorityExpr,
|
||||||
options.completedExpr,
|
options.completedExpr,
|
||||||
options.orderExpr,
|
options.orderExpr,
|
||||||
|
options.timesheetExpr,
|
||||||
|
options.subTaskExpr,
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
const values = Object.fromEntries(mappedFields.map((field) => [field, draft[field]]))
|
const values = Object.fromEntries(mappedFields.map((field) => [field, draft[field]]))
|
||||||
|
|
||||||
|
|
@ -884,22 +958,74 @@ const TodoBoardContent = ({
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:disabled:bg-gray-900'
|
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:disabled:bg-gray-900'
|
||||||
const labelClass = 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500'
|
const labelClass = 'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500'
|
||||||
|
const cellInputClass =
|
||||||
|
'w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-700 outline-none transition focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 disabled:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:disabled:bg-gray-900'
|
||||||
|
const stickyHeadCellClass =
|
||||||
|
'sticky top-0 z-10 border-b border-gray-200 bg-white px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:bg-gray-900'
|
||||||
|
const stickyFootCellClass =
|
||||||
|
'sticky bottom-0 z-10 border-t border-gray-200 bg-gray-50 px-3 py-2.5 dark:border-gray-700 dark:bg-gray-800/80'
|
||||||
const selectedTenantId = tenantIdValue(selected)
|
const selectedTenantId = tenantIdValue(selected)
|
||||||
const selectedTenantName = selectedTenantId
|
const selectedTenantName = selectedTenantId
|
||||||
? tenantNames.get(String(selectedTenantId).toLowerCase())
|
? tenantNames.get(String(selectedTenantId).toLowerCase())
|
||||||
: undefined
|
: undefined
|
||||||
|
const detailTabs = [
|
||||||
|
options.descriptionExpr
|
||||||
|
? {
|
||||||
|
key: 'description' as DetailTab,
|
||||||
|
label: translate('::App.Listform.ListformField.Description'),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
options.timesheetExpr
|
||||||
|
? {
|
||||||
|
key: 'timesheet' as DetailTab,
|
||||||
|
label: translate('::App.ListFormTodoBoard.Timesheets'),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
options.subTaskExpr
|
||||||
|
? { key: 'subtask' as DetailTab, label: translate('::App.ListFormTodoBoard.SubTasks') }
|
||||||
|
: undefined,
|
||||||
|
].filter(Boolean) as Array<{ key: DetailTab; label: string }>
|
||||||
|
const activeTab = detailTabs.some((tab) => tab.key === detailTab) ? detailTab : detailTabs[0]?.key
|
||||||
|
const timesheetRows = parseJsonRows(fieldValue(draft, options.timesheetExpr))
|
||||||
|
const subTaskRows = parseJsonRows(fieldValue(draft, options.subTaskExpr))
|
||||||
|
const timesheetTotal = timesheetRows.reduce(
|
||||||
|
(total, row) => total + (Number(row.duration) || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="min-h-[420px] rounded-xl shadow-sm">
|
<section className="min-h-[420px] rounded-xl">
|
||||||
{renderToolbar(true)}
|
{renderToolbar(true)}
|
||||||
<div className="mb-6 flex justify-end border-b border-gray-200 dark:border-gray-700">
|
<div className="mb-5 flex flex-wrap items-center justify-end gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
|
||||||
{editingOptions?.allowUpdating === true && !gridDto.gridOptions.updateServiceAddress && (
|
{editingOptions?.allowUpdating === true && !gridDto.gridOptions.updateServiceAddress && (
|
||||||
<span className="text-xs text-amber-600">
|
<span className="mr-auto text-xs text-amber-600">
|
||||||
{translate('::App.ListFormTodoBoard.EditRequiresUpdateService')}
|
{translate('::App.ListFormTodoBoard.EditRequiresUpdateService')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{options.statusExpr && (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
|
{statuses.map((status) => {
|
||||||
|
const isActiveStatus = String(draft[options.statusExpr!] ?? '') === status
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
disabled={!canUpdate}
|
||||||
|
title={statusText(status)}
|
||||||
|
className={`rounded-full border px-4 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-60 ${isActiveStatus ? 'border-emerald-500 bg-emerald-500 text-white shadow-sm' : 'border-gray-300 bg-white text-gray-600 hover:border-emerald-400 hover:text-emerald-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300'}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({ ...current, [options.statusExpr!]: status }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{statusText(status)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-5 lg:grid-cols-3">
|
<div className="grid items-stretch gap-5 lg:grid-cols-3">
|
||||||
<div className="space-y-5 lg:col-span-2">
|
<div className="flex flex-col gap-5 lg:col-span-2">
|
||||||
{options.titleExpr && (
|
{options.titleExpr && (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>{translate('::App.Listform.ListformField.Title')}</label>
|
<label className={labelClass}>{translate('::App.Listform.ListformField.Title')}</label>
|
||||||
|
|
@ -917,91 +1043,340 @@ const TodoBoardContent = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{options.descriptionExpr && (
|
{detailTabs.length > 0 && (
|
||||||
<div>
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<label className={labelClass}>
|
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||||
{translate('::App.Listform.ListformField.Description')}
|
{detailTabs.map((tab) => (
|
||||||
</label>
|
<button
|
||||||
<HtmlEditor
|
key={tab.key}
|
||||||
height={220}
|
type="button"
|
||||||
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
|
className={`-mb-px border-b-2 px-4 py-2 text-sm font-medium transition ${activeTab === tab.key ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-200'}`}
|
||||||
readOnly={!canUpdate}
|
onClick={() => setDetailTab(tab.key)}
|
||||||
value={String(draft[options.descriptionExpr] ?? '')}
|
>
|
||||||
onValueChanged={(event) =>
|
{tab.label}
|
||||||
setDraft((current) => ({
|
</button>
|
||||||
...current,
|
))}
|
||||||
[options.descriptionExpr!]: event.value,
|
</div>
|
||||||
}))
|
<div className="flex min-h-[260px] flex-1 flex-col pt-4">
|
||||||
}
|
{activeTab === 'description' && options.descriptionExpr && (
|
||||||
>
|
<HtmlEditor
|
||||||
<MediaResizing enabled={true} />
|
height="100%"
|
||||||
<ImageUpload fileUploadMode="base64" />
|
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
|
||||||
<HtmlEditorToolbar />
|
readOnly={!canUpdate}
|
||||||
</HtmlEditor>
|
value={String(draft[options.descriptionExpr] ?? '')}
|
||||||
|
onValueChanged={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
[options.descriptionExpr!]: event.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MediaResizing enabled={true} />
|
||||||
|
<ImageUpload fileUploadMode="base64" />
|
||||||
|
<HtmlEditorToolbar />
|
||||||
|
</HtmlEditor>
|
||||||
|
)}
|
||||||
|
{activeTab === 'timesheet' && options.timesheetExpr && (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
{hasInvalidJson(fieldValue(draft, options.timesheetExpr)) && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
{translate('::App.ListFormTodoBoard.InvalidJson')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 max-h-[34vh]">
|
||||||
|
<table className="w-full min-w-[640px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className={`${stickyHeadCellClass} w-36`}>
|
||||||
|
{translate('::App.ListFormTodoBoard.TimesheetDate')}
|
||||||
|
</th>
|
||||||
|
<th className={`${stickyHeadCellClass} w-56`}>
|
||||||
|
{translate('::App.ListFormTodoBoard.TimesheetEmployee')}
|
||||||
|
</th>
|
||||||
|
<th className={stickyHeadCellClass}>
|
||||||
|
{translate('::App.Listform.ListformField.Description')}
|
||||||
|
</th>
|
||||||
|
<th className={`${stickyHeadCellClass} w-32 text-right`}>
|
||||||
|
{translate('::App.ListFormTodoBoard.TimesheetDuration')}
|
||||||
|
</th>
|
||||||
|
<th className={`${stickyHeadCellClass} w-10`} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{timesheetRows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={String(row.id ?? index)}
|
||||||
|
className="border-b border-gray-100 last:border-b-0 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Input
|
||||||
|
unstyle
|
||||||
|
type="date"
|
||||||
|
className={cellInputClass}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
value={row.date ? dayjs(row.date).format('YYYY-MM-DD') : ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
|
||||||
|
date: event.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Select
|
||||||
|
isClearable
|
||||||
|
size="sm"
|
||||||
|
isDisabled={!canUpdate}
|
||||||
|
menuPortalTarget={document.body}
|
||||||
|
options={assigneeOptions}
|
||||||
|
value={assigneeOptions.filter(
|
||||||
|
(option) => option.value === String(row.userName ?? ''),
|
||||||
|
)}
|
||||||
|
onChange={(option) =>
|
||||||
|
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
|
||||||
|
userName: option?.value ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Input
|
||||||
|
ref={focusNewRow(options.timesheetExpr, index)}
|
||||||
|
unstyle
|
||||||
|
className={cellInputClass}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
value={String(row.description ?? '')}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
|
||||||
|
description: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Input
|
||||||
|
unstyle
|
||||||
|
type="time"
|
||||||
|
className={`${cellInputClass} text-right`}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
value={Number(row.duration) ? formatDuration(row.duration) : ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateJsonRow(options.timesheetExpr, timesheetRows, index, {
|
||||||
|
duration: timeToDuration(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1 text-center">
|
||||||
|
{canUpdate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950"
|
||||||
|
title={translate('::App.ListFormTodoBoard.DeleteRow')}
|
||||||
|
onClick={() =>
|
||||||
|
setJsonRows(
|
||||||
|
options.timesheetExpr,
|
||||||
|
timesheetRows.filter((_, item) => item !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaTrash className="text-xs" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!timesheetRows.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-xs text-gray-400">
|
||||||
|
{translate('::App.ListFormTodoBoard.NoRows')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
{timesheetRows.length > 0 && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<td
|
||||||
|
className={`${stickyFootCellClass} text-right text-[11px] font-semibold uppercase tracking-wide text-gray-500`}
|
||||||
|
colSpan={3}
|
||||||
|
>
|
||||||
|
{translate('::App.ListFormTodoBoard.TimesheetTotal')}
|
||||||
|
</td>
|
||||||
|
<td className={`${stickyFootCellClass} text-right`}>
|
||||||
|
<span className="inline-block rounded-md bg-gray-200 px-2 py-1 text-sm font-semibold tabular-nums text-gray-800 dark:bg-gray-700 dark:text-gray-100">
|
||||||
|
{formatDuration(timesheetTotal)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={stickyFootCellClass} />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{canUpdate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex w-fit items-center gap-2 self-start rounded-lg px-3 py-1.5 text-xs font-semibold text-blue-600 transition hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/40"
|
||||||
|
onClick={() =>
|
||||||
|
appendJsonRow(options.timesheetExpr, timesheetRows, {
|
||||||
|
id: createRowId(),
|
||||||
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
userName: currentUserName ?? '',
|
||||||
|
description: '',
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
{translate('::App.ListFormTodoBoard.AddRow')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'subtask' && options.subTaskExpr && (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
{hasInvalidJson(fieldValue(draft, options.subTaskExpr)) && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
{translate('::App.ListFormTodoBoard.InvalidJson')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 max-h-[34vh]">
|
||||||
|
<table className="w-full min-w-[640px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className={`${stickyHeadCellClass} w-12 text-center`}>
|
||||||
|
<FaCheck className="mx-auto text-xs" />
|
||||||
|
</th>
|
||||||
|
<th className={stickyHeadCellClass}>
|
||||||
|
{translate('::App.ListFormTodoBoard.SubTaskTitle')}
|
||||||
|
</th>
|
||||||
|
<th className={`${stickyHeadCellClass} w-56`}>
|
||||||
|
{translate('::App.ListFormTodoBoard.SubTaskAssignee')}
|
||||||
|
</th>
|
||||||
|
<th className={`${stickyHeadCellClass} w-36`}>
|
||||||
|
{translate('::App.ListFormTodoBoard.SubTaskDueDate')}
|
||||||
|
</th>
|
||||||
|
<th className={`${stickyHeadCellClass} w-10`} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{subTaskRows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={String(row.id ?? index)}
|
||||||
|
className="border-b border-gray-100 last:border-b-0 dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1 text-center">
|
||||||
|
<Input
|
||||||
|
unstyle
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-emerald-500"
|
||||||
|
disabled={!canUpdate}
|
||||||
|
checked={isCompleted(row.completed)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
|
||||||
|
completed: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Input
|
||||||
|
ref={focusNewRow(options.subTaskExpr, index)}
|
||||||
|
unstyle
|
||||||
|
className={`${cellInputClass} ${isCompleted(row.completed) ? 'text-gray-400 line-through' : ''}`}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
value={String(row.title ?? '')}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
|
||||||
|
title: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Select
|
||||||
|
isClearable
|
||||||
|
size="sm"
|
||||||
|
isDisabled={!canUpdate}
|
||||||
|
menuPortalTarget={document.body}
|
||||||
|
options={assigneeOptions}
|
||||||
|
value={assigneeOptions.filter(
|
||||||
|
(option) => option.value === String(row.assignee ?? ''),
|
||||||
|
)}
|
||||||
|
onChange={(option) =>
|
||||||
|
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
|
||||||
|
assignee: option?.value ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Input
|
||||||
|
unstyle
|
||||||
|
type="date"
|
||||||
|
className={cellInputClass}
|
||||||
|
disabled={!canUpdate}
|
||||||
|
value={row.dueDate ? dayjs(row.dueDate).format('YYYY-MM-DD') : ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateJsonRow(options.subTaskExpr, subTaskRows, index, {
|
||||||
|
dueDate: event.target.value || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1 text-center">
|
||||||
|
{canUpdate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950"
|
||||||
|
title={translate('::App.ListFormTodoBoard.DeleteRow')}
|
||||||
|
onClick={() =>
|
||||||
|
setJsonRows(
|
||||||
|
options.subTaskExpr,
|
||||||
|
subTaskRows.filter((_, item) => item !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaTrash className="text-xs" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!subTaskRows.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-xs text-gray-400">
|
||||||
|
{translate('::App.ListFormTodoBoard.NoRows')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{canUpdate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex w-fit items-center gap-2 self-start rounded-lg px-3 py-1.5 text-xs font-semibold text-blue-600 transition hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-950/40"
|
||||||
|
onClick={() =>
|
||||||
|
appendJsonRow(options.subTaskExpr, subTaskRows, {
|
||||||
|
id: createRowId(),
|
||||||
|
title: '',
|
||||||
|
assignee: '',
|
||||||
|
dueDate: null,
|
||||||
|
completed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaPlus />
|
||||||
|
{translate('::App.ListFormTodoBoard.AddRow')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{options.assigneeExpr && (
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>
|
|
||||||
{translate('::App.ListFormTodoBoard.Assignees')}
|
|
||||||
</label>
|
|
||||||
<TagBox
|
|
||||||
searchEnabled
|
|
||||||
showClearButton
|
|
||||||
showSelectionControls
|
|
||||||
disabled={!canUpdate || usersLoading}
|
|
||||||
displayExpr="label"
|
|
||||||
items={assigneeOptions}
|
|
||||||
valueExpr="value"
|
|
||||||
value={draftAssignees}
|
|
||||||
applyValueMode="instantly"
|
|
||||||
placeholder={
|
|
||||||
usersLoading
|
|
||||||
? translate('::App.ListFormTodoBoard.UsersLoading')
|
|
||||||
: translate('::App.ListFormTodoBoard.SelectUser')
|
|
||||||
}
|
|
||||||
onValueChanged={(event) => {
|
|
||||||
if (!event.event) return
|
|
||||||
const nextValue = (event.value ?? []).join(', ')
|
|
||||||
setDraft((current) =>
|
|
||||||
String(current[options.assigneeExpr!] ?? '') === nextValue
|
|
||||||
? current
|
|
||||||
: { ...current, [options.assigneeExpr!]: nextValue },
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{options.tagExpr && (
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>{translate('::App.Listform.ListformField.Tags')}</label>
|
|
||||||
<TagBox
|
|
||||||
acceptCustomValue
|
|
||||||
searchEnabled
|
|
||||||
showClearButton
|
|
||||||
showSelectionControls
|
|
||||||
disabled={!canUpdate}
|
|
||||||
items={tagSuggestions}
|
|
||||||
value={draftTags}
|
|
||||||
applyValueMode="instantly"
|
|
||||||
placeholder={translate('::App.ListFormTodoBoard.TagsPlaceholder')}
|
|
||||||
onCustomItemCreating={(event) => {
|
|
||||||
event.customItem = event.text?.trim()
|
|
||||||
}}
|
|
||||||
onValueChanged={(event) => {
|
|
||||||
if (!event.event) return
|
|
||||||
const nextValue = (event.value ?? []).join(', ')
|
|
||||||
setDraft((current) =>
|
|
||||||
String(current[options.tagExpr!] ?? '') === nextValue
|
|
||||||
? current
|
|
||||||
: { ...current, [options.tagExpr!]: nextValue },
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
|
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
|
||||||
{selectedTenantId && (
|
{selectedTenantId && (
|
||||||
|
|
@ -1026,23 +1401,63 @@ const TodoBoardContent = ({
|
||||||
value={String(draft[userNameField] ?? '')}
|
value={String(draft[userNameField] ?? '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{options.statusExpr && (
|
{options.assigneeExpr && (
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>{translate('::App.Listform.ListformField.Status')}</label>
|
<label className={labelClass}>
|
||||||
<Select
|
{translate('::App.ListFormTodoBoard.Assignees')}
|
||||||
size="sm"
|
</label>
|
||||||
isDisabled={!canUpdate}
|
<TagBox
|
||||||
menuPortalTarget={document.body}
|
searchEnabled
|
||||||
options={statusSelectOptions}
|
showClearButton
|
||||||
value={statusSelectOptions.filter(
|
showSelectionControls
|
||||||
(option) => option.value === String(draft[options.statusExpr!] ?? ''),
|
disabled={!canUpdate || usersLoading}
|
||||||
)}
|
displayExpr="label"
|
||||||
onChange={(option) =>
|
items={assigneeOptions}
|
||||||
setDraft((current) => ({
|
valueExpr="value"
|
||||||
...current,
|
value={draftAssignees}
|
||||||
[options.statusExpr!]: option?.value ?? '',
|
applyValueMode="instantly"
|
||||||
}))
|
placeholder={
|
||||||
|
usersLoading
|
||||||
|
? translate('::App.ListFormTodoBoard.UsersLoading')
|
||||||
|
: translate('::App.ListFormTodoBoard.SelectUser')
|
||||||
}
|
}
|
||||||
|
onValueChanged={(event) => {
|
||||||
|
if (!event.event) return
|
||||||
|
const nextValue = (event.value ?? []).join(', ')
|
||||||
|
setDraft((current) =>
|
||||||
|
String(current[options.assigneeExpr!] ?? '') === nextValue
|
||||||
|
? current
|
||||||
|
: { ...current, [options.assigneeExpr!]: nextValue },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{options.tagExpr && (
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{translate('::App.Listform.ListformField.Tags')}</label>
|
||||||
|
<TagBox
|
||||||
|
acceptCustomValue
|
||||||
|
searchEnabled
|
||||||
|
showClearButton
|
||||||
|
showSelectionControls
|
||||||
|
disabled={!canUpdate}
|
||||||
|
items={tagSuggestions}
|
||||||
|
value={draftTags}
|
||||||
|
applyValueMode="instantly"
|
||||||
|
placeholder={translate('::App.ListFormTodoBoard.TagsPlaceholder')}
|
||||||
|
onCustomItemCreating={(event) => {
|
||||||
|
event.customItem = event.text?.trim()
|
||||||
|
}}
|
||||||
|
onValueChanged={(event) => {
|
||||||
|
if (!event.event) return
|
||||||
|
const nextValue = (event.value ?? []).join(', ')
|
||||||
|
setDraft((current) =>
|
||||||
|
String(current[options.tagExpr!] ?? '') === nextValue
|
||||||
|
? current
|
||||||
|
: { ...current, [options.tagExpr!]: nextValue },
|
||||||
|
)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1356,9 +1771,10 @@ const TodoBoardContent = ({
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded bg-blue-600 px-2 text-white disabled:opacity-40"
|
className="rounded bg-blue-600 px-2 text-white disabled:opacity-40"
|
||||||
disabled={!newTodoTitle.trim()}
|
disabled={!newTodoTitle.trim()}
|
||||||
|
title={translate('::App.Platform.Save')}
|
||||||
onClick={() => addTodo(status)}
|
onClick={() => addTodo(status)}
|
||||||
>
|
>
|
||||||
<FaPlus />
|
<FaSave />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1383,6 +1799,13 @@ const TodoBoardContent = ({
|
||||||
? tenantNames.get(String(tenantId).toLowerCase())
|
? tenantNames.get(String(tenantId).toLowerCase())
|
||||||
: undefined
|
: undefined
|
||||||
const isDragged = draggedKey === key
|
const isDragged = draggedKey === key
|
||||||
|
const cardSubTasks = parseJsonRows(fieldValue(row, options.subTaskExpr))
|
||||||
|
const cardSubTasksDone = cardSubTasks.filter((item) =>
|
||||||
|
isCompleted(item.completed),
|
||||||
|
).length
|
||||||
|
const cardTimesheetTotal = parseJsonRows(
|
||||||
|
fieldValue(row, options.timesheetExpr),
|
||||||
|
).reduce((total, item) => total + (Number(item.duration) || 0), 0)
|
||||||
const hiddenAssigneeLabels = assignees
|
const hiddenAssigneeLabels = assignees
|
||||||
.slice(2)
|
.slice(2)
|
||||||
.map((name) => assigneeOptionMap.get(name)?.label ?? name)
|
.map((name) => assigneeOptionMap.get(name)?.label ?? name)
|
||||||
|
|
@ -1468,6 +1891,28 @@ const TodoBoardContent = ({
|
||||||
{dayjs(due).fromNow()}
|
{dayjs(due).fromNow()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{(cardSubTasks.length > 0 || cardTimesheetTotal > 0) && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{cardSubTasks.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
title={translate('::App.ListFormTodoBoard.SubTasks')}
|
||||||
|
>
|
||||||
|
<FaCheck className="text-[9px]" />
|
||||||
|
{cardSubTasksDone}/{cardSubTasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cardTimesheetTotal > 0 && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
title={translate('::App.ListFormTodoBoard.Timesheets')}
|
||||||
|
>
|
||||||
|
<FaClock className="text-[9px]" />
|
||||||
|
{formatDuration(cardTimesheetTotal)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{options.priorityExpr && (
|
{options.priorityExpr && (
|
||||||
<div
|
<div
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue