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 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ public class DevRequest : FullAuditedEntity<Guid>
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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<Agenda>(b =>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
|
|||
namespace Sozsoft.Platform.Migrations
|
||||
{
|
||||
[DbContext(typeof(PlatformDbContext))]
|
||||
[Migration("20260821153543_Initial")]
|
||||
[Migration("20260824093234_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
|
@ -2423,6 +2423,9 @@ namespace Sozsoft.Platform.Migrations
|
|||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("SubTasks")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
|
@ -2430,6 +2433,9 @@ namespace Sozsoft.Platform.Migrations
|
|||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Timesheets")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
|
|
@ -1318,6 +1318,8 @@ namespace Sozsoft.Platform.Migrations
|
|||
Priority = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Completed = table.Column<bool>(type: "bit", nullable: true, defaultValue: false),
|
||||
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),
|
||||
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
|
|
@ -2420,6 +2420,9 @@ namespace Sozsoft.Platform.Migrations
|
|||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("SubTasks")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
|
@ -2427,6 +2430,9 @@ namespace Sozsoft.Platform.Migrations
|
|||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Timesheets")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,6 +463,8 @@ export interface TodoOptionDto {
|
|||
priorityExpr?: string
|
||||
completedExpr?: string
|
||||
orderExpr?: string
|
||||
timesheetExpr?: string
|
||||
subTaskExpr?: string
|
||||
statusOrder?: string
|
||||
allowDragging?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
FaPencilAlt,
|
||||
FaPlus,
|
||||
FaRegCircle,
|
||||
FaSave,
|
||||
FaTag,
|
||||
FaTimes,
|
||||
FaTrash,
|
||||
|
|
@ -76,6 +77,10 @@ interface UserOption {
|
|||
|
||||
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> = {
|
||||
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<Map<string, string>>(new Map())
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
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 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 (
|
||||
<section className="min-h-[420px] rounded-xl shadow-sm">
|
||||
<section className="min-h-[420px] rounded-xl">
|
||||
{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 && (
|
||||
<span className="text-xs text-amber-600">
|
||||
<span className="mr-auto text-xs text-amber-600">
|
||||
{translate('::App.ListFormTodoBoard.EditRequiresUpdateService')}
|
||||
</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 className="grid items-stretch gap-5 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-5 lg:col-span-2">
|
||||
{options.titleExpr && (
|
||||
<div>
|
||||
<label className={labelClass}>{translate('::App.Listform.ListformField.Title')}</label>
|
||||
|
|
@ -917,91 +1043,340 @@ const TodoBoardContent = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{options.descriptionExpr && (
|
||||
<div>
|
||||
<label className={labelClass}>
|
||||
{translate('::App.Listform.ListformField.Description')}
|
||||
</label>
|
||||
<HtmlEditor
|
||||
height={220}
|
||||
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
|
||||
readOnly={!canUpdate}
|
||||
value={String(draft[options.descriptionExpr] ?? '')}
|
||||
onValueChanged={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[options.descriptionExpr!]: event.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<MediaResizing enabled={true} />
|
||||
<ImageUpload fileUploadMode="base64" />
|
||||
<HtmlEditorToolbar />
|
||||
</HtmlEditor>
|
||||
{detailTabs.length > 0 && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||
{detailTabs.map((tab) => (
|
||||
<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
|
||||
height="100%"
|
||||
placeholder={translate('::App.ListFormTodoBoard.DescriptionPlaceholder')}
|
||||
readOnly={!canUpdate}
|
||||
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 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>
|
||||
<aside className="space-y-4 rounded-xl bg-gray-50 p-4 dark:bg-gray-800/60">
|
||||
{selectedTenantId && (
|
||||
|
|
@ -1026,23 +1401,63 @@ const TodoBoardContent = ({
|
|||
value={String(draft[userNameField] ?? '')}
|
||||
/>
|
||||
</div>
|
||||
{options.statusExpr && (
|
||||
{options.assigneeExpr && (
|
||||
<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 ?? '',
|
||||
}))
|
||||
<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>
|
||||
)}
|
||||
|
|
@ -1356,9 +1771,10 @@ const TodoBoardContent = ({
|
|||
type="button"
|
||||
className="rounded bg-blue-600 px-2 text-white disabled:opacity-40"
|
||||
disabled={!newTodoTitle.trim()}
|
||||
title={translate('::App.Platform.Save')}
|
||||
onClick={() => addTodo(status)}
|
||||
>
|
||||
<FaPlus />
|
||||
<FaSave />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1383,6 +1799,13 @@ const TodoBoardContent = ({
|
|||
? tenantNames.get(String(tenantId).toLowerCase())
|
||||
: undefined
|
||||
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
|
||||
.slice(2)
|
||||
.map((name) => assigneeOptionMap.get(name)?.label ?? name)
|
||||
|
|
@ -1468,6 +1891,28 @@ const TodoBoardContent = ({
|
|||
{dayjs(due).fromNow()}
|
||||
</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 && (
|
||||
<div
|
||||
draggable={false}
|
||||
|
|
|
|||
Loading…
Reference in a new issue