diff --git a/backend/tests/integration/credit-notes.test.ts b/backend/tests/integration/credit-notes.test.ts index b4458bd..c2e8394 100644 --- a/backend/tests/integration/credit-notes.test.ts +++ b/backend/tests/integration/credit-notes.test.ts @@ -191,7 +191,10 @@ describe('POST /invoices/:id/credit-note', () => { const original = await issue() const cn = (await asAdmin()('POST', `/invoices/${original.id}/credit-note`)).json().creditNote - await runOverdueCheck(new Date('2099-06-01T00:00:00.000Z')) + // Two days from now, not a far-future date: the check is global, and a + // year-2099 run against the shared dev database would pile decades of + // interest onto every open invoice in it (it did, once). + await runOverdueCheck(new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)) const stored = await prisma.invoice.findUniqueOrThrow({ where: { id: cn.id } }) expect(stored.status).toBe('SENT') diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index e66db0f..e8497e3 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -907,6 +907,33 @@ the real library. --- +## 49. The UI is hand-built on a small token set, with no component library + +**Context:** the first frontend was functional and looked like a spreadsheet. +A portfolio project is judged in its first three seconds, and a financial +product is expected to look like one. + +**Decision:** a redesign on Tailwind 4 with a deliberately small token set in +`index.css` — one accent hue, one "ink" for dark surfaces, one reserved +colour for money that arrived — Inter for its tabular figures, and every +component still written by hand in `ui.tsx`. Sidebar navigation in the app, +because finance software is used for hours across many sections and a +vertical list with icons scans faster than a row of words. The landing page's +hero is a real invoice card whose ledger writes itself in, built from the +same formatting functions the app uses, because the ledger is the product. + +**Rejected:** a component library (shadcn, MUI). It would look polished in an +afternoon and leave nothing explainable — the whole point of this project is +that every line can be defended. Also rejected: stock illustrations. Every +visual on the landing page is a working component showing real behaviour. + +**Two rules the pass reinforced:** motion is used twice, briefly, and honours +`prefers-reduced-motion`; and a plain CSS rule outside Tailwind's layers +outranks a utility on the same element — the hero was white for one build +because a `background` shorthand reset the colour a utility had set. + +--- + ## Open decisions | Question | Status | diff --git a/docs/screenshots/aging.png b/docs/screenshots/aging.png index 3d8edf4..e3fafb8 100644 Binary files a/docs/screenshots/aging.png and b/docs/screenshots/aging.png differ diff --git a/docs/screenshots/audit-log.png b/docs/screenshots/audit-log.png index 916baa6..1d3b591 100644 Binary files a/docs/screenshots/audit-log.png and b/docs/screenshots/audit-log.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index a1d71cc..a98054e 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/invoice-ledger.png b/docs/screenshots/invoice-ledger.png index ac52000..13579a6 100644 Binary files a/docs/screenshots/invoice-ledger.png and b/docs/screenshots/invoice-ledger.png differ diff --git a/docs/screenshots/landing.png b/docs/screenshots/landing.png index d47cfa2..fe2550a 100644 Binary files a/docs/screenshots/landing.png and b/docs/screenshots/landing.png differ diff --git a/frontend/index.html b/frontend/index.html index 6184a53..5652bff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,20 @@ - Fakturly - + Fakturly — fakturering byggd som ett finansiellt system + + + + + + + + +
diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index c89a976..dc7500e 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -1,11 +1,31 @@ // AppLayout.tsx — the shell every authenticated page renders inside. +// +// A sidebar rather than a top bar. Finance software is used for hours at a +// time with many sections; a vertical list of sections with icons scans +// faster than a row of words, and it leaves the full width for tables. +// +// The navigation hides admin sections from clients for tidiness, not +// security. A client who types /reports is redirected by the route guard, +// and the API refuses them regardless. -import { Link, NavLink, Outlet } from 'react-router-dom' +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' import { useAuth } from '../lib/auth.tsx' -import { Button } from './ui.tsx' +import { Logo } from './Logo.tsx' + +type Item = { to: string; label: string; icon: React.ReactNode; admin?: boolean; end?: boolean } + +const ITEMS: Item[] = [ + { to: '/dashboard', label: 'Översikt', icon: , admin: true }, + { to: '/invoices', label: 'Fakturor', icon: , end: true }, + { to: '/clients', label: 'Kunder', icon: , admin: true }, + { to: '/reports', label: 'Rapporter', icon: , admin: true }, + { to: '/audit', label: 'Revisionslogg', icon: , admin: true } +] export function AppLayout() { const { user, logout } = useAuth() + const location = useLocation() + const isAdmin = user?.role === 'ADMIN' async function handleLogout() { // Clearing the user is enough: the route guard around this layout sees @@ -14,71 +34,98 @@ export function AppLayout() { await logout() } - const navLinkClass = ({ isActive }: { isActive: boolean }) => - 'rounded-md px-3 py-2 text-sm font-medium transition-colors ' + - (isActive ? 'bg-slate-100 text-slate-900' : 'text-slate-600 hover:text-slate-900') + const linkClass = ({ isActive }: { isActive: boolean }) => + 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ' + + (isActive ? 'bg-white/10 text-white' : 'text-slate-400 hover:bg-white/5 hover:text-white') return ( -
-
-
-
- {/* The wordmark goes to the app's start page, not the public - landing page — a logged-in user clicking the logo expects - their dashboard, not marketing. */} - - Fakturly - +
+ {/* ── Sidebar ─────────────────────────────────────────── */} +
+ -
-
-

{user?.email}

-

- {user?.role === 'ADMIN' ? 'Administratör' : 'Kund'} -

-
- +
+ {isAdmin && ( +

+ Demodata. Återställs varje natt kl 03:00. +

+ )}
-
-
- + + + + {/* ── Content ─────────────────────────────────────────── */} +
+
+ +
) } + +// ── Icons: 20px, 1.5px stroke, hand-drawn so there is no icon package ── + +function IconGrid() { + return ( + + + + + ) +} +function IconDoc() { + return ( + + + + ) +} +function IconPeople() { + return ( + + + + ) +} +function IconChart() { + return ( + + + + ) +} +function IconShield() { + return ( + + + + ) +} diff --git a/frontend/src/components/DemoLogins.tsx b/frontend/src/components/DemoLogins.tsx index 07921a9..dd0ce64 100644 --- a/frontend/src/components/DemoLogins.tsx +++ b/frontend/src/components/DemoLogins.tsx @@ -30,7 +30,7 @@ export function useDemoAccounts() { }) } -export function DemoLogins({ className = '' }: { className?: string }) { +export function DemoLogins({ className = '', tone = 'light' }: { className?: string; tone?: 'light' | 'dark' }) { const demo = useDemoAccounts() const { login } = useAuth() const navigate = useNavigate() @@ -53,14 +53,15 @@ export function DemoLogins({ className = '' }: { className?: string }) { return (
-

