Budowanie pulpitu nawigacyjnego ERP w czasie rzeczywistym z sgcHTML i Delphi

· Aplikacje

Systemy ERP to serce programowania w Delphi: klienci, zamówienia, faktury, stany magazynowe, przebieg produkcji. Logika biznesowa już istnieje w Object Pascal. Baza danych jest podłączona. Raporty działają. Czego często brakuje, to dostępny przez przeglądarkę pulpit nawigacyjny, który kadra zarządzająca może otworzyć na tablecie bez instalowania czegokolwiek. Ten artykuł pokazuje, jak zbudować dokładnie to za pomocą sgcHTML, pracując na realistycznym scenariuszu: sprzedażowego systemu ERP z żywymi danymi o przychodach, siatką faktur, tablicą Kanban dla dostaw i aktualizacjami push w czasie rzeczywistym przy nowych zamówieniach.

Cały kod kompiluje się w Delphi 10.4 Sydney i nowszych wersjach. Gotowy wynik to pojedynczy plik .exe, który serwuje pulpit nawigacyjny na porcie 8080, ze stylizacją Bootstrap 5, responsywnym układem i żywymi aktualizacjami przez WebSocket, bez żadnych plików JavaScript do wdrożenia obok pliku binarnego.

Konfiguracja serwera

sgcHTML opakowuje TsgcWSHTTPServer z sgcWebSockets. Umieść TsgcHTMLEngine_Server w module danych obok serwera HTTP i połącz je ze sobą. Silnik automatycznie przechwytuje żądania zasobów statycznych (Bootstrap CSS/JS, Chart.js, htmx); wszystko inne trafia do Twojego handlera OnCommandGet.

uses
  sgcWebSocket_Server, sgcHTML_Engine_Server,
  sgcHTML_Template_Bootstrap, sgcHTML_Page;

type
  TERP_Server = class(TDataModule)
    HTTPServer:    TsgcWSHTTPServer;
    HTMLEngine:    TsgcHTMLEngine_Server;
  private
    procedure HandleGet(AContext: TIdContext;
      AReq: TIdHTTPRequestInfo; AResp: TIdHTTPResponseInfo);
  public
    procedure Start(aPort: Integer);
  end;

procedure TERP_Server.Start(aPort: Integer);
begin
  HTTPServer.Port := aPort;
  HTMLEngine.Server := HTTPServer;
  HTTPServer.OnCommandGet := HandleGet;
  HTTPServer.Active := True;
end;

procedure TERP_Server.HandleGet(AContext: TIdContext;
  AReq: TIdHTTPRequestInfo; AResp: TIdHTTPResponseInfo);
begin
  if AReq.Document = '/' then
    ServeDashboard(AResp)
  else if AReq.Document = '/invoices' then
    ServeInvoices(AResp)
  else if AReq.Document = '/kanban' then
    ServeKanban(AResp)
  else
    AResp.ResponseNo := 404;
end;

Karty KPI

Górny wiersz pulpitu nawigacyjnego pokazuje cztery karty KPI: łączny przychód, otwarte faktury, liczba przeterminowanych i wskaźnik terminowości dostaw. TsgcHTMLComponent_StatCard obsługuje całą warstwę wizualną: gradient tła, tytuł, duża wartość, strzałka trendu i opcjonalna etykieta stopki.

uses
  sgcHTML_Component_StatCard;

procedure TERP_Server.BuildKPIRow(aPage: TsgcHTMLPage; aDB: TFDConnection);

  function MakeCard(aPage: TsgcHTMLPage; const aTitle, aValue: string;
    aColor: TsgcHTMLStatColor; aTrend: TsgcHTMLStatTrend;
    const aTrendValue, aFooter: string; aOrder: Integer): TsgcHTMLComponent_StatCard;
  begin
    Result := TsgcHTMLComponent_StatCard.Create(nil);
    Result.PageBuilder  := aPage.PageBuilder;
    Result.Section      := 'kpi';
    Result.SectionOrder := aOrder;
    Result.ColumnWidth  := cw3;
    Result.Title        := aTitle;
    Result.Value        := aValue;
    Result.Color        := aColor;
    Result.Trend        := aTrend;
    Result.TrendValue   := aTrendValue;
    Result.FooterText   := aFooter;
    Result.Gradient     := sgBlueViolet;
  end;

