Skip to content
Open
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
52 changes: 52 additions & 0 deletions .changeset/charges-record-design-refinement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@accounter/client': patch
---

Adopt the imported charges-list design refinement: per-type colour chips, a selection accent rail,
one unified card, an anchored expansion panel, and a real drop-target state.

**Per-type colour chip.** `CHARGE_TYPE_COLOR` in `helpers/charges.tsx` gives each of the eleven
charge types its own hue, rendered by `ChargeTypeBadge` as a tinted, ringed icon chip beside the type
name. Colour is what lets you pick every Salary charge out of a hundred rows without reading, which a
uniformly grey icon could not do.

Three of the designer's hues were reassigned because they collided with the status vocabulary the
record already uses — amber means "needs attention", emerald "accept / positive amount", red
"error / negative amount", and all three appear on the same row as the chip. Salary moved amber →
purple, Monthly VAT emerald → sky, Dividend rose → pink; the other eight are unchanged. A test
asserts the reservation, so a future palette edit cannot quietly put a status colour back on a type.
The hue class lists are complete literal strings rather than interpolated from the hue name, since
Tailwind only ever sees literals in source.

**Selection and drop feedback.** A selected record now carries a 2px left accent rail in addition to
its background tint. `DragFile` names its dropzone group so the record can style itself off Mantine's
`data-accept`, lighting the whole row on drag — previously the only sign a drag had registered was the
cursor, over a target with no visible edges. The drop ring is `primary`, not the `ring` token:
`--color-accent` and `--color-muted` hold the same value, so the tint alone was indistinguishable from
the selected background, and a grey ring at 40% did not separate them either.

**One surface.** Toolbar and record list now share a single bordered, rounded card instead of the
toolbar floating above a separately bordered list; its select-all checkbox in particular looked
disconnected from the rows it governs. The expansion panel is indented under its record behind a rail,
so it reads as belonging to that charge rather than as the next item in the list.

**Smaller fixes carried from the same design pass.** The CSV export gains a visible label — icon-only,
it had no accessible name at all, since a tooltip is a description rather than a name. Count chips gain
hover hints naming what they count and what is wrong when something is. "Delete Charge" is finally
styled destructive; it was the only irreversible item in the menu and sat in the same weight as "Copy
Charge Link" directly above it. The accountant-status control gains a section label, a dot and a
current-value checkmark per option, an `aria-label` on its trigger (it previously offered a hundred
identically unnamed buttons down a list), a note that Pending means a *downgrade* rather than a step
toward approval, and `dark:` variants it had none of — the VAT and business-trip reports share this
control and inherit all of it.

Charges-surface neutrals now use the colour tokens activated in the previous release; the amber,
emerald, red status accents and the eleven type hues stay literal, since no token expresses them. The
duplicated "absent value" placeholder, defined identically in two files, is now one shared component.

Two things in the imported mockup were deliberately **not** adopted, because the shipped code is
better: `role="status"` on the needs badge (a hundred rows would mean a hundred live regions
announcing on every refetch) and a count chip whose state never reaches its accessible name. Its
compact row also dropped the approval control and the date and reflowed the column spans; approval is
the primary triage action and the date the second-most-scanned field, and holding spans stable across
densities means toggling density does not reflow the columns.
52 changes: 52 additions & 0 deletions .changeset/charges-record-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@accounter/client': minor
---

Replace the charges table with a charge record list, driven by a per-type field spec.

Charges are a union of 11 types with genuinely different attributes, so a shared column set never
fitted them: of 14 display attributes, VAT and business trip each apply to exactly one type, which
left those columns blank on ten rows out of eleven. Rows also measured 166px, because `ListCapsule`
rendered tags and each metadata count as its own bordered box while the select and actions cells
stacked their controls vertically.

Each charge now renders as a record built from six regions at fixed horizontal positions — manage,
identity, meaning, health, money, actions. Region placement is identical on every record, which keeps
the vertical scanning a table gave you; which fields appear inside a region comes from a declarative
matrix in `charge-fields.ts`, so a record's shape is a pure function of its `__typename`. Records
measure ~85px, or 50px with the new density toggle.

The list keeps TanStack Table as its headless engine, so sorting, selection (keyed by charge id) and
expansion are unchanged, and every call site keeps the same props.

Other user-visible changes:

- Missing info is summarised once per record as a badge, counting only fields that charge type
actually displays, instead of up to six unlabelled red dots scattered across cells.
- Suggested descriptions and tags are offered inline with a one-click accept, replacing a solid
yellow background that read as an error rather than an offer.
- Sorting moved from column headers to a list toolbar bound to the server's `sortBy`, so it orders
every matching charge rather than only the loaded page. Select-all, batch actions and CSV export
moved to the same toolbar, which now announces the selection count.
- A document can be dropped anywhere on a record, not just onto the narrow More Info cell.
- Row density is togglable and remembered.

