Lewati ke konten utama

Dashboard Widget Templates — Generic Reusable Components

Tujuan Dokumen

Dokumen ini adalah lanjutan dari dashboard-widget-architecture.md dan menjawab pertanyaan:

Apakah developer harus membuat .razor component baru untuk setiap widget, atau bisa reuse template yang sudah ada?

Jawaban singkat: Untuk sebagian besar widget, developer hanya perlu membuat data service — tidak perlu .razor baru. Ini dimungkinkan dengan mendefinisikan Generic Widget Templates: sekumpulan component Blazor yang merender data dalam format standar, sehingga tampilan ditentukan oleh tipe template yang dipilih di catalog, bukan oleh component dedicated per widget.


Prinsip Dasar

Hampir semua widget di dashboard-widget-summary.md jatuh ke 5 pola tampilan:

┌──────────────────────────────────────────────────────────┐
│ TEMPLATE 1 TEMPLATE 2 TEMPLATE 3 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 42,500 │ │ Target │ │ ████ │ │
│ │ Total AR│ │ [====80%]│ │ ██████ │ │
│ │ Overdue │ │ │ │ ████ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ KPI Card KPI + Progress Bar / Line Chart │
│ │
│ TEMPLATE 4 TEMPLATE 5 │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ ● ITEM A !overdue │ │ ▶ Sales A │ │
│ │ ● ITEM B │ │ SO-001, SO-002... │ │
│ │ ● ITEM C !alert │ │ ▶ Sales B │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ Alert Table Grouped Expandable Table │
└──────────────────────────────────────────────────────────┘

Kelima template ini cukup untuk menampilkan seluruh widget di backlog Phase 1 tanpa .razor baru per widget.


Alur Pengembangan Widget Baru

Dengan Generic Templates (jalur utama)

Developer Database
│ │
├─ 1. Buat IWidgetXxxService ──────►│
│ + WidgetXxxService │
│ (return WidgetDataModel) │
│ │
├─ 2. Daftarkan di catalog ────────►│ INSERT dashboard_widget_catalog
│ wgt_component = null │ (wgt_template_type = 'KPI_CARD')
│ wgt_template_type = 'KPI' │
│ │
└─ 3. Assign ke role ─────────────►│ INSERT dashboard_role_config

Tidak ada file .razor baru yang dibuat.

Dengan Custom Component (jalur pengecualian)

Digunakan hanya jika widget butuh interaksi atau layout yang tidak bisa diakomodasi template manapun.

Developer Database
│ │
├─ 1. Buat IWidgetXxxService │
├─ 2. Buat WidgetXxx.razor ─────► │
├─ 3. Catalog: wgt_component = │ INSERT dashboard_widget_catalog
│ 'WidgetXxx' │ (wgt_component = 'WidgetXxx')
└─ 4. Assign ke role ─────────────►│ INSERT dashboard_role_config

Format Data Standar Per Template

Setiap template menerima data melalui interface standar. Service widget mengimplementasikan interface yang sesuai dengan template yang dipilih.

Template 1 — KPI Card

Digunakan untuk: Cash Position, Total AR, Total AP, Headcount, Count widget apapun.

// Interface yang diimplementasikan data service
public interface IWidgetKpiCardSource
{
Task<WidgetKpiCardData> GetDataAsync(WidgetQueryParams p);
}

public record WidgetKpiCardData(
string Value, // nilai yang ditampilkan, sudah diformat: "Rp 42.500.000"
string SubLabel, // label sekunder opsional: "vs last month: +12%"
string Icon, // FontAwesome class: "fas fa-coins"
string ColorScheme, // "success" | "warning" | "danger" | "info" | "primary"
string? DrillDownUrl // URL target klik, null = tidak clickable
);

Contoh service:

public class WidgetCashPositionService : IWidgetKpiCardSource
{
public async Task<WidgetKpiCardData> GetDataAsync(WidgetQueryParams p)
{
using var conn = SqlHelper.GetConnection();
var total = await conn.QueryFirstOrDefaultAsync<decimal>(
@"SELECT coalesce(sum(glbal_balance_open + glbal_balance_posted),0)
FROM bk_mstr
INNER JOIN glbal_balance ON glbal_ac_id = bk_ac_id
WHERE bk_active = 'Y'
AND bk_en_id = ANY(@entity_ids)",
new { entity_ids = p.EntityIds });

return new WidgetKpiCardData(
Value: "Rp " + total.ToString("#,##0"),
SubLabel: null,
Icon: "fas fa-university",
ColorScheme: total < 0 ? "danger" : "success",
DrillDownUrl: "/FGeneralLedgerCashReport"
);
}
}

Catalog entry:

