Skip to content

Commit eccea1c

Browse files
lucas19919claude
andcommitted
feat(web): mobile shell — bottom tab bar, compact toolbars, denser cards
On a 375px phone the chrome ate 36-43% of the screen before any content: a 99px collapsed sidebar (wordmark alone on row 1; AI badge, avatar, Abmelden and the burger on row 2) stacked on a module toolbar that wrapped to 3-5 rows. Navigation also cost two taps, and the burger menu opened in normal flow, shoving the page off-screen with no scrim. - SuiteNav renders a second shell at <=720px: the sidebar is hidden and navigation moves to a fixed bottom tab bar (4 modules + "Mehr" sheet). The burger and .side.open are gone. .app reserves the bar's height via padding-bottom, which keeps every inner scroller clear of it. - Module toolbars pack by CSS `order` (title+chip+primary / search / filters), so no view's JSX changed. Content now starts at 163px on Leads (was 347), 158px on Kunden (was 295), 51px on Übersicht (150). - Table cards pack two-up, keeping the label-left/value-right idiom; headlines, controls and tag lists still span full width. A lead row is 233px (was 367) — roughly one record per screen before. Fixes four measured bugs: - .dash-cards used `1fr`, whose automatic minimum is min-content, so the KPI columns resolved to 383px inside a 351px row and the whole page scrolled sideways. Now minmax(0, 1fr). - The Ausgaben "Zeitraum" filter was 446px wide and overflowed by 83px. - CustomersView had no data-label on any of its 25 cells, so the entire Kunden module rendered as unlabeled values on a phone. - The invoice editor's sticky head gave "Zurück" its own full-width row, pinning 182px of header for the whole editor; now 101px. Kanban columns also scroll-snap. Note the board still can't be used on touch at all — it relies on HTML5 `draggable`, which never fires there — but LeadsView already defaults <=720px to the table view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5b10378 commit eccea1c

4 files changed

Lines changed: 374 additions & 129 deletions

File tree

web/src/components/SuiteNav.tsx

Lines changed: 171 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,61 @@ const TABS: { id: Module; label: string; adminOnly?: boolean }[] = [
5555
{ id: 'settings', label: 'Einstellungen', adminOnly: true },
5656
]
5757

58+
// Phone bottom bar: the four everyday modules get a permanent tab, the rest
59+
// live behind "Mehr". Every one of these is role-independent, so the bar has
60+
// the same five slots for admins and members alike.
61+
const PRIMARY: Module[] = ['dashboard', 'leads', 'customers', 'documents']
62+
5863
const ROLE_LABEL: Record<string, string> = { admin: 'Admin', member: 'Team' }
5964

