Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dms/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,15 @@
"dms/static/src/models/*.js",
"dms/static/src/js/fields/path_json/path_owl.esm.js",
"dms/static/src/js/fields/preview_binary/preview_record.esm.js",
"dms/static/src/js/components/*.esm.js",
"dms/static/src/js/views/*.esm.js",
# XML
"dms/static/src/js/fields/path_json/path_owl.xml",
"dms/static/src/js/fields/preview_binary/preview_record.xml",
"dms/static/src/js/components/*.xml",
"dms/static/src/js/views/*.xml",
# SCSS
"dms/static/src/scss/dms_directory.scss",
],
"web.assets_frontend": [
"dms/static/src/scss/portal.scss",
Expand Down
84 changes: 84 additions & 0 deletions dms/models/directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import os
from ast import literal_eval
from collections import defaultdict
from datetime import timedelta
from typing import Literal # noqa # pylint: disable=unused-import

from odoo import api, fields, models, tools
Expand Down Expand Up @@ -786,3 +787,86 @@ def action_dms_files_all_directory(self):
searchpanel_default_directory_id=self.id,
)
return action

@api.model
def get_dashboard_stats(self):
# Global file stats scoped by the current user's ir.rule access.
# Stats are global across all readable files; directory-domain
# translation is deliberately not applied in this iteration.
#
# Sparklines + deltas are computed live via _read_group over
# create_date (always indexed by Odoo) — no snapshot table required.
# The arrays describe *activity* (creations), not state-over-time;
# storage_sparkline shows daily bytes-added, not the running total
# (which would require a snapshot to be faithful under deletions).
File = self.env["dms.file"]
now = fields.Datetime.now()
files_total = File.search_count([])
storage_groups = File._read_group(
domain=[], groupby=[], aggregates=["size:sum"]
)
storage_bytes = int(storage_groups[0][0] or 0) if storage_groups else 0
new_today = File.search_count([("create_date", ">=", now - timedelta(days=1))])

# 30-day daily buckets: (created_count, size_sum) per day.
day_start = (now - timedelta(days=29)).replace(
hour=0, minute=0, second=0, microsecond=0
)
daily_rows = File._read_group(
domain=[("create_date", ">=", day_start)],
groupby=["create_date:day"],
aggregates=["__count", "size:sum"],
)
daily_by_key = {}
for day_value, count, size_sum in daily_rows:
if not day_value:
continue
key = day_value.date().isoformat()
daily_by_key[key] = (int(count or 0), int(size_sum or 0))
files_sparkline = []
storage_sparkline = []
for offset in range(29, -1, -1):
day = (now - timedelta(days=offset)).date().isoformat()
count, size_sum = daily_by_key.get(day, (0, 0))
files_sparkline.append(count)
storage_sparkline.append(size_sum)

# 24 hourly buckets across the past day for the "new today" tile.
hour_start = (now - timedelta(hours=23)).replace(
minute=0, second=0, microsecond=0
)
hourly_rows = File._read_group(
domain=[("create_date", ">=", hour_start)],
groupby=["create_date:hour"],
aggregates=["__count"],
)
hourly_by_key = {}
for hour_value, count in hourly_rows:
if not hour_value:
continue
hourly_by_key[hour_value.replace(minute=0, second=0, microsecond=0)] = int(
count or 0
)
new_today_sparkline = []
for offset in range(23, -1, -1):
slot = (now - timedelta(hours=offset)).replace(
minute=0, second=0, microsecond=0
)
new_today_sparkline.append(hourly_by_key.get(slot, 0))

files_last_week = sum(files_sparkline[-7:])
storage_last_week = sum(storage_sparkline[-7:])
avg_per_day = round(sum(files_sparkline[-7:]) / 7.0, 1)

return {
"files_total": files_total,
"storage_total_bytes": storage_bytes,
"storage_total_human": human_size(storage_bytes),
"new_today": new_today,
"files_sparkline": files_sparkline,
"storage_sparkline": storage_sparkline,
"new_today_sparkline": new_today_sparkline,
"files_delta_week": files_last_week,
"storage_delta_week_human": human_size(storage_last_week),
"new_today_avg_per_day": avg_per_day,
}
154 changes: 154 additions & 0 deletions dms/static/src/js/components/dms_stat_bar.esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright 2026 ledoent — Don Kendall
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import {Component} from "@odoo/owl";