INSERT INTO dashboard_widget_catalog
(wgt_code, wgt_name, wgt_category, wgt_template_type, wgt_service_type, wgt_default_col_span)
VALUES
('CASH_POSITION', 'Cash Position', 'Financial', 'KPI_CARD',
'NeuronLibrary.Data.Smart_Report.Dashboard.WidgetCashPositionService', 3);

Template 2 — KPI Card + Progress Bar

Digunakan untuk: Sales Achievement vs Target, Budget Utilization, WO Completion Rate.

public interface IWidgetKpiProgressSource
{
Task<WidgetKpiProgressData> GetDataAsync(WidgetQueryParams p);
}

public record WidgetKpiProgressData(
string MainValue, // "Rp 1.250.000.000"
string TargetValue, // "Target: Rp 1.500.000.000"
double ProgressPct, // 83.3 (persen, 0-100+)
string ColorScheme, // otomatis: <70 danger, 70-90 warning, >90 success
string? DrillDownUrl
);

Template 3 — Chart (Bar / Line / Donut)

Digunakan untuk: Revenue Trend, AR Aging Stacked Bar, Sales by Category, Grade Packing Chart.

Template ini paling generik — satu template menanggung beberapa sub-tipe chart karena DevExpress Chart menggunakan pola yang seragam.

public interface IWidgetChartSource
{
Task<WidgetChartData> GetDataAsync(WidgetQueryParams p);
}

public record WidgetChartData(
string ChartType, // "Bar" | "StackedBar" | "Line" | "Spline" | "Donut" | "Pie"
List<ChartSeries> Series,
string? XAxisLabel,
string? YAxisLabel,
string? DrillDownUrl
);

public record ChartSeries(
string Name, // nama seri: "Grade A", "Grade B"
string Color, // hex: "#28a745" atau nama: "success"
List<ChartDataPoint> Points
);

public record ChartDataPoint(
string Argument, // label X: "Jan 2025", "Item ABC"
double Value
);

Contoh service untuk Aging Stacked Bar:

public class WidgetArAgingChartService : IWidgetChartSource
{
public async Task<WidgetChartData> GetDataAsync(WidgetQueryParams p)
{
using var conn = SqlHelper.GetConnection();
var rows = await conn.QueryAsync<ArAgingRow>(
@"SELECT
CASE WHEN days_overdue <= 30 THEN '0-30'
WHEN days_overdue <= 60 THEN '31-60'
WHEN days_overdue <= 90 THEN '61-90'
ELSE '>90' END AS bucket,
SUM(ar_outstanding) AS total
FROM v_ar_aging
WHERE ar_en_id = ANY(@entity_ids)
GROUP BY bucket",
new { entity_ids = p.EntityIds });

return new WidgetChartData(
ChartType: "StackedBar",
Series: rows.Select(r => new ChartSeries(
Name: r.Bucket,
Color: r.Bucket switch { "0-30" => "#28a745", "31-60" => "#ffc107",
"61-90" => "#fd7e14", _ => "#dc3545" },
Points: [new ChartDataPoint("AR Aging", r.Total)]
)).ToList(),
XAxisLabel: null,
YAxisLabel: "Rp",
DrillDownUrl: "/FARReportByAging"
);
}
}

Template 4 — Alert Table

Digunakan untuk: Low Stock Alert, Overdue AR List, Overdue PO, SO Belum WO.

Tabel dengan kolom fleksibel, support highlight baris berdasarkan severity, dan optional action button per baris.

public interface IWidgetAlertTableSource
{
Task<WidgetAlertTableData> GetDataAsync(WidgetQueryParams p);
}

public record WidgetAlertTableData(
List<string> Columns, // header kolom: ["No. SO", "Customer", "Aging"]
List<AlertTableRow> Rows,
string? DrillDownUrl,
int MaxRows = 10 // batas baris tampil, sisanya "Lihat semua"
);

public record AlertTableRow(
List<string> Cells, // nilai per kolom, sudah diformat sebagai string
string Severity, // "" | "warning" | "danger" → warna highlight baris
string? RowDrillDownUrl // URL klik per baris (opsional)
);

Contoh service SO Belum WO:

