unit View.TimeEntries;

interface

uses
  System.SysUtils, System.Classes, System.DateUtils, JS, Web, WEBLib.Graphics, WEBLib.Controls,
  WEBLib.Forms, WEBLib.Dialogs, Vcl.Controls, Vcl.StdCtrls, WEBLib.StdCtrls, WEBLib.ExtCtrls, System.StrUtils,
  XData.Web.Client, ConnectionModule, XData.Web.JsonDataset, Data.DB, XData.Web.Dataset, uTaskPickerOffCanvas;

type
  TFTimeEntries = class(TWebForm)
    edtWeekOf: TWebEdit;
    edtStartDate: TWebEdit;
    edtEndDate: TWebEdit;
    xdwcTimeEntries: TXDataWebClient;
    xdwdsTimeEntries: TXDataWebDataSet;
    xdwdsTimeEntriesentryId: TIntegerField;
    xdwdsTimeEntriestaskDate: TStringField;
    xdwdsTimeEntriestaskId: TStringField;
    xdwdsTimeEntriestaskDisplay: TStringField;
    xdwdsTimeEntrieshours: TFloatField;
    xdwdsTimeEntriestaskTime: TStringField;
    xdwdsTimeEntriescategory: TStringField;
    xdwdsTimeEntriessummary: TStringField;
    xdwdsTimeEntriescategoryDesc: TStringField;
    btnAddEntry: TWebButton;
    lblValidationMessage: TWebLabel;
    xdwdsTimeEntriesplace: TStringField;
    xdwdsTimeEntriesplaceDesc: TStringField;
    btnDeleteEntry: TWebButton;
    btnSearchRange: TWebButton;
    procedure WebFormCreate(Sender: TObject);
    procedure btnAddEntryClick(Sender: TObject);
    procedure btnDeleteEntryClick(Sender: TObject);
    procedure edtWeekOfExit(Sender: TObject);
    [async] procedure btnSearchRangeClick(Sender: TObject);
  private
    FUserId: string;
    FUserName: string;
    FStartDate: string;
    FEndDate: string;
    FTaskOptions: TJSArray;
    FCategoryOptions: TJSArray;
    FPlaceOptions: TJSArray;
    FUpdatingDates: Boolean;
    FPendingScrollTop: Integer;
    FPendingScrollLeft: Integer;
    FActiveRowIndex: Integer;
    FBlockedRowIndex: Integer;
    FPendingEntryId: string;
    FLastMouseDownRowIndex: Integer;
    FLastMouseDownTime: Double;
    FTaskPickerOffCanvas: TTaskPickerOffCanvas;
    FAddingNewEntry: Boolean;
    FNewEntryRowIndex: Integer;

    [async] procedure LoadTimeEntries;
    function BeginAddEntry: Boolean;
    function IsNewEntryRow(AIndex: Integer): Boolean;
    procedure ResetNewEntryState;
    procedure BindNewEntryButtons;
    procedure NewEntrySaveClick(Event: TJSEvent);
    procedure NewEntryCancelClick(Event: TJSEvent);
    [async] procedure SaveNewEntry;
    procedure CancelNewEntry;
    procedure RenderTable;
    procedure BindTableEditors;
    procedure DropdownItemClick(Event: TJSEvent);
    procedure EnableColumnResize;
    procedure EnableAutoGrowTextAreas;
    procedure GotoRowIndex(AIndex: Integer);
    function HtmlEncode(const s: string): string;
    function IsoToDate(const AValue: string): TDateTime;
    function DateToIso(const AValue: TDateTime): string;
    procedure SetWeekRangeFromAnchor(const ADateValue: string; ALoadAfterSet: Boolean);
    procedure SetTotalRowsLabel(ARowCount: Integer);
    procedure SetTimeEntriesLabel(const AName: string);
    procedure CaptureTableScroll;
    procedure RestoreTableScroll;
    procedure EditorInput(Event: TJSEvent);
    procedure RowFocusOut(Event: TJSEvent);
    procedure RowClick(Event: TJSEvent);
    function ValidateRow(AIndex: Integer): Boolean;
    procedure ApplyPendingEntryFocus;
    procedure ApplyRowValidation(AIndex: Integer);
    procedure ClearRowValidation(AIndex: Integer);
    procedure ShowRowValidationMessage(AIndex: Integer; const AMessage: string);
    procedure HideRowValidationMessage;
    procedure SetTopControlsEnabled(AEnabled: Boolean);
    function GetTargetRowIndex(ATarget: TJSHTMLElement): Integer;
    procedure DocumentMouseDown(Event: TJSEvent);
    [async] procedure TaskPickerButtonClick(Event: TJSEvent);
    procedure TaskPickerTaskSelected(ARowIndex: Integer; const ATaskId, ATaskDisplay: string);
    procedure ApplyActiveRowState;
    procedure EditorBlur(Event: TJSEvent);
    [async] procedure SaveField(AIndex: Integer; const AFieldName: string);
    [async] procedure DeleteSelectedEntry;
  public
  end;

var
  FTimeEntries: TFTimeEntries;

implementation

uses
  Auth.Service,
  Utils;

{$R *.dfm}

procedure TFTimeEntries.WebFormCreate(Sender: TObject);
var
  payload: TJSObject;
  anchorDate: string;
begin
  console.log('TFTimeEntries.WebFormCreate fired');

  FTaskOptions := TJSArray.new;
  FCategoryOptions := TJSArray.new;
  FPlaceOptions := TJSArray.new;
  FUpdatingDates := False;
  FPendingScrollTop := 0;
  FPendingScrollLeft := 0;
  FActiveRowIndex := -1;
  FBlockedRowIndex := -1;
  FPendingEntryId := '';
  FLastMouseDownRowIndex := -1;
  FLastMouseDownTime := 0;
  FAddingNewEntry := False;
  FNewEntryRowIndex := -1;

  FTaskPickerOffCanvas := TTaskPickerOffCanvas.Create(xdwcTimeEntries, @TaskPickerTaskSelected);

  document.addEventListener('mousedown', TJSEventHandler(@DocumentMouseDown));

  BindNewEntryButtons;

  payload := AuthService.TokenPayload;
  if Assigned(payload) then
  begin
    FUserId := JS.toString(payload.Properties['user_id']);
    FUserName := JS.toString(payload.Properties['user_fullname']);

    if FUserName = '' then
      FUserName := JS.toString(payload.Properties['user_name']);
  end;

  SetTimeEntriesLabel(FUserName);
  btnDeleteEntry.Enabled := False;

  anchorDate := Application.Parameters.Values['date'];
  if anchorDate = '' then
    anchorDate := DateToIso(Date);

  SetWeekRangeFromAnchor(anchorDate, False);

  if not DMConnection.ApiConnection.Connected then
  begin
    DMConnection.ApiConnection.Open(
      procedure
      begin
        LoadTimeEntries;
      end
    );
  end
  else
    LoadTimeEntries;