var
  oQ: TFDQuery;
begin
  oQ := TFDQuery.Create(nil);
  try
    oQ.Connection := aDB;

    oQ.SQL.Text := 'SELECT SUM(total) FROM invoices WHERE YEAR(issued)=YEAR(NOW())';
    oQ.Open;
    MakeCard(aPage, 'YTD Revenue', FormatCurr('$#,##0', oQ.Fields[0].AsFloat),
      scPrimary, stUp, '+14%', 'vs last year', 1);
    oQ.Close;

    oQ.SQL.Text := 'SELECT COUNT(*) FROM invoices WHERE status=''open''';
    oQ.Open;
    MakeCard(aPage, 'Open Invoices', oQ.Fields[0].AsString,
      scInfo, stNeutral, '', 'awaiting payment', 2);
    oQ.Close;

    oQ.SQL.Text := 'SELECT COUNT(*) FROM invoices WHERE status=''overdue''';
    oQ.Open;
    MakeCard(aPage, 'Overdue', oQ.Fields[0].AsString,
      scDanger, stDown, '-3', 'vs last month', 3);
    oQ.Close;

    oQ.SQL.Text := 'SELECT ROUND(100.0*SUM(on_time)/COUNT(*),1) FROM deliveries';
    oQ.Open;
    MakeCard(aPage, 'On-Time Delivery', oQ.Fields[0].AsString + '%',
      scSuccess, stUp, '+2pp', 'last 30 days', 4);
    oQ.Close;
  finally
    oQ.Free;
  end;
end;

Wykres trendu przychodów

Wykres przychodów zajmuje drugi wiersz. TsgcHTMLComponent_Chart przyjmuje tablice etykiet i zestawy danych z kolorami obramowania i wypełnienia. LoadFromDataSet mapuje wynik zapytania na dane wykresu jednym wywołaniem: przekaż zbiór danych, pole etykiety (nazwa miesiąca) i jedno lub więcej pól wartości (przychód, koszt).

uses
  sgcHTML_Component_Chart;

procedure TERP_Server.BuildRevenueChart(aPage: TsgcHTMLPage; aDB: TFDConnection);
var
  oChart: TsgcHTMLComponent_Chart;
  oQ:     TFDQuery;
begin
  oChart := TsgcHTMLComponent_Chart.Create(nil);
  oChart.PageBuilder  := aPage.PageBuilder;
  oChart.Section      := 'charts';
  oChart.SectionTitle := 'Revenue';
  oChart.SectionOrder := 1;
  oChart.ColumnWidth  := cw8;
  oChart.ChartType    := ctBar;
  oChart.Title        := 'Monthly Revenue vs Cost (last 12 months)';
  oChart.ShowLegend   := True;
  oChart.Stacked      := False;
  oChart.Responsive   := True;

  oQ := TFDQuery.Create(nil);
  try
    oQ.Connection := aDB;
    oQ.SQL.Text   :=
      'SELECT DATE_FORMAT(issued,''%b'') AS month, ' +
      '       SUM(total) AS revenue, ' +
      '       SUM(cost)  AS cost ' +
      'FROM invoices ' +
      'WHERE issued >= DATE_SUB(NOW(), INTERVAL 12 MONTH) ' +
      'GROUP BY YEAR(issued), MONTH(issued) ' +
      'ORDER BY YEAR(issued), MONTH(issued)';
    oQ.Open;
    // One call maps the query to labels + two datasets
    oChart.LoadFromDataSet(oQ, 'month', ['revenue', 'cost']);
  finally
    oQ.Free;
  end;
end;

Paginowana tabela faktur