public class WidgetSoBelumWoService : IWidgetAlertTableSource
{
public async Task<WidgetAlertTableData> GetDataAsync(WidgetQueryParams p)
{
using var conn = SqlHelper.GetConnection();
var rows = await conn.QueryAsync<SoBelumWoRow>(
@"SELECT so_no, ptnr_name, pt_desc1, so_qty_order,
so_req_delivery_date,
CURRENT_DATE - so_date AS aging_days
FROM so_mstr
INNER JOIN ptnr_mstr ON ptnr_id = so_ptnr_id_sold
INNER JOIN sod_det ON sod_so_oid = so_oid
INNER JOIN pt_mstr ON pt_id = sod_pt_id
WHERE so_trans_id = 'C' -- confirmed
AND NOT EXISTS (
SELECT 1 FROM wo_mstr WHERE wo_so_oid = so_oid
)
AND so_en_id = ANY(@entity_ids)
ORDER BY aging_days DESC
LIMIT 20",
new { entity_ids = p.EntityIds });

return new WidgetAlertTableData(
Columns: ["No. SO", "Customer", "Item", "Qty", "Req. Delivery", "Aging"],
Rows: rows.Select(r => new AlertTableRow(
Cells: [
r.SoNo, r.PtnrName, r.PtDesc1,
r.SoQtyOrder.ToString("N0"),
r.SoReqDeliveryDate?.ToString("dd/MM/yy") ?? "-",
$"{r.AgingDays} hari"
],
Severity: r.AgingDays > 7 ? "danger" : r.AgingDays > 3 ? "warning" : "",
RowDrillDownUrl: $"/FSalesOrderCreate?id={r.SoOid}"
)).ToList(),
DrillDownUrl: "/FSalesOrder",
MaxRows: 10
);
}
}

Template 5 — Grouped Expandable Table

Digunakan untuk: Outstanding SO per Salesperson, Kas Bank per rekening (dengan subtotal).

Tabel yang bisa di-group per satu field, dengan expand/collapse per group.

public interface IWidgetGroupedTableSource
{
Task<WidgetGroupedTableData> GetDataAsync(WidgetQueryParams p);
}

public record WidgetGroupedTableData(
string GroupByLabel, // "Salesperson"
List<string> DetailColumns, // ["No. SO", "Customer", "Nilai", "Aging"]
List<WidgetTableGroup> Groups,
string? DrillDownUrl
);

public record WidgetTableGroup(
string GroupLabel, // "Budi Santoso"
List<string> SummaryValues, // ringkasan di header group: ["5 SO", "Rp 250 jt", "12 hari"]
string Severity, // highlight group header jika ada alert
List<AlertTableRow> Rows // baris detail saat di-expand
);

Kolom Tambahan di dashboard_widget_catalog

Untuk mendukung generic templates, schema catalog di dashboard-widget-architecture.md perlu dua kolom tambahan:

ALTER TABLE dashboard_widget_catalog
ADD COLUMN wgt_template_type VARCHAR(30),
-- 'KPI_CARD' | 'KPI_PROGRESS' | 'CHART' | 'ALERT_TABLE' | 'GROUPED_TABLE' | NULL
-- NULL berarti widget punya component custom sendiri (wgt_component diisi)

ADD COLUMN wgt_service_type VARCHAR(200);
-- Fully-qualified type name service yang diinstansiasi via DI:
-- 'NeuronLibrary.Data.Smart_Report.Dashboard.WidgetCashPositionService'

Jika wgt_template_type diisi, DashboardContainer akan:

  1. Resolve service dari wgt_service_type via IServiceProvider
  2. Pass ke template component yang sesuai
  3. Template component memanggil GetDataAsync() dan merender hasilnya

Jika wgt_template_type NULL dan wgt_component diisi → fallback ke DynamicComponent (custom component, seperti dijelaskan di arsitektur doc).


Rendering Logic di DashboardContainer

// DashboardContainer.razor — @code (tambahan dari arsitektur doc)

private RenderFragment RenderWidget(DashboardWidgetConfigModel cfg) => builder =>
{
if (!string.IsNullOrEmpty(cfg.TemplateType))
{
// Generic template path
var templateType = cfg.TemplateType switch
{
"KPI_CARD" => typeof(TemplateKpiCard),
"KPI_PROGRESS" => typeof(TemplateKpiProgress),
"CHART" => typeof(TemplateChart),
"ALERT_TABLE" => typeof(TemplateAlertTable),
"GROUPED_TABLE" => typeof(TemplateGroupedTable),
_ => throw new InvalidOperationException($"Unknown template: {cfg.TemplateType}")
};

var service = _sp.GetRequiredService(
Type.GetType(cfg.ServiceType)
?? throw new InvalidOperationException($"Service type not found: {cfg.ServiceType}")
);

builder.OpenComponent(0, templateType);
builder.AddAttribute(1, "DataSource", service);
builder.AddAttribute(2, "QueryParams", BuildQueryParams(cfg));
builder.AddAttribute(3, "RefreshIntervalSec", cfg.RefreshIntervalSec);
builder.AddAttribute(4, "Title", cfg.WidgetName);
builder.CloseComponent();
}
else if (!string.IsNullOrEmpty(cfg.ComponentName))
{
// Custom component path (fallback)
var componentType = Type.GetType(
$"NeuronLibraryUI.Shared.Components.Dashboard.Widgets.{cfg.ComponentName}");
builder.OpenComponent(0, componentType!);
builder.AddAttribute(1, "QueryParams", BuildQueryParams(cfg));
builder.CloseComponent();
}
};