Bug fixes:

- The Date column never sorted. Its accessor returned the whole date object, so TanStack's automatic
sort fell back to comparing `"[object Object]"` against itself and returned the same answer for
every pair, in both directions.
- Lists silently truncated at 100 rows. The rendered row model sits at the end of a pipeline ending
in pagination, so the charges-ledger-validation screen (which streams without a limit) and the
unbounded VAT report sections could never show anything past row 100.
- Nothing inside a charge row was clickable when wrapped for drag-and-drop upload, because Mantine's
dropzone disables pointer events on its content. This went unnoticed while it only wrapped inert
text.
- The header row rendered one more cell than the body, adding a phantom column.
- CSV export could include charges selected in a different table, since the VAT report shares one
selection map across three lists.
- Batch "refresh selected" silently did nothing for selected charges that were not currently
rendered.
- Per-record validation state was conveyed by colour alone with no accessible name; count chips and
the missing-info badge now carry text, and the expand control, which was a nameless chevron, is
labelled.
13 changes: 6 additions & 7 deletions packages/client/src/components/business/charges-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,12 @@ export function ChargesSection({ businessId }: Props) {
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<ChargesTable
data={charges}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
</div>
{/* No border wrapper: the record list draws its own, and two nested ones read as a seam. */}
<ChargesTable
data={charges}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
</CardContent>
</Card>
);
Expand Down
12 changes: 11 additions & 1 deletion packages/client/src/components/charges-ledger-validation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { useQuery } from 'urql';
import { Loader, Progress, ThemeIcon } from '@mantine/core';
import type { RowSelectionState } from '@tanstack/react-table';
import { encodeFilters, ROUTES } from '@/router/routes.js';
import { ChargesLedgerValidationDocument, type ChargeFilter } from '../gql/graphql.js';
import {
ChargesLedgerValidationDocument,
type ChargeFilter,
type ChargeSortBy,
} from '../gql/graphql.js';
import { useUrlQuery } from '../hooks/use-url-query.js';
import { FiltersContext } from '../providers/filters-context.js';
import { ChargesFilters } from './charges/charges-filters.js';
Expand Down Expand Up @@ -85,6 +89,11 @@ export const ChargesLedgerValidation = (): ReactElement => {
[setFilter],
);

// Server-side sort, so it lives on this screen's filter rather than in the table.
const setSortBy = useCallback((sortBy: ChargeSortBy): void => {
setFilter(current => ({ ...current, sortBy }));
}, []);

