feat(pwa): значки операций в выписке

За неделю у активного водителя два десятка строк, и половина одинаковая по виду
(«аренда автомобиля» каждый день) — список сливается в стену текста. Значок даёт
зацепку для глаза: нужное находится, не читая каждую строку.

Подбор по тегу 1С, а не по знаку суммы: аренда, пени, штраф, ДТП, депозит,
пополнение, компенсация, коррекция — у каждого свой. Общий компонент, как в
складе CRM: разложенные по местам иконки разъезжаются размерами при первой правке.

Монохромные намеренно — цвет в этом списке уже занят и означает направление
денег, цветной значок с ним бы спорил.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:06:02 +10:00
co-authored by Claude Opus 5
parent 6becdd036c
commit 9187b5766e
3 changed files with 82 additions and 2 deletions
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getStatement, type StatementKind } from "@/api/driver"; import { getStatement, type StatementKind } from "@/api/driver";
import { formatDate, formatMoney } from "@/lib/format"; import { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
const FILTERS: { key: StatementKind; label: string }[] = [ const FILTERS: { key: StatementKind; label: string }[] = [
@@ -70,8 +71,9 @@ export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
<div> <div>
{items.map((op, idx) => ( {items.map((op, idx) => (
<div key={`${op.date}-${idx}`} <div key={`${op.date}-${idx}`}
className="flex justify-between items-start gap-3 py-3 border-b border-line last:border-0"> className="flex items-center gap-3 py-2.5 border-b border-line last:border-0">
<span className="min-w-0"> <OperationIcon title={op.title} />
<span className="min-w-0 flex-1">
<span className="block text-sm">{op.title}</span> <span className="block text-sm">{op.title}</span>
<span className="block text-muted text-xs"> <span className="block text-muted text-xs">
{formatDate(op.date)} {formatDate(op.date)}
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { operationIcon } from "./operationIcon";
/** Названия — настоящие теги 1С из выписки (finance_ledger_entries.tag). */
describe("значок операции", () => {
it("разные виды операций различаются", () => {
const names = [
"Аренда автомобиля",
"Пени",
"Штраф ГИБДД",
"Пополнение баланса через мобильное приложение",
"ДТП",
"Перевод с депозита на баланс",
].map((t) => operationIcon(t));
expect(new Set(names).size).toBe(names.length);
});
it("штраф ГИБДД и штраф компании — один значок: для водителя это одно и то же", () => {
expect(operationIcon("Штраф ГИБДД")).toBe(operationIcon("Штраф компании"));
});
it("незнакомый тег получает нейтральный значок, а не пустоту", () => {
expect(operationIcon("Списание за что-то новое")).toBeTruthy();
});
});
@@ -0,0 +1,53 @@
import {
Banknote, Car, Flag, Gavel, Gift, HandCoins, Percent, PiggyBank, Receipt,
RefreshCcw, ShieldAlert, TriangleAlert, Wrench, type LucideIcon,
} from "lucide-react";
/**
* Значок операции в выписке — по тегу 1С, а не по знаку суммы.
*
* Список сливается в стену текста: за неделю у активного водителя два десятка
* строк, и половина из них — одинаковая по виду «аренда автомобиля». Значок
* даёт зацепку для глаза, чтобы взгляд находил нужное, не читая каждую строку.
*
* Общий компонент, а не иконка на месте применения: тот же приём, что в складе
* CRM (modules/warehouse/categoryIcons.tsx). Разложены по местам — и размеры с
* толщиной линии разъезжаются при первой же правке.
*
* Монохромные намеренно: цвет в этом списке уже занят и означает направление
* денег (красное — списали, зелёное — пришло). Цветной значок спорил бы с ним.
*/
const BY_TAG: [RegExp, LucideIcon][] = [
[/аренда/i, Car],
[/пени/i, Percent],
[/штраф/i, Gavel],
[/дтп|повреждени/i, TriangleAlert],
[/депозит/i, PiggyBank],
[/пополнение|оплата/i, Banknote],
[/ремонт|запчаст/i, Wrench],
[/компенсац/i, HandCoins],
[/акци|бонус|подар/i, Gift],
[/коррекц|сторно|перерасч/i, RefreshCcw],
[/страхов/i, ShieldAlert],
[/остаток|opening/i, Flag],
];
export function operationIcon(title: string): LucideIcon {
for (const [re, icon] of BY_TAG) {
if (re.test(title)) return icon;
}
return Receipt;
}
/** Значок в рамке — как в списке склада: одинаковый размер у всех строк. */
export function OperationIcon({ title }: { title: string }) {
const Icon = operationIcon(title);
return (
<span
aria-hidden
className="shrink-0 w-8 h-8 rounded-xl2 border border-line bg-cream flex items-center justify-center text-muted"
>
<Icon size={16} strokeWidth={1.5} />
</span>
);
}