Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b9511e9
[IMP] dms: UX modernisation (kanban density + dashboard + side-pane p…
dnplkndll May 22, 2026
2c8784d
[IMP] dms: OWL 2.x adoption + responsive collapse + dead-code cleanup
dnplkndll May 26, 2026
04f472c
[IMP] dms: round directory kanban avatar to match file card
dnplkndll May 26, 2026
51dddef
[FIX] dms: bug-batch from runboat UX audit
dnplkndll May 26, 2026
a29b917
[FIX] dms: preview pane polish (B7 iframe bg + B8 empty-state collapse)
dnplkndll May 26, 2026
e2c59de
[IMP] dms: extract useStoredState + useDmsPreviewState OWL hooks
dnplkndll May 26, 2026
d0e79ff
[ADD] dms: F3 — inline quick-rename in kanban (dblclick → Enter / Esc)
dnplkndll May 26, 2026
695b4d1
[ADD] dms: F4 (lite) — Recent search-bar filter
dnplkndll May 26, 2026
d0076e5
[IMP] dms: sibling-module seams (hero_chips slot, preview_action_regi…
dnplkndll May 26, 2026
c681e27
[FIX] dms: pre-commit formatting + 19.0 kanban arch t-on-input violation
dnplkndll May 27, 2026
a234294
[FIX] dms: drop inline kanban rename + fix no-empty-function ESLint e…
dnplkndll May 27, 2026
6c33b73
[FIX] dms: wire notification service into preview action onClick payload
dnplkndll May 27, 2026
4c65ce7
[FIX] dms: pre-commit ESLint compliance
dnplkndll May 27, 2026
713e786
[FIX] dms: seams — colour map sync, extraActions tests, stat bar erro…
dnplkndll May 28, 2026
0513112
[FIX] dms: ESLint — sort imports, non-empty onClick stubs in test
dnplkndll May 28, 2026
d6949eb
[FIX] dms: ESLint — restore multiple-before-single import order, drop…
dnplkndll May 28, 2026
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 .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ on:
branches:
- "19.0"
- "19.0-ocabot-*"
# ledoent fork only: allow manual re-trigger when GitHub silently throttles
# fork-PR workflow runs after a burst of pushes. Strip before opening an
# upstream OCA PR (oca-addons-repo-template owns this file).
workflow_dispatch:

jobs:
unreleased-deps:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,10 @@ docs/_build/

# OCA rules
!static/lib/

# Session-local planning + audit artefacts (not for upstream)
*-PLAN.md
SESSION-HANDOFF-*.md
DMS-UX-AUDIT-*.md
dms-ux-prototype.html
screenshots/
18 changes: 17 additions & 1 deletion dms/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
{
"name": "Document Management System",
"summary": """Document Management System for Odoo""",
"version": "19.0.1.0.0",
"version": "19.0.1.4.0",
"category": "Document Management",
"license": "LGPL-3",
"website": "https://github.com/OCA/dms",
Expand Down Expand Up @@ -47,18 +47,34 @@
"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/utils/*.esm.js",
"dms/static/src/js/components/*.esm.js",
"dms/static/src/js/components/preview/*.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/components/preview/*.xml",
"dms/static/src/js/views/*.xml",
# SCSS
"dms/static/src/scss/dms_ext_palette.scss",
"dms/static/src/scss/file_kanban.scss",
"dms/static/src/scss/dms_directory.scss",
"dms/static/src/scss/dms_form_hero.scss",
"dms/static/src/scss/file_preview_pane.scss",
"dms/static/src/scss/dms_search_facets.scss",
],
"web.assets_frontend": [
"dms/static/src/scss/dms_ext_palette.scss",
"dms/static/src/scss/portal.scss",
],
"web.assets_tests": [
"dms/static/tests/tours/**/*",
],
"web.assets_unit_tests": [
"dms/static/tests/**/*.test.js",
],
},
"demo": [
"demo/res_users.xml",
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,
}
8 changes: 2 additions & 6 deletions dms/models/dms_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,12 +425,8 @@ def _compute_path(self):
},
)
current_dir = current_dir.parent_id
record.update(
{
"path_names": "/".join(path_names) if all(path_names) else "",
"path_json": json.dumps(path_json),
}
)
record.path_names = "/".join(path_names) if all(path_names) else ""
record.path_json = json.dumps(path_json)

@api.depends("name", "mimetype", "content")
def _compute_extension(self):
Expand Down
137 changes: 137 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,137 @@
// 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",
},
{
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",
},
];

export class DmsStatBar extends Component {
static template = "dms.StatBar";
static props = {
stats: {type: [Object, {value: null}], optional: true},
tiles: {type: Array, optional: true},
};
static defaultProps = {
tiles: DEFAULT_TILES,
};

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

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;
}
}
69 changes: 69 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,69 @@
<?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"
>
<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-esc="valueFor(tile)" />
</div>
<div class="o_dms_stat_bar__label">
<t t-esc="tile.label" />
</div>
<div t-if="deltaText(tile)" class="o_dms_stat_bar__delta">
<t t-esc="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>
Loading
Loading