DevRequest Timesheet ve Subtask sütunları açıldı

This commit is contained in:
Sedat ÖZTÜRK 2026-08-24 13:33:03 +03:00
parent 0d0834efaf
commit b1dac82d56
15 changed files with 753 additions and 117 deletions

View file

@ -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;
} }

View file

@ -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."
} }
] ]
} }

View file

@ -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
}); });

View file

@ -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
} }

View file

@ -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)

View file

@ -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 =>

View file

@ -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)

View file

@ -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),

View file

@ -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)

View file

@ -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;
}

View file

@ -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
} }

View file

@ -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) => {

View file

@ -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' },
] ]
/** /**

View file

@ -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[] = [

View file

@ -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 className="grid gap-5 lg:grid-cols-3"> )}
<div className="space-y-5 lg:col-span-2"> </div>
<div className="grid items-stretch gap-5 lg:grid-cols-3">
<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,13 +1043,24 @@ 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
key={tab.key}
type="button"
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'}`}
onClick={() => setDetailTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="flex min-h-[260px] flex-1 flex-col pt-4">
{activeTab === 'description' && options.descriptionExpr && (
<HtmlEditor <HtmlEditor
height={220} height="100%"
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')} placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
readOnly={!canUpdate} readOnly={!canUpdate}
value={String(draft[options.descriptionExpr] ?? '')} value={String(draft[options.descriptionExpr] ?? '')}
@ -938,9 +1075,332 @@ const TodoBoardContent = ({
<ImageUpload fileUploadMode="base64" /> <ImageUpload fileUploadMode="base64" />
<HtmlEditorToolbar /> <HtmlEditorToolbar />
</HtmlEditor> </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> </div>
)} )}
<div className="grid gap-4 sm:grid-cols-2"> {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>
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
{selectedTenantId && (
<div>
<label className={labelClass}>Tenant</label>
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={selectedTenantName ?? ''}
/>
</div>
)}
<div>
<label className={labelClass}>
{translate('::Abp.Mailing.Smtp.UserName')}
</label>
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={String(draft[userNameField] ?? '')}
/>
</div>
{options.assigneeExpr && ( {options.assigneeExpr && (
<div> <div>
<label className={labelClass}> <label className={labelClass}>
@ -1001,51 +1461,6 @@ const TodoBoardContent = ({
/> />
</div> </div>
)} )}
</div>
</div>
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
{selectedTenantId && (
<div>
<label className={labelClass}>Tenant</label>
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={selectedTenantName ?? ''}
/>
</div>
)}
<div>
<label className={labelClass}>
{translate('::Abp.Mailing.Smtp.UserName')}
</label>
<Input
unstyle
readOnly
className={`${inputClass} cursor-default`}
value={String(draft[userNameField] ?? '')}
/>
</div>
{options.statusExpr && (
<div>
<label className={labelClass}>{translate('::App.Listform.ListformField.Status')}</label>
<Select
size="sm"
isDisabled={!canUpdate}
menuPortalTarget={document.body}
options={statusSelectOptions}
value={statusSelectOptions.filter(
(option) => option.value === String(draft[options.statusExpr!] ?? ''),
)}
onChange={(option) =>
setDraft((current) => ({
...current,
[options.statusExpr!]: option?.value ?? '',
}))
}
/>
</div>
)}
{options.dueDateExpr && ( {options.dueDateExpr && (
<div> <div>
<label className={labelClass}>{translate('::App.ListFormTodoBoard.DueDate')}</label> <label className={labelClass}>{translate('::App.ListFormTodoBoard.DueDate')}</label>
@ -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}