Add responsive mobile layout and drawer menu

This commit is contained in:
MarcWieland
2026-07-27 00:11:10 +02:00
parent 4588468ed6
commit a405bfb91f
7 changed files with 389 additions and 156 deletions
+25 -1
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Menu, Wrench } from 'lucide-react';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { DashboardView } from './views/DashboardView'; import { DashboardView } from './views/DashboardView';
import { DispatchingView } from './views/DispatchingView'; import { DispatchingView } from './views/DispatchingView';
@@ -9,6 +10,7 @@ import { SupabaseSyncView } from './views/SupabaseSyncView';
export const App: React.FC = () => { export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState('dashboard'); const [activeTab, setActiveTab] = useState('dashboard');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const renderView = () => { const renderView = () => {
switch (activeTab) { switch (activeTab) {
@@ -31,7 +33,29 @@ export const App: React.FC = () => {
return ( return (
<div className="app-container"> <div className="app-container">
<Sidebar activeTab={activeTab} setActiveTab={setActiveTab} /> {/* Mobile Header Bar */}
<header className="mobile-top-bar">
<button
className="mobile-hamburger-btn"
onClick={() => setMobileMenuOpen(true)}
aria-label="Menü öffnen"
>
<Menu size={24} />
</button>
<div className="mobile-brand">
<Wrench size={20} color="#38bdf8" />
<span>Handwerksfreund</span>
</div>
</header>
<Sidebar
activeTab={activeTab}
setActiveTab={setActiveTab}
isOpen={mobileMenuOpen}
onClose={() => setMobileMenuOpen(false)}
/>
<main className="main-content"> <main className="main-content">
{renderView()} {renderView()}
</main> </main>
+29 -27
View File
@@ -9,34 +9,36 @@ export const LiveOrdersTable: React.FC<LiveOrdersTableProps> = ({ orders }) => {
return ( return (
<div className="content-card"> <div className="content-card">
<h2 className="card-heading">Heutige Live-Aufträge (Dispatching)</h2> <h2 className="card-heading">Heutige Live-Aufträge (Dispatching)</h2>
<table className="orders-table"> <div className="table-responsive">
<thead> <table className="orders-table">
<tr> <thead>
<th>Kunde</th> <tr>
<th>Monteur</th> <th>Kunde</th>
<th>Status</th> <th>Monteur</th>
</tr> <th>Status</th>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{order.customerName}</td>
<td style={{ color: '#334155' }}>{order.technicianName}</td>
<td>
<span
className={`badge ${
order.status === 'In Bearbeitung'
? 'badge-in-progress'
: 'badge-scheduled'
}`}
>
{order.status === 'Geplant' ? `Geplant (${order.startTime})` : order.status}
</span>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {orders.map((order) => (
<tr key={order.id}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{order.customerName}</td>
<td style={{ color: '#334155' }}>{order.technicianName}</td>
<td>
<span
className={`badge ${
order.status === 'In Bearbeitung'
? 'badge-in-progress'
: 'badge-scheduled'
}`}
>
{order.status === 'Geplant' ? `Geplant (${order.startTime})` : order.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
); );
}; };
+62 -29
View File
@@ -6,15 +6,23 @@ import {
Package, Package,
Users, Users,
Clock, Clock,
Database Database,
X
} from 'lucide-react'; } from 'lucide-react';
interface SidebarProps { interface SidebarProps {
activeTab: string; activeTab: string;
setActiveTab: (tab: string) => void; setActiveTab: (tab: string) => void;
isOpen: boolean;
onClose: () => void;
} }
export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) => { export const Sidebar: React.FC<SidebarProps> = ({
activeTab,
setActiveTab,
isOpen,
onClose
}) => {
const menuItems = [ const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'dispatching', label: 'Dispatching', icon: CalendarDays }, { id: 'dispatching', label: 'Dispatching', icon: CalendarDays },
@@ -24,33 +32,58 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'supabase', label: 'Supabase Sync', icon: Database }, { id: 'supabase', label: 'Supabase Sync', icon: Database },
]; ];
return ( const handleSelect = (tabId: string) => {
<aside className="sidebar"> setActiveTab(tabId);
<div className="brand"> onClose(); // Close mobile sidebar on selection
<Wrench className="brand-icon" size={24} /> };
<span>Handwerksfreund</span>
</div>
<nav> return (
<ul className="nav-list"> <>
{menuItems.map((item) => { {/* Mobile Backdrop */}
const Icon = item.icon; {isOpen && (
const isActive = activeTab === item.id; <div
return ( className="sidebar-backdrop"
<li key={item.id}> onClick={onClose}
<button aria-hidden="true"
className={`nav-item ${isActive ? 'active' : ''}`} />
onClick={() => setActiveTab(item.id)} )}
style={{ width: '100%', background: 'none', border: 'none', textAlign: 'left' }}
> <aside className={`sidebar ${isOpen ? 'open' : ''}`}>
<Icon className="icon" size={20} /> <div className="brand">
<span>{item.label}</span> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
</button> <Wrench className="brand-icon" size={24} />
</li> <span>Handwerksfreund</span>
); </div>
})}
</ul> <button
</nav> className="mobile-close-btn"
</aside> onClick={onClose}
aria-label="Menü schließen"
>
<X size={24} />
</button>
</div>
<nav>
<ul className="nav-list">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<li key={item.id}>
<button
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelect(item.id)}
>
<Icon className="icon" size={20} />
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
</aside>
</>
); );
}; };
+171 -2
View File
@@ -31,12 +31,62 @@ body {
color: var(--text-primary); color: var(--text-primary);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden;
} }
.app-container { .app-container {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
width: 100vw; width: 100vw;
position: relative;
}
/* Mobile Top Navigation Bar (Hidden on Desktop) */
.mobile-top-bar {
display: none;
align-items: center;
justify-content: space-between;
background-color: var(--bg-sidebar);
color: #ffffff;
padding: 14px 20px;
position: sticky;
top: 0;
z-index: 40;
width: 100%;
}
.mobile-hamburger-btn {
background: none;
border: none;
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
}
.mobile-hamburger-btn:hover {
background-color: var(--bg-sidebar-hover);
}
.mobile-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.1rem;
}
/* Sidebar Backdrop for Mobile */
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background-color: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
z-index: 49;
} }
/* Sidebar Styles */ /* Sidebar Styles */
@@ -48,12 +98,14 @@ body {
flex-direction: column; flex-direction: column;
padding: 24px 16px; padding: 24px 16px;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 50;
} }
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: space-between;
color: #ffffff; color: #ffffff;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
@@ -64,6 +116,21 @@ body {
color: #38bdf8; color: #38bdf8;
} }
.mobile-close-btn {
display: none;
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 4px;
border-radius: 6px;
}
.mobile-close-btn:hover {
color: #ffffff;
background-color: var(--bg-sidebar-hover);
}
.nav-list { .nav-list {
list-style: none; list-style: none;
display: flex; display: flex;
@@ -83,6 +150,10 @@ body {
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
text-decoration: none; text-decoration: none;
width: 100%;
background: none;
border: none;
text-align: left;
} }
.nav-item:hover { .nav-item:hover {
@@ -109,9 +180,10 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; gap: 32px;
max-width: 100%;
} }
/* Top Header */ /* Top Header Bar */
.header-bar { .header-bar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -121,6 +193,8 @@ body {
border-radius: 16px; border-radius: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05); box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 16px;
} }
.header-title { .header-title {
@@ -137,6 +211,10 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: #10b981; color: #10b981;
background-color: #f0fdf4;
padding: 6px 14px;
border-radius: 20px;
border: 1px solid #bbf7d0;
} }
.status-dot { .status-dot {
@@ -197,6 +275,7 @@ body {
border-radius: 16px; border-radius: 16px;
padding: 28px; padding: 28px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04); box-shadow: 0 1px 3px rgba(0,0,0,0.04);
overflow: hidden;
} }
.card-heading { .card-heading {
@@ -206,11 +285,19 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
/* Table Container for Mobile Horizontal Scroll */
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Table Styles */ /* Table Styles */
.orders-table { .orders-table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: separate;
border-spacing: 0 12px; border-spacing: 0 12px;
min-width: 500px; /* Ensures table readability on small screens */
} }
.orders-table th { .orders-table th {
@@ -219,6 +306,7 @@ body {
font-weight: 600; font-weight: 600;
color: var(--text-secondary); color: var(--text-secondary);
padding: 0 16px 8px 16px; padding: 0 16px 8px 16px;
white-space: nowrap;
} }
.orders-table td { .orders-table td {
@@ -228,6 +316,7 @@ body {
background-color: #ffffff; background-color: #ffffff;
border-top: 1px solid #f1f5f9; border-top: 1px solid #f1f5f9;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
} }
.orders-table tr td:first-child { .orders-table tr td:first-child {
@@ -278,6 +367,7 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
word-break: break-word;
} }
.status-item { .status-item {
@@ -298,11 +388,90 @@ body {
color: #ffffff; color: #ffffff;
} }
/* ==========================================================================
Responsive Breakpoints
========================================================================== */
/* Tablet & Mobile Layout (< 1024px) */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.kpi-grid { .kpi-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* Mobile Phone Layout (< 768px) */
@media (max-width: 768px) {
.app-container {
flex-direction: column;
}
.mobile-top-bar {
display: flex;
}
.sidebar-backdrop {
display: block;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
height: 100vh;
transform: translateX(-100%);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
}
.sidebar.open {
transform: translateX(0);
}
.mobile-close-btn {
display: flex;
align-items: center;
justify-content: center;
}
.main-content {
padding: 20px 16px;
gap: 20px;
}
.header-bar {
padding: 16px 20px;
border-radius: 12px;
}
.header-title {
font-size: 1.35rem;
}
.kpi-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.kpi-card {
padding: 16px 18px;
border-radius: 12px;
}
.kpi-value {
font-size: 1.45rem;
}
.content-card {
padding: 20px 16px;
border-radius: 12px;
}
.card-heading {
font-size: 1.1rem;
margin-bottom: 14px;
}
}
+34 -32
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { CalendarDays, Plus, UserCheck } from 'lucide-react'; import { Plus, UserCheck } from 'lucide-react';
export const DispatchingView: React.FC = () => { export const DispatchingView: React.FC = () => {
const scheduleData = [ const scheduleData = [
@@ -30,42 +30,44 @@ export const DispatchingView: React.FC = () => {
</header> </header>
<section className="content-card"> <section className="content-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px', flexWrap: 'wrap', gap: '10px' }}>
<h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition (Heute)</h2> <h2 className="card-heading" style={{ margin: 0 }}>Tagesdisposition (Heute)</h2>
<span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>10 Monteure im System</span> <span style={{ fontSize: '0.9rem', color: '#64748b', fontWeight: 600 }}>10 Monteure im System</span>
</div> </div>
<table className="orders-table"> <div className="table-responsive">
<thead> <table className="orders-table">
<tr> <thead>
<th>Uhrzeit</th> <tr>
<th>Monteur</th> <th>Uhrzeit</th>
<th>Kunde</th> <th>Monteur</th>
<th>Aufgabe / Tätigkeit</th> <th>Kunde</th>
<th>Status</th> <th>Aufgabe / Tätigkeit</th>
</tr> <th>Status</th>
</thead>
<tbody>
{scheduleData.map((item, idx) => (
<tr key={idx}>
<td style={{ fontWeight: 600, color: '#2563eb' }}>{item.time}</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserCheck size={16} color="#64748b" />
{item.technician}
</div>
</td>
<td>{item.customer}</td>
<td style={{ color: '#475569' }}>{item.task}</td>
<td>
<span className={`badge ${item.status === 'In Bearbeitung' ? 'badge-in-progress' : 'badge-scheduled'}`}>
{item.status}
</span>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {scheduleData.map((item, idx) => (
<tr key={idx}>
<td style={{ fontWeight: 600, color: '#2563eb' }}>{item.time}</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<UserCheck size={16} color="#64748b" />
{item.technician}
</div>
</td>
<td>{item.customer}</td>
<td style={{ color: '#475569' }}>{item.task}</td>
<td>
<span className={`badge ${item.status === 'In Bearbeitung' ? 'badge-in-progress' : 'badge-scheduled'}`}>
{item.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section> </section>
</> </>
); );
+37 -35
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Package, AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
export const InventoryView: React.FC = () => { export const InventoryView: React.FC = () => {
const parts = [ const parts = [
@@ -21,41 +21,43 @@ export const InventoryView: React.FC = () => {
<section className="content-card"> <section className="content-card">
<h2 className="card-heading">Ersatzteilbestand & Bestellwarnungen</h2> <h2 className="card-heading">Ersatzteilbestand & Bestellwarnungen</h2>
<table className="orders-table"> <div className="table-responsive">
<thead> <table className="orders-table">
<tr> <thead>
<th>Artikelbezeichnung</th> <tr>
<th>Kategorie</th> <th>Artikelbezeichnung</th>
<th>Lagerbestand</th> <th>Kategorie</th>
<th>Mindestbestand</th> <th>Lagerbestand</th>
<th>Status</th> <th>Mindestbestand</th>
</tr> <th>Status</th>
</thead>
<tbody>
{parts.map((p, idx) => (
<tr key={idx}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.name}</td>
<td style={{ color: '#64748b' }}>{p.category}</td>
<td style={{ fontWeight: 700, color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
{p.stock} Stk.
</td>
<td>{p.minStock} Stk.</td>
<td>
<span className={`badge ${
p.status === 'Kritisch' || p.status === 'Nachbestellen'
? 'badge-scheduled'
: 'badge-in-progress'
}`} style={{
backgroundColor: p.status === 'Kritisch' ? '#fef2f2' : p.status === 'Nachbestellen' ? '#fffbeb' : '#dcfce7',
color: p.status === 'Kritisch' ? '#991b1b' : p.status === 'Nachbestellen' ? '#b45309' : '#15803d'
}}>
{p.status}
</span>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {parts.map((p, idx) => (
<tr key={idx}>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{p.name}</td>
<td style={{ color: '#64748b' }}>{p.category}</td>
<td style={{ fontWeight: 700, color: p.stock <= p.minStock ? '#ef4444' : '#0f172a' }}>
{p.stock} Stk.
</td>
<td>{p.minStock} Stk.</td>
<td>
<span className={`badge ${
p.status === 'Kritisch' || p.status === 'Nachbestellen'
? 'badge-scheduled'
: 'badge-in-progress'
}`} style={{
backgroundColor: p.status === 'Kritisch' ? '#fef2f2' : p.status === 'Nachbestellen' ? '#fffbeb' : '#dcfce7',
color: p.status === 'Kritisch' ? '#991b1b' : p.status === 'Nachbestellen' ? '#b45309' : '#15803d'
}}>
{p.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section> </section>
</> </>
); );
+31 -30
View File
@@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { Clock, CheckCircle } from 'lucide-react';
export const TimeTrackingView: React.FC = () => { export const TimeTrackingView: React.FC = () => {
const timeEntries = [ const timeEntries = [
@@ -18,36 +17,38 @@ export const TimeTrackingView: React.FC = () => {
<section className="content-card"> <section className="content-card">
<h2 className="card-heading">Erfasste Arbeitszeiten der Monteure</h2> <h2 className="card-heading">Erfasste Arbeitszeiten der Monteure</h2>
<table className="orders-table"> <div className="table-responsive">
<thead> <table className="orders-table">
<tr> <thead>
<th>Datum</th> <tr>
<th>Monteur</th> <th>Datum</th>
<th>Kunde / Projekt</th> <th>Monteur</th>
<th>Tätigkeit</th> <th>Kunde / Projekt</th>
<th>Dauer</th> <th>Tätigkeit</th>
<th>Status</th> <th>Dauer</th>
</tr> <th>Status</th>
</thead>
<tbody>
{timeEntries.map((e, idx) => (
<tr key={idx}>
<td style={{ color: '#64748b' }}>{e.date}</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{e.technician}</td>
<td>{e.customer}</td>
<td style={{ color: '#334155' }}>{e.activity}</td>
<td style={{ fontWeight: 700, color: '#2563eb' }}>{e.hours}</td>
<td>
<span className={`badge ${
e.status === 'Freigegeben' || e.status === 'Abgerechnet' ? 'badge-in-progress' : 'badge-scheduled'
}`}>
{e.status}
</span>
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {timeEntries.map((e, idx) => (
<tr key={idx}>
<td style={{ color: '#64748b' }}>{e.date}</td>
<td style={{ fontWeight: 600, color: '#0f172a' }}>{e.technician}</td>
<td>{e.customer}</td>
<td style={{ color: '#334155' }}>{e.activity}</td>
<td style={{ fontWeight: 700, color: '#2563eb' }}>{e.hours}</td>
<td>
<span className={`badge ${
e.status === 'Freigegeben' || e.status === 'Abgerechnet' ? 'badge-in-progress' : 'badge-scheduled'
}`}>
{e.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section> </section>
</> </>
); );