Skip to content
Closed
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ semver-governed public surface — a breaking change to a seam is a major bump.

## [Unreleased]

## [0.6.6] - 2026-07-29

### Added
- **Config-driven list filters (`listFilters`).** A doctype config may now
declare `listFilters: string[]` — field names (each a `type: "select"`
field) rendered as dropdowns above the list, their selection sent as a plain
column filter (`status=Contacted`, `fit=A`). When set, these replace the
built-in Draft/Submitted/Cancelled status dropdown — the right call for
non-submittable doctypes whose `status` is a business field, not a docstatus.
Omit it to keep the default status filter (every existing doctype unchanged).
Uses the ad-hoc column-filter path added in 0.6.0; no backend change needed.

### Fixed
- **Date-range filters now work on any doctype with a declared `dateField`.**
The list/count/adjacent endpoints filtered `from_date`/`to_date` against a
hardcoded server-side `DATE_FIELDS` map that only knew the built-in core
doctypes, so a plugin doctype's date pickers were silently ignored. The
frontend now sends its declared `dateField` as a `date_field` query param;
the backend honors it when it names a real column (falling back to the map
otherwise, and degrading to *no* date filter — never a 400 — for a synthetic
`dateField`). `date_field` is validated against live columns before it ever
touches SQL. Covers list, count, and prev/next (`DocPager`) alike.

## [0.6.5] - 2026-07-29

### Added
Expand Down
13 changes: 13 additions & 0 deletions api/routers/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
_LIST_RESERVED = {
"status", "party", "from_date", "to_date", "docstatus",
"include_discarded", "limit", "offset", "order_by", "order",
"date_field",
}


