
## client/custom/modules/hyb-report/src/views/report-viewer/widget.js
20:             // Lấy flags từ config — KHÔNG dùng default all-true vì widget sẽ hiện tất cả kể cả khi tắt
23:             // Chọn currentView == view đầu tiên được bật theo thứ tự ưu tiên: pivot > chart > detail
111:             const chart = configs.chart || { type: 'BarChart' };
116:                 name: this.widgetData.name || 'Báo cáo',
123:                 isBarChart: wType === 'BarChart',
124:                 isLineChart: wType === 'LineChart',
125:                 isPieChart: wType === 'PieChart',
126:                 showGeneralConfig: ['BarChart', 'LineChart', 'PieChart', 'ScatterChart', 'FunnelChart', 'RadarChart', 'GaugeChart', 'NumberMetric'].includes(wType),
155:                     // Gọi song song để tiết kiệm thời gian
163:                     // Detail-only: bỏ qua getPivotData hoàn toàn
177:             const data = await Espo.Ajax.postRequest('CReportWidget/action/getMatrixData', { id: widgetId });
198:             const data = await Espo.Ajax.postRequest('CReportWidget/action/getDetailData', {
228:                     // [FIX GrandTotal]: StudioPivotRenderer đọc view.displayConfig
229:                     // Luôn re-resolve từ widgetData mỗi lần render để nhận settings mới nhất sau khi lưu
251:                 type: chartConfig.type || 'BarChart',
266:                 console.log("HybReport: Magic Wand splitting value:", val);
305:             // Nếu đã có detailData được fetch sẵn từ fetchData vòng đầu, dùng luôn
311:             // Fetch lại khi chuyển trang hoặc lần đầu render (mixed-view widget)
321:             Espo.Ajax.postRequest('CReportWidget/action/getDetailData', {
325:                 sortParams: this.sortParams  // [#10] Bug1 fix: truyền sortParams
337:                 $container.html('<div style="padding: 20px; text-align: center; color: #94a3b8;">Không có dữ liệu</div>');
360:                         Hiển thị ${startRow}&ndash;${endRow} trong tổng số <strong>${total.toLocaleString()}</strong> bản ghi
365:                             ${[20, 50, 100, 200].map(n => `<option value="${n}" ${n === limit ? 'selected' : ''}>${n} dòng</option>`).join('')}
382:                 // [S4] Sort icon: detect trạng thái sort cho cột này
386:                 const icon = sortDir === 'ASC' ? '&#9650;' : (sortDir === 'DESC' ? '&#9660;' : '&#8693;');
430:                     this.sortParams = [{ sourceId, field, dir: 'DESC' }];
439:                     this.sortParams[idx].dir = 'DESC';
476:             // Re-compute currentView từ flags mới (fix: currentView không được reset khi save Studio)
488:                 this.currentView = 'detail'; // fallback an toàn
512:             const viewKey = type + 'SettingsView';

## client/custom/modules/hyb-report/src/views/report-viewer/modals/drill-down-modal.js
27:                 if (e.key === 'Escape') this.close();
73:                 let response = await Espo.Ajax.postRequest('CReportWidget/action/getDrillDownData', {
173:                     displayValue = '(trống)';
180:                 label: 'Metric',
235:                     let d = new Date(val + 'T00:00:00');

## client/custom/modules/hyb-report/src/views/report-viewer/helpers/renderers/tables/pivot-table.js
12:             // [FIX LỖI NONE]: Hiển thị Placeholder đẹp mắt nếu chưa chọn Chart
17:                         <div style="font-size: 16px; font-weight: bold; color: #475569;">Chưa chọn Loại Biểu đồ</div>
18:                         <div style="font-size: 13px; margin-top: 5px;">Vui lòng thiết lập cấu hình nâng cao để hiển thị bảng dữ liệu.</div>
37:             // EPIC 7: Nếu Backend đã trả về Grand Total dạng dòng (row), ta dùng nó luôn
96:                 if (val === 'None' || val === '' || val === null || val === undefined) return '-';
112:              * [MAGIC-WAND]: Bóc tách nhãn hiển thị từ chuỗi Combo-String (ID:::Label)
140:                         id: rowIndexCounter++, depth: depth, label: rowLabel || "Tổng hợp",
147:                             if (v !== 'None' && v !== null && v !== '') colVals.push(translateVal(v, f, cItem.col_entities ? cItem.col_entities[f] : node.entity));
149:                         let colKey = colVals.join(' | ') || 'Total';
151:                             // Lưu lại nhãn đã format cho header hiển thị
162:             // EPIC 7 Logic: Nếu đã là mảng phẳng (Dataset-oriented), render thẳng
167:                 // Biến đổi flat object sang pivotRows format tối thiểu để loop
170:                     if (row._isGrandTotal) lbl = "GRAND TOTAL";
171:                     else if (row._isSubtotal) lbl = "SUBTOTAL";
182:                         dataPoints: { 'Total': row } // Trong mảng phẳng, metric nằm trực tiếp trong row
195:             // Sắp xếp từng cấp độ để đảm bảo các nhóm cùng cha đứng liền nhau
206:                 uniqueColPaths = uniqueColPaths.filter(path => path.join(' | ') !== 'Total');
216:                         let colKey = colPath.join(' | ') || 'Total';
233:             const headerRowHeight = 42; // Chiều cao cố định cho mỗi hàng header để tính top offset
244:                 /* [FIXED HEADER]: Hỗ trợ Sticky cho N hàng động */
274:                 // RENDER TRỤC CỘT (NHIỀU LỚP)
293:                         theadHtml += `<th colspan="${group.count * numMetrics}" style="text-align: center; border-bottom: 1px solid #dee2e6; font-weight: bold; color: #2563eb;">${group.label}</th>`;
299:                 // HÀNG CUỐI CÙNG: CÁC METRIC
308:                 // TRƯỜNG HỢP KHÔNG CÓ TRỤC CỘT (CHỈ CÓ METRIC)
336:                     let colKey = colPath.join(' | ') || 'Total';
361:             // EPIC 7: Chỉ hiển thị tfoot nếu KHÔNG có Server-side Totals (để tránh lặp dữ liệu)
367:                     let colKey = colPath.join(' | ') || 'Total';
411:          * [NEW] Logic chuẩn hoá dữ liệu phẳng sang cấu trúc node lồng nhau.
412:          * Đảm bảo Renderer hiểu được cấu trúc JSON đã thống nhất.
420:             // Xác định trục hàng (Row Dimensions)
423:                 // Phỏng đoán nếu thiếu meta (Lấy key đầu tiên không phải metric)
439:                         entity: (meta.dimensions && meta.dimensions[0]) ? meta.dimensions[0].entity : (widget.targetEntity || 'Lead')
444:                 // Xác định trục cột (Những gì không phải metric và không phải row dim)
447:                     if (!rowDimKeys.includes(k) && !metricKeys.includes(k) && k !== '_isGrandTotalNode') {

## client/custom/modules/hyb-report/src/views/report-viewer/helpers/renderers/echarts/basic-charts.js
10:             // BỘ SƯU TẬP BẢNG MÀU CHUYÊN NGHIỆP
24:             // [TÍNH NĂNG MỚI]: Giao diện chờ khi chưa chọn loại biểu đồ (Widget Type = None)
29:                         <div style="font-size: 16px; font-weight: bold; color: #475569;">Chưa chọn Loại Biểu đồ</div>
30:                         <div style="font-size: 13px; margin-top: 5px;">Vui lòng mở Khối 2 (Cấu hình Nâng cao) để chọn định dạng hiển thị.</div>
43:             // Loại bỏ Node Grand Total chuẩn của BE để không bị vẽ thành 1 cột "Tổng hợp" khổng lồ
50:             // ĐỌC VÀ CHUẨN HÓA CẤU HÌNH TỪ JSON
62:                 xAxisDimension: '', legendDimension: '', // [MỚI - EPIC 6]: Ánh xạ trục động
109:             // HÀM FORMAT TIỀN TỆ & RÚT GỌN
127:                 if (val === 'None' || val === '' || val === null || val === undefined) return '-';
145:              * [MAGIC-WAND]: Bóc tách nhãn hiển thị từ Combo-String (ID:::Label)
157:             // [PHASE 3 FIX]: Tự động lấy nhãn Metric từ columnOrder của Backend if headers is empty
166:             // [EPIC 6 - MỚI]: BỘ GIẢI MÃ DATASET (MÀU CHỐT CỦA CHIẾN DỊCH)
167:             // Ưu tiên sử dụng source phẳng từ BE nếu có
170:             let dimensionKeys = []; // Danh sách các key cột thực tế
171:             let actualMetricKeys = []; // [BUG 2 FIX]: Khai báo toàn cục để Number Metric dùng được
174:                 // EPIC 7: Nhận mảng Object từ BE
177:                 // Lọc bỏ các dòng Tổng (Subtotal/Grandtotal/Node) để biểu đồ ko bị vọt chỉ số khổng lồ
180:                 // [PHASE 2 FIX]: Ưu tiên lấy Axis từ rowAxis/colAxis của BE
189:                     // Fallback: Tự suy luận từ object đầu tiên (vẫn lọc bỏ metrics)
194:                 // Nhận dạng chốt Metric (Sử dụng columnOrder - Chân lý duy nhất của Metric)
198:                     // Fallback tương thích ngược: Tìm các key bắt đầu bằng calc_ hoặc form_
202:                 // [PHASE 3 FIX]: Dataset dimensions phải bao gồm cả Axis và Metrics
205:                 // Fallback logic cũ (traverseAndFlatten) - Giữ để ko hỏng các báo cáo cũ
217:                         if (currentVal && currentVal !== 'Tổng hợp') currentPath.push(currentVal);
222:                             let fullRowKey = currentPath.join(' | ') || 'Tổng';
244:                                 if (nodeHasMetrics || colKey) rowGroups[fullRowKey].pieDataRaw.push({ colKey: colKey || 'Chung', metrics: cItem.metrics });
254:                 // Lọc bỏ rác
257:                     if (rowGroups['Tổng']) delete rowGroups['Tổng'];
258:                     if (rowGroups['Tổng hợp']) delete rowGroups['Tổng hợp'];
262:                 // Chuyển kết quả vào scope chung để logic cũ phía dưới (Pie/Fallback) sử dụng
271:             // Loại bỏ logic thừa ở scope global
274:             if (type === 'NumberMetric' || type === 'List') {
275:                 if (type === 'NumberMetric') {
293:                     // [F51] List: hỗ trợ cả dataset path và legacy path
295:                     let metricLabel = 'Giá trị';
310:                         metricLabel = legacy.seriesNames[0] || 'Giá trị';
342:                 trigger: (type === 'PieChart' || type === 'Sunburst' || type === 'RadarChart') ? 'item' : 'axis',
344:                 // [EPIC 6]: Tooltip Format tự động
352:                         // [PHASE 3 FIX]: Xử lý lấy giá trị từ Object Dataset chuẩn xác
375:             // [LAYOUT ENGINE]: TÍNH TOÁN KHOẢNG CÁCH ĐỂ TRÁNH CHỒNG LẤP (OVERLAP)
382:             // [FIX]: Trục X nằm ở trên trong chế độ ngang cần thêm padding
383:             if (dConfig.barOrientation === 'horizontal' && type !== 'LineChart' && type !== 'PieChart' && type !== 'Sunburst') {
387:             // 1. Tính toán cho Title
395:                 topPad = 40; // Dành chỗ cho Title
398:             // 2. Tính toán cho Legend
409:                     topPad += 25; // Legend cao khoảng 25px
411:                     // Legend sẽ nằm trên DataZoom nếu cả hai cùng ở bottom
419:                     leftPad = '15%'; // Bóp lề trái để hiện legend dọc
424:                     rightPad = '15%'; // Bóp lề phải để hiện legend dọc
433:                         dataView: { readOnly: true, title: 'Xem Dữ liệu', lang: ['Dữ liệu Biểu đồ', 'Đóng', 'Làm mới'] },
434:                         saveAsImage: { title: 'Lưu Ảnh', name: widget.name || 'chart' }
439:             // [BUG #50] Gán dataset cho ECharts trước khi Pie/Sunburst/Scatter sử dụng datasetIndex
440:             // Funnel/Gauge dùng manual data riêng, không cần kết
446:             if (type === 'Sunburst') {
449:                     // Ưu tiên user config (Chart Mapping), fallback BE meta
511:             else if (type === 'PieChart') {
541:                         let lDim = (dConfig.legendDimensions || [])[0] || dConfig.legendDimension; // [#16] Pie chỉ dùng 1 legend dim
564:             else if (type === 'ScatterChart') {
593:             else if (type === 'FunnelChart') {
598:                     // [F51] Aggregate SUM theo xDim trước khi render
599:                     // Fix: multi-dim data (vd 36 rows cross-join) phải group thành N tầng
622:             // Mỗi metric = 1 spoke; mỗi dimension value = 1 polygon
624:             else if (type === 'RadarChart') {
625:                 // [BUG #52][Q2 guard] Radar cần tối thiểu 2 chỉ số (1 spoke không tạo được polygon)
633:                             text: 'Radar Chart cần tối thiểu 2 chỉ số (Metrics)',
642:                     // [BUG #52][R5 fix] groupDim fallback exclude metric keys (dimensionKeys merge metrics tại L203)
648:                         : ['Tổng'];
650:                     // [F51] Pre-aggregate SUM theo group để tính cả indicator max lẫn series data
668:                     // [F51] Aggregate SUM theo group trước khi render radar
669:                     // Fix: multi-dim data (vd cross-join) phải aggregate thay vì chỉ lấy row đầu
676:                         // [BUG #52][R2 fix] thêm areaStyle/lineStyle/symbol để polygon hiển thị (kể cả N=2 thoái hoá đoạn)
688:             // Hỗ trợ Aggregation Method: sum | max | min | avg | first
690:             else if (type === 'GaugeChart') {
719:                 // 1. Xác định Trục và Metric
740:                     // Auto mode: dùng BE colAxis
745:                 // [#36] sanitizeKey() đã bị xóa — dropdown values giờ dùng BE meta key trực tiếp, khớp 1:1 với data row keys
752:                 // 2. Thu thập và Sắp xếp X-Axis Categories (Truy quét toàn tập)
756:                 // 3. Thu thập Legend Values (Composite)
762:                 // 4. Xây dựng Lookup Map với cơ chế Cộng dồn (Aggregation)
778:                 // 5. Build Series với Data đã được Căn lề (Aligned)
795:                             type: type === 'LineChart' ? 'line' : 'bar',
811:                 // 6. Cấu hình Option
812:                 // 6. Cấu hình Option (Hỗ trợ xoay ngang Horizontal)
813:                 if (dConfig.barOrientation === 'horizontal' && type !== 'LineChart') {
816:                         position: 'top', // Trục giá trị nằm trên đầu
855:                 // [LAYOUT FIX]: Hỗ trợ DataZoom với cơ chế không chồng lấn
878:                 // Cập nhật Grid cuối cùng sau khi đã cộng dồn toàn bộ padding
888:             // RENDER: BAR & LINE CHART (FALLBACK CŨ)
891:                 // xAxisName đã bị xóa nên không cần cộng thêm
938:                         type: type === 'LineChart' ? 'line' : 'bar',
949:                     if (type === 'BarChart') {
956:                     if (type === 'LineChart') {
969:                         markLineData.push({ yAxis: dConfig.markLineTarget, name: 'Target', lineStyle: { type: 'solid', color: '#10b981', width: 2 } });
995:                 if (type === 'BarChart' && dConfig.barOrientation === 'horizontal') {