useEffect(() => {
if (filter) {
validateLedger();
Expand Down Expand Up @@ -166,6 +175,7 @@ export const ChargesLedgerValidation = (): ReactElement => {
[]
}
isAllOpened={isAllOpened}
sort={{ value: filter?.sortBy, onChange: setSortBy }}
/>
<div className="flex flex-row justify-center my-2">
{progress > 0 && progress < 100 && <Loader />}
Expand Down
152 changes: 152 additions & 0 deletions packages/client/src/components/charges/__tests__/charge-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import { MissingChargeInfo } from '../../../gql/graphql.js';
import { CHARGE_TYPE_NAME, type ChargeType } from '../../../helpers/index.js';
import {
CHARGE_FIELDS,
isFieldVisible,
isMissing,
isSpecialField,
relevantMissingInfo,
visibleFields,
type ChargeField,
} from '../charge-fields.js';

const ALL_TYPES = Object.keys(CHARGE_TYPE_NAME) as ChargeType[];

/**
* These assertions describe the *shape* of the spec sheet rather than restating it cell by cell, so
* they catch the mistakes a hand-transcribed matrix actually makes: a transposed row, a digit in the
* wrong column, a row of the wrong length. A deliberate spec change will fail them, which is the
* point — it forces a second look at the sheet.
*/
describe('charge field matrix', () => {
it('covers every charge type the client knows about', () => {
expect(ALL_TYPES).toHaveLength(11);
for (const type of ALL_TYPES) {
// A type missing from the matrix would silently fall back to "show everything".
expect(visibleFields(type).length, `${type} has no matrix row`).toBeGreaterThan(0);
}
});

it('gives every type a full-width row', () => {
// Rows are positional against CHARGE_FIELDS; a short row would make later fields read as hidden.
for (const type of ALL_TYPES) {
const decided = CHARGE_FIELDS.filter(
field => isFieldVisible(type, field) || !isFieldVisible(type, field),
);
expect(decided).toHaveLength(CHARGE_FIELDS.length);
}
});
Comment on lines +31 to +39

it.each<ChargeField>(['type', 'mainDate', 'description', 'tags', 'ledgerCount'])(
'shows %s on all 11 types',
field => {
for (const type of ALL_TYPES) {
expect(isFieldVisible(type, field), `${type}.${field}`).toBe(true);
}
},
);

/**
* The heart of why the record composes fields per type instead of sharing table columns: these two
* attributes apply to exactly one charge type each, so as columns they were blank 10 rows out of 11.
*/
it('shows vat and businessTrip on exactly one type each', () => {
expect(ALL_TYPES.filter(type => isFieldVisible(type, 'vat'))).toEqual(['CommonCharge']);
expect(ALL_TYPES.filter(type => isFieldVisible(type, 'businessTrip'))).toEqual([
'BusinessTripCharge',
]);
});

it('hides amount only on FinancialCharge and transactions only on FinancialCharge', () => {
expect(ALL_TYPES.filter(type => !isFieldVisible(type, 'amount'))).toEqual(['FinancialCharge']);
expect(ALL_TYPES.filter(type => !isFieldVisible(type, 'transactionsCount'))).toEqual([
'FinancialCharge',
]);
});

it('matches the spec on how many types show each optional field', () => {
const count = (field: ChargeField): number =>
ALL_TYPES.filter(type => isFieldVisible(type, field)).length;

expect({
dateRange: count('dateRange'),
mainCounterparty: count('mainCounterparty'),
mainTaxCategory: count('mainTaxCategory'),
documentsCount: count('documentsCount'),
miscExpensesCount: count('miscExpensesCount'),
}).toEqual({
dateRange: 7,
mainCounterparty: 4,
mainTaxCategory: 5,
documentsCount: 5,
miscExpensesCount: 7,
});
});

it('special-cases exactly the two fields the spec footnotes call out', () => {
const specials = ALL_TYPES.flatMap(type =>
CHARGE_FIELDS.filter(field => isSpecialField(type, field)).map(field => `${type}.${field}`),
);
expect(specials.sort()).toEqual([
'ConversionCharge.amount',
'InternalTransferCharge.mainCounterparty',
]);
});

it('treats a special-cased field as visible', () => {
expect(isFieldVisible('ConversionCharge', 'amount')).toBe(true);
expect(isFieldVisible('InternalTransferCharge', 'mainCounterparty')).toBe(true);
});

it('shows everything for an unknown charge type rather than rendering an empty record', () => {
const unknown = 'FutureCharge' as ChargeType;
expect(visibleFields(unknown)).toEqual([...CHARGE_FIELDS]);
});
});

describe('relevantMissingInfo', () => {
it('drops missing info the charge type has nowhere to show', () => {
// The server flags a missing counterparty on charges whose spec row hides the field — without
// filtering, the record would advertise a need the user cannot act on.
expect(isFieldVisible('SalaryCharge', 'mainCounterparty')).toBe(false);
expect(relevantMissingInfo('SalaryCharge', [MissingChargeInfo.Counterparty])).toEqual([]);
});

it('keeps missing info the charge type does show', () => {
expect(relevantMissingInfo('CommonCharge', [MissingChargeInfo.Counterparty])).toEqual([
MissingChargeInfo.Counterparty,
]);
});

it('keeps description and tags for every type, since all 11 display them', () => {
for (const type of ALL_TYPES) {
expect(
relevantMissingInfo(type, [MissingChargeInfo.Description, MissingChargeInfo.Tags]),
type,
).toEqual([MissingChargeInfo.Description, MissingChargeInfo.Tags]);
}
});

it('drops VAT for the ten types that do not display it', () => {
for (const type of ALL_TYPES.filter(t => t !== 'CommonCharge')) {
expect(relevantMissingInfo(type, [MissingChargeInfo.Vat]), type).toEqual([]);
}
expect(relevantMissingInfo('CommonCharge', [MissingChargeInfo.Vat])).toEqual([
MissingChargeInfo.Vat,
]);
});

it('handles an absent validationData', () => {
expect(relevantMissingInfo('CommonCharge', undefined)).toEqual([]);
});
});

describe('isMissing', () => {
it('requires both that the server reported it and that the type displays it', () => {
expect(isMissing('CommonCharge', [MissingChargeInfo.Vat], MissingChargeInfo.Vat)).toBe(true);
expect(isMissing('SalaryCharge', [MissingChargeInfo.Vat], MissingChargeInfo.Vat)).toBe(false);
expect(isMissing('CommonCharge', [], MissingChargeInfo.Vat)).toBe(false);
expect(isMissing('CommonCharge', undefined, MissingChargeInfo.Vat)).toBe(false);
});
});
Loading
Loading