diff --git a/CHANGELOG.md b/CHANGELOG.md index 4065289..c7a8600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/routers/documents.py b/api/routers/documents.py index 01a5e2a..d1e4889 100644 --- a/api/routers/documents.py +++ b/api/routers/documents.py @@ -28,6 +28,7 @@ _LIST_RESERVED = { "status", "party", "from_date", "to_date", "docstatus", "include_discarded", "limit", "offset", "order_by", "order", + "date_field", } @@ -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, @@ -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"): @@ -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. @@ -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"): diff --git a/api/services.py b/api/services.py index a03f51a..e1f2776 100644 --- a/api/services.py +++ b/api/services.py @@ -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.""" @@ -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 == "": @@ -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) @@ -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 == "": @@ -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: @@ -524,7 +541,7 @@ 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 @@ -532,11 +549,13 @@ def _filter_where(db, doctype: str, filters: dict, include_discarded: bool): 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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 28e8feb..043bb18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,13 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.5", + "version": "0.6.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lambda-development/erp-core", - "version": "0.6.5", - "license": "MIT", + "version": "0.6.6", + "license": "Apache-2.0", "dependencies": { "@fontsource/inter": "^5.2.8" }, diff --git a/frontend/package.json b/frontend/package.json index eef1896..a8a3f7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/lib/doctypes.ts b/frontend/src/lib/doctypes.ts index fd3160d..ad72d80 100644 --- a/frontend/src/lib/doctypes.ts +++ b/frontend/src/lib/doctypes.ts @@ -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[]; diff --git a/frontend/src/pages/documents/document-list.tsx b/frontend/src/pages/documents/document-list.tsx index 92a94a4..5e76971 100644 --- a/frontend/src/pages/documents/document-list.tsx +++ b/frontend/src/pages/documents/document-list.tsx @@ -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 = {}; + 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 @@ -98,14 +111,23 @@ export default function DocumentListPage() { const filters = useMemo(() => { const f: Record = {}; - 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 ?? []; @@ -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 }); @@ -245,15 +266,33 @@ export default function DocumentListPage() {
- ({ + 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 ( + 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"):