65+
/** 20px stroke glyphs — labels alone don't fit five tabs across a 375px phone. */
66+
function TabIcon({ id }: { id: Module | 'more' | 'search' }) {
67+
const p = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
68+
return (
69+
<svg className="tab-icon" viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
70+
{id === 'dashboard' && (
71+
<g {...p}>
72+
<rect x="2.5" y="2.5" width="6" height="6" rx="1.5" />
73+
<rect x="11.5" y="2.5" width="6" height="6" rx="1.5" />
74+
<rect x="2.5" y="11.5" width="6" height="6" rx="1.5" />
75+
<rect x="11.5" y="11.5" width="6" height="6" rx="1.5" />
76+
</g>
77+
)}
78+
{id === 'leads' && (
79+
<g {...p}>
80+
<path d="M2.5 3.5h15l-5.75 6.75v5.5l-3.5 1.75v-7.25z" />
81+
</g>
82+
)}
83+
{id === 'customers' && (
84+
<g {...p}>
85+
<circle cx="7.5" cy="6.5" r="2.75" />
86+
<path d="M2.5 16.5c0-2.5 2.2-4.25 5-4.25s5 1.75 5 4.25" />
87+
<path d="M13.5 4.4a2.75 2.75 0 0 1 0 5.2M14.5 12.6c2.05.45 3.5 1.95 3.5 3.9" />
88+
</g>
89+
)}
90+
{id === 'documents' && (
91+
<g {...p}>
92+
<path d="M4.5 2.5h7l4 4v11h-11z" />
93+
<path d="M11.5 2.5v4h4M7 10h6M7 13h6" />
94+
</g>
95+
)}
96+
{id === 'search' && (
97+
<g {...p}>
98+
<circle cx="8.75" cy="8.75" r="5.25" />
99+
<path d="M12.75 12.75l4 4" />
100+
</g>
101+
)}
102+
{id === 'more' && (
103+
<g fill="currentColor">
104+
<circle cx="4" cy="10" r="1.7" />
105+
<circle cx="10" cy="10" r="1.7" />
106+
<circle cx="16" cy="10" r="1.7" />
107+
</g>
108+
)}
109+
</svg>
110+
)
111+
}
112+
60113
export function SuiteNav({
61114
module,
62115
setModule,
@@ -71,8 +124,8 @@ export function SuiteNav({
71124
onSearch?: () => void
72125
}) {
73126
const [aiStatus, setAiStatus] = useState<AiStatus | null>(null)
74-
// Mobile only: the nav list collapses behind a burger. Selecting a tab closes it.
75-
const [menuOpen, setMenuOpen] = useState(false)
127+
// Phone only: the non-primary modules live in a bottom sheet behind "Mehr".
128+
const [sheetOpen, setSheetOpen] = useState(false)
76129
useEffect(() => {
77130
let alive = true
78131
const load = () => api.aiStatus().then((s) => alive && setAiStatus(s)).catch(() => {})
@@ -84,64 +137,138 @@ export function SuiteNav({
84137
}
85138
}, [])
86139

140+
// Escape closes the sheet, like every other overlay in the app.
141+
useEffect(() => {
142+
if (!sheetOpen) return
143+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setSheetOpen(false)
144+
window.addEventListener('keydown', onKey)
145+
return () => window.removeEventListener('keydown', onKey)
146+
}, [sheetOpen])
147+
87148
const tabs = TABS.filter((t) => !t.adminOnly || user.role === 'admin')
149+
const primary = PRIMARY.map((id) => tabs.find((t) => t.id === id)!).filter(Boolean)
150+
const rest = tabs.filter((t) => !PRIMARY.includes(t.id))
88151

89152
function pick(m: Module) {
90153
setModule(m)
91-
setMenuOpen(false)
154+
setSheetOpen(false)
92155
}
93156

94-
return (
95-
<aside className={`side${menuOpen ? ' open' : ''}`}>
96-
<div className="brand">
97-
Open<i>Leads</i>
157+
const userBlock = (
158+
<div className="side-user">
159+
<span className="avatar">{user.username.slice(0, 2)}</span>
160+
<div>
161+
<div className="side-user-name">{user.username}</div>
162+
<div className="side-user-role">{ROLE_LABEL[user.role] ?? user.role}</div>
98163
</div>
99-
<button
100-
className="nav-burger"
101-
aria-label="Menü"
102-
aria-expanded={menuOpen}
103-
onClick={() => setMenuOpen((o) => !o)}
104-
>
105-
{menuOpen ? '✕' : '☰'}
106-
</button>
107-
<nav className="nav">
108-
{onSearch && (
109-
<button
110-
className="nav-item nav-search"
111-
onClick={() => {
112-
onSearch()
113-
setMenuOpen(false)
114-
}}
115-
>
116-
<span className="dot" />
117-
Suche
118-
<kbd>Strg K</kbd>
164+
</div>
165+
)
166+
167+
return (
168+
<>
169+
{/* Desktop / tablet: the full sidebar. Hidden on phones. */}
170+
<aside className="side">
171+
<div className="brand">
172+
Open<i>Leads</i>
173+
</div>
174+
<nav className="nav">
175+
{onSearch && (
176+
<button className="nav-item nav-search" onClick={onSearch}>
177+
<span className="dot" />
178+
Suche
179+
<kbd>Strg K</kbd>
180+
</button>
181+
)}
182+
{tabs.map((t) => (
183+
<button
184+
key={t.id}
185+
className={`nav-item${module === t.id ? ' active' : ''}`}
186+
onClick={() => setModule(t.id)}
187+
>
188+
<span className="dot" />
189+
{t.label}
190+
</button>
191+
))}
192+
</nav>
193+
<div className="side-foot">
194+
<AiBadge status={aiStatus} />
195+
{userBlock}
196+
<button className="ghost" onClick={onLogout}>
197+
Abmelden
119198
</button>
120-
)}
121-
{tabs.map((t) => (
199+
</div>
200+
</aside>
201+
202+
{/* Phone: fixed bottom tab bar — one tap per module, no chrome at the top. */}
203+
<nav className="tabbar" aria-label="Hauptnavigation">
204+
{primary.map((t) => (
122205
<button
123206
key={t.id}
124-
className={`nav-item${module === t.id ? ' active' : ''}`}
207+
className={`tab${module === t.id ? ' active' : ''}`}
208+
aria-current={module === t.id ? 'page' : undefined}
125209
onClick={() => pick(t.id)}
126210
>
127-
<span className="dot" />
211+
<TabIcon id={t.id} />
128212
{t.label}
129213
</button>
130214
))}
215+
<button
216+
className={`tab${rest.some((t) => t.id === module) ? ' active' : ''}`}
217+
aria-expanded={sheetOpen}
218+
onClick={() => setSheetOpen((o) => !o)}
219+
>
220+
<TabIcon id="more" />
221+
Mehr
222+
</button>
131223
</nav>
132-
<div className="side-foot">
133-
<AiBadge status={aiStatus} />
134-
<div className="side-user">
135-
<span className="avatar">{user.username.slice(0, 2)}</span>
136-
<div>
137-
<div className="side-user-name">{user.username}</div>
138-
<div className="side-user-role">{ROLE_LABEL[user.role] ?? user.role}</div>
224+
225+
{sheetOpen && (
226+
<>
227+
<div className="overlay sheet-scrim" onClick={() => setSheetOpen(false)} />
228+
<div className="sheet" role="dialog" aria-label="Weitere Bereiche">
229+
<div className="sheet-head">
230+
<div className="brand">
231+
Open<i>Leads</i>
232+
</div>
233+
<button className="ghost" onClick={() => setSheetOpen(false)}>
234+
Schließen
235+
</button>
236+
</div>
237+
<div className="sheet-nav">
238+
{onSearch && (
239+
<button
240+
className="sheet-item"
241+
onClick={() => {
242+
onSearch()
243+
setSheetOpen(false)
244+
}}
245+
>
246+
<TabIcon id="search" />
247+
Suche
248+
</button>
249+
)}
250+
{rest.map((t) => (
251+
<button
252+
key={t.id}
253+
className={`sheet-item${module === t.id ? ' active' : ''}`}
254+
onClick={() => pick(t.id)}
255+
>
256+
<span className="dot" />
257+
{t.label}
258+
</button>
259+
))}
260+
</div>
261+
<div className="sheet-foot">
262+
{userBlock}
263+
<AiBadge status={aiStatus} />
264+
<div className="spacer" />
265+
<button className="ghost" onClick={onLogout}>
266+
Abmelden
267+
</button>
268+
</div>
139269
</div>
140-
</div>
141-
<button className="ghost" onClick={onLogout}>
142-
Abmelden
143-
</button>
144-
</div>
145-
</aside>
270+
</>
271+
)}
272+
</>
146273
)
147274
}

web/src/components/customers/CustomersView.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ export function CustomersView({
512512
render={(doc) => (
513513
<tr key={doc.id}>
514514
<td data-label="Art">{KIND_LABEL[doc.kind] ?? doc.kind}</td>
515-
<td data-label="Nr.">{doc.number ?? '—'}</td>
515+
<td className="cell-primary" data-label="Nr.">{doc.number ?? '—'}</td>
516516
<td data-label="Status">{STATUS_LABEL[doc.status] ?? doc.status}</td>
517517
<td className="num" data-label="Brutto">{euro(doc.gross_cents)}</td>
518518
<td className="num" data-label="Offen">{euro(doc.open_cents)}</td>
@@ -546,7 +546,7 @@ export function CustomersView({
546546
columns={['Nr.', 'Titel', 'Status', 'Wert', 'Unterschrift', '']}
547547
render={(krow) => (
548548
<tr key={krow.id}>
549-
<td data-label="Nr.">{krow.number ?? '—'}</td>
549+
<td className="cell-primary" data-label="Nr.">{krow.number ?? '—'}</td>
550550
<td data-label="Titel">{krow.title ?? '—'}</td>
551551
<td data-label="Status">{STATUS_LABEL[krow.status] ?? krow.status}</td>
552552
<td className="num" data-label="Wert">{euro(krow.value_cents)}</td>
@@ -589,7 +589,7 @@ export function CustomersView({
589589
columns={['Titel', 'Vertrag', 'Turnus', 'Nächster Lauf', 'Status', '']}
590590
render={(s) => (
591591
<tr key={s.id}>
592-
<td data-label="Titel">{s.title ?? '—'}</td>
592+
<td className="cell-primary" data-label="Titel">{s.title ?? '—'}</td>
593593
<td data-label="Vertrag">
594594
{s.contract_number
595595
? s.contract_number
@@ -834,7 +834,9 @@ function AttachModal({
834834
</div>
835835
) : (
836836
<div className="table-wrap" style={{ maxHeight: 320, overflow: 'auto' }}>
837-
<table className="leads">
837+
{/* .picker opts out of the mobile card reflow: this is a tick list,
838+
not a data table, so a checkbox + label stay on one line. */}
839+
<table className="leads picker">
838840
<tbody>
839841
{visible.map((c) => (
840842
<tr key={c.id} className="clickable" onClick={() => onToggle(c.id)}>

0 commit comments

Comments
 (0)