TsgcHTMLComponent_DataTable łączy TsgcHTMLComponent_Grid i TsgcHTMLComponent_Pagination w jeden widget z wbudowanym polem wyszukiwania, etykietą liczby wierszy, opcjonalnym przyciskiem eksportu i konfigurowalną liczbą wierszy na stronie. LoadFromDataSet automatycznie odczytuje nazwy kolumn i typy; możesz następnie nadpisać etykiety nagłówków i szerokości kolumn przez Grid.Columns.

uses
  sgcHTML_Component_DataTable;

procedure TERP_Server.BuildInvoiceTable(aPage: TsgcHTMLPage; aDB: TFDConnection);
var
  oTable: TsgcHTMLComponent_DataTable;
  oQ:     TFDQuery;
begin
  oTable := TsgcHTMLComponent_DataTable.Create(nil);
  oTable.PageBuilder    := aPage.PageBuilder;
  oTable.Section        := 'invoices';
  oTable.SectionTitle   := 'Invoices';
  oTable.SectionOrder   := 1;
  oTable.ColumnWidth    := cw12;
  oTable.Title          := 'Recent Invoices';
  oTable.ShowSearch     := True;
  oTable.ShowExport     := True;
  oTable.ShowPageSize   := True;
  oTable.PageSizes      := '10,25,50';

  oQ := TFDQuery.Create(nil);
  try
    oQ.Connection := aDB;
    oQ.SQL.Text   :=
      'SELECT number, customer, issued, due, total, status ' +
      'FROM invoices ' +
      'ORDER BY issued DESC ' +
      'LIMIT 200';
    oQ.Open;
    oTable.LoadFromDataSet(oQ, 20);
  finally
    oQ.Free;
  end;
end;

Tablica Kanban dla realizacji zamówień

TsgcHTMLComponent_KanbanBoard reprezentuje wielokolumnową tablicę z obsługą przeciągania. Każda kolumna zawiera kolekcję kart; każda karta ma tytuł, opcjonalny opis, osobę przypisaną, tag i kolor. W kontekście ERP kolumny naturalnie odpowiadają stanom zamówień: Received, Picking, Packed, Shipped.

uses
  sgcHTML_Component_KanbanBoard;

procedure TERP_Server.BuildKanban(aPage: TsgcHTMLPage; aDB: TFDConnection);
var
  oBoard:  TsgcHTMLComponent_KanbanBoard;
  oCol:    TsgcHTMLKanbanColumn;
  oQ:      TFDQuery;
  vStatus: string;
begin
  oBoard := TsgcHTMLComponent_KanbanBoard.Create(nil);
  oBoard.PageBuilder  := aPage.PageBuilder;
  oBoard.Section      := 'kanban';
  oBoard.SectionTitle := 'Order Fulfilment';
  oBoard.SectionOrder := 1;
  oBoard.ColumnWidth  := cw12;

  // Create the four columns
  for vStatus in ['Received', 'Picking', 'Packed', 'Shipped'] do
  begin
    oCol := oBoard.Columns.Add;
    oCol.Title := vStatus;
    oCol.Color := hcLight;
  end;

  oQ := TFDQuery.Create(nil);
  try
    oQ.Connection := aDB;
    oQ.SQL.Text   :=
      'SELECT order_no, customer, qty_total, assigned_to, status ' +
      'FROM orders ' +
      'WHERE status IN (''Received'',''Picking'',''Packed'',''Shipped'') ' +
      'ORDER BY created DESC LIMIT 100';
    oQ.Open;
    while not oQ.Eof do
    begin
      // Find the column whose title matches the order status
      oCol := oBoard.Columns.FindByTitle(oQ.FieldByName('status').AsString);
      if Assigned(oCol) then
        oCol.AddCard(
          oQ.FieldByName('order_no').AsString,
          oQ.FieldByName('customer').AsString + ' — ' +
            oQ.FieldByName('qty_total').AsString + ' items',
          hcLight,
          oQ.FieldByName('assigned_to').AsString
        );
      oQ.Next;
    end;
  finally
    oQ.Free;
  end;
end;

Składanie pełnej strony

