glanzlicht / MACHWEIS

Mach’s. Beleg’s. Rechne es ab. MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Verified secure showcase
180 Files 605 KB Public size 26 Redacted Dart · PHP · SQL · Markdown · XML · PLIST

Project overview

MACHWEIS is a verified code showcase with 180 visible project entries across 8 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MACHWEIS

Mach’s. Beleg’s. Rechne es ab.

Produktansicht

MACHWEIS Dashboard auf Laptop und Smartphone

MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Aktueller Funktionsumfang

  • lokale Firmen-, Kunden-, Auftrags- und Mitarbeiterdaten (SQLite auf Android/iOS, Browser-Speicher im Web)
  • mobile Stammdaten-Synchronisation für Kunden, Baustellen, Aufträge, Mitarbeiterprofile und Auftragszuweisungen über die serverseitigen Push-/Pull-Endpunkte
  • atomare lokale Änderungen mit Outbox, Cursor, Retry, sichtbaren Konflikten und einer ausdrücklichen Importentscheidung bei der ersten Verbindung
  • unveränderliche Berichtsversionen mit sichtbarem Prüfverlauf
  • persistente Offline-Synchronisationswarteschlange mit Retry-Strategie
  • lokale Fotodokumentation per Kamera, Galerie oder Web-Dateiauswahl
  • gebrandete PDF-Leistungsnachweise mit Unterschrift, Fotodokumentation und vorbereitetem Teilen per E-Mail
  • prüfpflichtiger KI-Assistent für Baustellennotizen mit OpenAI, Google Gemini oder eigenem Proxy
  • Rollen: Inhaber, Büro, Bauleiter und Monteur
  • strukturierte Arbeitszeit mit Leistungsdatum, Pause und Nettozeit
  • mehrere Leistungs- und Materialpositionen
  • Zusatzaufträge mit Kundenbestätigung
  • Vollständigkeitsprüfung vor Unterschrift
  • digitale Kundenunterschrift und unveränderlicher Abschluss
  • CSV-Export und vorbereitete PDF-Renderer-Schnittstelle
  • responsive Smartphone- und Webnavigation
  • aktuelle App-Version und Build-Nummer dauerhaft am unteren Rand der Navigation

Lokale Entwicklung

flutter pub get
flutter analyze
flutter test
flutter run -d chrome

Produktkontext und technische Leitplanken stehen in [docs/PRODUCT_BRIEF.md](docs/PRODUCT_BRIEF.md). Architektur und Serververtrag der KI-Unterstützung beschreibt [docs/AI_INTEGRATION.md](docs/AI_INTEGRATION.md).

Die Stammdaten-Synchronisation ist ausschließlich für iOS und Android aktiviert. Sie verwendet das Access-Token der mobilen Anmeldung und erneuert es bei Bedarf über die bestehende Authentifizierung. Im Web bleibt die Synchronisation bewusst deaktiviert.

Backend-Grundgerüst

Das separate PHP-8.4-REST-Grundgerüst liegt in [backend/](backend/README.md). Es enthält in diesem Stand ausschließlich Infrastruktur und den Health-Endpunkt; Login, Fachlogik und KI sind noch nicht enthalten.

