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

  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'));
  if Assigned(el) then
    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'));
  if Assigned(el) then
    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;

    if not Assigned(response.Result) then
      Exit;

    resultObj := TJSObject(response.Result);

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

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

    if resultObj.hasOwnProperty('taskOptions') then
      FTaskOptions := TJSArray(resultObj['taskOptions'])
    else
      FTaskOptions := TJSArray.new;

    if resultObj.hasOwnProperty('categoryOptions') then
      FCategoryOptions := TJSArray(resultObj['categoryOptions'])
    else
      FCategoryOptions := TJSArray.new;

    itemsArray := TJSArray(resultObj['items']);
    if not Assigned(itemsArray) then
      itemsArray := TJSArray.new;

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

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

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

    if Assigned(Items) then
      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" readonly ' +
        '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'));
  if not Assigned(host) then
    Exit;

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

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

  if triggerId <> '' then
  begin
    btn := TJSHTMLElement(document.getElementById(triggerId));
    if Assigned(btn) then
    begin
      labelEl := TJSHTMLElement(btn.querySelector('.time-dd-label'));
      if Assigned(labelEl) then
        labelEl.textContent := newDisplay;
    end;
  end;
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.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;

end.