Cztery sekcje są składane przez jedną procedurę ServeDashboard, która tworzy stronę, wywołuje każdy budowniczy, opakowuje wynik w szablon Bootstrap i zapisuje go do odpowiedzi:

procedure TERP_Server.ServeDashboard(AResp: TIdHTTPResponseInfo);
var
  oPage:     TsgcHTMLPage;
  oTemplate: TsgcHTMLTemplate_Bootstrap;
begin
  oPage     := TsgcHTMLPage.Create(nil);
  oTemplate := TsgcHTMLTemplate_Bootstrap.Create(nil);
  try
    BuildKPIRow(oPage, FDB);
    BuildRevenueChart(oPage, FDB);
    BuildInvoiceTable(oPage, FDB);
    BuildKanban(oPage, FDB);

    oTemplate.Title       := 'ERP Dashboard';
    oTemplate.Page        := oPage;
    oTemplate.DarkMode    := False;
    oTemplate.BodyClass   := 'bg-light';

    AResp.ContentType := 'text/html; charset=utf-8';
    AResp.ContentText := oTemplate.GetHTML;
    AResp.ResponseNo  := 200;
  finally
    oPage.Free;
    oTemplate.Free;
  end;
end;

Aktualizacje na żywo przez WebSocket

Pulpit nawigacyjny byłby statyczny bez danych na żywo. sgcHTML opiera się na sgcWebSockets, więc wysłanie aktualizacji fragmentu przy nowym zamówieniu to jedno wywołanie rozgłoszenia. Po stronie klienta karta KPI ma atrybut id, a rozszerzenie WebSocket htmx zastępuje jej zawartość po nadejściu wiadomości.

// Called from the order-processing thread when a new order is saved
procedure TERP_Server.OnNewOrderSaved(aOrder: TERPOrder);
var
  oCard: TsgcHTMLComponent_StatCard;
begin
  // Rebuild the "Open Invoices" KPI card with the updated count
  oCard := TsgcHTMLComponent_StatCard.Create(nil);
  try
    oCard.CardID  := 'kpi-open-invoices';
    oCard.Title   := 'Open Invoices';
    oCard.Value   := IntToStr(FDB.OpenInvoiceCount);
    oCard.Color   := scInfo;
    oCard.Trend   := stUp;
    // Broadcast the new card HTML to all connected dashboard clients
    HTTPServer.Broadcast(oCard.HTML, '/dashboard');
  finally
    oCard.Free;
  end;
end;

Fragment zastępuje pasującą kartę w przeglądarce natychmiast, bez przeładowywania strony i bez konieczności klikania czegokolwiek przez użytkownika. Każdy podłączony klient widzi aktualizację w ciągu milisekund.

Co otrzymujesz na końcu

Kompletny przykład to pojedynczy unit Delphi i moduł danych. Skompiluj i uruchom: dostępny przez przeglądarkę pulpit nawigacyjny ERP pojawia się na skonfigurowanym porcie z czterema kartami KPI, 12-miesięcznym wykresem słupkowym przychód a koszt, przeszukiwalną i paginowaną tabelą faktur oraz wielokolumnową tablicą Kanban dla realizacji zamówień. Gdy składane są nowe zamówienia, karty KPI aktualizują się w czasie rzeczywistym we wszystkich podłączonych kartach przeglądarki. Łączna liczba napisanych linii kodu front-endowego: zero.

Ten sam wzorzec działa dla dowolnej aplikacji biznesowej Delphi: zarządzanie magazynem, harmonogramowanie produkcji, systemy HR, śledzenie floty. Jeśli dane są w TDataSet, mogą znaleźć się na żywym pulpicie nawigacyjnym w sieci w ciągu jednego poranka.

Pobierz bezpłatną wersję próbną sgcHTML ze strony esegece.com/products/sgchtml/download i przejrzyj dołączone przykładowe aplikacje: demonstrację ERP, konsolę administracyjną, monitor na żywo i portal klientów, aby zobaczyć kompletny działający przykład.

Pytania? Skontaktuj się z nami. Odpowiedź otrzymasz od osób, które napisały ten kod.