Een Real-Time ERP-dashboard bouwen met sgcHTML en Delphi

· Applicaties

ERP-systemen zijn het hart van Delphi-ontwikkeling: klanten, orders, facturen, voorraadhoeveelheden, productieruns. De bedrijfslogica leeft al in Object Pascal. De database is aangesloten. De rapporten draaien. Wat vaak ontbreekt is een via de browser toegankelijk dashboard dat het management op een tablet kan openen zonder iets te hoeven installeren. Dit artikel laat zien hoe je precies dat bouwt met sgcHTML, aan de hand van een realistisch scenario: een verkoop-ERP met live omzetcijfers, een facturenraster, een bezorging-Kanban-bord en real-time push wanneer nieuwe orders binnenkomen.

Alle code compileert in Delphi 10.4 Sydney en later. Het eindresultaat is een enkel .exe-bestand dat het dashboard serveert op poort 8080, met Bootstrap 5-opmaak, een responsieve layout en WebSocket live-updates, en zonder JavaScript-bestanden die naast het binaire bestand geplaatst moeten worden.

De server instellen

sgcHTML omhult een TsgcWSHTTPServer van sgcWebSockets. Zet TsgcHTMLEngine_Server op de datamodule naast de HTTP-server en verbind ze met elkaar. De engine onderschept automatisch verzoeken voor statische assets (Bootstrap CSS/JS, Chart.js, htmx); al het overige gaat naar jouw OnCommandGet-handler.

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;

KPI-statkaarten

De bovenste rij van het dashboard toont vier KPI-kaarten: totale omzet, openstaande facturen, aantal vervallen facturen en het percentage tijdige leveringen. TsgcHTMLComponent_StatCard verzorgt de volledige weergave: achtergrondverloop, titel, grote waarde, trendpijl en een optioneel voettekstlabel.

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;

Omzettrend-grafiek

De omzetgrafiek beslaat de tweede rij. TsgcHTMLComponent_Chart accepteert labelreeksen en datasets met rand- en vulkleuren. LoadFromDataSet koppelt een queryresultaat aan grafiekdata met één aanroep: geef de dataset mee, het labelveld (maandnaam) en een of meer waardevelden (omzet, kosten).

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;

Gepagineerde factuurentabel

TsgcHTMLComponent_DataTable combineert een TsgcHTMLComponent_Grid en een TsgcHTMLComponent_Pagination tot één widget met een ingebouwd zoekvak, een rijentellabel, een optionele exportknop en configureerbare paginagroottes. LoadFromDataSet leest kolomnamen en typen automatisch uit; daarna kun je kopteksten en kolombreedten aanpassen via 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;

Kanban-bord voor orderverwerking

TsgcHTMLComponent_KanbanBoard stelt een meerkolomig, sleepbaar bord voor. Elke kolom bevat een verzameling kaarten; elke kaart heeft een titel, een optionele beschrijving, een toegewezen persoon, een tag en een kleur. In een ERP-context komen de kolommen op natuurlijke wijze overeen met orderstaten: 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;

De volledige pagina samenstellen

De vier secties worden samengebracht in één ServeDashboard-procedure die een pagina aanmaakt, elke builder aanroept, het resultaat inpakt in een Bootstrap-sjabloon en naar de respons schrijft:

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;

Live updates via WebSocket

Het dashboard zou statisch zijn zonder live data. sgcHTML bouwt voort op sgcWebSockets, waardoor het pushen van een fragmentupdate wanneer een nieuwe order wordt geplaatst slechts één broadcast-aanroep vereist. Aan de clientzijde bevat de statkaart een id-attribuut en de htmx WebSocket-extensie vervangt de inhoud wanneer een bericht binnenkomt.

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

Het fragment vervangt de bijbehorende kaart in de browser direct, zonder het herladen van de pagina en zonder dat de gebruiker ergens op hoeft te klikken. Elke verbonden client ziet de update binnen milliseconden.

Wat je aan het einde hebt

Het volledige voorbeeld bestaat uit één Delphi-unit en een datamodule. Compileer en start: er verschijnt een via de browser toegankelijk ERP-dashboard op de geconfigureerde poort, met vier KPI-kaarten, een staafgrafiek van omzet versus kosten over 12 maanden, een doorzoekbare en gepagineerde factuurentabel en een meerkolomig Kanban-bord voor orderverwerking. Wanneer nieuwe orders worden geplaatst, worden de KPI-kaarten in real time bijgewerkt in elk verbonden browsertabblad. Het totale aantal geschreven front-endregels: nul.

Hetzelfde patroon werkt voor elke Delphi-bedrijfsapplicatie: voorraadbeheer, productieplanning, HR-systemen, vlootbeheer. Als de data in een TDataSet zit, kan die 's ochtends al op een live webdashboard staan.

Download de gratis proefversie van sgcHTML via esegece.com/products/sgchtml/download en bekijk de meegeleverde ERP-demo, Beheerconsole, Live Monitor en Klantenportal-applicaties voor een volledig werkend voorbeeld.

Vragen? Neem contact op. Je krijgt antwoord van de mensen die de code hebben geschreven.