+

Prova demot

- {demo.data.accounts.map((account) => ( + {demo.data.accounts.map((account, index) => (
{error && ( -

+

{error}

)} -

- Demodata återställs varje natt. Allt du ändrar försvinner. +

+ Inget konto behövs. Demodata återställs varje natt kl 03:00.

) diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx new file mode 100644 index 0000000..9872c80 --- /dev/null +++ b/frontend/src/components/Logo.tsx @@ -0,0 +1,19 @@ +// Logo.tsx — the wordmark, with a mark that is a ledger row. +// +// Hand-drawn SVG rather than an image file: it scales, it takes the text +// colour, and there is nothing to load. The mark is three lines of unequal +// length ending in a full one — an invoice's rows and its total. + +export function Logo({ tone = 'dark', className = '' }: { tone?: 'dark' | 'light'; className?: string }) { + const text = tone === 'light' ? 'text-white' : 'text-slate-900' + return ( + + + + + + + Fakturly + + ) +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 560c33b..d5b3816 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -10,7 +10,8 @@ import type { Invoice, InvoiceStatus, LedgerType } from '../lib/types.ts' // ───────────────────────────────────────────────────────────── type ButtonProps = ButtonHTMLAttributes & { - variant?: 'primary' | 'secondary' | 'danger' + /** inverted and ghost are for dark surfaces (the landing hero). */ + variant?: 'primary' | 'secondary' | 'danger' | 'inverted' | 'ghost' isLoading?: boolean } @@ -23,14 +24,16 @@ export function Button({ ...props }: ButtonProps) { const base = - 'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm ' + + 'inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm ' + 'font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ' + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2' const variants = { - primary: 'bg-brand-600 text-white hover:bg-brand-700', - secondary: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50', - danger: 'bg-red-600 text-white hover:bg-red-700' + primary: 'bg-brand-600 text-white shadow-sm hover:bg-brand-700', + secondary: 'border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50', + danger: 'bg-red-600 text-white shadow-sm hover:bg-red-700', + inverted: 'bg-white text-ink-950 hover:bg-slate-100', + ghost: 'border border-white/20 text-white hover:bg-white/10' } return ( @@ -105,14 +108,25 @@ export function Field({ label, error, hint, id, className = '', ...props }: Fiel export function Card({ children, className = '' }: { children: ReactNode; className?: string }) { return ( -
{children}
+
{children}
) } -export function PageHeader({ title, action }: { title: string; action?: ReactNode }) { +export function PageHeader({ + title, + subtitle, + action +}: { + title: string + subtitle?: string + action?: ReactNode +}) { return ( -
-

{title}

+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
{action}
) @@ -127,25 +141,26 @@ export function PageHeader({ title, action }: { title: string; action?: ReactNod * colour-blind reader, and roughly 8% of men are. */ export function StatusBadge({ status, type = 'INVOICE' }: { status: InvoiceStatus; type?: Invoice['type'] }) { - const styles: Record = { - DRAFT: { label: 'Utkast', className: 'bg-slate-100 text-slate-700' }, - SENT: { label: 'Skickad', className: 'bg-blue-100 text-blue-800' }, - PAID: { label: 'Betald', className: 'bg-green-100 text-green-800' }, - OVERDUE: { label: 'Förfallen', className: 'bg-red-100 text-red-800' }, - CREDITED: { label: 'Krediterad', className: 'bg-amber-100 text-amber-900' } + const styles: Record = { + DRAFT: { label: 'Utkast', className: 'bg-slate-100 text-slate-700', dot: 'bg-slate-400' }, + SENT: { label: 'Skickad', className: 'bg-blue-50 text-blue-800', dot: 'bg-blue-500' }, + PAID: { label: 'Betald', className: 'bg-emerald-50 text-emerald-800', dot: 'bg-emerald-500' }, + OVERDUE: { label: 'Förfallen', className: 'bg-red-50 text-red-800', dot: 'bg-red-500' }, + CREDITED: { label: 'Krediterad', className: 'bg-amber-50 text-amber-900', dot: 'bg-amber-500' } } // A credit note is "sent" in the database but that word means nothing to // a reader; the document kind is what matters. - const { label, className } = + const { label, className, dot } = type === 'CREDIT_NOTE' - ? { label: 'Kreditfaktura', className: 'bg-violet-100 text-violet-900' } + ? { label: 'Kreditfaktura', className: 'bg-violet-50 text-violet-900', dot: 'bg-violet-500' } : styles[status] return ( + {label} ) diff --git a/frontend/src/index.css b/frontend/src/index.css index 221287d..c6de81b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,20 +1,37 @@ -/* index.css — Tailwind and the few design tokens the app uses. +/* index.css — Tailwind and the design tokens the app is built from. * * Tailwind v4 configures itself in CSS rather than a JS config file. @theme - * defines custom values that become utility classes automatically. + * defines custom values that become utility classes automatically: + * --color-brand-600 becomes bg-brand-600, text-brand-600 and so on. */ @import 'tailwindcss'; @theme { - /* One accent colour, used for primary actions and nothing else. Having a - * single deliberate colour is what stops an app looking like a component - * library sampler. */ - --color-brand-50: oklch(0.97 0.02 250); - --color-brand-100: oklch(0.93 0.04 250); - --color-brand-500: oklch(0.55 0.18 250); - --color-brand-600: oklch(0.48 0.18 250); - --color-brand-700: oklch(0.41 0.16 250); + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + + /* The accent. One hue, used for primary actions, links and the active + * navigation item — and nothing else. A single deliberate colour is what + * stops an app looking like a component-library sampler. */ + --color-brand-50: oklch(0.97 0.02 255); + --color-brand-100: oklch(0.93 0.05 255); + --color-brand-200: oklch(0.87 0.09 255); + --color-brand-400: oklch(0.66 0.17 255); + --color-brand-500: oklch(0.57 0.2 255); + --color-brand-600: oklch(0.5 0.2 255); + --color-brand-700: oklch(0.43 0.17 255); + + /* "Ink": the near-black the dark surfaces are made of. A touch of blue in + * it rather than pure black, so the dark hero and the sidebar read as one + * material with the accent. */ + --color-ink-950: oklch(0.17 0.03 260); + --color-ink-900: oklch(0.22 0.03 260); + --color-ink-800: oklch(0.28 0.03 260); + --color-ink-700: oklch(0.36 0.03 260); + + /* Money that arrived. Reserved for that one meaning. */ + --color-mint-400: oklch(0.8 0.15 165); + --color-mint-600: oklch(0.6 0.14 165); } /* Tabular figures for money. @@ -26,3 +43,63 @@ .tabular { font-variant-numeric: tabular-nums; } + +/* ── Landing page motion ────────────────────────────────────── + * + * Two animations only, both subtle. The ledger in the hero writes its rows + * in one by one, because the story of the product is "every event is a + * row, and they sum to zero". Nothing loops forever; nothing moves after + * the first few seconds. Motion that never stops is a distraction on a + * page someone is trying to read. + */ +@keyframes rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.rise { + animation: rise 0.6s ease-out both; +} + +/* Ledger rows appear in sequence; the delay is set inline per row. */ +.ledger-row { + animation: rise 0.5s ease-out both; +} + +/* People who asked their OS for less motion get none. */ +@media (prefers-reduced-motion: reduce) { + .rise, .ledger-row { animation: none; } +} + +/* A soft radial glow behind the hero. Pure CSS, no image to load. + * + * background-image, NOT the `background` shorthand: the shorthand resets + * background-color to transparent, and because this rule sits outside + * Tailwind's layers it outranks the bg-ink-950 utility on the same element. + * The first version of this rule turned the hero white with white text. */ +.hero-glow { + background-color: var(--color-ink-950); + background-image: + radial-gradient(60rem 30rem at 20% -10%, oklch(0.5 0.2 255 / 0.45), transparent 60%), + radial-gradient(40rem 24rem at 90% 10%, oklch(0.6 0.14 165 / 0.25), transparent 60%); +} + +/* Fine grid behind dark sections — the "engineering paper" texture. */ +.grid-paper { + background-color: var(--color-ink-950); + background-image: + linear-gradient(oklch(1 0 0 / 0.04) 1px, transparent 1px), + linear-gradient(90deg, oklch(1 0 0 / 0.04) 1px, transparent 1px); + background-size: 32px 32px; +} + +/* Both on one element: the glow layers on top of the grid. Sizes are + * per-layer, so the grid keeps its 32px tile and the glows stretch. */ +.hero-glow.grid-paper { + background-image: + radial-gradient(60rem 30rem at 20% -10%, oklch(0.5 0.2 255 / 0.45), transparent 60%), + radial-gradient(40rem 24rem at 90% 10%, oklch(0.6 0.14 165 / 0.25), transparent 60%), + linear-gradient(oklch(1 0 0 / 0.04) 1px, transparent 1px), + linear-gradient(90deg, oklch(1 0 0 / 0.04) 1px, transparent 1px); + background-size: auto, auto, 32px 32px, 32px 32px; +} diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index b9d528a..3e09587 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,223 +1,531 @@ // LandingPage.tsx — the public front door. // -// The only page anyone sees without logging in. Its job is to let a visitor -// understand what Fakturly is in thirty seconds and try it in one click, -// which is why the demo buttons sit in the hero rather than behind a login. +// The only page anyone sees without logging in. It has thirty seconds to make +// a recruiter understand what Fakturly is and one click to let them try it. // -// Everything on it is static except the demo accounts, which come from the -// API and disappear when the API is not in demo mode. +// Everything on it is built from the app's own components and plain CSS — +// no illustration library, no stock image. The "product" in the hero is a +// real invoice card whose ledger writes itself in, because the ledger IS the +// product. If a reviewer asks "what does it actually do", the hero is the +// answer. +// +// The demo accounts come from the API and disappear when it is not in demo +// mode; the rest of the page is static. import { Link } from 'react-router-dom' import { useAuth } from '../lib/auth.tsx' -import { Button } from '../components/ui.tsx' +import { Button, formatOre } from '../components/ui.tsx' import { DemoLogins, useDemoAccounts } from '../components/DemoLogins.tsx' +import { Logo } from '../components/Logo.tsx' const GITHUB_URL = 'https://github.com/devinder-dev/fakturly' const ADR_URL = `${GITHUB_URL}/blob/main/docs/architecture-decisions.md` +const API_DOCS_URL = `${import.meta.env.VITE_API_URL ?? 'http://localhost:3000'}/docs` export function LandingPage() { const { user } = useAuth() const demo = useDemoAccounts() return ( -
+
{/* ── Nav ─────────────────────────────────────────────── */} -
-
- Fakturly -