From b1dac82d562f28f36c8db8797c3bc757e412a8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:33:03 +0300 Subject: [PATCH] =?UTF-8?q?DevRequest=20Timesheet=20ve=20Subtask=20s=C3=BC?= =?UTF-8?q?tunlar=C4=B1=20a=C3=A7=C4=B1ld=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ListForms/GridOptionsDto/TodoOptionDto.cs | 2 + .../Migrations/LanguagesData.json | 102 +++ .../Migrations/ListFormSeeder_DefaultJsons.cs | 4 + .../Migrations/ListFormSeeder_Saas.cs | 42 +- .../Entities/Tenant/Saas/DevRequest.cs | 2 + .../EntityFrameworkCore/PlatformDbContext.cs | 2 + ....cs => 20260824093234_Initial.Designer.cs} | 8 +- ...3_Initial.cs => 20260824093234_Initial.cs} | 2 + .../PlatformDbContextModelSnapshot.cs | 6 + ui/src/assets/styles/components/_input.css | 10 + ui/src/proxy/form/models.ts | 2 + .../views/admin/listForm/edit/FormTabTodo.tsx | 2 + .../listForm/wizard/WizardStepTodoLayout.tsx | 5 + .../developerKit/SqlTableDesignerDialog.tsx | 18 + ui/src/views/list/TodoBoard.tsx | 663 +++++++++++++++--- 15 files changed, 753 insertions(+), 117 deletions(-) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260821153543_Initial.Designer.cs => 20260824093234_Initial.Designer.cs} (99%) rename api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/{20260821153543_Initial.cs => 20260824093234_Initial.cs} (99%) diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/TodoOptionDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/TodoOptionDto.cs index 7d783291..97e19419 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/TodoOptionDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/TodoOptionDto.cs @@ -15,6 +15,8 @@ public class TodoOptionDto public string PriorityExpr { get; set; } public string CompletedExpr { get; set; } public string OrderExpr { get; set; } + public string TimesheetExpr { get; set; } + public string SubTaskExpr { get; set; } public string StatusOrder { get; set; } public bool AllowDragging { get; set; } = true; } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index b32e933c..ae68e954 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -26885,6 +26885,108 @@ "key": "App.CatalogForm.FormPermissionDescription", "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." + }, + { + "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." } ] } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs index f3fad547..e75caf42 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_DefaultJsons.cs @@ -113,6 +113,8 @@ public static class ListFormSeeder_DefaultJsons string PriorityExpr = "Priority", string CompletedExpr = "Completed", string OrderExpr = "SortOrder", + string TimesheetExpr = "Timesheets", + string SubTaskExpr = "SubTasks", string StatusOrder = "Backlog, Ready to Start, In Progress, Code Review, Testing, Staging", bool AllowDragging = true ) => JsonSerializer.Serialize(new TodoOptionDto @@ -126,6 +128,8 @@ public static class ListFormSeeder_DefaultJsons PriorityExpr = PriorityExpr, CompletedExpr = CompletedExpr, OrderExpr = OrderExpr, + TimesheetExpr = TimesheetExpr, + SubTaskExpr = SubTaskExpr, StatusOrder = StatusOrder, AllowDragging = AllowDragging }); diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs index 938b1244..51270d52 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs @@ -9716,7 +9716,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency new() { FieldName = "RequestDate", FieldDbType = DbType.Date, Value = "@NOW", CustomValueType = FieldCustomValueTypeEnum.CustomKey }, new() { FieldName = "Importance", 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() }); @@ -9818,12 +9818,12 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency DisplayExpr = "name", ValueExpr = "key", LookupQuery = JsonSerializer.Serialize(new LookupDataDto[] { - new () { Key="backlog", Name="App.StaticLookup.Backlog" }, - new () { Key="ready_to_start", Name="App.StaticLookup.ReadyToStart" }, - new () { Key="in_progress", Name="App.StaticLookup.InProgress" }, - new () { Key="code_review", Name="App.StaticLookup.CodeReview" }, - new () { Key="testing", Name="App.StaticLookup.Testing" }, - new () { Key="staging", Name="App.StaticLookup.Staging" } + new () { Key="Backlog", Name="App.StaticLookup.Backlog" }, + new () { Key="Ready to Start", Name="App.StaticLookup.ReadyToStart" }, + new () { Key="In Progress", Name="App.StaticLookup.InProgress" }, + new () { Key="Code Review", Name="App.StaticLookup.CodeReview" }, + new () { Key="Testing", Name="App.StaticLookup.Testing" }, + new () { Key="Staging", Name="App.StaticLookup.Staging" } }), }), ColSpan = 1, @@ -9976,6 +9976,34 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency PermissionJson = DefaultFieldPermissionJson(listForm.Name), 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 } diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/DevRequest.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/DevRequest.cs index a37da81a..53f02975 100644 --- a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/DevRequest.cs +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Saas/DevRequest.cs @@ -18,6 +18,8 @@ public class DevRequest : FullAuditedEntity public string? Priority { get; set; } public bool? Completed { get; set; } public int? SortOrder { get; set; } + public string? Timesheets { get; set; } + public string? SubTasks { get; set; } public DevRequest(Guid id) diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs index b44e7f05..8a56a496 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs @@ -1616,6 +1616,8 @@ public class PlatformDbContext : b.Property(e => e.Priority).HasMaxLength(64); b.Property(e => e.Completed).HasDefaultValue(false); 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(b => diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260821153543_Initial.Designer.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260824093234_Initial.Designer.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260821153543_Initial.Designer.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260824093234_Initial.Designer.cs index ae3c5607..19e209e0 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260821153543_Initial.Designer.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260824093234_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Sozsoft.Platform.Migrations { [DbContext(typeof(PlatformDbContext))] - [Migration("20260821153543_Initial")] + [Migration("20260824093234_Initial")] partial class Initial { /// @@ -2423,6 +2423,9 @@ namespace Sozsoft.Platform.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("SubTasks") + .HasColumnType("nvarchar(max)"); + b.Property("Tags") .HasMaxLength(256) .HasColumnType("nvarchar(256)"); @@ -2430,6 +2433,9 @@ namespace Sozsoft.Platform.Migrations b.Property("TenantId") .HasColumnType("uniqueidentifier"); + b.Property("Timesheets") + .HasColumnType("nvarchar(max)"); + b.Property("Title") .IsRequired() .HasMaxLength(512) diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260821153543_Initial.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260824093234_Initial.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260821153543_Initial.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260824093234_Initial.cs index c0c0e1b7..0e238666 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260821153543_Initial.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260824093234_Initial.cs @@ -1318,6 +1318,8 @@ namespace Sozsoft.Platform.Migrations Priority = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), Completed = table.Column(type: "bit", nullable: true, defaultValue: false), SortOrder = table.Column(type: "int", nullable: true, defaultValue: 0), + Timesheets = table.Column(type: "nvarchar(max)", nullable: true), + SubTasks = table.Column(type: "nvarchar(max)", nullable: true), CreationTime = table.Column(type: "datetime2", nullable: false), CreatorId = table.Column(type: "uniqueidentifier", nullable: true), LastModificationTime = table.Column(type: "datetime2", nullable: true), diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs index dbc11ecf..ea97aea0 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs @@ -2420,6 +2420,9 @@ namespace Sozsoft.Platform.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("SubTasks") + .HasColumnType("nvarchar(max)"); + b.Property("Tags") .HasMaxLength(256) .HasColumnType("nvarchar(256)"); @@ -2427,6 +2430,9 @@ namespace Sozsoft.Platform.Migrations b.Property("TenantId") .HasColumnType("uniqueidentifier"); + b.Property("Timesheets") + .HasColumnType("nvarchar(max)"); + b.Property("Title") .IsRequired() .HasMaxLength(512) diff --git a/ui/src/assets/styles/components/_input.css b/ui/src/assets/styles/components/_input.css index d5227741..0fb5faae 100644 --- a/ui/src/assets/styles/components/_input.css +++ b/ui/src/assets/styles/components/_input.css @@ -55,3 +55,13 @@ .input-suffix-end { @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; +} diff --git a/ui/src/proxy/form/models.ts b/ui/src/proxy/form/models.ts index 7713d6c7..c2984616 100644 --- a/ui/src/proxy/form/models.ts +++ b/ui/src/proxy/form/models.ts @@ -463,6 +463,8 @@ export interface TodoOptionDto { priorityExpr?: string completedExpr?: string orderExpr?: string + timesheetExpr?: string + subTaskExpr?: string statusOrder?: string allowDragging?: boolean } diff --git a/ui/src/views/admin/listForm/edit/FormTabTodo.tsx b/ui/src/views/admin/listForm/edit/FormTabTodo.tsx index a6c45fb7..49f020bb 100644 --- a/ui/src/views/admin/listForm/edit/FormTabTodo.tsx +++ b/ui/src/views/admin/listForm/edit/FormTabTodo.tsx @@ -34,6 +34,8 @@ const mappings: Array<{ { name: 'priorityExpr', labelKey: 'TodoPriorityExpr' }, { name: 'completedExpr', labelKey: 'TodoCompletedExpr' }, { name: 'orderExpr', labelKey: 'TodoOrderExpr' }, + { name: 'timesheetExpr', labelKey: 'TodoTimesheetExpr' }, + { name: 'subTaskExpr', labelKey: 'TodoSubTaskExpr' }, ] const FormTabTodo = ({ onSubmit }: FormEditProps) => { diff --git a/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx b/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx index 0df2847d..f9b9d503 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepTodoLayout.tsx @@ -52,6 +52,11 @@ const mappings: Array<{ translationKey: '::App.ListFormFieldEdit.TodoCompletedExpr', }, { name: 'orderExpr', translationKey: '::App.ListFormFieldEdit.TodoOrderExpr' }, + { + name: 'timesheetExpr', + translationKey: '::App.ListFormFieldEdit.TodoTimesheetExpr', + }, + { name: 'subTaskExpr', translationKey: '::App.ListFormFieldEdit.TodoSubTaskExpr' }, ] /** diff --git a/ui/src/views/developerKit/SqlTableDesignerDialog.tsx b/ui/src/views/developerKit/SqlTableDesignerDialog.tsx index 024a7849..4b65ba97 100644 --- a/ui/src/views/developerKit/SqlTableDesignerDialog.tsx +++ b/ui/src/views/developerKit/SqlTableDesignerDialog.tsx @@ -389,6 +389,24 @@ const TODO_COLUMNS: ColumnDefinition[] = [ defaultValue: '0', 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[] = [ diff --git a/ui/src/views/list/TodoBoard.tsx b/ui/src/views/list/TodoBoard.tsx index aa5071ff..641bde2a 100644 --- a/ui/src/views/list/TodoBoard.tsx +++ b/ui/src/views/list/TodoBoard.tsx @@ -20,6 +20,7 @@ import { FaPencilAlt, FaPlus, FaRegCircle, + FaSave, FaTag, FaTimes, FaTrash, @@ -76,6 +77,10 @@ interface UserOption { type LookupDisplayValues = Record> +type DetailTab = 'description' | 'timesheet' | 'subtask' + +type JsonRow = Record + const TODO_STATUS_LANGUAGE_KEYS: Record = { backlog: 'App.StaticLookup.Backlog', 'ready to start': 'App.StaticLookup.ReadyToStart', @@ -107,6 +112,42 @@ const textList = (value: unknown) => .map((item) => item.trim()) .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) => value === true || value === 1 || @@ -248,6 +289,8 @@ const TodoBoardContent = ({ const [tenantNames, setTenantNames] = useState>(new Map()) const [usersLoading, setUsersLoading] = useState(false) const [lookupDisplayValues, setLookupDisplayValues] = useState({}) + const [detailTab, setDetailTab] = useState('description') + const focusRowRef = useRef<{ field?: string; index: number } | undefined>(undefined) const columnConfigRef = useRef({ columns: [], retired: [] }) const dragActiveRef = useRef(false) const { translate } = useLocalization() @@ -280,11 +323,6 @@ const TodoBoardContent = ({ [lookupText, options.statusExpr, translate], ) - const statusSelectOptions = statuses.map((status) => ({ - value: status, - label: statusText(status), - })) - const clearDragState = useCallback(() => { dragActiveRef.current = false setDraggedKey(undefined) @@ -535,6 +573,39 @@ const TodoBoardContent = ({ [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( (columns: string[], retired = columnConfigRef.current.retired) => { const next = { columns, retired } @@ -829,6 +900,7 @@ const TodoBoardContent = ({ const openTodo = (row: TodoRow) => { setSelected(row) setDraft({ ...row }) + setDetailTab('description') } const saveTodo = async (exitAfterSave = false) => { @@ -843,6 +915,8 @@ const TodoBoardContent = ({ options.priorityExpr, options.completedExpr, options.orderExpr, + options.timesheetExpr, + options.subTaskExpr, ].filter(Boolean) as string[] const values = Object.fromEntries(mappedFields.map((field) => [field, draft[field]])) @@ -884,22 +958,74 @@ const TodoBoardContent = ({ 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' 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 selectedTenantName = selectedTenantId ? tenantNames.get(String(selectedTenantId).toLowerCase()) : 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 ( -
+
{renderToolbar(true)} -
+
{editingOptions?.allowUpdating === true && !gridDto.gridOptions.updateServiceAddress && ( - + {translate('::App.ListFormTodoBoard.EditRequiresUpdateService')} )} + {options.statusExpr && ( +
+ {statuses.map((status) => { + const isActiveStatus = String(draft[options.statusExpr!] ?? '') === status + return ( + + ) + })} +
+ )}
-
-
+
+
{options.titleExpr && (
@@ -917,91 +1043,340 @@ const TodoBoardContent = ({ />
)} - {options.descriptionExpr && ( -
- - - setDraft((current) => ({ - ...current, - [options.descriptionExpr!]: event.value, - })) - } - > - - - - + {detailTabs.length > 0 && ( +
+
+ {detailTabs.map((tab) => ( + + ))} +
+
+ {activeTab === 'description' && options.descriptionExpr && ( + + setDraft((current) => ({ + ...current, + [options.descriptionExpr!]: event.value, + })) + } + > + + + + + )} + {activeTab === 'timesheet' && options.timesheetExpr && ( +
+ {hasInvalidJson(fieldValue(draft, options.timesheetExpr)) && ( +

+ {translate('::App.ListFormTodoBoard.InvalidJson')} +

+ )} +
+ + + + + + + + + + + {timesheetRows.map((row, index) => ( + + + + + + + + ))} + {!timesheetRows.length && ( + + + + )} + + {timesheetRows.length > 0 && ( + + + + + + + )} +
+ {translate('::App.ListFormTodoBoard.TimesheetDate')} + + {translate('::App.ListFormTodoBoard.TimesheetEmployee')} + + {translate('::App.Listform.ListformField.Description')} + + {translate('::App.ListFormTodoBoard.TimesheetDuration')} + +
+ + updateJsonRow(options.timesheetExpr, timesheetRows, index, { + date: event.target.value || null, + }) + } + /> + + + + updateJsonRow(options.timesheetExpr, timesheetRows, index, { + description: event.target.value, + }) + } + /> + + + updateJsonRow(options.timesheetExpr, timesheetRows, index, { + duration: timeToDuration(event.target.value), + }) + } + /> + + {canUpdate && ( + + )} +
+ {translate('::App.ListFormTodoBoard.NoRows')} +
+ {translate('::App.ListFormTodoBoard.TimesheetTotal')} + + + {formatDuration(timesheetTotal)} + + +
+
+ {canUpdate && ( + + )} +
+ )} + {activeTab === 'subtask' && options.subTaskExpr && ( +
+ {hasInvalidJson(fieldValue(draft, options.subTaskExpr)) && ( +

+ {translate('::App.ListFormTodoBoard.InvalidJson')} +

+ )} +
+ + + + + + + + + + + {subTaskRows.map((row, index) => ( + + + + + + + + ))} + {!subTaskRows.length && ( + + + + )} + +
+ + + {translate('::App.ListFormTodoBoard.SubTaskTitle')} + + {translate('::App.ListFormTodoBoard.SubTaskAssignee')} + + {translate('::App.ListFormTodoBoard.SubTaskDueDate')} + +
+ + updateJsonRow(options.subTaskExpr, subTaskRows, index, { + completed: event.target.checked, + }) + } + /> + + + updateJsonRow(options.subTaskExpr, subTaskRows, index, { + title: event.target.value, + }) + } + /> + + + + updateJsonRow(options.subTaskExpr, subTaskRows, index, { + dueDate: event.target.value || null, + }) + } + /> + + {canUpdate && ( + + )} +
+ {translate('::App.ListFormTodoBoard.NoRows')} +
+
+ {canUpdate && ( + + )} +
+ )} +
)} -
- {options.assigneeExpr && ( -
- - { - if (!event.event) return - const nextValue = (event.value ?? []).join(', ') - setDraft((current) => - String(current[options.assigneeExpr!] ?? '') === nextValue - ? current - : { ...current, [options.assigneeExpr!]: nextValue }, - ) - }} - /> -
- )} - {options.tagExpr && ( -
- - { - 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 }, - ) - }} - /> -
- )} -
- {options.statusExpr && ( + {options.assigneeExpr && (
- -