feat(ui): year date presets, task client filter, public banner hint, no fake expense receipt

Merged
EV
14d ago
+1724-121

Summary

Four small, additive web UI improvements. Nothing changes until someone uses them. The one place that used to allow something it no longer does: on pages that disallow past dates, the date picker no longer lists presets that are already over (Yesterday, Last Week, …), and its Previous arrow stops at today. Picking one of those presets gave a range that ended before it started, and Previous selected past days.

  • Date picker (#985): the header date picker now has Current Year and Last Year presets. Pick one and the arrows move a whole year at a time.
  • Add task dialog (#6847): a new optional Client field. Picking a client shows only that client's projects, like the Desktop timer and the web timer already do. Left empty, every project is listed, as before.
  • Public page banner (#1869): the Banner field now shows an example in its placeholder and has an info button that explains what the banner is. The seed gives Ever Technologies LTD a real tagline.
  • Expense receipt (#1882): new expenses no longer get a sample invoice saved as their receipt, and the Attach receipt dialog no longer shows that sample as if it were the receipt.

Why

  • #985: you could pick a day, week or month in one click, but not a year.
  • #6847: the Desktop part was done. The web Add task dialog had no client field.
  • #1869: the banner is the one-line tagline under the company name on the public page. The edit form didn't say so: its placeholder just repeated the label. The seed filled it with lorem ipsum, and the GraphQL docs called it "a URL".
  • #1882: expenses-mutation pre-filled receipt with ./assets/images/others/invoice-template.png. Because of that, every expense saved without an upload stored that path as its receipt, and the dialog showed a sample invoice.

What changed

packages/ui-core/shared — date range picker (#985)

  • date-picker.interface.ts: DateRangeKeyEnum.CURRENT_YEAR / LAST_YEAR.
  • date-picker.utils.ts: new pure helpers that replace the inline switch and label map in the component:
    • PRESET_UNITS_OF_TIME (day, week, month, year)
    • PRESET_UNIT_OF_TIME (preset label → unit)
    • buildPresetRanges(units, now, maxDate, minDate)
  • Every preset is one whole unit. A preset that would run past maxDate ends on maxDate, so on an organization that doesn't allow future dates, "Current Year" runs from 1 January to today.
  • Where past dates are disallowed (minDate is today), a preset starts on minDate at the earliest, and presets that end before that day are left out. The picker library keeps such an entry in its list even though it skips it, so before this change Yesterday, Last Week and Last Month were offered there and gave an inverted range. Last Year would have done the same. A preset whose limits leave nothing of the unit (maxDate before minDate, even on the same day) is left out too, instead of being listed with its end before its start.
  • Where past dates are disallowed, the Previous arrow now stops at today, like Next already did on the other side. isPreviousRangeDisabled() disables it once the selected range starts today or earlier, and keepRangeFromToday() makes a step back from a later range start today at the earliest, as the presets do at minDate. Before, Previous from a "Current Year" clamped to start today selected 1 January to yesterday.
  • date-range-picker.component.ts:
    • createDateRangeMenus() uses the helper. Locked pages (isLockDatePicker) still offer only their own unit.
    • rangeClicked() uses the map.
    • isCustomDate() delegates to a pure isCustomDateRange(), which treats a locked picker's range as never custom. A locked picker always selects one whole unit, but where future dates are disallowed the Current Month preset ends today, so the whole month counted as custom and Previous stepped back 31 days instead of one month.
    • In the organization pipeline, the menu is now built after setPastStrategy()/setFutureStrategy(), so the presets use the minDate/maxDate those settle.
  • No other code needed changes for year: the arrow strategies, toPickerRange and the resolver already work for any unit. unit_of_time=year survives a reload, and the specs now cover year in each of them.

packages/ui-core/shared — Add task dialog (#6847)

  • add-task-dialog.component.{ts,html}:
    • The ga-contact-selector is bound to a standalone organizationContactControl. It sits outside the task form, so it never reaches the task payload: a task has no client of its own.
    • ga-project-selector gets [organizationContactId].
    • Picking a client clears a selected project that belongs to another client, along with that project's modules.
  • add-task-dialog.utils.ts: pure isProjectOfClient(project, organizationContactId).
  • add-task-dialog.module.ts: imports ContactSelectorModule.
  • selectors/project/project.component.ts (shared ga-project-selector). The dialog now reloads the project list whenever the client changes, which exposed two problems:
    • Stale responses. An earlier request that answered after a later one put the previous client's projects back. getProjects() now numbers its requests and ignores a response once a newer request has started.
    • URL replay. Every reload re-applied the URL's ?projectId=. That replaced the project picked in the form with the page's, and brought the page's project back after the form had cleared it. A form's selector (skipGlobalChange) now takes the URL's project only as its starting value: on its first list, and only while nothing is selected in it. As a side effect, editing a task while the URL names another project no longer swaps the task's project for the page's. The header's global selector follows the URL exactly as before. The rule is shouldSelectUrlProject in the new project-selector.utils.ts. getProjects() now returns whether its list is the one shown, and only a list that actually loaded counts as the first one, so a failed first request doesn't use up that starting value.
  • add-task-dialog.component.ts:
    • loadAvailableModules() ignores an answer once the selected project has changed, so the old project's modules can't arrive late after a client cleared the project.
    • The projectId subscription now comes first in ngOnInit. The edited task's project and the page's selected project now load their modules directly. Before, they loaded only through the selector's URL replay, so without this change an unchanged save from the project's page would have dropped the task's modules.
  • The Client label is tied to the selector's input (for="add-task-client"). ga-contact-selector gets an optional labelForId input, which it passes to ng-select's own labelForId. Its other users don't set it, so nothing changes for them.

packages/plugins/public-layout-ui + packages/core — banner (#1869)

  • Edit public page dialog:
    • The placeholder is now POP_UPS.BANNER_PLACEHOLDER ("e.g. We build open-source software for businesses").
    • An info button beside the label shows a tooltip, POP_UPS.BANNER_HINT ("A short tagline shown under your company name on your public page").
    • The tooltip opens on hover, keyboard focus and click/tap, and closes on mouseleave, blur and Escape. Each of Nebular's built-in triggers covers only one of those: the default hint is hover-only, and focus drops hover. So the button uses nbTooltipTrigger="noop" and calls show()/hide() itself.
    • Escape on an open hint closes only the hint, wherever focus is (the hint can be open by hover while focus stays in the banner field). The dialog closes on an Escape keyup that reaches the document, which would discard unsaved edits. So while the dialog is open, a window capture-phase listener closes a shown hint and stops that one Escape. With no hint open, Escape closes the dialog as before.
  • default-organizations.ts / organization.seed.ts: Ever Technologies LTD is seeded with a real tagline. Other seeded organizations get faker.company.catchPhrase() instead of lorem ipsum.
  • organization.type.gql + schema.graphql snapshot: Organization.banner is now described as the tagline, not a URL. core:graphql-snapshot --write reports the committed snapshot is up to date.
  • public-layout-ui/jest.config.ts: loads the shared jest.angular-defaults.ts, like the other plugin UIs do, for the new component spec. Its two existing suites still pass.

packages/ui-core/shared — expense receipt (#1882)

  • expense-receipt.utils.ts:
    • PLACEHOLDER_RECEIPT
    • isPlaceholderReceipt(): recognises the old path written as ./, /, bare, or a full app URL
    • toActualReceipt()
  • expenses-mutation.component.ts:
    • The receipt now starts as null.
    • When an existing expense is edited or duplicated, a stored placeholder is read as "no receipt".
    • defaultImage stays, marked @deprecated, so nothing reading it breaks.
  • attach-receipt:
    • With no receipt, the dialog shows only the upload prompt plus a "No receipt attached yet. Click to upload one." hint (EXPENSES_PAGE.MUTATION.NO_RECEIPT).
    • Save stays disabled until an image is uploaded, as before.
  • The Attach a receipt button shows a check mark (EXPENSES_PAGE.MUTATION.RECEIPT_ATTACHED) once a receipt is set.

i18n

  • Four new keys, added to all 14 files under packages/ui-core/i18n/assets/i18n, each with a translation (English in ach.json, matching recent additions there).
  • Keys are only inserted, nothing is reordered: each file has +4 lines.

Flags / settings and defaults

No new flags. Every change is opt-in by use:

  • No year preset is picked by default, and each page keeps its own default unit.
  • The Client field starts empty, which lists every project.
  • The banner keeps its stored value.
  • Only the receipt default changes: from a sample image to no receipt.

Decisions made on the way (please check)

  1. #6847 uses ga-contact-selector, not ga-contact-select. It is the id-valued selector that the web timer and the Edit time log dialog already pair with ga-project-selector [organizationContactId], so the filter is a plain id binding. Like everywhere else it is used, it lets users with ORG_CONTACT_EDIT create a client inline.
  2. #6847: the client is not prefilled when editing a task. Prefilling it would narrow the project list for every edited task whose project has a client, which would hide projects that are visible today. The field is a filter, and empty means no filter.
  3. #6847: no "client required" rule, even when the organization has requireClient set. That setting is about time logs, and the plan here was to add no new required fields.
  4. #1869: no maxlength on the banner input. The column is a plain varchar, which has no limit on PostgreSQL and SQLite, so a cap would be a new restriction for existing users.
  5. #1882: no migration for old rows. Rows that already store the placeholder path keep it until they are edited, and the UI treats them as having no receipt. When one is edited and saved, it is saved with receipt: null, which clears the placeholder; UpdateExpenseDTO already allows that (@IsOptional). New expenses are created with receipt: null.
  6. #985 is partial, so this PR says Refs, not Closes. The issue also asks that "nothing selected" show data since the business started. That needs an all-time range across pages and the API, and is not part of this PR.
  7. The preset labels (Today, Current Year, …) stay English, like the existing ones: the picker has never translated its range labels.
  8. The project selector fixes live in the shared component, not the dialog. The race and the URL replay are both in ga-project-selector, so a dialog-only workaround would have left them in every other form that filters projects by client or employee. The behaviour change is limited to form selectors (skipGlobalChange): they take the URL's project once, as a starting value, instead of on every reload.

Overlap with an open PR

#11566 (task dialog Jira layout) rewrites add-task-dialog.{html,ts,scss,module}. The #6847 change is kept small so that whichever PR merges second has an easy rebase:

  • one standalone control and one onClientChanged method in the .ts (which also clears the modules)
  • one self-contained template block plus one [organizationContactId] binding
  • one module import
  • the rule itself in a new file, add-task-dialog.utils.ts

#11566 also adds i18n keys, in a different section. It doesn't touch the shared project selector, so the selector fixes don't overlap with it.

Tests

All run locally on Node 26.10.

npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core date-range-picker add-task-dialog expenses-mutation selector-builder feature-i18n-keys selectors/project
→ Test Suites: 12 passed, 12 total; Tests: 183 passed, 183 total

npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core
→ Test Suites: 83 passed, 83 total; Tests: 642 passed, 642 total (the whole ui-core project)

npx jest -c packages/plugins/public-layout-ui/jest.config.ts --rootDir packages/plugins/public-layout-ui
→ Test Suites: 3 passed, 3 total; Tests: 57 passed, 57 total

TZ=Pacific/Auckland / TZ=America/Los_Angeles npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core date-range-picker
→ 97 passed each

node -r ts-node/register -r tsconfig-paths/register packages/core/src/lib/graphql/schema-snapshot.ts --write
→ "schema.graphql is up to date"

New or extended specs:

  • date-picker.utils.spec.ts: isCustomDateRange (presets, other ranges, library date objects, locked whole month), buildPresetRanges (menu order, whole year, unchanged day/week/month presets, locked units, clamp to maxDate, past presets left out and starts clamped with minDate, no inverted preset when the limits leave nothing), isPreviousRangeDisabled, keepRangeFromToday, PRESET_UNIT_OF_TIME, locked-year toPickerRange
  • next-previous.strategy.spec.ts (new): arrows step a year, including from a year cut short at today, and across a leap year
  • date-range-picker.resolver.spec.ts: unit_of_time=year reloads; locked routes ignore a stale year
  • add-task-dialog.utils.spec.ts (new) and add-task-dialog.component.spec.ts: the selector renders empty; project list unfiltered until a client is picked; a project of another client is cleared, with its modules; a project of the picked client stays, with its modules; modules that arrive after the project was cleared are ignored; an edited task saved unchanged keeps its modules; a new task gets the selected project's modules; the Client label names the selector's input; the client never enters the task payload
  • project-selector.utils.spec.ts and project.component.spec.ts (both new): a late answer for an earlier client doesn't replace the list; a form's selector starts from the URL project, keeps a project picked in it (or on the task being edited) across reloads, and doesn't bring the URL project back once it was cleared, and still starts from it when the first list failed and a reload succeeded; the global selector still follows the URL. Four of these fail on develop's selector code.
  • public-page-organization-mutation.component.spec.ts (new): example placeholder, info button with tooltip and aria-label, the hint opening on mouseenter, focus and click and closing on mouseleave, blur and Escape, Escape on an open hint not reaching the dialog (also for a hint opened by hover while focus stays in the banner field), existing banner still editable
  • expense-receipt.utils.spec.ts, expenses-mutation.component.spec.ts and attach-receipt.component.spec.ts (all new): new expense has no receipt and saves receipt: null; placeholder rows read as empty; real receipts kept; dialog shows no image and shows the hint when empty; Save enables after upload

Type-check: tsc -p packages/ui-core/tsconfig.lib.json and tsc -p packages/plugins/public-layout-ui/tsconfig.{lib,spec}.json report nothing in the changed files; the only errors are in other packages' spec globals and were already there. ESLint on the changed files reports no new problems; the errors it does report are on lines this PR doesn't touch.

Manual verification still needed

I didn't run the app (yarn start:gauzy) here. Please check:

  • Accounting > Income / Expenses and the dashboard:
    • Pick "Current Year": requests should use 1 Jan – 31 Dec, or 1 Jan – today when future dates are off.
    • The arrows should step a year.
    • Reload: the year should stay.
  • Tasks > Add task:
    • Pick a client: the project list should narrow.
    • A project of another client should clear.
    • Clear the client: all projects should come back.
    • Pick a project, then a client it belongs to: the project should stay, even if the page URL has ?projectId= for a different project.
    • Edit a task from a page filtered to another project: the task's own project should stay.
    • With the URL naming a project, pick a client that excludes it (the project clears), then clear the client: the project field should stay empty.
    • A screen reader should announce the Client field by its label.
    • Edit a task that has modules from its project's page: the Module field should list them, and saving unchanged should keep them.
    • Save: the request body should have no client.
  • A month-locked page with future dates disallowed: Previous from the current month should select the whole previous month.
  • Any page that disallows past dates:
    • The date picker should list only Today and the Current … presets.
    • Previous should be disabled on a range that starts today (e.g. "Current Year").
    • From next week, Previous should select today to the end of this week.
  • Organization > public page > Edit: the example placeholder and the info tooltip should show (hover, focus and tap). With the hint open, Escape should close only the hint, including when it was opened by hover while typing in the Banner field; pressed again, it should close the dialog.
  • Fresh demo seed: Ever Technologies LTD should show "We build open-source software for businesses" under its name on /share/organization/....
  • Accounting > Expenses > Add:
    • Open Attach receipt: you should see the empty state with the hint.
    • Save without uploading: the request should carry receipt: null.
    • Upload one: it should persist, the check mark should appear, and the receipt should show when the dialog is reopened.

Risk and rollback

Low. The changes are UI-only, apart from seed data and one GraphQL description string; there is no schema, migration or endpoint change. Each issue has its own commits, so any one can be reverted on its own:

  • #985: d16c957c9c, 3a9468470f (presets with minDate), ffd90c0b66 (locked ranges never custom), 24c97f56b3 (no inverted presets; Previous stops at today)
  • #6847: f6d055df59, dadf84a0e9 (modules), dd7a52712b + 9d3e9891ef + 59c2bda5ef (project selector), 4dd71278de (late module answers, client label), e9bcc8b7fc (starting project's modules)
  • #1869: 79c283a697, 6780eda51c, 6642fe7916 (hint on focus and tap), ebf45a7a17 + fb268837b3 (Escape closes only the hint)
  • #1882: 1edad926ed
  • 567a5ec35a only runs prettier on the new specs.

Review follow-ups

Every review thread has a reply and is resolved.

  • Banner hint was hover-only (Greptile, Copilot, CodeRabbit): fixed in 6642fe7916. This PR's first version wrongly said the hint opened on focus and tap.
  • Modules kept after a client clears the project (Copilot, CodeRabbit): fixed in dadf84a0e9.
  • Project selector re-applied ?projectId= over the form's pick (Copilot): fixed in dd7a52712b.
  • A stale project request could replace the filtered list (CodeRabbit): fixed in dd7a52712b.
  • Last Year listed where past dates are disallowed (CodeRabbit, outside the diff): fixed in 3a9468470f, together with the older Yesterday, Last Week and Last Month entries that had the same flaw.
  • Copilot's second pass listed four "previously missed" issues in its review summary (not as threads), all fixed:
    • URL project brought back after the form cleared it: 9d3e9891ef
    • late module answers after the project was cleared: 4dd71278de
    • Client label not tied to the combobox: 4dd71278de
    • locked month picker treating the whole month as custom: ffd90c0b66
  • An edited task's modules no longer loaded once the selector stopped replaying the URL (Copilot, third pass): fixed in e9bcc8b7fc, with regression tests.
  • Escape on the hint also closed the dialog (Copilot summary, fourth pass): fixed in ebf45a7a17.
  • CodeRabbit's fifth pass (four threads), all fixed:
    • Escape on a hint opened by hover, with focus still in the banner field, closed the dialog: fb268837b3 (window capture listener)
    • a preset could be listed with its end before its start when the limits leave nothing of the unit: 24c97f56b3
    • Previous crossed minDate where past dates are disallowed: 24c97f56b3
    • a failed first project request used up a form selector's URL starting value: 59c2bda5ef
  • Copilot's fourth pass also asked about the new colours: they already use the theme's tokens (nb-theme(...), var(--gauzy-text-color-2)), so nothing changed.
  • A project picked from the list while the next client's list is still loading (Copilot summary, fourth pass; CodeRabbit fifth pass, outside the diff): not changed. In that window, about one request long, the dropdown still shows the previous list, and a project picked from it stays picked. The client is only a filter and is never saved with the task, so the task gets exactly the project the user clicked. Stale responses can no longer overwrite the list (dd7a52712b).
  • public-layout-ui jest setup needs the generated environment (CodeRabbit): not changed. 17 other Angular jest configs load the same jest.angular-defaults.ts, and the Unit Tests workflow runs yarn config:dev before any suite. Any component spec in this package needs that environment anyway, through @gauzy/ui-core/core.
  • Codacy: no new issues. DeepScan: passed.

Refs #985
Closes #6847
Closes #1869
Closes #1882

🤖 Generated with Claude Code


Summary by cubic

Four UI improvements land in this PR: the date range picker gains year presets, the Add task dialog an optional client filter, the public page banner field explains itself, and expenses no longer save a sample invoice as their receipt.

  • Date picker: adds Current Year and Last Year presets; each selects a whole year, the arrows step by year, and the selection survives reloads. Presets are clamped to minDate/maxDate, so "Current Year" runs from 1 January to today where future dates are disallowed, and presets already over are left out where past dates are disallowed. A preset whose limits leave nothing of the unit is left out rather than listed with its end before its start. Where past dates are disallowed, Previous is disabled once the range starts today, and stepping back starts at today, never earlier. Locked pickers never treat a whole-unit range as custom, so their arrows still step one unit.

  • Add task dialog: new optional Client field narrows the project list and clears a selected project (and its modules) that belongs to another client; empty keeps every project. The client stays out of the task payload. Stale module answers are dropped once the project changes, and the dialog now loads modules for its starting project itself — the task being edited or the page's selected one — since the selector no longer re-applies the URL's ?projectId=, which is used only as a form selector's starting value. The project selector drops stale responses, keeps the project picked in a form, and only spends a form selector's one URL ?projectId= start on a list that actually loaded.

  • Public page editor: the banner placeholder shows an example and an info button explains what the banner is; the hint opens on hover, keyboard focus, and tap, and Escape closes just the hint, not the dialog that would otherwise discard unsaved edits. Seed data uses a real tagline instead of lorem ipsum.

  • Expenses: new expenses start without a receipt; the sample invoice is never shown, and older expenses saved with it are read as having none.

Notes

  • No schema, migration, or API changes; the only changed default is the expense receipt (now null).