Skip to content
Merged
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
5 changes: 4 additions & 1 deletion backend/tests/integration/credit-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
27 changes: 27 additions & 0 deletions docs/architecture-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Binary file modified docs/screenshots/aging.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/audit-log.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/invoice-ledger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/landing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 14 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fakturly</title>
<meta name="description" content="Fakturering för småföretag" />
<title>Fakturly — fakturering byggd som ett finansiellt system</title>
<meta name="description" content="Fakturor, betalningar, dröjsmålsränta och kreditfakturor för svenska småföretag. Pengar i heltal, historik som aldrig skrivs om." />
<meta name="theme-color" content="#0b1220" />
<meta property="og:title" content="Fakturly" />
<meta property="og:description" content="Fakturering byggd som ett finansiellt system, inte som en CRUD-app." />
<meta property="og:type" content="website" />
<!--
Inter: the typeface most finance products use, for a reason — its
figures are designed for tables. Loaded from Google Fonts with
font-display: swap, so text renders in the fallback until it arrives.
-->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
Expand Down
163 changes: 105 additions & 58 deletions frontend/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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: <IconGrid />, admin: true },
{ to: '/invoices', label: 'Fakturor', icon: <IconDoc />, end: true },
{ to: '/clients', label: 'Kunder', icon: <IconPeople />, admin: true },
{ to: '/reports', label: 'Rapporter', icon: <IconChart />, admin: true },
{ to: '/audit', label: 'Revisionslogg', icon: <IconShield />, 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
Expand All @@ -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 (
<div className="min-h-screen bg-slate-50">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-6">
{/* 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. */}
<Link to="/app" className="text-lg font-semibold text-slate-900">
Fakturly
</Link>
<div className="min-h-screen bg-slate-50 font-sans antialiased lg:flex">
{/* ── Sidebar ─────────────────────────────────────────── */}
<aside className="flex items-center justify-between bg-ink-950 px-4 py-3 text-white lg:sticky lg:top-0 lg:h-screen lg:w-64 lg:flex-col lg:items-stretch lg:justify-start lg:px-4 lg:py-6">
{/* The wordmark goes to the app's start page, not the public landing
page — a logged-in user clicking the logo expects their own screen. */}
<Link to="/app" className="lg:mb-8 lg:px-2">
<Logo tone="light" />
</Link>

<nav className="flex items-center gap-1">
{/*
The admin link is hidden from clients — for tidiness, not
security. A client who types /clients gets redirected by the
route guard, and the API refuses them regardless.
*/}
{user?.role === 'ADMIN' && (
<>
<NavLink to="/dashboard" className={navLinkClass}>
Översikt
</NavLink>
<NavLink to="/clients" className={navLinkClass}>
Kunder
</NavLink>
</>
)}
<NavLink to="/invoices" className={navLinkClass} end>
Fakturor
</NavLink>
{user?.role === 'ADMIN' && (
<>
<NavLink to="/reports" className={navLinkClass}>
Rapporter
</NavLink>
<NavLink to="/audit" className={navLinkClass}>
Logg
</NavLink>
</>
)}
</nav>
</div>
<nav className="flex gap-1 lg:flex-col" aria-label="Huvudmeny">
{ITEMS.filter((item) => !item.admin || isAdmin).map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={linkClass}>
<span className="hidden lg:inline">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>

<div className="flex items-center gap-4">
<div className="hidden text-right sm:block">
<p className="text-sm font-medium text-slate-900">{user?.email}</p>
<p className="text-xs text-slate-500">
{user?.role === 'ADMIN' ? 'Administratör' : 'Kund'}
</p>
</div>
<Button variant="secondary" onClick={handleLogout}>
<div className="hidden lg:mt-auto lg:block">
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="truncate text-sm font-medium text-white">{user?.email}</p>
<p className="text-xs text-slate-400">{isAdmin ? 'Administratör' : 'Kund'}</p>
<button
type="button"
onClick={handleLogout}
className="mt-3 w-full rounded-lg border border-white/15 px-3 py-1.5 text-sm text-slate-200 transition hover:bg-white/10"
>
Logga ut
</Button>
</button>
</div>
{isAdmin && (
<p className="mt-4 px-2 text-[11px] leading-relaxed text-slate-500">
Demodata. Återställs varje natt kl 03:00.
</p>
)}
</div>
</header>

<main className="mx-auto max-w-6xl px-4 py-8">
<Outlet />
<button type="button" onClick={handleLogout} className="text-sm text-slate-300 lg:hidden">
Logga ut
</button>
</aside>

{/* ── Content ─────────────────────────────────────────── */}
<main className="min-w-0 flex-1">
<div key={location.pathname} className="rise mx-auto max-w-6xl px-6 py-8 lg:px-10 lg:py-10">
<Outlet />
</div>
</main>
</div>
)
}

// ── Icons: 20px, 1.5px stroke, hand-drawn so there is no icon package ──

function IconGrid() {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<rect x="3" y="3" width="6" height="6" rx="1.5" /><rect x="11" y="3" width="6" height="6" rx="1.5" />
<rect x="3" y="11" width="6" height="6" rx="1.5" /><rect x="11" y="11" width="6" height="6" rx="1.5" />
</svg>
)
}
function IconDoc() {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<path d="M5 3h7l4 4v10H5z" /><path d="M8 10h5M8 13h5" />
</svg>
)
}
function IconPeople() {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<circle cx="8" cy="7" r="3" /><path d="M2.5 17c.6-3 3-4.5 5.5-4.5s4.9 1.5 5.5 4.5" /><path d="M13.5 4.5a3 3 0 0 1 0 5.5M17.5 17c-.4-2.2-1.7-3.6-3.5-4.2" />
</svg>
)
}
function IconChart() {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<path d="M3 17h14" /><path d="M5 14v-4M9 14V6M13 14v-3M17 14V8" />
</svg>
)
}
function IconShield() {
return (
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M10 2.5 4 5v5c0 3.5 2.5 6 6 7.5 3.5-1.5 6-4 6-7.5V5z" /><path d="m7.5 10 1.8 1.8L12.8 8" />
</svg>
)
}
15 changes: 8 additions & 7 deletions frontend/src/components/DemoLogins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -53,14 +53,15 @@ export function DemoLogins({ className = '' }: { className?: string }) {

return (
<div className={className}>
<p className="mb-3 text-center text-xs font-medium uppercase tracking-wide text-slate-500">
<p className={`mb-3 text-xs font-semibold uppercase tracking-wider ${tone === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Prova demot
</p>
<div className="grid gap-2 sm:grid-cols-2">
{demo.data.accounts.map((account) => (
{demo.data.accounts.map((account, index) => (
<Button
key={account.email}
variant="secondary"
// On the dark hero the first button is the bright one.
variant={tone === 'dark' ? (index === 0 ? 'inverted' : 'ghost') : index === 0 ? 'primary' : 'secondary'}
onClick={() => signIn(account)}
isLoading={busy === account.email}
disabled={busy !== null}
Expand All @@ -71,12 +72,12 @@ export function DemoLogins({ className = '' }: { className?: string }) {
))}
</div>
{error && (
<p className="mt-2 text-center text-sm text-red-700" role="alert">
<p className="mt-2 text-sm text-red-500" role="alert">
{error}
</p>
)}
<p className="mt-3 text-center text-xs text-slate-500">
Demodata återställs varje natt. Allt du ändrar försvinner.
<p className={`mt-3 text-xs ${tone === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
Inget konto behövs. Demodata återställs varje natt kl 03:00.
</p>
</div>
)
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/Logo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={`inline-flex items-center gap-2.5 ${text} ${className}`}>
<svg width="26" height="26" viewBox="0 0 26 26" aria-hidden>
<rect width="26" height="26" rx="7" className="fill-brand-600" />
<path d="M7 8.5h8M7 13h12M7 17.5h5" stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx="18" cy="17.5" r="1.6" className="fill-mint-400" />
</svg>
<span className="text-lg font-bold tracking-tight">Fakturly</span>
</span>
)
}
Loading
Loading