Dashboard Widget Templates — Generic Reusable Components
Tujuan Dokumen
Dokumen ini adalah lanjutan dari dashboard-widget-architecture.md dan menjawab pertanyaan:
Apakah developer harus membuat
.razorcomponent 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:
- Resolve service dari
wgt_service_typeviaIServiceProvider - Pass ke template component yang sesuai
- 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 Baru | Yang Perlu Dibuat |
|---|---|
| KPI angka tunggal (apapun) | Data service saja |
| KPI + progress bar vs target | Data service saja |
| Bar / line / donut chart | Data service saja |
| Tabel dengan alert highlight | Data service saja |
| Tabel grouped + expandable | Data 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) | Template | Custom? |
|---|---|---|
| Cash Position | KPI_CARD | - |
| Total AR / AP Outstanding | KPI_CARD | - |
| Gross Profit Margin | KPI_CARD | - |
| Headcount Summary | KPI_CARD | - |
| Sales Achievement | KPI_PROGRESS | - |
| Budget Utilization | KPI_PROGRESS | - |
| WO Completion Rate | KPI_PROGRESS | - |
| Revenue vs Target (Bar+Line) | CHART | - |
| Sales by Product Category (Donut) | CHART | - |
| Top 5 Customers by Revenue | CHART | - |
| AR Aging Summary (Stacked Bar) | CHART | - |
| Production Trend (Line) | CHART | - |
| Grade Packing Rol (Grouped Bar) | CHART | - |
| Grade Packing Kg (Grouped Bar) | CHART | - |
| Overdue AR List | ALERT_TABLE | - |
| Low Stock Alert | ALERT_TABLE | - |
| SO Belum WO | ALERT_TABLE | - |
| Overdue PO Delivery | ALERT_TABLE | - |
| GL Unposted Transactions | ALERT_TABLE | - |
| Upcoming AP Due | ALERT_TABLE | - |
| Kas Bank List | ALERT_TABLE | - |
| Outstanding SO per Salesperson | GROUPED_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.