sgcHTML과 Delphi로 실시간 ERP 대시보드 구축하기

· 응용 프로그램

ERP 시스템은 Delphi 개발의 핵심 영역입니다. 고객, 주문, 청구서, 재고 수준, 생산 실행 등이 포함됩니다. 비즈니스 로직은 이미 Object Pascal에 구현되어 있습니다. 데이터베이스도 연결되어 있습니다. 보고서도 실행됩니다. 그러나 종종 빠진 것은 관리자가 아무것도 설치하지 않고 태블릿에서 열 수 있는 브라우저 접근 가능한 대시보드입니다. 이 글에서는 현실적인 시나리오를 바탕으로 sgcHTML을 사용하여 정확히 그런 대시보드를 구축하는 방법을 보여줍니다. 라이브 매출 수치, 청구서 그리드, 배송 칸반 보드, 새 주문이 도착할 때의 실시간 푸시가 포함된 영업 ERP입니다.

모든 코드는 Delphi 10.4 Sydney 이상에서 컴파일됩니다. 완성된 결과물은 포트 8080에서 대시보드를 제공하는 단일 .exe 파일로, Bootstrap 5 스타일링, 반응형 레이아웃, WebSocket 실시간 업데이트를 포함하며 바이너리와 함께 배포할 JavaScript 파일이 없습니다.

서버 설정

sgcHTML은 sgcWebSockets의 TsgcWSHTTPServer를 래핑합니다. 데이터 모듈에 HTTP 서버와 함께 TsgcHTMLEngine_Server를 놓고 연결합니다. 엔진은 정적 자산 요청(Bootstrap CSS/JS, Chart.js, htmx)을 자동으로 가로채고 나머지는 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;

KPI 통계 카드

대시보드 상단 행에는 네 개의 KPI 카드가 표시됩니다. 총 매출, 미결 청구서, 연체 건수, 정시 배송률입니다. TsgcHTMLComponent_StatCard가 배경 그라데이션, 제목, 큰 값, 추세 화살표, 선택적 푸터 레이블 등 전체 시각 요소를 처리합니다.

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;

매출 추세 차트

매출 차트는 두 번째 행에 걸쳐 있습니다. TsgcHTMLComponent_Chart는 레이블 배열과 테두리 및 채우기 색상이 있는 데이터셋을 받습니다. LoadFromDataSet은 단일 호출로 쿼리 결과를 차트 데이터로 매핑합니다. 데이터셋, 레이블 필드(월 이름), 하나 이상의 값 필드(매출, 비용)를 전달합니다.

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;

페이지 분할된 청구서 테이블

TsgcHTMLComponent_DataTableTsgcHTMLComponent_GridTsgcHTMLComponent_Pagination을 내장된 검색 박스, 행 수 레이블, 선택적 내보내기 버튼, 구성 가능한 페이지 크기를 갖춘 하나의 위젯으로 구성합니다. LoadFromDataSet은 열 이름과 유형을 자동으로 읽으며, 이후 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;

주문 처리를 위한 칸반 보드

TsgcHTMLComponent_KanbanBoard는 다중 열 드래그 친화적인 보드를 나타냅니다. 각 열에는 카드 컬렉션이 있으며, 각 카드에는 제목, 선택적 설명, 담당자, 태그, 색상이 있습니다. ERP 맥락에서 열은 자연스럽게 주문 상태에 매핑됩니다. 수령(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;

전체 페이지 조합

네 개의 섹션은 단일 ServeDashboard 프로시저로 조합됩니다. 이 프로시저는 페이지를 만들고, 각 빌더를 호출하고, 결과를 Bootstrap 템플릿으로 감싸서 응답에 씁니다.

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;

WebSocket을 통한 실시간 업데이트

라이브 데이터 없이는 대시보드가 정적입니다. sgcHTML은 sgcWebSockets 위에 구축되어 있으므로, 새 주문이 접수될 때 프래그먼트 업데이트를 푸시하는 것은 단일 브로드캐스트 호출입니다. 클라이언트 측에서 통계 카드는 id 속성을 가지며, htmx WebSocket 확장은 메시지가 도착할 때 해당 내용을 교체합니다.

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

프래그먼트는 페이지를 새로 고치지 않고, 사용자가 아무것도 클릭하지 않아도 브라우저의 일치하는 카드를 즉시 교체합니다. 연결된 각 클라이언트는 밀리초 내에 업데이트를 확인합니다.

최종 결과물

완성된 예제는 단일 Delphi 유닛과 데이터 모듈입니다. 컴파일하고 실행하면 설정된 포트에 브라우저 접근 가능한 ERP 대시보드가 나타납니다. 네 개의 KPI 카드, 12개월 매출 대 비용 막대 차트, 검색 및 페이지 분할된 청구서 테이블, 주문 처리를 위한 다중 열 칸반 보드가 포함됩니다. 새 주문이 접수되면 연결된 모든 브라우저 탭에서 KPI 카드가 실시간으로 업데이트됩니다. 작성한 프런트엔드 코드의 총량은 0줄입니다.

동일한 패턴이 모든 Delphi 비즈니스 애플리케이션에 적용됩니다. 재고 관리, 생산 일정, HR 시스템, 차량 추적 등. 데이터가 TDataSet에 있다면 오전 내에 라이브 웹 대시보드로 만들 수 있습니다.

esegece.com/products/sgchtml/download에서 sgcHTML 무료 체험판을 다운로드하고 포함된 ERP 데모, 관리 콘솔, 라이브 모니터, 고객 포털 애플리케이션을 살펴보시면 완전한 작동 예제를 확인하실 수 있습니다.

질문이 있으신가요? 문의하기. 코드를 작성한 개발자들로부터 직접 답변을 받으실 수 있습니다.