Expand All @@ -43,6 +44,7 @@ def list_docs(
include_discarded: bool = False,
order_by: str | None = None,
order: str = "desc",
date_field: str | None = None,
limit: int = Query(default=50, le=500),
offset: int = Query(default=0, ge=0),
_user: dict = _viewer,
Expand Down Expand Up @@ -70,6 +72,14 @@ def list_docs(
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
filters[key] = value

# Which column from_date/to_date filter on. The frontend passes its declared
# dateField so plugin doctypes get working date filters without a server-side
# map. Passed through untouched here; list_documents ignores it unless it's a
# real column (so a config with a synthetic dateField degrades to no date
# filter rather than 400-ing the whole list), and never interpolates it blind.
if date_field:
filters["date_field"] = date_field

if order_by is not None and order_by not in columns:
raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
if order.lower() not in ("asc", "desc"):
Expand All @@ -94,6 +104,7 @@ def adjacent_doc(
include_discarded: bool = False,
order_by: str | None = None,
order: str = "desc",
date_field: str | None = None,
_user: dict = _viewer,
):
"""Prev/next record around `name` in the same order+filters the list uses.
Expand All @@ -117,6 +128,8 @@ def adjacent_doc(
if key not in columns:
raise HTTPException(status_code=400, detail=f"Unknown filter field: {key}")
filters[key] = value
if date_field:
filters["date_field"] = date_field
if order_by is not None and order_by not in columns:
raise HTTPException(status_code=400, detail=f"Unknown order_by field: {order_by}")
if order.lower() not in ("asc", "desc"):
Expand Down
27 changes: 23 additions & 4 deletions api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,17 @@ def convert_document(doctype_slug: str, name: str, target_doctype: str) -> dict:
}


def _resolve_date_field(db, doctype: str, override: str | None) -> str | None:
"""The column that from_date/to_date range-filter against. An explicit
override (the frontend's declared `dateField`, passed as the `date_field`
filter) wins when it names a real column; otherwise fall back to the
built-in DATE_FIELDS map for core doctypes. Guarded so the override is never
interpolated into SQL unless it's an actual column."""
if override and override in db._get_table_columns(doctype):
return override
return DATE_FIELDS.get(doctype)


def _exclude_discarded(db, doctype: str, db_filters: dict, include_discarded: bool) -> None:
"""Hide voided drafts (discarded=1) unless explicitly requested. Guarded on
the column existing so non-submittable doctypes are unaffected."""
Expand Down Expand Up @@ -421,6 +432,7 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
db_filters = {}
from_date = None
to_date = None
date_field_override = None
if filters:
for key, value in filters.items():
if value is None or value == "":
Expand All @@ -429,13 +441,15 @@ def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, off
from_date = value
elif key == "to_date":
to_date = value
elif key == "date_field":
date_field_override = value
else:
db_filters[key] = value

_exclude_discarded(db, doctype, db_filters, include_discarded)

# Date range filtering via the doctype's primary date field
date_field = DATE_FIELDS.get(doctype)
date_field = _resolve_date_field(db, doctype, date_field_override)
if date_field:
if from_date:
db_filters[date_field] = (">=", from_date)
Expand Down Expand Up @@ -481,6 +495,7 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
db_filters = {}
from_date = None
to_date = None
date_field_override = None
if filters:
for key, value in filters.items():
if value is None or value == "":
Expand All @@ -489,12 +504,14 @@ def count_documents(doctype_slug: str, filters: dict = None, include_discarded:
from_date = value
elif key == "to_date":
to_date = value
elif key == "date_field":
date_field_override = value
else:
db_filters[key] = value

_exclude_discarded(db, doctype, db_filters, include_discarded)

date_field = DATE_FIELDS.get(doctype)
date_field = _resolve_date_field(db, doctype, date_field_override)
where_parts = []
params = []
if date_field and from_date:
Expand Down Expand Up @@ -524,19 +541,21 @@ def _filter_where(db, doctype: str, filters: dict, include_discarded: bool):
(equality filters + from_date/to_date on the doctype's date field + discard
exclusion). Same semantics as list_documents/count_documents so prev/next
matches exactly what the list shows."""
db_filters, from_date, to_date = {}, None, None
db_filters, from_date, to_date, date_field_override = {}, None, None, None
for key, value in (filters or {}).items():
if value is None or value == "":
continue
if key == "from_date":
from_date = value
elif key == "to_date":
to_date = value
elif key == "date_field":
date_field_override = value
else:
db_filters[key] = value
_exclude_discarded(db, doctype, db_filters, include_discarded)

date_field = DATE_FIELDS.get(doctype)
date_field = _resolve_date_field(db, doctype, date_field_override)
where_parts, params = [], []
if date_field and from_date:
where_parts.append(f'"{date_field}" >= ?'); params.append(from_date)
Expand Down
6 changes: 3 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lambda-development/erp-core",
"version": "0.6.5",
"version": "0.6.6",
"description": "Frontend core of Lambda ERP — app shell, document/master pages, chat UI, reports, and extension registries.",
"license": "Apache-2.0",
"repository": {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/doctypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export interface DoctypeConfig {
fields: FieldDef[];
childTables: ChildTableDef[];
listColumns: string[];
// Field names to expose as dropdown filters above the list. Each must name a
// `type: "select"` field in `fields`; its options drive the dropdown and the
// selection is sent as a plain column filter (status=Contacted, fit=A). When
// set, these replace the built-in Draft/Submitted/Cancelled status dropdown —
// the right call for non-submittable doctypes whose `status` is a business
// field, not a docstatus. Omit to keep the default status filter.
listFilters?: string[];
canSubmit: boolean;
canCancel: boolean;
conversions: ConversionDef[];
Expand Down
63 changes: 51 additions & 12 deletions frontend/src/pages/documents/document-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ export default function DocumentListPage() {
const baseCurrency = useBaseCurrency();
const { doctype } = useParams<{ doctype: string }>();
const config = getDoctypeConfig(doctype ?? "");
const location = useLocation();

// Config-driven filter dropdowns (opt-in via config.listFilters). Their values
// live in the URL like every other filter; read them generically since the
// field names are dynamic. Each names a select field whose options drive the
// dropdown; the selection is sent as a plain column filter.
const configFilters = config?.listFilters ?? [];
const filterValues = useMemo(() => {
const params = new URLSearchParams(location.search);
const out: Record<string, string> = {};
for (const f of configFilters) out[f] = params.get(f) ?? "";
return out;
}, [location.search, configFilters]);

// All user-facing filter state lives in the URL. The param names are the
// short human-readable form (`from` / `to` / `per_page`); the backend still
Expand Down Expand Up @@ -98,14 +111,23 @@ export default function DocumentListPage() {

const filters = useMemo(() => {
const f: Record<string, string | number | undefined> = {};
if (status !== "All") f.status = status;
// The default status dropdown is hidden when a doctype opts into its own
// filters, so only apply it in that default case.
if (configFilters.length === 0 && status !== "All") f.status = status;
if (fromDate) f.from_date = fromDate;
if (toDate) f.to_date = toDate;
// Tell the backend which column the date range filters on. Without this the
// server only knows the built-in core doctypes; plugin doctypes' date
// pickers would be silently ignored.
if (config?.dateField) f.date_field = config.dateField;
for (const key of configFilters) {
if (filterValues[key]) f[key] = filterValues[key];
}
if (showDiscarded) f.include_discarded = "true";
f.limit = pageSize;
f.offset = page * pageSize;
return f;
}, [status, fromDate, toDate, showDiscarded, pageSize, page]);
}, [status, fromDate, toDate, showDiscarded, pageSize, page, config?.dateField, configFilters, filterValues]);

const { data, isLoading } = useDocumentList(doctype ?? "", filters);
const rows = data?.rows ?? [];
Expand All @@ -114,7 +136,6 @@ export default function DocumentListPage() {
// Remember this list's filters + URL so a detail page's prev/next (DocPager)
// follows it, and "back to list" returns here. Pagination is dropped — it's
// not a record filter.
const location = useLocation();
useEffect(() => {
const { limit, offset, ...ctxFilters } = filters;
setListContext(doctype ?? "", { filters: ctxFilters, search: location.search });
Expand Down Expand Up @@ -245,15 +266,33 @@ export default function DocumentListPage() {
</div>

<div className="flex flex-wrap items-end gap-4">
<Select
label={t("fields.Status", { defaultValue: "Status" })}
options={STATUS_OPTIONS.map((s) => ({
value: s,
label: s === "All" ? t("common.all") : t(`status.${s}`, { defaultValue: s }),
}))}
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
{configFilters.length === 0 ? (
<Select
label={t("fields.Status", { defaultValue: "Status" })}
options={STATUS_OPTIONS.map((s) => ({
value: s,
label: s === "All" ? t("common.all") : t(`status.${s}`, { defaultValue: s }),
}))}
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
) : (
configFilters.map((key) => {
const field = config.fields.find((f) => f.name === key);
const opts = (field?.options ?? []).map((o) =>
typeof o === "string" ? { value: o, label: o } : o,
);
return (
<Select
key={key}
label={t(`fields.${field?.label ?? key}`, { defaultValue: field?.label ?? key })}
options={[{ value: "", label: t("common.all") }, ...opts]}
value={filterValues[key]}
onChange={(e) => patchUrl({ [key]: e.target.value || null, page: null })}
/>
);
})
)}
<Input
label={t("fields.From Date", { defaultValue: "From Date" })}
type="date"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lambda-erp"
version = "0.6.5"
version = "0.6.6"
description = "Core ERP logic - accounting, sales, purchasing, inventory"
readme = "README.md"
license = "Apache-2.0"
Expand Down
28 changes: 26 additions & 2 deletions tests/test_adjacent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import sys

WIDGET_TABLE = """CREATE TABLE IF NOT EXISTS "Widget" (
name TEXT PRIMARY KEY, label TEXT, kind TEXT, discarded INTEGER DEFAULT 0,
name TEXT PRIMARY KEY, label TEXT, kind TEXT, made_on TEXT,
discarded INTEGER DEFAULT 0,
docstatus INTEGER DEFAULT 0, creation TEXT, modified TEXT
)"""

Expand Down Expand Up @@ -64,7 +65,8 @@ def check_adjacent():
for i in range(5):
n = client.post("/api/documents/widget",
json={"label": f"W{i}", "kind": "A" if i % 2 == 0 else "B"}).json()["name"]
db.set_value("Widget", n, {"creation": f"2026-07-2{i}T00:00:00"})
db.set_value("Widget", n, {"creation": f"2026-07-2{i}T00:00:00",
"made_on": f"2026-07-2{i}"})
names.append(n)

def adj(name, **params):
Expand All @@ -81,6 +83,28 @@ def adj(name, **params):
# unknown filter field -> 400
assert client.get(f"/api/documents/widget/{names[2]}/adjacent?bogus=1").status_code == 400

# --- date_field override. Widget isn't in the server's built-in
# DATE_FIELDS map, so date filtering only kicks in when the caller passes
# its declared dateField (the frontend sends config.dateField).
def wlist(**params):
qs = "&".join(f"{k}={v}" for k, v in params.items())
return client.get("/api/documents/widget" + (f"?{qs}" if qs else "")).json()

# no date_field -> range silently ignored (unmapped doctype, all 5)
assert wlist(from_date="2026-07-22", to_date="2026-07-23")["total"] == 5
# with date_field -> filters + counts on made_on (w2,w3,w4 >= the 22nd)
r = wlist(date_field="made_on", from_date="2026-07-22")
assert r["total"] == 3, r
assert {row["name"] for row in r["rows"]} == {names[2], names[3], names[4]}, r
# both bounds
assert wlist(date_field="made_on", from_date="2026-07-22", to_date="2026-07-23")["total"] == 2
# a date_field that isn't a real column degrades to no date filter (not a
# 400 — a config with a synthetic dateField must not break the whole list)
assert wlist(date_field="not_a_column", from_date="2026-07-22")["total"] == 5
# adjacent honors the same window: inside [22..24] (w4,w3,w2), w3 steps w4/w2
assert adj(names[3], date_field="made_on", from_date="2026-07-22", to_date="2026-07-24") \
== {"prev": names[4], "next": names[2]}, "date_field window not respected by adjacent"

# --- Masters: /masters/{type}/{name}/adjacent, name ASC (list order).
cust = []
for label in ("Aster Trading AG", "Borea Logistik GmbH", "Cirrus Metall AG"):
Expand Down