This commit is contained in:
2025-08-19 15:05:55 +07:00
parent 70ae66ee4b
commit f78dc34aee
161 changed files with 13703 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 135.46666 135.46667"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
sodipodi:docname="icon.svg"
inkscape:export-filename="icon.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="1.4378367"
inkscape:cx="97.020753"
inkscape:cy="192.65053"
inkscape:window-width="3200"
inkscape:window-height="1960"
inkscape:window-x="-12"
inkscape:window-y="-12"
inkscape:window-maximized="1"
inkscape:current-layer="g5" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g5"
transform="matrix(1.1612554,0,0,1.1612119,-10.922364,-10.919417)"
style="stroke-width:0.861153">
<path
id="path1"
style="fill:#bc5fd3;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.911393;stroke-linejoin:round;stroke-miterlimit:3.2"
d="M 67.73333,15.268131 A 52.320512,52.320495 0 0 0 15.412774,67.588688 52.320512,52.320495 0 0 0 67.73333,119.90924 52.320512,52.320495 0 0 0 120.05389,67.588688 52.320512,52.320495 0 0 0 67.73333,15.268131 Z m 0,21.381665 A 30.939746,30.939746 0 0 1 98.672226,67.588688 30.939746,30.939746 0 0 1 67.73333,98.528682 30.939746,30.939746 0 0 1 36.793336,67.588688 30.939746,30.939746 0 0 1 67.73333,36.649796 Z" />
<rect
style="mix-blend-mode:multiply;fill:#71baf4;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.911385;stroke-linejoin:round;stroke-miterlimit:3.2"
id="rect4"
width="74.61174"
height="24.603153"
x="85.679924"
y="-11.51065"
ry="5.8131294"
transform="rotate(45)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+32
View File
@@ -0,0 +1,32 @@
/** @odoo-module **/
export const QUICKBOARD_BG_COLORS = {
"def": ["#845ec2","#d65db1","#ff6f91","#ff9671","#ffc75f","#2c73d2","#0081cf","#0089ba","#008e9b","#008f7a"],
"alt": ["#005f73","#ee9b00","#94d2bd","#ca6702","#e9d8a6","#bb3e03","#0a9396","#9b2226","#ae2012","#c0d896"],
"cld": ["#a9d6e5","#89c2d9","#61a5c2","#468faf","#2c7da0","#2a6f97","#014f86","#01497c","#013a63","#012a4a"],
"hot": ["#ffb950","#ffad33","#ff931f","#ff7e33","#fa5e1f","#ec3f13","#b81702","#a50104","#8e0103","#7a0103"],
"ert": ["#bfc882","#7b4618","#a4b75c","#532a09","#647332","#915c27","#3e4c22","#ad8042","#2e401c","#bfab67"],
"clr": ["#1f2ba0","#0063ff","#0087ff","#19b6ec","#038659","#006f26","#563c0c","#803c00","#ed9180","#ff1002"],
"ptl": ["#66c5cc","#f6cf71","#f89c74","#dcb0f2","#87c55f","#9eb9f3","#fe88b1","#c9db74","#8be0a4","#b497e7"],
"pur": ["#f992ad","#fbbcee","#fab4c8","#f78ecf","#cfb9f7","#e0cefd","#a480f2","#d4b0f9","#c580ed","#d199f1"]
}
export const QUICKBOARD_FG_COLORS = {
"def": ["#000000","#ffffff"],
"alt": ["#000000","#ffffff"],
"cld": ["#000000","#ffffff"],
"hot": ["#000000","#ffffff"],
"ert": ["#000000","#ffffff"],
"clr": ["#000000","#ffffff"],
"ptl": ["#000000","#ffffff"],
"pur": ["#000000","#ffffff"]
}
export function getBackgroundColor(index, theme="def") {
let idx = index % QUICKBOARD_BG_COLORS[theme].length;
return QUICKBOARD_BG_COLORS[theme][idx];
}
export function getForegroundColor(index, theme="def") {
let idx = index % QUICKBOARD_FG_COLORS[theme].length;
return QUICKBOARD_FG_COLORS[theme][idx];
}
@@ -0,0 +1,55 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { Component, useRef, useState, useExternalListener } from "@odoo/owl";
export class QbColorList extends Component {
static template = "quickboard.QbColorList";
static defaultProps = {
forceExpanded: false,
isExpanded: false,
};
static props = {
canToggle: { type: Boolean, optional: true },
colors: Array,
forceExpanded: { type: Boolean, optional: true },
isExpanded: { type: Boolean, optional: true },
onColorSelected: Function,
selectedColor: { type: Number, optional: true },
};
setup() {
this.colorlistRef = useRef("colorlist");
this.state = useState({ isExpanded: this.props.isExpanded });
useExternalListener(window, "click", this.onOutsideClick);
}
get colors() {
return this.props.colors;
}
onColorSelected(id) {
const idx = this.props.colors.indexOf(id);
this.props.onColorSelected(idx);
if (!this.props.forceExpanded) {
this.state.isExpanded = false;
}
}
onOutsideClick(ev) {
if (this.colorlistRef.el.contains(ev.target) || this.props.forceExpanded) {
return;
}
this.state.isExpanded = false;
}
onToggle(ev) {
if (this.props.canToggle) {
ev.preventDefault();
ev.stopPropagation();
this.state.isExpanded = !this.state.isExpanded;
this.colorlistRef.el.firstElementChild.focus();
}
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="quickboard.QbColorList">
<div class="o_colorlist d-flex flex-wrap align-items-center mw-100 gap-2" aria-atomic="true" t-ref="colorlist">
<t t-if="!props.forceExpanded and !state.isExpanded">
<button t-on-click="onToggle"
role="menuitem"
t-att-data-color="colors[props.selectedColor]"
t-attf-style="background-color: {{ colors[props.selectedColor] }};"
t-attf-class="btn p-0 rounded-0 o_colorlist_toggler"/>
</t>
<t t-else="" t-foreach="props.colors" t-as="colorId" t-key="colorId">
<button t-on-click.prevent.stop="() => this.onColorSelected(colorId)"
role="menuitem"
t-att-data-color="colorId"
t-attf-style="background-color: {{ colorId }};"
t-attf-class="btn p-0 rounded-0 {{ colorId === props.selectedColor ? 'o_colorlist_selected' : '' }}"/>
</t>
</div>
</t>
</templates>
+28
View File
@@ -0,0 +1,28 @@
.quickboard {
background-color: #555;
}
.grid-stack-item-content {
background-color: whitesmoke;
border: 1px solid black;
}
.quickboard-item-basic-title {
font-weight: 500;
}
.quickboard-item-basic-value {
font-size: 3em;
}
.quickboard-item-basic-icon {
font-size: 3em;
}
.quickboard-item-chart-title, .quickboard-item-list-title {
font-weight: 500;
}
.quickboard-item-chart-icon, .quickboard-item-list-icon {
font-size: 1em !important;
}
@@ -0,0 +1,241 @@
/** @odoo-module **/
import { Component, useRef, useEffect, useState, onPatched } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { DateTimeInput } from "@web/core/datetime/datetime_input";
import { SelectMenu } from "@web/core/select_menu/select_menu";
import { deserializeDateTime, serializeDateTime } from "@web/core/l10n/dates";
import { QuickboardItem } from "./quickboard_item";
import { QUICKBOARD_BG_COLORS } from "../core/colors"
class Quickboard extends Component {
static template = "quickboard";
static components = { SelectMenu, DateTimeInput, QuickboardItem };
static props = {
...standardActionServiceProps,
};
setup() {
this.action = useService("action");
this.dialog = useService("dialog");
let theme = "def";
if (user.settings.quickboard_theme) {
theme = user.settings.quickboard_theme;
}
let startDate = luxon.DateTime.local().startOf("month");
if (user.settings.quickboard_start_date) {
startDate = deserializeDateTime(
user.settings.quickboard_start_date
);
}
let endDate = luxon.DateTime.now();
if (user.settings.quickboard_end_date) {
endDate = deserializeDateTime(user.settings.quickboard_end_date);
}
this.gridRef = useRef("grid-stack");
this.state = useState({
"theme": theme,
"startDate": startDate,
"endDate": endDate,
"items": [],
});
this.quickboard = useState(useService("quickboard"));
this.quickboard.getQuickboardItemDefs(
this.state.startDate,
this.state.endDate
);
useEffect(
(isReady) => {
self = this;
let items = Object.entries(this.quickboard.items)
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => Object.assign({}, v));
this.state.items = items;
},
() => [this.quickboard.isReady]
);
onPatched(() => {
this.gridRef.current =
this.gridRef.current ||
GridStack.init({
float: true,
columnOpts: {
breakpoints: [{ w: 768, c: 1 }],
},
cellHeight: "10rem",
});
if (this.gridRef.current) {
const grid = this.gridRef.current;
grid.batchUpdate();
grid.removeAll(false);
for (let i = 0; i < this.state.items.length; i++) {
const element = document.querySelector(
`#grid-stack-item-${this.state.items[i]["id"]}`
);
if (element) {
grid.makeWidget(element);
}
}
grid.batchUpdate(false);
}
});
this.busService = this.env.services.bus_service;
this.busService.addChannel("quickboard");
this.busService.subscribe("quickboard_updated", ({}) => {
this.onQuickboardUpdated();
});
this.setupNoData();
}
onQuickboardUpdated() {
this.applyFilter();
let grid = this.gridRef.current;
grid.compact();
}
onStartDateChanged(date) {
this.state.startDate = date;
}
onEndDateChanged(date) {
this.state.endDate = date;
}
saveQuickboard(ev) {
let serializedData = this.gridRef.current.save(false);
this.quickboard.saveLayout(serializedData);
}
compact(ev) {
let grid = this.gridRef.current;
grid.compact();
}
async generateQuickboard(ev) {
const cell_width = this.gridRef.current.cellWidth();
// DO NOT REMOVE: Without this the getCellHeight will return weird number
const h_0 = this.gridRef.current.cellHeight().el.clientHeight;
const cell_height = this.gridRef.current.getCellHeight();
const screen_width = screen.width;
const screen_height = screen.height;
this.action.doAction(
{
type: "ir.actions.act_window",
name: "Generate Quickboard",
res_model: "quickboard.generator",
views: [[false, "form"]],
view_mode: "form",
target: "new",
context: {
dialog_size: "medium",
cell_width: cell_width,
cell_height: cell_height,
screen_width: screen_width,
screen_height: screen_height,
},
}
);
}
async addItem(ev) {
this.action.doAction(
{
type: "ir.actions.act_window",
name: "New",
res_model: "quickboard.item",
views: [[false, "form"]],
view_mode: "form",
target: "new",
context: {
dialog_size: "medium",
quick_add: true
},
},
{
onClose: () => {
this.applyFilter();
},
}
);
}
setupNoData() {
Chart.register({
id: "NoData",
afterDraw: function (chart) {
if (
chart.data.datasets
.map((d) => d.data.length)
.reduce((p, a) => p + a, 0) === 0
) {
const ctx = chart.ctx;
const width = chart.width;
const height = chart.height;
chart.clear();
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("No data to display.", width / 2, height / 2);
ctx.restore();
}
},
});
}
async applyFilter(ev) {
await user.setUserSettings(
"quickboard_start_date",
serializeDateTime(this.state.startDate)
);
await user.setUserSettings(
"quickboard_end_date",
serializeDateTime(this.state.endDate)
);
await this.quickboard.getQuickboardItemDefs();
}
async onSelectTheme(val) {
this.state.theme = val;
await user.setUserSettings("quickboard_theme", val);
this.quickboard.getQuickboardItemDefs();
}
getThemeSelectionItem(label, theme){
return {
value: theme,
label: label,
colors: QUICKBOARD_BG_COLORS[theme]
}
}
get themes() {
return [
this.getThemeSelectionItem("Default", "def"),
this.getThemeSelectionItem("Alternative", "alt"),
this.getThemeSelectionItem("Cold", "cld"),
this.getThemeSelectionItem("Hot", "hot"),
this.getThemeSelectionItem("Earth", "ert"),
this.getThemeSelectionItem("Colorful", "clr"),
this.getThemeSelectionItem("Pastel", "ptl"),
this.getThemeSelectionItem("Pink Purple", "pur"),
];
}
}
registry.category("lazy_components").add("Quickboard", Quickboard);
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard" owl="1">
<div class="quickboard h-100 overflow-auto" style="min-height:-webkit-fill-available;">
<div class="container-fluid flex-column flex-md-row bg-light d-flex align-items-center justify-content-between">
<div class="btn-toolbar w-100 w-md-auto my-2 d-flex" role="toolbar">
<div class="btn-group me-1 flex-grow-1" role="group">
<button id="save" type="button" class="btn btn-primary" t-on-click="(ev) => this.saveQuickboard(ev)">
<i class="fa fa-floppy-o me-2 d-none d-md-inline" />Save
</button>
<button id="compant" type="button" class="btn btn-secondary" t-on-click="(ev) => this.compact(ev)">
<i class="fa fa-th me-2 d-none d-md-inline" />Arrange
</button>
</div>
<div class="btn-group ms-1 flex-grow-1" role="group">
<button id="generate" type="button" class="btn btn-secondary" t-on-click="(ev) => this.addItem(ev)">
<i class="fa fa-plus me-2 d-none d-md-inline" />New
</button>
<button id="generate" type="button" class="btn btn-secondary" t-on-click="(ev) => this.generateQuickboard(ev)">
<i class="fa fa-magic me-2 d-none d-md-inline" />Generate
</button>
</div>
</div>
<div class="d-flex my-auto mb-2">
<div class="input-group input-group-sm ms-1 me-1">
<div class="input-group-text d-none d-md-block"><span class="align-middle">Theme</span></div>
<div class="flex-fill" style="width: 5rem;">
<SelectMenu
choices="themes"
value="this.state.theme"
onSelect.bind="this.onSelectTheme"
required="true"
searchable="false"
togglerClass="'fw-normal'"
class="'theme-menu'">
<t t-set-slot="choice" t-slot-scope="choice">
<div class="d-flex">
<t t-foreach="choice.data.colors" t-as="color" t-key="color">
<div class="border border-2 rounded-circle" t-attf-style="background-color: {{ color }}; width: 1.5em; height: 1.5em">
</div>
</t>
</div>
</t>
</SelectMenu>
</div>
</div>
<div class="input-group input-group-sm ms-1 me-1">
<div class="input-group-text d-none d-md-block"><span class="align-middle">Start Date</span></div>
<div class="form-control">
<DateTimeInput type="'date'" value="state.startDate" placeholder="'Start Date'"
onChange="(d) => this.onStartDateChanged(d)"/>
</div>
</div>
<div class="input-group input-group-sm ms-1 me-1">
<div class="input-group-text d-none d-md-block"><span class="align-middle">End Date</span></div>
<div class="form-control">
<DateTimeInput type="'date'" value="state.endDate" placeholder="'End Date'"
onChange="(d) => this.onEndDateChanged(d)"/>
</div>
</div>
<button id="applyFilter" type="button" class="btn btn-primary" t-on-click="(ev) => this.applyFilter(ev)">
Apply
</button>
</div>
</div>
<div class="grid-stack " t-ref="grid-stack">
<div t-foreach="state.items" t-as="item" t-key="item.id"
t-attf-gs-id="{{item.id}}"
t-attf-gs-x="{{item.x_pos}}"
t-attf-gs-y="{{item.y_pos}}"
t-attf-gs-w="{{item.width}}"
t-attf-gs-h="{{item.height}}"
t-attf-id="grid-stack-item-{{item.id}}">
<div class="grid-stack-item-content rounded">
<QuickboardItem itemType="item.type"
itemId="item.id"
action="action"
theme="state.theme"
startDate="state.startDate"
endDate="state.endDate"
/>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,36 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { QuickboardItemBasic } from "./quickboard_item_basic";
import { QuickboardItemChart } from "./quickboard_item_chart";
import { QuickboardItemList } from "./quickboard_item_list";
import { standardQuickboardItemProps } from "./standard_quickboard_item_props"
export class QuickboardItem extends Component {
static template = "quickboard.QuickboardItem"
static props = {
...standardQuickboardItemProps,
itemType: { type: String },
}
get _itemComponent(){
if (this.props.itemType === "basic"){
return QuickboardItemBasic;
} else if (this.props.itemType === "chart"){
return QuickboardItemChart
} else if (this.props.itemType === "list"){
return QuickboardItemList
}
return Component;
}
get _itemProps(){
return {
"action": this.props.action,
"itemId": this.props.itemId,
"theme": this.props.theme,
"startDate": this.props.startDate,
"endDate": this.props.endDate
}
}
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItem" owl="1">
<t t-component="_itemComponent" t-props="_itemProps"/>
</t>
</templates>
@@ -0,0 +1,73 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { user } from "@web/core/user";
import { useService } from "@web/core/utils/hooks";
import { standardQuickboardItemProps } from "./standard_quickboard_item_props"
export class QuickboardItemBase extends Component {
static props = {
...standardQuickboardItemProps
}
setup() {
this.action = this.props.action;
this.itemId = this.props.itemId;
this.quickboard = useState(useService("quickboard"));
this.busService = this.env.services.bus_service;
this.busService.subscribe("quickboard_item_updated", ({ id }) => {
this.onMessage(id)
});
}
onMessage(id) {
console.log(id);
}
showItemConfig(ev) {
this._showItemConfig();
}
_getSpinnerOpt(){
var opts = {
"lines": 10, // The number of lines to draw
"length": 0, // The length of each line
"width": 2, // The line thickness
"radius": 4, // The radius of the inner circle
"scale": 4, // Scales overall size of the spinner
"corners": 1, // Corner roundness (0..1)
"speed": 0.7, // Rounds per second
"rotate": 0, // The rotation offset
"animation": 'spinner-line-fade-more', // The CSS animation name for the lines
"direction": 1, // 1: clockwise, -1: counterclockwise
"color": '#7a008a', // CSS color or array of colors
"fadeColor": 'transparent', // CSS color or array of colors
"top": '51%', // Top position relative to parent
"left": '50%', // Left position relative to parent
"shadow": '0 0 1px transparent', // Box-shadow for the lines
"zIndex": 2000000000, // The z-index (defaults to 2e9)
"className": 'spinner', // The CSS class to assign to the spinner
"position": 'absolute', // Element positioning
};
return opts;
}
_showItemConfig(){
var self = this;
this.action.doAction({
'type': 'ir.actions.act_window',
'name': 'Quickboard Item',
'res_model': 'quickboard.item',
'res_id': self.itemId,
'views': [[false, 'form']],
'view_mode': 'form',
'target': 'new',
'context': {
'dialog_size': 'medium',
'quick_edit': true
}
});
}
}
@@ -0,0 +1,102 @@
/** @odoo-module **/
import { useState, onMounted, useRef } from "@odoo/owl";
import { parseFloat, parseInteger, parseMonetary } from "@web/views/fields/parsers";
import { formatFloat, formatInteger, formatMonetary } from "@web/views/fields/formatters";;
import { QuickboardItemBase } from "./quickboard_item_base";
import { getBackgroundColor, getForegroundColor } from "../core/colors";
export class QuickboardItemBasic extends QuickboardItemBase {
static template = "quickboard.QuickboardItemBasic";
setup(){
super.setup();
this.gsItemRef = useRef("grid-stack-item");
this.containerRef = useRef("container");
this.spinner = new Spin.Spinner(this._getSpinnerOpt());
this.state = useState({
"title": "",
"icon": "",
"valueFieldType": "",
"aggregateValue": "",
"value": "",
"aggregateFunction": "",
"textColor": "",
"backgroundColor": "",
"theme": this.props.theme,
"startDate": this.props.startDate,
"endDate": this.props.endDate,
});
onMounted(async () => {
var target = this.gsItemRef.el;
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {this.spinner.stop()});
})
}
async onMessage(id) {
if (id == this.itemId){
var target = this.gsItemRef.el;
if (this.containerRef.el){
this.containerRef.el.classList.add("d-none");
}
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
if (this.containerRef.el){
this.containerRef.el.classList.remove("d-none");
}
this.spinner.stop()
});
}
}
async loadData(itemId, startDate, endDate) {
const res = await this.quickboard.getQuickboardItem(itemId, startDate, endDate)
this.state.title = res.name;
this.state.icon = res.icon;
this.state.valueFieldType = res.value_field_type;
this.state.aggregateValue = res.aggregate_value;
this.state.value = this.getFormattedValue();
this.state.aggregateFunction = this.aggregate_function;
this.state.textColor = getForegroundColor(res.text_color, this.state.theme);
this.state.backgroundColor = getBackgroundColor(res.background_color, this.state.theme);
}
getFormattedValue(){
let val;
let val_formatted;
switch (this.state.valueFieldType){
case "integer":
val = parseInteger(String(this.state.aggregateValue));
val_formatted = formatInteger(val);
break;
case "float":
val = parseFloat(String(this.state.aggregateValue));
val_formatted = formatFloat(val);
break;
case "monetary":
val = parseMonetary(String(this.state.aggregateValue));
val_formatted = formatMonetary(val);
break;
default:
if (Number.isSafeInteger(this.state.aggregateValue))
val_formatted = formatInteger(this.state.aggregateValue);
else
val_formatted = formatFloat(this.state.aggregateValue);
}
return val_formatted
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItemBasic" owl="1">
<div t-ref="grid-stack-item" class="grid-stack-item h-100 overflow-hidden d-flex flex-column">
<div t-ref="container" class="d-flex flex-column flex-fill p-1">
<div class="d-flex p-1 flex-grow-1"
t-att-style="'color:' + state.textColor + '; background-color: ' + state.backgroundColor">
<div class="quickboard-item-basic-icon ps-2">
<span><i t-att-class="'fa ' + state.icon"/></span>
</div>
<div class="d-flex flex-column text-end flex-grow-1 align-self-end h-100">
<span class="quickboard-item-chart-icon me-1 flex-fill">
<i class="fa fa-cog" t-on-click="(ev) => this.showItemConfig(ev)"/>
</span>
<div class="quickboard-item-basic-value flex-wrap w-100 pe-1">
<span><t t-out="this.state.value"/></span>
</div>
</div>
</div>
<div class="quickboard-item-basic-title text-center"><t t-out="this.state.title"/></div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,263 @@
/** @odoo-module **/
import { parseDate, parseDateTime } from "@web/core/l10n/dates";
import { useState, useRef, onMounted } from "@odoo/owl";
import { getBackgroundColor } from "../core/colors";
import { QuickboardItemBase } from "./quickboard_item_base";
export class QuickboardItemChart extends QuickboardItemBase {
static template = "quickboard.QuickboardItemChart";
setup() {
super.setup();
this.gsItemRef = useRef("grid-stack-item");
this.spinner = new Spin.Spinner(this._getSpinnerOpt());
this.state = useState({
"title": "",
"icon": "",
"chartType": "",
"data": "",
"valueFieldName": "",
"valueFieldType": "",
"dimensionFieldName": "",
"dimensionFieldType": "",
"datetimeGranularity": "",
"aggregateFunction": "",
"datetimeGranularity": "",
"theme": this.props.theme,
"startDate": this.props.startDate,
"endDate": this.props.endDate,
});
this.chartCanvasRef = useRef("chartCanvas");
onMounted(async () => {
var target = this.gsItemRef.el;
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
this.spinner.stop();
});
});
}
async onMessage(id) {
if (id == this.itemId) {
var target = this.gsItemRef.el;
this.spinner.spin(target);
if (this.chartCanvasRef.el) {
this.chartCanvasRef.el.style.display = "none";
}
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
this.spinner.stop();
});
}
}
async loadData(itemId, startDate, endDate) {
var target = this.gsItemRef.el;
this.spinner.spin(target);
const res = await this.quickboard.getQuickboardItem(
itemId,
startDate,
endDate
);
this.state.title = res.name;
this.state.icon = res.icon;
this.state.chartType = res.chart_type;
this.state.data = res.data;
this.state.valueFieldName = res.value_field_name;
this.state.valueFieldType = res.value_field_type;
this.state.dimensionFieldName = res.dimension_field_name;
this.state.dimensionFieldType = res.dimension_field_type;
this.state.groupFieldName = res.group_field_name;
this.state.groupFieldType = res.group_field_type;
this.state.aggregateFunction = res.aggregate_function;
this.state.datetimeGranularity = res.datetime_granularity;
this.renderChart(
this.state.chartType,
this.state.data,
this.state.valueFieldName,
this.state.aggregateFunction,
this.state.dimensionFieldType,
this.state.datetimeGranularity
);
}
_getCircularChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity){
if (chartType === "polar") {
chartType = "polarArea";
}
const chartData = [];
let labels = [];
let dataset_color = [];
data.forEach((element, index) => {
const lbl = Object.entries(element["dataset"])
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => v[0]);
labels = Array.from(new Set(labels.concat(lbl)))
});
dataset_color = labels.map((_, index) => getBackgroundColor(index, this.state.theme));
data.forEach((element, index) => {
const dt = Object.entries(element["dataset"])
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => v[1]);
chartData.push({
label: element.label,
data: dt,
backgroundColor: dataset_color
});
});
let chartConfig = {
type: chartType,
data: {
labels: labels,
datasets: chartData,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom"
}
}
},
};
return chartConfig;
}
_getXYChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity){
const chartData = [];
data.forEach((element, index) => {
const dt = Object.entries(element["dataset"])
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => {
let x_val;
let y_val;
let dateTimeDimension = ["date", "datetime"].includes(dimensionFieldType)
if (chartType === 'horizontal-bar'){
x_val = v[1];
y_val = dateTimeDimension ? parseDateTime(v[0]) : v[0]
} else {
x_val = dateTimeDimension ? parseDateTime(v[0]) : v[0];
y_val = v[1]
}
return Object.assign({}, {
x: x_val,
y: y_val
})
}
);
const dataset_color = getBackgroundColor(index, this.state.theme);
chartData.push({
label: element.label,
data: dt,
backgroundColor: dataset_color,
borderColor: dataset_color,
borderWidth: 3,
cubicInterpolationMode: 'monotone',
});
});
let x_axis_option = {};
let y_axis_option = {}
if (["date", "datetime"].includes(dimensionFieldType)) {
if (chartType === 'horizontal-bar') {
chartType = 'bar';
y_axis_option = {
indexAxis: 'y',
scales: {
y: {
type: "time",
time: {
unit: datetimeGranularity,
},
},
},
}
} else {
x_axis_option = {
scales: {
x: {
type: "time",
time: {
unit: datetimeGranularity,
},
},
},
};
}
} else {
if (chartType === 'horizontal-bar') {
chartType = 'bar';
y_axis_option = {
indexAxis: 'y',
}
}
}
let chartConfig = {
type: chartType,
data: {
datasets: chartData
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom"
}
}
},
};
Object.assign(chartConfig.options, x_axis_option);
Object.assign(chartConfig.options, y_axis_option);
return chartConfig;
}
renderChart(chartType, chartData, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity) {
let data = Object.entries(chartData)
.filter(([k, v]) => !isNaN(k))
.map(([k, v]) => Object.assign({}, v));
let chartConfig = {}
if (["doughnut", "pie", "polar"].includes(chartType)) {
chartConfig = this._getCircularChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity)
} else {
chartConfig = this._getXYChartConfig(chartType, data, valueFieldName, aggregateFunction, dimensionFieldType, datetimeGranularity)
}
if (this.chartCanvasRef.el) {
const ctx = this.chartCanvasRef.el.getContext("2d");
if (this.chart) {
this.chart.destroy();
}
this.chart = new Chart(ctx, chartConfig);
}
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItemChart" owl="1">
<div t-ref="grid-stack-item" class="grid-stack-item h-100 p-2 pb-5 overflow-hidden">
<div class="quickboard-item-chart-title d-flex justify-content-between">
<div>
<span class="quickboard-item-chart-icon mx-1"><i t-att-class="'fa ' + this.state.icon"/></span>
<span><t t-out="state.title"/></span>
</div>
<span class="quickboard-item-cog me-1"><i class="fa fa-cog" t-on-click="(ev) => this.showItemConfig(ev)"/></span>
</div>
<div class="pt-4 px-3 w-100 h-100">
<div class="h-100 w-100">
<canvas t-ref="chartCanvas" />
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,112 @@
/** @odoo-module **/
import { useState, useRef, onMounted } from "@odoo/owl";
import { parseFloat, parseInteger, parseMonetary } from "@web/views/fields/parsers";
import { formatFloat, formatInteger, formatMonetary } from "@web/views/fields/formatters";;
import { QuickboardItemBase } from "./quickboard_item_base";
export class QuickboardItemList extends QuickboardItemBase {
static template = "quickboard.QuickboardItemList";
setup() {
super.setup();
this.gsItemRef = useRef("grid-stack-item");
this.containerRef = useRef("container");
this.spinner = new Spin.Spinner(this._getSpinnerOpt());
this.state = useState({
"title": "",
"icon": "",
"data": "",
"valueFieldName": "",
"valueFieldType": "",
"aggregateFunction": "",
"dimensionFieldName": "",
"dimensionFieldType": "",
"startDate": this.props.startDate,
"endDate": this.props.endDate,
});
onMounted(async () => {
var target = this.gsItemRef.el;
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
this.spinner.stop();
});
});
}
async onMessage(id) {
if (id == this.itemId) {
var target = this.gsItemRef.el;
if (this.containerRef.el){
this.containerRef.el.classList.add("d-none");
}
this.spinner.spin(target);
await this.loadData(
this.props.itemId,
this.state.startDate,
this.state.endDate
).then(() => {
if (this.containerRef.el){
this.containerRef.el.classList.remove("d-none");
}
this.spinner.stop();
});
}
}
async loadData(itemId, startDate, endDate) {
var target = this.gsItemRef.el;
this.spinner.spin(target);
const res = await this.quickboard.getQuickboardItem(
itemId,
startDate,
endDate
);
this.state.title = res.name;
this.state.icon = res.icon;
this.state.data = res.data;
this.state.valueFieldName = res.value_field_name;
this.state.valueFieldType = res.value_field_type;
this.state.dimensionFieldName = res.dimension_field_name;
this.state.dimensionFieldType = res.dimension_field_type;
this.state.aggregateFunction = res.aggregate_function;
}
formatValue(value){
let val;
let val_formatted;
switch (this.state.valueFieldType){
case "integer":
val = parseInteger(String(value));
val_formatted = formatInteger(val);
break;
case "float":
val = parseFloat(String(value));
val_formatted = formatFloat(val);
break;
case "monetary":
val = parseMonetary(String(value));
val_formatted = formatMonetary(val);
break;
default:
if (Number.isSafeInteger(value))
val_formatted = formatInteger(value);
else
val_formatted = formatFloat(value);
}
return val_formatted
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="quickboard.QuickboardItemList" owl="1">
<div t-ref="grid-stack-item" class="grid-stack-item h-100 p-2 pb-5 overflow-hidden">
<div class="quickboard-item-list-title d-flex justify-content-between">
<div>
<span class="quickboard-item-list-icon mx-1"><i t-att-class="'fa ' + this.state.icon"/></span>
<span><t t-out="state.title"/></span>
</div>
<span class="quickboard-item-cog me-1"><i class="fa fa-cog" t-on-click="(ev) => this.showItemConfig(ev)"/></span>
</div>
<div t-ref="container" class="d-flex flex-column flex-fill p-1">
<div class="p-3 w-100 h-100">
<div class="h-100 w-100 d-flex">
<t t-if="state.data.length > 0" >
<table class="table table-sm table-hover table-striped align-self-start">
<thead>
<tr>
<th scope="col"><t t-out="state.dimensionFieldName"/></th>
<th scope="col" class="text-end"><t t-out="state.valueFieldName"/></th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.data" t-as="item" t-key="item.seq">
<td><t t-out="item['x']"/></td>
<td class="text-end"><t t-esc="formatValue(item['y'])"/></td>
</tr>
</tbody>
</table>
</t>
<t t-else="">
<div class="flex-fill text-center align-self-center">
No data to display.
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,58 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
const quickboardService = {
start(env, services) {
const quickboard = reactive({
items: {},
isReady: false
});
async function getQuickboardItemDefs() {
quickboard.isReady = false;
quickboard.items = {};
const updates = await rpc("/quickboard/item_defs",{});
Object.assign(quickboard.items, updates);
quickboard.isReady = true;
};
async function getQuickboardItem(itemId, startDate, endDate) {
return await rpc("/quickboard/item",{
item_id: itemId,
start_date: startDate.toSQLDate(),
end_date: endDate.toSQLDate()
});
};
async function saveLayout(layout){
await rpc("/quickboard/save_layout",{
"layout": layout
});
};
async function saveFilter(startDate, endDate) {
return await rpc("/quickboard/save_filter",{
start_date: startDate.toSQLDate(),
end_date: endDate.toSQLDate()
});
};
async function saveTheme(theme) {
return await rpc("/quickboard/save_theme",{
theme: theme
});
};
quickboard.saveTheme = saveTheme;
quickboard.saveFilter = saveFilter;
quickboard.getQuickboardItemDefs = getQuickboardItemDefs;
quickboard.getQuickboardItem = getQuickboardItem;
quickboard.saveLayout = saveLayout;
return quickboard;
}
};
registry.category("services").add("quickboard", quickboardService);
@@ -0,0 +1,10 @@
/** @odoo-module **/
export const standardQuickboardItemProps = {
action: { type: Object },
itemId: { type: Number },
theme: { type: String },
startDate: { type: luxon.DateTime },
endDate: { type: luxon.DateTime },
};
@@ -0,0 +1,20 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { LazyComponent } from "@web/core/assets";
import { Component, xml } from "@odoo/owl";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class QuickboardLoader extends Component {
static components = { LazyComponent };
static template = xml`
<LazyComponent bundle="'quickboard.assets'" Component="'Quickboard'" props="props"/>
`;
static props = {
...standardActionServiceProps,
props: { type: Object, optional: true },
Component: { type: Function, optional: true },
};
}
registry.category("actions").add("quickboard", QuickboardLoader);
@@ -0,0 +1,63 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { QbColorList } from "../../../core/qb_color_list/qb_color_list";
import { user } from "@web/core/user";
import { Component } from "@odoo/owl";
import { QUICKBOARD_BG_COLORS, QUICKBOARD_FG_COLORS } from "../../../core/colors";
export class QbColorPickerField extends Component {
static template = "quickboard.QbColorPickerField";
static components = {
QbColorList,
};
static props = {
...standardFieldProps,
canToggle: { type: Boolean },
mode: { type: String },
};
currentColorPalette() {
let theme = user.settings.quickboard_theme;
if (this.props.mode === "foreground"){
return QUICKBOARD_FG_COLORS[theme];
} else {
return QUICKBOARD_BG_COLORS[theme];
}
}
get isExpanded() {
return !this.props.canToggle && !this.props.readonly;
}
switchColor(colorIndex) {
this.props.record.update({ [this.props.name]: colorIndex });
}
}
export const qbColorPickerField = {
component: QbColorPickerField,
supportedTypes: ["integer"],
supportedOptions: [
{
label: _t("Mode"),
name: "mode",
type: "selection",
choices: [
{ label: "Foreground", value: "fg" },
{ label: "Background", value: "bg" },
],
default: "bg",
},
],
extractProps: ({ options, viewType }) => ({
canToggle: viewType !== "list",
mode: options.mode,
}),
};
registry.category("fields").add("qb_color_picker", qbColorPickerField);
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="quickboard.QbColorPickerField">
<QbColorList canToggle="props.canToggle"
colors="currentColorPalette()"
forceExpanded="isExpanded"
onColorSelected.bind="switchColor"
selectedColor="props.record.data[props.name] || 0"/>
</t>
</templates>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { SelectMenu } from "@web/core/select_menu/select_menu";
import { Component } from "@odoo/owl";
import { fa_icons } from "./fa_icons";
export class QbIconPickerField extends Component {
static template = "quickboard.QbIconPickerField";
static components = {
SelectMenu,
};
static props = {
...standardFieldProps,
};
async onSelectIcon(val) {
this.props.record.update({ [this.props.name]: val });
}
get icons() {
return fa_icons;
}
}
export const qbIconPickerField = {
component: QbIconPickerField,
supportedTypes: ["char"],
};
registry.category("fields").add("qb_icon_picker", qbIconPickerField);
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="quickboard.QbIconPickerField">
<SelectMenu
choices="icons"
value="props.record.data[props.name] || ''"
onSelect.bind="this.onSelectIcon"
required="false"
searchable="true"
togglerClass="'fw-normal'"
class="'icon-select'">
<t t-set-slot="choice" t-slot-scope="choice">
<i t-attf-class="{{ 'fa ' + choice.data.value + ' me-1' }}"/><t t-esc="choice.data.label" />
</t>
</SelectMenu>
</t>
</templates>