// Reusable stat bar. Driven by a `stats` prop shaped like:
// {files_total, storage_total_human, new_today,
// files_sparkline, storage_sparkline, new_today_sparkline,
// files_delta_week, storage_delta_week_human, new_today_avg_per_day}
// Tile config is declared inline so future dashboards (Phase 3+) can extend
// or remix the same component by passing a different `tiles` prop. Each tile
// names the value key, sparkline key, delta key, and a `chart` hint that
// picks the SVG renderer (line vs bar).
const SPARK_WIDTH = 80;
const SPARK_HEIGHT = 26;

const DEFAULT_TILES = [
{
key: "files_total",
label: "Files",
icon: "fa-file-text-o",
tint: "files",
sparklineKey: "files_sparkline",
chart: "line",
deltaKey: "files_delta_week",
deltaSuffix: " this week",
deltaTrend: "up",
action: "files",
},
{
key: "storage_total_human",
label: "Storage",
icon: "fa-database",
tint: "storage",
sparklineKey: "storage_sparkline",
chart: "bar",
deltaKey: "storage_delta_week_human",
deltaSuffix: " added this week",
deltaTrend: "neutral",
},
{
key: "new_today",
label: "New today",
icon: "fa-clock-o",
tint: "fresh",
sparklineKey: "new_today_sparkline",
chart: "line",
deltaKey: "new_today_avg_per_day",
deltaPrefix: "vs avg ",
deltaSuffix: "/day",
deltaTrend: "neutral",
action: "today",
},
];