end;


function TFTimeEntries.HtmlEncode(const s: string): string;
begin
  Result := s;
  Result := StringReplace(Result, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
  Result := StringReplace(Result, '"', '&quot;', [rfReplaceAll]);
  Result := StringReplace(Result, '''', '&#39;', [rfReplaceAll]);
end;


function TFTimeEntries.IsoToDate(const AValue: string): TDateTime;
var
  yearValue: Integer;
  monthValue: Integer;
  dayValue: Integer;
begin
  if Length(AValue) < 10 then
    Exit(Date);

  yearValue := StrToIntDef(Copy(AValue, 1, 4), YearOf(Date));
  monthValue := StrToIntDef(Copy(AValue, 6, 2), MonthOf(Date));
  dayValue := StrToIntDef(Copy(AValue, 9, 2), DayOf(Date));

  Result := EncodeDate(yearValue, monthValue, dayValue);
end;

function TFTimeEntries.DateToIso(const AValue: TDateTime): string;
begin
  Result := FormatDateTime('yyyy-mm-dd', AValue);
end;

procedure TFTimeEntries.SetWeekRangeFromAnchor(const ADateValue: string; ALoadAfterSet: Boolean);
var
  anchorDate: TDateTime;
  weekStart: TDateTime;
  weekEnd: TDateTime;
begin
  anchorDate := IsoToDate(ADateValue);
  weekStart := anchorDate - (DayOfTheWeek(anchorDate) - 1);
  weekEnd := weekStart + 6;

  FUpdatingDates := True;
  try
    edtWeekOf.Text := DateToIso(anchorDate);
    edtStartDate.Text := DateToIso(weekStart);
    edtEndDate.Text := DateToIso(weekEnd);

    FStartDate := edtStartDate.Text;
    FEndDate := edtEndDate.Text;
  finally
    FUpdatingDates := False;
  end;

  if ALoadAfterSet then
    LoadTimeEntries;
end;


procedure TFTimeEntries.edtWeekOfExit(Sender: TObject);
begin
  if FUpdatingDates then
    Exit;

  if edtWeekOf.Text = '' then
    Exit;

  SetWeekRangeFromAnchor(edtWeekOf.Text, True);
end;


procedure TFTimeEntries.GotoRowIndex(AIndex: Integer);
var
  i: Integer;
begin
  if (AIndex < 0) or (not xdwdsTimeEntries.Active) then
    Exit;

  xdwdsTimeEntries.First;
  i := 0;
  while (i < AIndex) and (not xdwdsTimeEntries.Eof) do
  begin
    xdwdsTimeEntries.Next;
    Inc(i);
  end;
end;

procedure TFTimeEntries.SetTotalRowsLabel(ARowCount: Integer);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(document.getElementById('lbl_time_total_rows'));
  el.innerText := 'Total Rows: ' + IntToStr(ARowCount);
end;

procedure TFTimeEntries.SetTimeEntriesLabel(const AName: string);
var
  el: TJSHTMLElement;
  displayName: string;
begin
  displayName := Trim(AName);
  if displayName = '' then
    displayName := 'User';

  el := TJSHTMLElement(document.getElementById('lbl_time_entries_title'));
  el.innerText := displayName + '''s Time Entries';
end;

[async] procedure TFTimeEntries.LoadTimeEntries;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
  itemsArray: TJSArray;
  userNameValue: string;
  rowCount: Integer;
begin
  console.log('ITimeEntryService.GetTimeEntries called with user_id=' + FUserId + ' start=' + FStartDate + ' end=' + FEndDate);

  if FUserId = '' then
  begin
    Utils.ShowErrorModal('Unable to determine logged-in user.');
    Exit;
  end;

  Utils.ShowSpinner('spinner');
  try
    try
      response := await(xdwcTimeEntries.RawInvokeAsync(
        'ITimeEntryService.GetTimeEntries', [FUserId, FStartDate, FEndDate]
      ));
    except
      on E: EXDataClientRequestException do
      begin
        Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
        Exit;
      end;
    end;

    resultObj := TJSObject(response.Result);

    userNameValue := string(resultObj['userName']);
    if userNameValue <> '' then
      SetTimeEntriesLabel(userNameValue)
    else
      SetTimeEntriesLabel(FUserName);

    rowCount := StrToIntDef(string(resultObj['count']), 0);
    SetTotalRowsLabel(rowCount);

    FTaskOptions := TJSArray(resultObj['taskOptions']);
    FCategoryOptions := TJSArray(resultObj['categoryOptions']);
    FPlaceOptions := TJSArray(resultObj['placeOptions']);
    itemsArray := TJSArray(resultObj['items']);

    xdwdsTimeEntries.Close;
    xdwdsTimeEntries.SetJsonData(itemsArray);
    xdwdsTimeEntries.Open;

    FActiveRowIndex := -1;
    HideRowValidationMessage;
    SetTopControlsEnabled(True);
    btnDeleteEntry.Enabled := False;

    RenderTable;
  finally
    Utils.HideSpinner('spinner');
  end;
end;




procedure TFTimeEntries.RenderTable;
var
  host: TJSHTMLElement;
  html: string;
  rowIdx: Integer;
  hoursText: string;
  actionsEl: TJSHTMLElement;
  rowEl: TJSHTMLElement;
  rowRect: TJSObject;
  topPos: Integer;

  function Th(const s: string): string;
  begin
    Result := '<th scope="col">' + s + '</th>';
  end;

  function TdNowrap(const s: string): string;
  begin
    Result := '<td class="align-top nowrap-cell">' + s + '</td>';
  end;

  function TdWrap(const s: string): string;
  begin
    Result := '<td class="align-top wrap-cell">' + s + '</td>';
  end;

  function TextInput(const FieldName, Value: string; const AIdx: Integer): string;
  begin
    Result :=
      '<input class="form-control form-control-sm cell-input time-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(Value) + '">';
  end;

  function DateInput(const FieldName, Value: string; const AIdx: Integer): string;
  var
    dateValue: string;
  begin
    dateValue := Utils.NormalizeDateValue(Value);

    Result :=
      '<input type="date" class="form-control form-control-sm cell-input time-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(dateValue) + '">';
  end;

  function HoursInput(const FieldName, Value: string; const AIdx: Integer): string;
  begin
    Result :=
      '<input type="number" step="0.01" min="0" class="form-control form-control-sm cell-input time-editor w-100 text-end" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(Value) + '">';
  end;

  function SelectList(const FieldName, CurrentValue, CurrentDisplay: string; const AIdx: Integer; const Items: TJSArray; const ValueProp, DisplayProp: string; AAllowBlank, AShowTaskPicker: Boolean): string;
  var
    i: Integer;
    optionObj: TJSObject;
    optionValue: string;
    optionDisplay: string;
    triggerId: string;
  begin
    triggerId := 'time_dd_' + FieldName + '_' + IntToStr(AIdx);

    Result := '';

    if AShowTaskPicker then
      Result := Result +
        '<div class="input-group input-group-sm flex-nowrap w-100">';

    Result := Result +
      '<div class="dropdown flex-grow-1" style="min-width:0;">' +
      '<button id="' + triggerId + '" class="btn btn-sm border w-100 d-flex justify-content-between align-items-center text-start time-dd-toggle btn-light' +
        IfThen(AShowTaskPicker, ' rounded-end-0', '') + '" ' +
        'type="button" data-bs-toggle="dropdown" aria-expanded="false" ' +
          'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '">' +
          '<span class="time-dd-label text-truncate">' + HtmlEncode(CurrentDisplay) + '</span>' +
          '<span class="dropdown-toggle dropdown-toggle-split border-0 ms-2"></span>' +
        '</button>' +
        '<div class="dropdown-menu w-100 p-0 pe-1 overflow-hidden">';

    if AAllowBlank then
    begin
      Result := Result +
        '<button type="button" class="dropdown-item time-dd-item" ' +
          'data-idx="' + IntToStr(AIdx) + '" ' +
          'data-field="' + FieldName + '" ' +
          'data-value="" ' +
          'data-display="" ' +
          'data-trigger-id="' + triggerId + '">&nbsp;</button>';
    end;

    for i := 0 to Items.length - 1 do
    begin
      optionObj := TJSObject(Items[i]);
      optionValue := string(optionObj[ValueProp]);
      optionDisplay := string(optionObj[DisplayProp]);

      Result := Result +
        '<button type="button" class="dropdown-item time-dd-item" ' +
          'data-idx="' + IntToStr(AIdx) + '" ' +
          'data-field="' + FieldName + '" ' +
          'data-value="' + HtmlEncode(optionValue) + '" ' +
          'data-display="' + HtmlEncode(optionDisplay) + '" ' +
          'data-trigger-id="' + triggerId + '">' + HtmlEncode(optionDisplay) + '</button>';
    end;

    Result := Result +
        '</div>' +
      '</div>';

    if AShowTaskPicker then
    begin
      Result := Result +
      '<button type="button" class="btn btn-outline-secondary time-task-picker-btn flex-shrink-0 text-nowrap" ' +
        'data-idx="' + IntToStr(AIdx) + '" title="Find task">Find</button>' +
        '</div>';
    end;
  end;

  function SummaryTextArea(const FieldName, Value: string; const AIdx: Integer): string;
  begin
    Result :=
      '<textarea rows="1" class="form-control form-control-sm cell-textarea time-textarea time-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'style="min-height:31px; height:31px; overflow:hidden; resize:none;">' + HtmlEncode(Value) + '</textarea>';
  end;

begin
  host := TJSHTMLElement(document.getElementById('time_entries_table_host'));

  html :=
    '<div class="time-vscroll">' +
      '<div class="time-hscroll">' +
        '<table class="table table-sm table-bordered align-middle mb-0 time-table" style="min-width: 1710px; table-layout: fixed;">' +
          '<colgroup>' +
            '<col style="width:140px">' +  // Date
            '<col style="width:500px">' +  // Task
            '<col style="width:90px">' +   // Hours
            '<col style="width:150px">' +  // Time
            '<col style="width:160px">' + // Place
            '<col style="width:190px">' +  // Category
            '<col style="width:480px">' +  // Summary
          '</colgroup>' +
          '<thead><tr>' +
            Th('Date') +
            Th('Task') +
            Th('Hours') +
            Th('Time') +
            Th('Place') +
            Th('Category') +
            Th('Summary') +
          '</tr></thead><tbody>';

  rowIdx := 0;
  xdwdsTimeEntries.First;
  while not xdwdsTimeEntries.Eof do
  begin
    if xdwdsTimeEntrieshours.IsNull then
      hoursText := ''
    else
      hoursText := FormatFloat('0.##', xdwdsTimeEntrieshours.AsFloat);

    html := html +
       '<tr class="time-row-selectable" data-idx="' + IntToStr(rowIdx) + '" data-entry-id="' + IntToStr(xdwdsTimeEntriesentryId.AsInteger) + '" data-task-id="' + xdwdsTimeEntriestaskId.AsString + '">' +
        TdNowrap(DateInput('taskDate', xdwdsTimeEntriestaskDate.AsString, rowIdx)) +
        TdNowrap(SelectList('taskId', xdwdsTimeEntriestaskId.AsString, xdwdsTimeEntriestaskDisplay.AsString, rowIdx, FTaskOptions, 'taskId', 'taskDisplay', False, True)) +
        TdNowrap(HoursInput('hours', hoursText, rowIdx)) +
        TdNowrap(TextInput('taskTime', xdwdsTimeEntriestaskTime.AsString, rowIdx)) +
        TdNowrap(SelectList('place', xdwdsTimeEntriesplace.AsString, xdwdsTimeEntriesplaceDesc.AsString, rowIdx, FPlaceOptions, 'code', 'codeDesc', True, False)) +
        TdNowrap(SelectList('category', xdwdsTimeEntriescategory.AsString, xdwdsTimeEntriescategoryDesc.AsString, rowIdx, FCategoryOptions, 'code', 'codeDesc', False, False)) +
        TdWrap(SummaryTextArea('summary', xdwdsTimeEntriessummary.AsString, rowIdx)) +
      '</tr>';

    xdwdsTimeEntries.Next;
    Inc(rowIdx);
  end;

  html := html + '</tbody></table></div></div>';
  SetTotalRowsLabel(rowIdx);
  host.innerHTML := html;

  actionsEl := TJSHTMLElement(document.getElementById('time_new_entry_actions'));
  if Assigned(actionsEl) then
  begin
    if FAddingNewEntry then
    begin
      rowEl := TJSHTMLElement(document.querySelector('tr[data-idx="' + IntToStr(FNewEntryRowIndex) + '"]'));

      if Assigned(rowEl) then
      begin
        actionsEl.classList.remove('d-none');

        rowRect := TJSObject(rowEl.getBoundingClientRect);
        topPos := Round(JS.toNumber(rowRect['bottom']) - 2);

        actionsEl.setAttribute(
          'style',
          'z-index: 1030; ' +
          'right: 12px; ' +
          'top: ' + IntToStr(topPos) + 'px; ' +
          'left: auto; ' +
          'bottom: auto;'
        );
      end
      else
        actionsEl.classList.add('d-none');
    end
    else
      actionsEl.classList.add('d-none');
  end;

  BindTableEditors;
  EnableAutoGrowTextAreas;
  EnableColumnResize;
  RestoreTableScroll;
  ApplyPendingEntryFocus;
  ApplyActiveRowState;
end;


procedure TFTimeEntries.BindNewEntryButtons;
var
  btnSave: TJSHTMLElement;
  btnCancel: TJSHTMLElement;
begin
  btnSave := TJSHTMLElement(document.getElementById('btn_time_new_save'));
  if Assigned(btnSave) then
    btnSave.addEventListener('click', TJSEventHandler(@NewEntrySaveClick));

  btnCancel := TJSHTMLElement(document.getElementById('btn_time_new_cancel'));
  if Assigned(btnCancel) then
    btnCancel.addEventListener('click', TJSEventHandler(@NewEntryCancelClick));
end;


procedure TFTimeEntries.NewEntrySaveClick(Event: TJSEvent);
begin
  Event.preventDefault;
  Event.stopPropagation;

  SaveNewEntry;
end;


procedure TFTimeEntries.NewEntryCancelClick(Event: TJSEvent);
begin
  Event.preventDefault;
  Event.stopPropagation;

  Utils.ShowConfirmationModal(
    'Discard the new unsaved time entry?',
    'Discard',
    'Cancel',
    procedure(AConfirmed: Boolean)
    begin
      if AConfirmed then
        CancelNewEntry;
    end
  );
end;


[async] procedure TFTimeEntries.SaveNewEntry;
var
  response: TXDataClientResponse;
  resultObj: TJSObject;
  payload: TJSObject;
begin
  if not FAddingNewEntry then
    Exit;

  GotoRowIndex(FNewEntryRowIndex);
  if xdwdsTimeEntries.Eof then
    Exit;

  if not ValidateRow(FNewEntryRowIndex) then
  begin
    ApplyRowValidation(FNewEntryRowIndex);
    ShowRowValidationMessage(FNewEntryRowIndex, 'Complete required fields before saving this entry.');
    SetTopControlsEnabled(False);
    Exit;
  end;

  payload := TJSObject.new;
  payload['entryId'] := '0';
  payload['userId'] := FUserId;
  payload['taskDate'] := xdwdsTimeEntriestaskDate.AsString;
  payload['taskId'] := xdwdsTimeEntriestaskId.AsString;
  payload['hours'] := xdwdsTimeEntrieshours.AsFloat;
  payload['taskTime'] := xdwdsTimeEntriestaskTime.AsString;
  payload['place'] := xdwdsTimeEntriesplace.AsString;
  payload['category'] := xdwdsTimeEntriescategory.AsString;
  payload['summary'] := xdwdsTimeEntriessummary.AsString;

  Utils.ShowSpinner('spinner');
  try
    try
      response := await(xdwcTimeEntries.RawInvokeAsync(
        'ITimeEntryService.SaveTimeEntry',
        [payload]
      ));

      resultObj := TJSObject(response.Result);
      FPendingEntryId := string(resultObj['value']);
    except
      on E: EXDataClientRequestException do
      begin
        console.log('SaveNewEntry ERROR: ' + E.ErrorResult.ErrorMessage);
        Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
        Exit;
      end;
    end;

    ResetNewEntryState;
    FActiveRowIndex := -1;
    HideRowValidationMessage;
    SetTopControlsEnabled(True);
    btnDeleteEntry.Enabled := False;

    CaptureTableScroll;
    await(LoadTimeEntries);
  finally
    Utils.HideSpinner('spinner');
  end;
end;


procedure TFTimeEntries.CancelNewEntry;
begin
  if not FAddingNewEntry then
    Exit;

  if xdwdsTimeEntries.Active then
  begin
    GotoRowIndex(FNewEntryRowIndex);
    if not xdwdsTimeEntries.Eof then
      xdwdsTimeEntries.Delete;
  end;

  ResetNewEntryState;
  FActiveRowIndex := -1;
  HideRowValidationMessage;
  SetTopControlsEnabled(True);
  btnDeleteEntry.Enabled := False;

  RenderTable;
end;

procedure TFTimeEntries.BindTableEditors;
var
  nodes: TJSNodeList;
  i: Integer;
  el: TJSHTMLElement;
begin
  console.log('BindTableEditors: wiring time entry handlers...');

  nodes := document.querySelectorAll('.time-editor');
  console.log('BindTableEditors: time-editor count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('input', TJSEventHandler(@EditorInput));
    el.addEventListener('blur', TJSEventHandler(@EditorBlur));
  end;

  nodes := document.querySelectorAll('.time-dd-item');
  console.log('BindTableEditors: time-dd-item count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@DropdownItemClick));
  end;

  nodes := document.querySelectorAll('.time-task-picker-btn');
  console.log('BindTableEditors: time-task-picker-btn count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@TaskPickerButtonClick));
  end;

  nodes := document.querySelectorAll('.time-row-selectable');
  console.log('BindTableEditors: time-row-selectable count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@RowClick));
    el.addEventListener('focusout', TJSEventHandler(@RowFocusOut));
  end;
end;


procedure TFTimeEntries.EditorInput(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr: string;
  fieldName: string;
  newVal: string;
begin
  if not xdwdsTimeEntries.Active then
    Exit;

  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  idx := StrToIntDef(idxStr, -1);

  if (idx < 0) or (fieldName = '') then
    Exit;

  FActiveRowIndex := idx;
  btnDeleteEntry.Enabled := not IsNewEntryRow(idx);

  GotoRowIndex(idx);
  if xdwdsTimeEntries.Eof then
    Exit;

  newVal := string(TJSObject(el)['value']);

  xdwdsTimeEntries.Edit;

  if SameText(fieldName, 'taskDate') then
    xdwdsTimeEntriestaskDate.AsString := newVal
  else if SameText(fieldName, 'hours') then
  begin
    if Trim(newVal) = '' then
      xdwdsTimeEntrieshours.Clear
    else
      xdwdsTimeEntrieshours.AsFloat := StrToFloatDef(newVal, 0);
  end
  else if SameText(fieldName, 'taskTime') then
    xdwdsTimeEntriestaskTime.AsString := newVal
  else if SameText(fieldName, 'summary') then
    xdwdsTimeEntriessummary.AsString := newVal
  else
    xdwdsTimeEntries.FieldByName(fieldName).AsString := newVal;

  xdwdsTimeEntries.Post;

  el.setAttribute('data-unsaved-data', '1');

  if IsNewEntryRow(idx) then
  begin
    if ValidateRow(idx) then
      ClearRowValidation(idx);

    SetTopControlsEnabled(False);
    Exit;
  end;

  if ValidateRow(idx) then
  begin
    ClearRowValidation(idx);
    SetTopControlsEnabled(True);
  end
  else
    SetTopControlsEnabled(False);
end;


procedure TFTimeEntries.RowClick(Event: TJSEvent);
var
  rowEl: TJSHTMLElement;
  idx: Integer;
begin
  rowEl := TJSHTMLElement(Event.currentTarget);

  idx := StrToIntDef(string(rowEl.getAttribute('data-idx')), -1);
  if idx < 0 then
    Exit;

  if FAddingNewEntry and (idx <> FNewEntryRowIndex) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    FActiveRowIndex := FNewEntryRowIndex;
    btnDeleteEntry.Enabled := False;
    SetTopControlsEnabled(False);

    FBlockedRowIndex := FNewEntryRowIndex;

    lblValidationMessage.Caption := 'Save or cancel the new entry before selecting another row.';
    lblValidationMessage.ElementHandle.classList.remove('d-none');
    lblValidationMessage.ElementHandle.classList.remove('invisible');

    ApplyActiveRowState;
    Exit;
  end;

  if (FActiveRowIndex >= 0) and
     (idx <> FActiveRowIndex) and
     (not ValidateRow(FActiveRowIndex)) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    ApplyRowValidation(FActiveRowIndex);
    ShowRowValidationMessage(FActiveRowIndex, 'Complete required fields before leaving this row.');
    ApplyActiveRowState;
    Exit;
  end;

  FActiveRowIndex := idx;
  btnDeleteEntry.Enabled := not IsNewEntryRow(idx);
  ApplyActiveRowState;
end;


procedure TFTimeEntries.RowFocusOut(Event: TJSEvent);
var
  rowEl: TJSHTMLElement;
  idx: Integer;
begin
  rowEl := TJSHTMLElement(Event.currentTarget);

  window.setTimeout(
    procedure
    begin
      idx := StrToIntDef(string(rowEl.getAttribute('data-idx')), -1);
      if idx < 0 then
        Exit;

      if IsNewEntryRow(idx) then
        Exit;

      if (FLastMouseDownRowIndex = idx) and ((TJSDate.now - FLastMouseDownTime) < 500) then
        Exit;

      if Assigned(document.activeElement) and rowEl.contains(document.activeElement) then
        Exit;

      if not ValidateRow(idx) then
      begin
        FActiveRowIndex := idx;
        btnDeleteEntry.Enabled := True;
        ApplyRowValidation(idx);
        ShowRowValidationMessage(idx, 'Complete required fields before leaving this row.');
        SetTopControlsEnabled(False);
        Exit;
      end;

      ClearRowValidation(idx);
      SetTopControlsEnabled(True);
    end,
    0
  );
end;


procedure TFTimeEntries.DropdownItemClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr: string;
  fieldName: string;
  newValue: string;
  newDisplay: string;
  triggerId: string;
  btn: TJSHTMLElement;
  labelEl: TJSHTMLElement;
begin
  if not xdwdsTimeEntries.Active then
    Exit;

  Event.preventDefault;

  el := TJSHTMLElement(Event.currentTarget);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  newValue := string(el.getAttribute('data-value'));
  newDisplay := string(el.getAttribute('data-display'));
  triggerId := string(el.getAttribute('data-trigger-id'));

  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  if FAddingNewEntry and (idx <> FNewEntryRowIndex) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    FActiveRowIndex := FNewEntryRowIndex;
    btnDeleteEntry.Enabled := False;
    SetTopControlsEnabled(False);

    FBlockedRowIndex := FNewEntryRowIndex;

    lblValidationMessage.Caption := 'Save or cancel the new entry before changing another row.';
    lblValidationMessage.ElementHandle.classList.remove('d-none');
    lblValidationMessage.ElementHandle.classList.remove('invisible');

    ApplyActiveRowState;
    Exit;
  end;

  if (FActiveRowIndex >= 0) and
     (idx <> FActiveRowIndex) and
     (not ValidateRow(FActiveRowIndex)) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    ApplyRowValidation(FActiveRowIndex);
    ShowRowValidationMessage(FActiveRowIndex, 'Complete required fields before changing another row.');
    SetTopControlsEnabled(False);
    ApplyActiveRowState;
    Exit;
  end;

  GotoRowIndex(idx);
  if xdwdsTimeEntries.Eof then
    Exit;

  xdwdsTimeEntries.Edit;

  if SameText(fieldName, 'taskId') then
  begin
    xdwdsTimeEntriestaskId.AsString := newValue;
    xdwdsTimeEntriestaskDisplay.AsString := newDisplay;
  end
  else if SameText(fieldName, 'place') then
  begin
    xdwdsTimeEntriesplace.AsString := newValue;
    xdwdsTimeEntriesplaceDesc.AsString := newDisplay;
  end
  else if SameText(fieldName, 'category') then
  begin
    xdwdsTimeEntriescategory.AsString := newValue;
    xdwdsTimeEntriescategoryDesc.AsString := newDisplay;
  end
  else
    xdwdsTimeEntries.FieldByName(fieldName).AsString := newValue;

  xdwdsTimeEntries.Post;

  FActiveRowIndex := idx;
  btnDeleteEntry.Enabled := not IsNewEntryRow(idx);

  btn := TJSHTMLElement(document.getElementById(triggerId));
  if Assigned(btn) then
  begin
    labelEl := TJSHTMLElement(btn.querySelector('.time-dd-label'));
    if Assigned(labelEl) then
      labelEl.textContent := newDisplay;

    btn.focus;
  end;

  if not IsNewEntryRow(idx) then
    SaveField(idx, fieldName);

  if IsNewEntryRow(idx) then
  begin
    if ValidateRow(idx) then
      ClearRowValidation(idx);

    SetTopControlsEnabled(False);
    Exit;
  end;

  if ValidateRow(idx) then
  begin
    ClearRowValidation(idx);
    SetTopControlsEnabled(True);
  end
  else
    SetTopControlsEnabled(False);
end;


function TFTimeEntries.ValidateRow(AIndex: Integer): Boolean;
var
  hasDate: Boolean;
  hasTask: Boolean;
  hasHours: Boolean;
  hasCategory: Boolean;
  hasSummary: Boolean;
begin
  Result := False;

  if not xdwdsTimeEntries.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTimeEntries.Eof then
    Exit;

  hasDate := Trim(xdwdsTimeEntriestaskDate.AsString) <> '';
  hasTask := Trim(xdwdsTimeEntriestaskId.AsString) <> '';
  if xdwdsTimeEntrieshours.IsNull then
    hasHours := False
  else
    hasHours := xdwdsTimeEntrieshours.AsFloat > 0;
  hasCategory := Trim(xdwdsTimeEntriescategory.AsString) <> '';
  hasSummary := Trim(xdwdsTimeEntriessummary.AsString) <> '';

  Result := hasDate and hasTask and hasHours and hasCategory and hasSummary;
end;


procedure TFTimeEntries.ApplyPendingEntryFocus;
var
  rowEl: TJSHTMLElement;
  idx: Integer;
  firstEditor: TJSHTMLElement;
begin
  if FPendingEntryId = '' then
    Exit;

  rowEl := TJSHTMLElement(document.querySelector('tr[data-entry-id="' + FPendingEntryId + '"]'));
  if not Assigned(rowEl) then
  begin
    FPendingEntryId := '';
    Exit;
  end;

  idx := StrToIntDef(string(rowEl.getAttribute('data-idx')), -1);
  if idx < 0 then
  begin
    FPendingEntryId := '';
    Exit;
  end;

  FActiveRowIndex := idx;
  btnDeleteEntry.Enabled := True;
  SetTopControlsEnabled(True);
  ApplyActiveRowState;

  firstEditor := TJSHTMLElement(rowEl.querySelector('[data-field="taskId"]'));
  if not Assigned(firstEditor) then
    firstEditor := TJSHTMLElement(rowEl.querySelector('[data-field="hours"]'));

  if Assigned(firstEditor) then
  begin
    asm
      firstEditor.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' });
      firstEditor.focus();
    end;
  end;

  FPendingEntryId := '';
end;


procedure TFTimeEntries.ApplyRowValidation(AIndex: Integer);
var
  rowEl: TJSHTMLElement;
  hoursInvalid: Boolean;

  procedure SetInvalid(const AFieldName: string; AInvalid: Boolean);
  var
    fieldEl: TJSHTMLElement;
  begin
    fieldEl := TJSHTMLElement(rowEl.querySelector('[data-field="' + AFieldName + '"]'));

    if AInvalid then
      fieldEl.classList.add('is-invalid')
    else
      fieldEl.classList.remove('is-invalid');
  end;

begin
  if not xdwdsTimeEntries.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTimeEntries.Eof then
    Exit;

  rowEl := TJSHTMLElement(document.querySelector('tr[data-idx="' + IntToStr(AIndex) + '"]'));

  if xdwdsTimeEntrieshours.IsNull then
    hoursInvalid := True
  else
    hoursInvalid := xdwdsTimeEntrieshours.AsFloat <= 0;

  SetInvalid('taskDate', Trim(xdwdsTimeEntriestaskDate.AsString) = '');
  SetInvalid('taskId', Trim(xdwdsTimeEntriestaskId.AsString) = '');
  SetInvalid('hours', hoursInvalid);
  SetInvalid('category', Trim(xdwdsTimeEntriescategory.AsString) = '');
  SetInvalid('summary', Trim(xdwdsTimeEntriessummary.AsString) = '');
end;


procedure TFTimeEntries.EnableAutoGrowTextAreas;
begin
  asm
    (function(){
      const host = document.getElementById('time_entries_table_host');
      if(!host) return;

      host.querySelectorAll('.time-textarea').forEach(function(editor){
        const fit = function() {
          editor.style.height = 'auto';
          editor.style.height = editor.scrollHeight + 'px';
        };
        fit();
        editor.addEventListener('input', fit);
      });
    })();
  end;
end;


procedure TFTimeEntries.ClearRowValidation(AIndex: Integer);
var
  rowEl: TJSHTMLElement;
  nodes: TJSNodeList;
  i: Integer;
  el: TJSHTMLElement;
begin
  rowEl := TJSHTMLElement(document.querySelector('tr[data-idx="' + IntToStr(AIndex) + '"]'));

  nodes := rowEl.querySelectorAll('.is-invalid');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.classList.remove('is-invalid');
  end;

  SetTopControlsEnabled(True);
  HideRowValidationMessage;
end;


procedure TFTimeEntries.ShowRowValidationMessage(AIndex: Integer; const AMessage: string);
var
  rowEl: TJSHTMLElement;
  firstInvalidEl: TJSHTMLElement;
begin
  FBlockedRowIndex := AIndex;

  lblValidationMessage.Caption := AMessage;
  lblValidationMessage.ElementHandle.classList.remove('d-none');
  lblValidationMessage.ElementHandle.classList.remove('invisible');

  rowEl := TJSHTMLElement(document.querySelector('tr[data-idx="' + IntToStr(AIndex) + '"]'));
  firstInvalidEl := TJSHTMLElement(rowEl.querySelector('.is-invalid'));
  firstInvalidEl.focus;

  ApplyActiveRowState;
end;


procedure TFTimeEntries.HideRowValidationMessage;
begin
  FBlockedRowIndex := -1;

  lblValidationMessage.Caption := '';
  lblValidationMessage.ElementHandle.classList.add('invisible');

  ApplyActiveRowState;
end;


function TFTimeEntries.GetTargetRowIndex(ATarget: TJSHTMLElement): Integer;
var
  el: TJSHTMLElement;
  idxText: string;
begin
  Result := -1;

  el := ATarget;
  while Assigned(el) do
  begin
    if el.hasAttribute('data-idx') then
    begin
      idxText := string(el.getAttribute('data-idx'));
      Result := StrToIntDef(idxText, -1);

      if Result >= 0 then
        Exit;
    end;

    el := TJSHTMLElement(el.parentElement);
  end;
end;


procedure TFTimeEntries.DocumentMouseDown(Event: TJSEvent);
var
  targetEl: TJSHTMLElement;
  targetRowIndex: Integer;
begin
  targetEl := TJSHTMLElement(Event.target);
  targetRowIndex := GetTargetRowIndex(targetEl);

  if FAddingNewEntry and
     (targetRowIndex >= 0) and
     (targetRowIndex <> FNewEntryRowIndex) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    FActiveRowIndex := FNewEntryRowIndex;
    btnDeleteEntry.Enabled := False;
    SetTopControlsEnabled(False);

    FBlockedRowIndex := FNewEntryRowIndex;

    lblValidationMessage.Caption := 'Save or cancel the new entry before selecting another row.';
    lblValidationMessage.ElementHandle.classList.remove('d-none');
    lblValidationMessage.ElementHandle.classList.remove('invisible');

    FLastMouseDownRowIndex := FNewEntryRowIndex;
    FLastMouseDownTime := TJSDate.now;
    ApplyActiveRowState;
    Exit;
  end;

  if (FActiveRowIndex >= 0) and
     (targetRowIndex >= 0) and
     (targetRowIndex <> FActiveRowIndex) and
     (not ValidateRow(FActiveRowIndex)) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    ApplyRowValidation(FActiveRowIndex);
    ShowRowValidationMessage(FActiveRowIndex, 'Complete required fields before leaving this row.');
    SetTopControlsEnabled(False);

    FLastMouseDownRowIndex := FActiveRowIndex;
    FLastMouseDownTime := TJSDate.now;
    ApplyActiveRowState;
    Exit;
  end;

  FLastMouseDownRowIndex := targetRowIndex;
  FLastMouseDownTime := TJSDate.now;
end;


procedure TFTimeEntries.EnableColumnResize;
begin
  asm
    (function(){
      const host = document.getElementById('time_entries_table_host');
      if(!host) return;

      const table = host.querySelector('table');
      if(!table) return;

      const ths = table.querySelectorAll('thead th');
      const cols = table.querySelectorAll('colgroup col');

      ths.forEach((th, index) => {
        th.classList.add('th-resize');
        if (th.querySelector('.th-resize-handle')) return;

        const handle = document.createElement('div');
        handle.className = 'th-resize-handle';
        th.appendChild(handle);

        handle.addEventListener('mousedown', function(e){
          e.preventDefault();
          e.stopPropagation();

          const startX = e.clientX;
          const startW = th.getBoundingClientRect().width;

          function onMove(ev){
            const w = Math.max(20, startW + (ev.clientX - startX));

            th.style.width = w + 'px';
            th.style.minWidth = '0';

            if (cols && cols[index]) {
              cols[index].style.width = w + 'px';
            }
          }

          function onUp(){
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('mouseup', onUp);
          }

          document.addEventListener('mousemove', onMove);
          document.addEventListener('mouseup', onUp);
        });
      });
    })();
  end;
end;

function TFTimeEntries.IsNewEntryRow(AIndex: Integer): Boolean;
begin
  Result := FAddingNewEntry and (AIndex = FNewEntryRowIndex);
end;


procedure TFTimeEntries.ResetNewEntryState;
begin
  FAddingNewEntry := False;
  FNewEntryRowIndex := -1;
end;


function TFTimeEntries.BeginAddEntry: Boolean;
begin
  Result := False;

  if FUserId = '' then
  begin
    Utils.ShowErrorModal('Unable to determine logged-in user.');
    Exit;
  end;

  if edtWeekOf.Text = '' then
  begin
    Utils.ShowErrorModal('Select a week/date before adding an entry.');
    Exit;
  end;

  if FAddingNewEntry then
  begin
    Utils.ShowErrorModal('Save or cancel the new entry before adding another entry.');
    Exit;
  end;

  if (FActiveRowIndex >= 0) and (not ValidateRow(FActiveRowIndex)) then
  begin
    ApplyRowValidation(FActiveRowIndex);
    ShowRowValidationMessage(FActiveRowIndex, 'Complete required fields before adding another entry.');
    SetTopControlsEnabled(False);
    Exit;
  end;

  xdwdsTimeEntries.Append;
  xdwdsTimeEntriesentryId.AsInteger := 0;
  xdwdsTimeEntriestaskDate.AsString := edtWeekOf.Text;
  xdwdsTimeEntriestaskId.AsString := '';
  xdwdsTimeEntriestaskDisplay.AsString := '';
  xdwdsTimeEntrieshours.Clear;
  xdwdsTimeEntriestaskTime.AsString := '';
  xdwdsTimeEntriesplace.AsString := '';
  xdwdsTimeEntriesplaceDesc.AsString := '';
  xdwdsTimeEntriescategory.AsString := '';
  xdwdsTimeEntriescategoryDesc.AsString := '';
  xdwdsTimeEntriessummary.AsString := '';
  xdwdsTimeEntries.Post;

  FAddingNewEntry := True;
  FNewEntryRowIndex := xdwdsTimeEntries.RecordCount - 1;
  FActiveRowIndex := FNewEntryRowIndex;

  SetTopControlsEnabled(False);
  btnDeleteEntry.Enabled := False;
  HideRowValidationMessage;

  CaptureTableScroll;
  RenderTable;

  Result := True;
end;


procedure TFTimeEntries.btnAddEntryClick(Sender: TObject);
begin
  BeginAddEntry;
end;


procedure TFTimeEntries.btnDeleteEntryClick(Sender: TObject);
begin
  if FActiveRowIndex < 0 then
  begin
    Utils.ShowErrorModal('Select a time entry to delete.');
    Exit;
  end;

  Utils.ShowConfirmationModal(
    'Delete the selected time entry?',
    'Delete',
    'Cancel',
    procedure(AConfirmed: Boolean)
    begin
      if AConfirmed then
        DeleteSelectedEntry;
    end
  );
end;

[async] procedure TFTimeEntries.btnSearchRangeClick(Sender: TObject);
begin
  if edtStartDate.Text = '' then
  begin
    Utils.ShowErrorModal('Select a start date.');
    Exit;
  end;

  if edtEndDate.Text = '' then
  begin
    Utils.ShowErrorModal('Select an end date.');
    Exit;
  end;

  if IsoToDate(edtEndDate.Text) < IsoToDate(edtStartDate.Text) then
  begin
    Utils.ShowErrorModal('End date cannot be before start date.');
    Exit;
  end;

  FStartDate := edtStartDate.Text;
  FEndDate := edtEndDate.Text;

  await(LoadTimeEntries);
end;

procedure TFTimeEntries.CaptureTableScroll;
begin
  asm
    const vscroll = document.querySelector('.time-vscroll');
    const hscroll = document.querySelector('.time-hscroll');

    this.FPendingScrollTop = vscroll ? vscroll.scrollTop : 0;
    this.FPendingScrollLeft = hscroll ? hscroll.scrollLeft : 0;
  end;
end;


procedure TFTimeEntries.RestoreTableScroll;
begin
  asm
    const vscroll = document.querySelector('.time-vscroll');
    const hscroll = document.querySelector('.time-hscroll');

    if (vscroll) vscroll.scrollTop = this.FPendingScrollTop || 0;
    if (hscroll) hscroll.scrollLeft = this.FPendingScrollLeft || 0;
  end;
end;

procedure TFTimeEntries.SetTopControlsEnabled(AEnabled: Boolean);
begin
  edtWeekOf.Enabled := AEnabled;
  edtStartDate.Enabled := AEnabled;
  edtEndDate.Enabled := AEnabled;
  btnSearchRange.Enabled := AEnabled;
  btnAddEntry.Enabled := AEnabled;
end;


[async] procedure TFTimeEntries.TaskPickerButtonClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
begin
  Event.preventDefault;
  Event.stopPropagation;

  el := TJSHTMLElement(Event.currentTarget);
  idx := StrToIntDef(string(el.getAttribute('data-idx')), -1);

  if idx < 0 then
    Exit;

  if FAddingNewEntry and (idx <> FNewEntryRowIndex) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    FActiveRowIndex := FNewEntryRowIndex;
    btnDeleteEntry.Enabled := False;
    SetTopControlsEnabled(False);

    FBlockedRowIndex := FNewEntryRowIndex;

    lblValidationMessage.Caption := 'Save or cancel the new entry before changing another row.';
    lblValidationMessage.ElementHandle.classList.remove('d-none');
    lblValidationMessage.ElementHandle.classList.remove('invisible');

    ApplyActiveRowState;
    Exit;
  end;

  if (FActiveRowIndex >= 0) and
     (idx <> FActiveRowIndex) and
     (not ValidateRow(FActiveRowIndex)) then
  begin
    Event.preventDefault;
    Event.stopPropagation;

    ApplyRowValidation(FActiveRowIndex);
    ShowRowValidationMessage(FActiveRowIndex, 'Complete required fields before changing another row.');
    SetTopControlsEnabled(False);
    ApplyActiveRowState;
    Exit;
  end;

  FActiveRowIndex := idx;
  btnDeleteEntry.Enabled := not IsNewEntryRow(idx);

  if not Assigned(FTaskPickerOffCanvas) then
    FTaskPickerOffCanvas := TTaskPickerOffCanvas.Create(xdwcTimeEntries, @TaskPickerTaskSelected);

  await(FTaskPickerOffCanvas.Open(FUserId, idx));
end;


procedure TFTimeEntries.TaskPickerTaskSelected(ARowIndex: Integer; const ATaskId, ATaskDisplay: string);
var
  btn: TJSHTMLElement;
  triggerId: string;
begin
  if not xdwdsTimeEntries.Active then
    Exit;

  GotoRowIndex(ARowIndex);
  if xdwdsTimeEntries.Eof then
    Exit;

  xdwdsTimeEntries.Edit;
  xdwdsTimeEntriestaskId.AsString := ATaskId;
  xdwdsTimeEntriestaskDisplay.AsString := ATaskDisplay;
  xdwdsTimeEntries.Post;

  FActiveRowIndex := ARowIndex;
  btnDeleteEntry.Enabled := not IsNewEntryRow(ARowIndex);

  CaptureTableScroll;
  RenderTable;

  triggerId := 'time_dd_taskId_' + IntToStr(ARowIndex);
  btn := TJSHTMLElement(document.getElementById(triggerId));

  if Assigned(btn) then
    btn.focus;

  if not IsNewEntryRow(ARowIndex) then
    SaveField(ARowIndex, 'taskId');

  if IsNewEntryRow(ARowIndex) then
  begin
    if ValidateRow(ARowIndex) then
      ClearRowValidation(ARowIndex);

    SetTopControlsEnabled(False);
    Exit;
  end;

  if ValidateRow(ARowIndex) then
  begin
    ClearRowValidation(ARowIndex);
    SetTopControlsEnabled(True);
  end
  else
    SetTopControlsEnabled(False);
end;

procedure TFTimeEntries.ApplyActiveRowState;
begin
  asm
    const activeRowIndex = this.FActiveRowIndex;
    const blockedRowIndex = this.FBlockedRowIndex;

    document.querySelectorAll('.time-row-selectable').forEach(function(row) {
      const rowIndex = parseInt(row.getAttribute('data-idx') || '-1', 10);

      row.classList.remove('table-active');
      row.classList.remove('table-danger');

      if ((blockedRowIndex >= 0) && (rowIndex === blockedRowIndex))
        row.classList.add('table-danger');
      else if ((activeRowIndex >= 0) && (rowIndex === activeRowIndex))
        row.classList.add('table-active');
    });
  end;
end;


procedure TFTimeEntries.EditorBlur(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr: string;
  fieldName: string;
begin
  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));

  if string(el.getAttribute('data-unsaved-data')) <> '1' then
  begin
    console.log('EditorBlur: skip (not unsaved) idx=' + idxStr + ' field=' + fieldName);
    Exit;
  end;

  el.removeAttribute('data-unsaved-data');

  idx := StrToIntDef(idxStr, -1);
  if idx < 0 then
    Exit;

  if IsNewEntryRow(idx) then
  begin
    console.log('EditorBlur: skip local new row idx=' + IntToStr(idx) + ' field=' + fieldName);
    Exit;
  end;

  console.log('EditorBlur: SAVE idx=' + IntToStr(idx) + ' field=' + fieldName);
  SaveField(idx, fieldName);
end;


[async] procedure TFTimeEntries.SaveField(AIndex: Integer; const AFieldName: string);
var
  response: TXDataClientResponse;
  payload: TJSObject;
  fieldValue: string;
begin
  if not xdwdsTimeEntries.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTimeEntries.Eof then
    Exit;

  if xdwdsTimeEntriesentryId.AsInteger <= 0 then
    Exit;

  fieldValue := '';

  if SameText(AFieldName, 'taskDate') then
    fieldValue := xdwdsTimeEntriestaskDate.AsString
  else if SameText(AFieldName, 'taskId') then
    fieldValue := xdwdsTimeEntriestaskId.AsString
  else if SameText(AFieldName, 'hours') then
  begin
    if xdwdsTimeEntrieshours.IsNull then
      fieldValue := ''
    else
      fieldValue := xdwdsTimeEntrieshours.AsString;
  end
  else if SameText(AFieldName, 'taskTime') then
    fieldValue := xdwdsTimeEntriestaskTime.AsString
  else if SameText(AFieldName, 'place') then
    fieldValue := xdwdsTimeEntriesplace.AsString
  else if SameText(AFieldName, 'category') then
    fieldValue := xdwdsTimeEntriescategory.AsString
  else if SameText(AFieldName, 'summary') then
    fieldValue := xdwdsTimeEntriessummary.AsString
  else
    Exit;

  payload := TJSObject.new;
  payload['entryId'] := xdwdsTimeEntriesentryId.AsInteger;
  payload['userId'] := FUserId;
  payload['fieldName'] := AFieldName;
  payload['value'] := fieldValue;

  console.log('SaveField: idx=' + IntToStr(AIndex) + ' field=' + AFieldName + ' value=' + fieldValue);

  try
    response := await(xdwcTimeEntries.RawInvokeAsync(
      'ITimeEntryService.SaveTimeEntryField',
      [payload]
    ));

    console.log('SaveField: response=' + string(TJSJSON.stringify(response.Result)));
  except
    on E: EXDataClientRequestException do
    begin
      console.log('SaveField ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;

[async] procedure TFTimeEntries.DeleteSelectedEntry;
var
  entryId: Integer;
begin
  if not xdwdsTimeEntries.Active then
    Exit;

  if FActiveRowIndex < 0 then
  begin
    Utils.ShowErrorModal('Select a time entry to delete.');
    Exit;
  end;

  GotoRowIndex(FActiveRowIndex);
  if xdwdsTimeEntries.Eof then
    Exit;

  entryId := xdwdsTimeEntriesentryId.AsInteger;
  if entryId <= 0 then
  begin
    Utils.ShowErrorModal('Unable to determine the selected time entry.');
    Exit;
  end;

  Utils.ShowSpinner('spinner');
  try
    try
      await(xdwcTimeEntries.RawInvokeAsync(
        'ITimeEntryService.DeleteTimeEntry',
        [FUserId, entryId]
      ));
    except
      on E: EXDataClientRequestException do
      begin
        console.log('DeleteSelectedEntry ERROR: ' + E.ErrorResult.ErrorMessage);
        Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
        Exit;
      end;
    end;

    FActiveRowIndex := -1;
    FPendingEntryId := '';
    HideRowValidationMessage;
    SetTopControlsEnabled(True);
    btnDeleteEntry.Enabled := False;

    CaptureTableScroll;
    await(LoadTimeEntries);
  finally
    Utils.HideSpinner('spinner');
  end;
end;

end.