lib/features/employees/presentation/employees_screen.dartDart
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../../../app/theme.dart';
import '../../../core/identifiers/uuid.dart';
import '../../../core/auth/auth_store.dart';
import '../../../core/sync/master_data_sync_repository.dart';
import '../../../core/web/web_office_api.dart';
import '../data/employee_store.dart';
class EmployeesScreen extends StatelessWidget {
const EmployeesScreen({super.key});
Future<void> editEmployee(BuildContext context, [Employee? existing]) async {
final name = TextEditingController(text: existing?.name);
final email = TextEditingController(text: existing?.email);
var role = existing?.role ?? EmployeeRole.technician;
final roleOptions = existing?.role == EmployeeRole.owner
? const [EmployeeRole.owner]
: const [
EmployeeRole.office,
EmployeeRole.supervisor,
EmployeeRole.technician,
];
final saved = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(
existing == null ? 'Mitarbeiter anlegen' : 'Mitarbeiter bearbeiten',
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: name,
decoration: const InputDecoration(
labelText: 'Name',
prefixIcon: Icon(Icons.person_outline_rounded),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
TextField(
controller: email,
decoration: const InputDecoration(
labelText: 'E-Mail',
prefixIcon: Icon(Icons.mail_outline_rounded),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
DropdownButtonFormField<EmployeeRole>(
initialValue: role,
decoration: const InputDecoration(
labelText: 'Rolle',
prefixIcon: Icon(Icons.badge_outlined),
border: OutlineInputBorder(),
),
items: roleOptions
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.label),
),
)
.toList(),
onChanged: existing?.role == EmployeeRole.owner
? null
: (value) => value == null
? null
: setDialogState(() => role = value),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => name.text.trim().isEmpty
? null
: Navigator.pop(context, true),
child: const Text('Speichern'),
),
],
),
),
);
if (saved == true) {
await EmployeeStore.instance.save(
Employee(
id: existing?.id ?? newUuid(),
name: name.text.trim(),
email: email.text.trim(),
role: role,
active: existing?.active ?? true,
employeeNumber: existing?.employeeNumber ?? '',
phone: existing?.phone ?? '',
rowVersion: existing?.rowVersion ?? 0,
syncStatus: existing?.syncStatus ?? EntitySyncStatus.localOnly,
syncError: existing?.syncError,
),
);
}
name.dispose();
email.dispose();
}
Future<void> inviteEmployee(BuildContext context) async {
final firstName = TextEditingController();
final lastName = TextEditingController();
final email = TextEditingController();
final employeeNumber = TextEditingController();
final phone = TextEditingController();
var role = EmployeeRole.technician;
final saved = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Mitarbeiter einladen'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: firstName,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Vorname',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: lastName,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Nachname',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: email,
onChanged: (_) => setState(() {}),
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'E-Mail',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: employeeNumber,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Personalnummer',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Telefon (optional)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<EmployeeRole>(
initialValue: role,
decoration: const InputDecoration(
labelText: 'Rolle',
border: OutlineInputBorder(),
),
items:
[
EmployeeRole.office,
EmployeeRole.supervisor,
EmployeeRole.technician,
]
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.label),
),
)
.toList(),
onChanged: (value) =>
value == null ? null : setState(() => role = value),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed:
firstName.text.trim().isEmpty ||
lastName.text.trim().isEmpty ||
email.text.trim().isEmpty ||
employeeNumber.text.trim().isEmpty
? null
: () => Navigator.pop(context, true),
child: const Text('Einladung erstellen'),
),
],
),
),
);
if (saved == true && context.mounted) {
try {
final result = await WebOfficeApi.instance.createInvitation({
'firstName': firstName.text.trim(),
'lastName': lastName.text.trim(),
'email': email.text.trim(),
'employeeNumber': employeeNumber.text.trim(),
'phone': phone.text.trim(),
'role': role.name,
});
final token = result['token'] as String? ?? '';
final link = Uri.base
.replace(fragment: 'invite=${Uri.encodeComponent(token)}')
.toString();
if (context.mounted) {
await showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Einladung erstellt'),
content: SelectableText(
'Sende diesen persönlichen Link an ${email.text.trim()}:\n\n$link\n\nDer Link ist sieben Tage gültig und kann nur einmal verwendet werden.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Schließen'),
),
],
),
);
await EmployeeStore.instance.initialize();
}
} on Object {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Die Einladung konnte nicht erstellt werden.'),
),
);
}
}
}
firstName.dispose();
lastName.dispose();
email.dispose();
employeeNumber.dispose();
phone.dispose();
}
Future<void> editPermissions(BuildContext context) async {
try {
final items = await WebOfficeApi.instance.listPermissions();
if (!context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => _PermissionsDialog(items: items),
);
} on Object {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Berechtigungen konnten nicht geladen werden.'),
),
);
}
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Mitarbeiter & Rollen'),
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
actions: [
if (kIsWeb &&
(AuthStore.instance.session?.role == 'owner' ||
AuthStore.instance.session?.role == 'office'))
IconButton(
tooltip: 'Berechtigungen verwalten',
onPressed: () => editPermissions(context),
icon: const Icon(Icons.security_outlined),
),
if (kIsWeb &&
(AuthStore.instance.session?.role == 'owner' ||
AuthStore.instance.session?.role == 'office'))
IconButton(
tooltip: 'Mitarbeiter einladen',
onPressed: () => inviteEmployee(context),
icon: const Icon(Icons.mail_outline_rounded),
),
],
),
floatingActionButton:
(AuthStore.instance.session == null ||
AuthStore.instance.session!.role == 'owner' ||
AuthStore.instance.session!.role == 'office')
? FloatingActionButton.extended(
onPressed: () => editEmployee(context),
icon: const Icon(Icons.person_add_alt_rounded),
label: const Text('Mitarbeiter'),
)
: null,
body: ListenableBuilder(
listenable: EmployeeStore.instance,
builder: (context, _) => ListView.separated(
padding: const EdgeInsets.all(24),
itemCount: EmployeeStore.instance.employees.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final employee = EmployeeStore.instance.employees[index];
return Card(
child: ListTile(
onTap:
AuthStore.instance.session == null ||
AuthStore.instance.session!.role == 'owner' ||
AuthStore.instance.session!.role == 'office'
? () => editEmployee(context, employee)
: null,
contentPadding: const EdgeInsets.all(18),
leading: CircleAvatar(
backgroundColor: employee.active
? AppColors.lime
: AppColors.canvas,
child: Text(
employee.name
.trim()
.split(' ')
.map((part) => part.isEmpty ? '' : part[0])
.take(2)
.join(),
style: const TextStyle(
color: AppColors.ink,
fontWeight: FontWeight.w800,
),
),
),
title: Text(
employee.name,
style: const TextStyle(fontWeight: FontWeight.w800),
),
subtitle: Text(
'${employee.role.label}${employee.email.isEmpty ? '' : ' · ${employee.email}'}\n${employee.syncStatus.label}${employee.syncError == null ? '' : ': ${employee.syncError}'}',
),
trailing: Switch(
value: employee.active,
onChanged:
AuthStore.instance.session == null ||
AuthStore.instance.session!.role == 'owner' ||
AuthStore.instance.session!.role == 'office'
? (value) =>
EmployeeStore.instance.setActive(employee, value)
: null,
),
),
);
},
),
),
);
}
class _PermissionsDialog extends StatefulWidget {
const _PermissionsDialog({required this.items});
final List<Map<String, dynamic>> items;
@override
State<_PermissionsDialog> createState() => _PermissionsDialogState();
}
class _PermissionsDialogState extends State<_PermissionsDialog> {
static const _roleLabels = {
'owner': 'Inhaber',
'office': 'Büro',
'supervisor': 'Bauleiter',
'technician': 'Monteur',
};
static const _permissionLabels = {
'tenant.manage': 'Betrieb verwalten',
'users.manage': 'Mitarbeiter verwalten',
'users.read': 'Mitarbeiter lesen',
'company_profile.read': 'Firmenprofil lesen',
'company_profile.update': 'Firmenprofil ändern',
'master_data.read': 'Stammdaten lesen',
'master_data.write': 'Stammdaten ändern',
'sync.read': 'Synchronisation lesen',
'sync.write': 'Synchronisation schreiben',
'reports.create': 'Nachweise erstellen',
'reports.read_all': 'Nachweise lesen',
'reports.update': 'Nachweise ändern',
'reports.finalize': 'Nachweise freigeben',
'reports.delete': 'Nachweise löschen',
'reports.send': 'Nachweise versenden',
'assets.read': 'Dateien lesen',
'assets.write': 'Dateien verwalten',
'permissions.manage': 'Berechtigungen verwalten',
'roles.manage': 'Rollen verwalten',
'audit.read': 'Änderungsprotokoll lesen',
};
late final List<Map<String, dynamic>> _items = widget.items
.map((item) => Map<String, dynamic>.from(item))
.toList();
String? _saving;
@override
Widget build(BuildContext context) {
final roles = ['owner', 'office', 'supervisor', 'technician'];
return AlertDialog(
title: const Text('Berechtigungen'),
content: SizedBox(
width: 560,
child: ListView(
shrinkWrap: true,
children: [
for (final role in roles) ...[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
_roleLabels[role] ?? role,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
for (final item in _items.where((value) => value['role'] == role))
CheckboxListTile(
dense: true,
value: item['granted'] == true,
title: Text(
_permissionLabels[item['permission']] ??
'${item['permission']}',
),
subtitle: item['overridden'] == true
? const Text('Mandantenregel')
: null,
controlAffinity: ListTileControlAffinity.leading,
onChanged: role == 'owner' || _saving != null
? null
: (value) => _change(item, value ?? false),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Schließen'),
),
],
);
}
Future<void> _change(Map<String, dynamic> item, bool granted) async {
final key = '${item['role']}:${item['permission']}';
setState(() => _saving = key);
try {
final result = await WebOfficeApi.instance.updatePermission(
role: item['role'] as String,
permission: item['permission'] as String,
granted: granted,
);
setState(() {
item['granted'] = result['granted'] == true;
item['overridden'] = true;
});
} on Object {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Berechtigung konnte nicht gespeichert werden.'),
),
);
}
} finally {
if (mounted) setState(() => _saving = null);
}
}
}

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

backendmodule
libmodule
iosmodule
testmodule
androidmodule
docsmodule
webmodule
assetsmodule

Technology stack

DartPHPLOCKPBXPROJSQLMarkdownFlutter / Dart
Dart66.7%
PHP14.5%
LOCK4.0%
PBXPROJ3.8%
SQL3.1%
Markdown1.3%
XML0.9%
YAML0.9%