export class DmsStatBar extends Component {
static template = "dms.StatBar";
static props = {
stats: {type: [Object, {value: null}], optional: true},
tiles: {type: Array, optional: true},
// Optional drill-down: called with the clicked tile when it carries
// an `action` and a handler is wired by the owning renderer.
onTileClick: {type: Function, optional: true},
};
static defaultProps = {
tiles: DEFAULT_TILES,
};

get isLoading() {
return !this.props.stats;
}

isClickable(tile) {
return (
Boolean(tile.action) && Boolean(this.props.onTileClick) && !this.isLoading
);
}

onTileClick(tile) {
if (this.isClickable(tile)) {
this.props.onTileClick(tile);
}
}

valueFor(tile) {
if (this.isLoading) {
return "—";
}
const raw = this.props.stats[tile.key];
return raw === undefined || raw === null ? "—" : raw;
}

// Returns {points, polygon, max, min, hasData} for the tile's series.
// Empty / all-zero series → hasData=false so the template can skip the
// chart and still keep the tile's vertical rhythm.
sparkPath(tile) {
if (this.isLoading || !tile.sparklineKey) {
return {hasData: false};
}
const series = this.props.stats[tile.sparklineKey];
if (!Array.isArray(series) || series.length === 0) {
return {hasData: false};
}
const max = Math.max(...series, 0);
const min = Math.min(...series, 0);
const range = max - min || 1;
const stepX = series.length > 1 ? SPARK_WIDTH / (series.length - 1) : 0;
const points = series.map((v, i) => {
const x = Number((i * stepX).toFixed(2));
const y = Number(
(SPARK_HEIGHT - ((v - min) / range) * SPARK_HEIGHT).toFixed(2)
);
return {x, y, value: v};
});
const linePath = points.map((p) => `${p.x},${p.y}`).join(" ");
const areaPath = `0,${SPARK_HEIGHT} ${linePath} ${SPARK_WIDTH},${SPARK_HEIGHT}`;
const barWidth = series.length ? (SPARK_WIDTH / series.length) * 0.7 : 0;
const bars = points.map((p, i) => ({
x: Number((i * (SPARK_WIDTH / series.length)).toFixed(2)),
y: p.y,
width: barWidth,
height: Number((SPARK_HEIGHT - p.y).toFixed(2)),
}));
return {
hasData: max > 0,
points,
linePath,
areaPath,
bars,
last: points[points.length - 1],
};
}

deltaText(tile) {
if (this.isLoading || !tile.deltaKey) {
return "";
}
const raw = this.props.stats[tile.deltaKey];
if (raw === undefined || raw === null) {
return "";
}
const prefix = tile.deltaPrefix || "";
const suffix = tile.deltaSuffix || "";
return `${prefix}${raw}${suffix}`;
}

sparkWidth() {
return SPARK_WIDTH;
}

sparkHeight() {
return SPARK_HEIGHT;
}
}
73 changes: 73 additions & 0 deletions dms/static/src/js/components/dms_stat_bar.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright 2026 ledoent — Don Kendall
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
-->
<templates xml:space="preserve">
<t t-name="dms.StatBar">
<div class="o_dms_stat_bar" t-att-data-loading="isLoading ? 'true' : 'false'">
<div
class="o_dms_stat_bar__tile"
t-foreach="props.tiles"
t-as="tile"
t-key="tile.key"
t-att-data-tint="tile.tint"
t-att-class="{'o_dms_stat_bar__tile--clickable': isClickable(tile)}"
t-att-role="isClickable(tile) ? 'button' : undefined"
t-att-tabindex="isClickable(tile) ? '0' : undefined"
t-on-click="() => this.onTileClick(tile)"
>
<i t-attf-class="fa #{tile.icon} o_dms_stat_bar__icon" />
<div class="o_dms_stat_bar__body">
<div class="o_dms_stat_bar__value">
<t t-out="valueFor(tile)" />
</div>
<div class="o_dms_stat_bar__label">
<t t-out="tile.label" />
</div>
<div t-if="deltaText(tile)" class="o_dms_stat_bar__delta">
<t t-out="deltaText(tile)" />
</div>
</div>
<t t-set="spark" t-value="sparkPath(tile)" />
<svg
t-if="spark.hasData"
class="o_dms_stat_bar__spark"
t-att-viewBox="'0 0 ' + sparkWidth() + ' ' + sparkHeight()"
preserveAspectRatio="none"
aria-hidden="true"
>
<t t-if="tile.chart === 'bar'">
<rect
t-foreach="spark.bars"
t-as="bar"
t-key="bar_index"
t-att-x="bar.x"
t-att-y="bar.y"
t-att-width="bar.width"
t-att-height="bar.height"
class="o_dms_stat_bar__spark_bar"
/>
</t>
<t t-else="">
<polygon
t-att-points="spark.areaPath"
class="o_dms_stat_bar__spark_area"
/>
<polyline
t-att-points="spark.linePath"
class="o_dms_stat_bar__spark_line"
fill="none"
/>
<circle
t-att-cx="spark.last.x"
t-att-cy="spark.last.y"
r="1.8"
class="o_dms_stat_bar__spark_dot"
/>
</t>
</svg>
</div>
</div>
</t>
</templates>
59 changes: 59 additions & 0 deletions dms/static/src/js/views/dms_directory_kanban_renderer.esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2026 ledoent — Don Kendall
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import {onWillStart, useState} from "@odoo/owl";
import {DmsStatBar} from "../components/dms_stat_bar.esm";
import {KanbanRenderer} from "@web/views/kanban/kanban_renderer";
import {_t} from "@web/core/l10n/translation";
import {serializeDateTime} from "@web/core/l10n/dates";
import {useService} from "@web/core/utils/hooks";

const {DateTime} = luxon;

export class DmsDirectoryKanbanRenderer extends KanbanRenderer {
static template = "dms.DirectoryKanbanRenderer";
static components = {
...KanbanRenderer.components,
DmsStatBar,
};

setup() {
super.setup();
this.orm = useService("orm");
this.action = useService("action");
this.statsState = useState({stats: null});
onWillStart(async () => {
this.statsState.stats = await this.orm.call(
"dms.directory",
"get_dashboard_stats",
[]
);
});
}

get stats() {
return this.statsState.stats;
}

// Drill-down from a dashboard tile into the matching file list — the
// native Odoo-dashboard interaction. "Files" opens all files; "New
// today" opens files created since local midnight.
onTileClick(tile) {
if (tile.action === "files") {
this.action.doAction("dms.action_dms_file");
} else if (tile.action === "today") {
const since = serializeDateTime(DateTime.local().startOf("day"));
this.action.doAction({
type: "ir.actions.act_window",
name: _t("Files added today"),
res_model: "dms.file",
views: [
[false, "kanban"],
[false, "list"],
],
domain: [["create_date", ">=", since]],
target: "current",
});
}
}
}
Loading
Loading