Ringkasan: Kapan Developer Perlu Buat Apa?

Kebutuhan Widget BaruYang Perlu Dibuat
KPI angka tunggal (apapun)Data service saja
KPI + progress bar vs targetData service saja
Bar / line / donut chartData service saja
Tabel dengan alert highlightData service saja
Tabel grouped + expandableData service saja
Widget dengan interaksi custom (form input, tab, toggle)Data service + .razor component
Widget yang merupakan mini-report embed (DevExpress viewer)Data service + .razor component

Estimasi coverage: ~85% widget dari backlog Phase 1 masuk jalur "data service saja". Hanya widget seperti "SO Pipeline dengan filter stage interaktif" atau "Grade Packing dengan toggle Rol↔Kg" yang perlu custom component.


Mapping Widget Backlog ke Template

Widget (dari dashboard-widget-summary.md)TemplateCustom?
Cash PositionKPI_CARD-
Total AR / AP OutstandingKPI_CARD-
Gross Profit MarginKPI_CARD-
Headcount SummaryKPI_CARD-
Sales AchievementKPI_PROGRESS-
Budget UtilizationKPI_PROGRESS-
WO Completion RateKPI_PROGRESS-
Revenue vs Target (Bar+Line)CHART-
Sales by Product Category (Donut)CHART-
Top 5 Customers by RevenueCHART-
AR Aging Summary (Stacked Bar)CHART-
Production Trend (Line)CHART-
Grade Packing Rol (Grouped Bar)CHART-
Grade Packing Kg (Grouped Bar)CHART-
Overdue AR ListALERT_TABLE-
Low Stock AlertALERT_TABLE-
SO Belum WOALERT_TABLE-
Overdue PO DeliveryALERT_TABLE-
GL Unposted TransactionsALERT_TABLE-
Upcoming AP DueALERT_TABLE-
Kas Bank ListALERT_TABLE-
Outstanding SO per SalespersonGROUPED_TABLE-
SO Pipeline (Confirmed→WO→DO→Delivered)-✓ Custom
Grade Packing dengan toggle Rol↔Kg-✓ Custom
My Pending Tasks (Operator)-✓ Custom

Lokasi File yang Diperbarui

NeuronLibraryUI/
└── Shared/Components/Dashboard/
├── Templates/ ← BARU
│ ├── TemplateKpiCard.razor
│ ├── TemplateKpiCard.razor.css
│ ├── TemplateKpiProgress.razor
│ ├── TemplateChart.razor
│ ├── TemplateAlertTable.razor
│ ├── TemplateAlertTable.razor.css
│ └── TemplateGroupedTable.razor
├── Widgets/ ← hanya untuk custom component
│ ├── WidgetSoPipeline.razor
│ ├── WidgetGradePackingToggle.razor
│ └── WidgetPendingTasks.razor
├── DashboardContainer.razor
└── WidgetShell.razor

NeuronLibrary/
└── Data/Smart Report/Dashboard/
├── Models/
│ ├── WidgetQueryParams.cs ← parameter standar semua service
│ ├── WidgetKpiCardData.cs
│ ├── WidgetKpiProgressData.cs
│ ├── WidgetChartData.cs
│ ├── WidgetAlertTableData.cs
│ └── WidgetGroupedTableData.cs
├── Interfaces/
│ ├── IWidgetKpiCardSource.cs
│ ├── IWidgetKpiProgressSource.cs
│ ├── IWidgetChartSource.cs
│ ├── IWidgetAlertTableSource.cs
│ └── IWidgetGroupedTableSource.cs
└── Services/ ← satu file per widget
├── WidgetCashPositionService.cs
├── WidgetArAgingService.cs
├── WidgetSalesAchievementService.cs
├── WidgetSoBelumWoService.cs
└── ...

WidgetQueryParams — Parameter Standar

Semua service menerima parameter tunggal ini untuk konsistensi:

public record WidgetQueryParams(
int[] EntityIds,
int[] BranchIds,
int UserId,
DateTime DateFrom,
DateTime DateTo,
string? Extra1 = null, // parameter tambahan spesifik widget jika perlu
string? Extra2 = null
);

DashboardContainer membangun WidgetQueryParams dari context user (entity, branch, user_id) dan periode default yang dikonfigurasi di dashboard_role_config. Developer service tidak perlu tahu dari mana parameter ini datang.