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,
  XData.Web.Client, ConnectionModule, XData.Web.JsonDataset, Data.DB, XData.Web.Dataset;

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;
    procedure edtWeekOfChange(Sender: TObject);
    procedure edtStartDateChange(Sender: TObject);
    procedure edtEndDateChange(Sender: TObject);
    procedure WebFormCreate(Sender: TObject);
    [async] procedure btnAddEntryClick(Sender: TObject);
  private
    FUserId: string;
    FUserName: string;
    FStartDate: string;
    FEndDate: string;
    FTaskOptions: TJSArray;
    FCategoryOptions: TJSArray;
    FUpdatingDates: Boolean;
    FPendingScrollTop: Integer;
    FPendingScrollLeft: Integer;
    FActiveRowIndex: Integer;
    FPendingEntryId: string;
    FLastMouseDownRowIndex: Integer;
    FLastMouseDownTime: Double;
    [async] procedure LoadTimeEntries;
    [async] function AddTimeEntry: Boolean;
    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;
    [async] procedure SaveRow(AIndex: Integer);
    procedure SetTopControlsEnabled(AEnabled: Boolean);
    function GetTargetRowIndex(ATarget: TJSHTMLElement): Integer;
    procedure DocumentMouseDown(Event: TJSEvent);
  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;
  FUpdatingDates := False;
  FPendingScrollTop := 0;
  FPendingScrollLeft := 0;
  FActiveRowIndex := -1;
  FPendingEntryId := '';
  FLastMouseDownRowIndex := -1;
  FLastMouseDownTime := 0;

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

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

  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.edtWeekOfChange(Sender: TObject);
begin
  if FUpdatingDates then
    Exit;

  SetWeekRangeFromAnchor(edtWeekOf.Text, True);
end;

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

  FStartDate := edtStartDate.Text;
  LoadTimeEntries;
end;

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

  FEndDate := edtEndDate.Text;
  LoadTimeEntries;
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']);
    itemsArray := TJSArray(resultObj['items']);

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

    FActiveRowIndex := -1;
    HideRowValidationMessage;
    SetTopControlsEnabled(True);

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


[async] function TFTimeEntries.AddTimeEntry: Boolean;
var
  response: TXDataClientResponse;
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;

  try
    response := await(xdwcTimeEntries.RawInvokeAsync(
      'ITimeEntryService.AddTimeEntry',
      [FUserId, edtWeekOf.Text]
    ));

    FPendingEntryId := JS.toString(response.Result);

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


procedure TFTimeEntries.RenderTable;
var
  host: TJSHTMLElement;
  html: string;
  rowIdx: Integer;
  hoursText: string;

  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): string;
  var
    i: Integer;
    optionObj: TJSObject;
    optionValue: string;
    optionDisplay: string;
    triggerId: string;
  begin
    triggerId := 'time_dd_' + FieldName + '_' + IntToStr(AIdx);

    Result :=
      '<div class="dropdown w-100">' +
        '<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" ' +
          '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 overflow-hidden">';

    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 + '"></button>';


      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>';
  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: 1550px; 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:190px">' +  // Category
            '<col style="width:480px">' +  // Summary
          '</colgroup>' +
          '<thead><tr>' +
            Th('Date') +
            Th('Task') +
            Th('Hours') +
            Th('Time') +
            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')) +
        TdNowrap(HoursInput('hours', hoursText, rowIdx)) +
        TdNowrap(TextInput('taskTime', xdwdsTimeEntriestaskTime.AsString, rowIdx)) +
        TdNowrap(SelectList('category', xdwdsTimeEntriescategory.AsString, xdwdsTimeEntriescategoryDesc.AsString, rowIdx, FCategoryOptions, 'code', 'codeDesc')) +
        TdWrap(SummaryTextArea('summary', xdwdsTimeEntriessummary.AsString, rowIdx)) +
      '</tr>';

    xdwdsTimeEntries.Next;
    Inc(rowIdx);
  end;

  html := html + '</tbody></table></div></div>';
  host.innerHTML := html;

  BindTableEditors;
  EnableAutoGrowTextAreas;
  EnableColumnResize;
  RestoreTableScroll;
  ApplyPendingEntryFocus;
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));
  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-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;

  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 ValidateRow(idx) then
  begin
    ClearRowValidation(idx);
    SetTopControlsEnabled(True);
  end
  else
    SetTopControlsEnabled(False);
end;


[async] procedure TFTimeEntries.SaveRow(AIndex: Integer);
var
  saveObj: TJSObject;
  response: TXDataClientResponse;
  rowEl: TJSHTMLElement;
  nodes: TJSNodeList;
  i: Integer;
  el: TJSHTMLElement;
begin
  if not xdwdsTimeEntries.Active then
    Exit;

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

  if not ValidateRow(AIndex) then
  begin
    ApplyRowValidation(AIndex);
    ShowRowValidationMessage(AIndex, 'Complete required fields before leaving this row.');
    Exit;
  end;

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

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

    console.log('SaveRow response=' + string(TJSJSON.stringify(response.Result)));

    rowEl := TJSHTMLElement(document.querySelector('tr[data-idx="' + IntToStr(AIndex) + '"]'));
    nodes := rowEl.querySelectorAll('[data-unsaved-data="1"]');
    for i := 0 to nodes.length - 1 do
    begin
      el := TJSHTMLElement(nodes.item(i));
      el.removeAttribute('data-unsaved-data');
    end;

    ClearRowValidation(AIndex);
  except
    on E: EXDataClientRequestException do
    begin
      console.log('SaveRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
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;

  FActiveRowIndex := idx;
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 (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;
        ApplyRowValidation(idx);
        ShowRowValidationMessage(idx, 'Complete required fields before leaving this row.');
        SetTopControlsEnabled(False);
        Exit;
      end;

      ClearRowValidation(idx);
      SetTopControlsEnabled(True);

      if Assigned(rowEl.querySelector('[data-unsaved-data="1"]')) then
        SaveRow(idx);
    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;

  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, 'category') then
  begin
    xdwdsTimeEntriescategory.AsString := newValue;
    xdwdsTimeEntriescategoryDesc.AsString := newDisplay;
  end
  else
    xdwdsTimeEntries.FieldByName(fieldName).AsString := newValue;

  xdwdsTimeEntries.Post;

  FActiveRowIndex := idx;

  btn := TJSHTMLElement(document.getElementById(triggerId));
  btn.setAttribute('data-unsaved-data', '1');

  labelEl := TJSHTMLElement(btn.querySelector('.time-dd-label'));
  labelEl.textContent := newDisplay;
  btn.focus;

  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;
  SetTopControlsEnabled(False);

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

  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
  lblValidationMessage.Caption := AMessage;
  lblValidationMessage.ElementHandle.classList.remove('d-none');

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


procedure TFTimeEntries.HideRowValidationMessage;
begin
  lblValidationMessage.Caption := '';
  lblValidationMessage.ElementHandle.classList.add('d-none');
end;


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

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

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


procedure TFTimeEntries.DocumentMouseDown(Event: TJSEvent);
begin
  FLastMouseDownRowIndex := GetTargetRowIndex(TJSHTMLElement(Event.target));
  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;


[async] procedure TFTimeEntries.btnAddEntryClick(Sender: TObject);
begin
  Utils.ShowSpinner('spinner');
  try
    if await(AddTimeEntry) then
    begin
      CaptureTableScroll;
      LoadTimeEntries;
    end;
  finally
    Utils.HideSpinner('spinner');
  end;
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;
  btnAddEntry.Enabled := AEnabled;
end;

end.
