myBusiness — Developer Documentation
MAUI 10 · .NET 10 · Firebase Firestore (REST, online-only) · MVVM CommunityToolkit
Stand: April 2026 · v2.5 · Architektur: Online-Only (kein SQLite-Sync)
Architektur-Übersicht
Schichten
🔒 Auth-Strategie
Email/Password via Firebase Auth REST API. Token wird in Preferences (verschlüsselt) gespeichert. Auto-Refresh 5 Min vor Ablauf.
companyId === userId (Firebase Auth UID) — MVP single-user.
☁️ Daten-Strategie
100% online-only. Kein lokaler Cache. Jede Navigation lädt frisch aus Firestore.
Vorteil: immer aktuelle Daten, kein Sync-Konflikt. Nachteil: kein Offline.
📦 DI-Lebenszyklus
Singleton: HttpClient, FirebaseAuthService, FirebaseRestService, DataService, SettingsService, PdfService
Transient: alle ViewModels und Views
Datenfluss: Beispiel "Kundenliste laden"
- Page.Appearing →
EventToCommandBehaviorfeuertLoadCommand CustomersViewModel.LoadAsync()→DataService.CheckPrerequisites()CheckPrerequisites()prüft:Connectivity.NetworkAccess != None+IsLoggedIn+CompanyId != nullDataService.GetCustomersAsync()→LoadCollectionAsync("customers", MapCustomer)FirebaseRestService.GetCollectionAsync(companyId, "customers")FirebaseAuthService.GetValidTokenAsync()→ Token aus Preferences oder Refresh- HTTP GET →
https://firestore.googleapis.com/v1/projects/mytime-7c3cd/databases/(default)/documents/companies/{uid}/customers - Response:
FirestoreListResponse { documents: [...] } - Jedes Dokument →
MapCustomer(doc, companyId)→LocalCustomer - ViewModel:
Customers = new ObservableCollection<LocalCustomer>(list)→ UI aktualisiert
Firestore URL-Struktur
Base URL
https://firestore.googleapis.com/v1/projects/mytime-7c3cd/databases/(default)/documents
Collection (Liste)
{BaseUrl}/companies/{companyId}/{collection}
// z.B.: .../documents/companies/abc123uid/customers
Dokument (Einzeln)
{BaseUrl}/companies/{companyId}/{collection}/{docId}
// z.B.: .../documents/companies/abc123uid/customers/k1a2b3c4
Alle Collections
| C#-Konstante | Firestore-Pfad |
|---|---|
Collections.Customers | companies/{uid}/customers |
Collections.Projects | companies/{uid}/projects |
Collections.TimeEntries | companies/{uid}/timeEntries |
Collections.Invoices | companies/{uid}/invoices |
Collections.Quotes | companies/{uid}/quotes |
Collections.Payments | companies/{uid}/payments |
Collections.MaintenanceContracts | companies/{uid}/maintenanceContracts |
Collections.ActivityReports | companies/{uid}/activityReports |
Collections.ServiceCatalog | companies/{uid}/serviceCatalog |
Collections.Settings | companies/{uid}/settings |
companies/{uid}/... speichert, wobei uid die Firebase Auth UID ist. Wenn die Web App eine andere Struktur hat, kommen keine Daten an.FirebaseAuthService
Aufgabe
Verwaltet Login, Token-Lifecycle und CompanyId. Alles in Preferences gespeichert.
Properties
| Property | Typ | Was |
|---|---|---|
CurrentUserId | string? | Firebase UID aus Preferences |
CurrentCompanyId | string? | Manuell gesetzt per SetCompanyId() — aktuell = UserId |
IsLoggedIn | bool | UserId != null |
Token-Flow
GetValidTokenAsync()
→ TokenExpiry in Preferences > UtcNow + 5min?
→ ja: return IdToken aus Preferences
→ nein: RefreshTokenAsync()
→ POST securetoken.googleapis.com/v1/token
→ neues IdToken + RefreshToken → Preferences
→ return neues IdToken
Methoden
| Methode | Beschreibung |
|---|---|
SignInAsync(email, pw) | POST identitytoolkit signInWithPassword |
GetValidTokenAsync() | Gibt valides Token zurück, refresht automatisch |
RefreshTokenAsync() | Tauscht RefreshToken gegen neues IdToken |
SetCompanyId(id) | Schreibt CompanyId in Preferences |
SignOut() | Löscht alle Token + IDs aus Preferences |
LoginViewModel: _auth.SetCompanyId(_auth.CurrentUserId!) direkt nach erfolgreichem Login. Bei App-Neustart ist die ID aus Preferences vorhanden.FirebaseRestService
Aufgabe
Low-level HTTP-Wrapper für Firestore REST API. Kein Business-Logic. Alle Methoden brauchen ein valides Token (→ wird intern per GetValidTokenAsync() geholt).
Methoden
| Methode | HTTP | Was |
|---|---|---|
GetCollectionAsync(cId, coll) | GET | Alle Docs einer Sub-Collection, mit Pagination |
GetDocumentAsync(cId, coll, docId) | GET | Einzelnes Dokument |
UpsertDocumentAsync(cId, coll, docId, fields) | PATCH | Dokument anlegen oder überschreiben |
DeleteDocumentAsync(cId, coll, docId) | DELETE | Dokument löschen |
GetCompanyDocAsync(cId) | GET | Firmen-Root-Dokument |
ResolveCompanyIdAsync(userId) | GET | CompanyId aus /users/{userId} auflösen |
Fehlerverhalten
Bei HTTP-Fehler: loggt Warning + Response-Body, gibt leere Liste / null / false zurück. Keine Exception nach oben.
DataService
Aufgabe
Zentrale Datenschicht. Einzige Abhängigkeit der ViewModels für Datenzugriff. Enthält alle Firestore-Mapper.
CheckPrerequisites()
null zurück wenn alles OK. Gibt Fehlermeldung (string) zurück bei:•
NetworkAccess == None oder Unknown → "Keine Internetverbindung"•
!IsLoggedIn oder CompanyId == null → "Du bist nicht angemeldet"Alle List-ViewModels rufen CheckPrerequisites() als erstes auf. Bei Fehler → Toast + früher Return.
Typed Getters (Collections)
| Methode | Firestore-Collection | Return |
|---|---|---|
GetCustomersAsync() | customers | List<LocalCustomer> |
GetProjectsAsync() | projects | List<LocalProject> |
GetTimeEntriesAsync() | timeEntries | List<LocalTimeEntry> |
GetInvoicesAsync() | invoices | List<LocalInvoice> |
GetQuotesAsync() | quotes | List<LocalQuote> |
GetPaymentsAsync() | payments | List<LocalPayment> |
GetContractsAsync() | maintenanceContracts | List<LocalContract> |
GetActivityReportsAsync() | activityReports | List<LocalActivityReport> |
GetServiceCatalogAsync() | serviceCatalog | List<LocalServiceEntry> |
GetSettingsAsync() | settings | LocalCompanySettings |
Typed Single-Item Getters
Für jede Collection: GetXxxAsync(string id) → liefert einzelnes Objekt oder null.
Beispiel: GetCustomerAsync(id), GetProjectAsync(id), ...
CRUD
| Methode | Was |
|---|---|
SaveAsync(collection, docId, fields) | PATCH zu Firestore → true/false |
DeleteAsync(collection, docId) | DELETE in Firestore → true/false |
SaveSettingsAsync(settings) | Speichert Firmeneinstellungen unter "settings/settings" |
SettingsService
Thin wrapper um DataService für Firmeneinstellungen. Wird von Detail-VMs (Invoice, Quote) und SettingsViewModel genutzt.
public Task<LocalCompanySettings> GetAsync()
=> _data.GetSettingsAsync();
public Task<bool> SaveAsync(LocalCompanySettings settings)
=> _data.SaveSettingsAsync(settings);
Wenn noch keine Settings in Firestore: GetSettingsAsync() gibt Fallback-Defaults zurück (TaxRate=20, Currency=EUR, etc.).
PdfService
Generiert PDFs mit Syncfusion.PDF. Abhängigkeiten: SettingsService (Firmeninfos) + DataService (Kundendaten für Anschrift).
| Methode | Was |
|---|---|
GenerateInvoicePdfAsync(LocalInvoice) | PDF mit Rechnungsdetails, speichert in CacheDirectory |
GenerateQuotePdfAsync(LocalQuote) | PDF mit Angebotsdetails, speichert in CacheDirectory |
Gibt string? filePath zurück → wird via Share.RequestAsync() geteilt.
LocalModels
Alle C#-Klassen, die Firestore-Daten repräsentieren. Kein SQLite mehr nötig — die Attribute bleiben aber kompatibel für spätere Offline-Erweiterung.
Basis-Klasse LocalBase
public class LocalBase {
public string Id { get; set; } // Firestore Document ID
public string CompanyId { get; set; } // = Firebase Auth UID
public string? CreatedAt { get; set; }
public string? UpdatedAt { get; set; }
public SyncState SyncState { get; set; } // für künftigen Offline-Support
}
Models Übersicht
| Klasse | Wichtige Felder |
|---|---|
LocalCustomer | Name, Email, Phone, Type (company/person), Street, PostalCode, City, Country, ContactsJson |
LocalProject | Title, CustomerId, Status, BudgetHours?, FixedPrice?, ServiceIdsJson (JSON-Array der zugewiesenen Tätigkeits-IDs) |
LocalTimeEntry | StartedAt (ISO-String!), EndedAt, DurationMinutes?, HourlyRate?, IsRunning, ProjectId, CustomerId, ServiceId |
LocalInvoice | Number, CustomerId, Date, DueDate, Status, TaxRate, TotalNet, TotalGross, LineItemsJson |
LocalQuote | Number, CustomerId, Date, ValidUntil, Status, TaxRate, TotalNet, TotalGross, LineItemsJson |
LocalPayment | Date, Amount, Currency, PaymentMethod, CategoryId |
LocalContract | Title, CustomerId, Interval (monthly/quarterly/biannual/annual), Status, TotalNet, TaxRate |
LocalActivityReport | Title, CustomerId, ProjectId, Date, SignedAt, InvoiceId, TimeEntryIdsJson, PositionsJson |
LocalServiceEntry | Name, DefaultHourlyRate, Unit (Stunde/Tag/Pauschal/Stück) |
LocalCompanySettings | Name, TaxRate, Currency, InvoicePrefix, QuotePrefix, NextInvoiceNumber, NextQuoteNumber, PaymentDueDays, Language |
FirestoreModels — JSON-Besonderheiten
FirestoreValue Union-Typ
Firestore REST gibt Werte in einem Union-Objekt zurück. Nur eine Property ist gesetzt:
// Firestore REST Response (JSON)
{
"fields": {
"name": { "stringValue": "Acme GmbH" },
"taxRate": { "doubleValue": 20.0 },
"count": { "integerValue": "42" }, // ← String! nicht Number!
"active": { "booleanValue": true },
"createdAt": { "timestampValue": "2024-01-15T10:00:00Z" },
"deleted": { "nullValue": "NULL_VALUE" }
}
}
Firestore gibt
integerValue als JSON-String zurück ("42"), nicht als Number.
System.Text.Json kann das standardmäßig nicht deserialisieren.Fix:
[JsonNumberHandling(AllowReadingFromString | WriteAsString)] auf long? integerValue gesetzt.Getter-Extension-Methoden (auf Dictionary)
| Methode | Was |
|---|---|
GetString(key) | stringValue ?? timestampValue, null wenn nicht vorhanden |
GetDouble(key) | doubleValue ?? integerValue, 0 wenn nicht vorhanden |
GetBool(key) | booleanValue, false wenn nicht vorhanden |
GetStringArray(key) | arrayValue.values als List<string> |
DocumentId Extraction
public string DocumentId => name?.Split('/').LastOrDefault() ?? "";
// name = ".../documents/companies/uid/customers/abc123"
// → DocumentId = "abc123"
ViewModels — Übersicht
| ViewModel | Injected Services | DataService-Methoden |
|---|---|---|
LoginViewModel | FirebaseAuthService | — (nur Auth) |
DashboardViewModel | DataService | GetInvoicesAsync, GetProjectsAsync, GetContractsAsync, GetSettingsAsync |
CustomersViewModel | DataService | GetCustomersAsync |
CustomerDetailViewModel | DataService | GetCustomerAsync, GetInvoicesAsync, GetQuotesAsync (für Offene Posten Tab), SaveAsync, DeleteAsync |
ProjectsViewModel | DataService | GetProjectsAsync, GetCustomersAsync (für Kunden-Filter) |
ProjectDetailViewModel | DataService | GetCustomersAsync, GetProjectAsync, SaveAsync, DeleteAsync |
TimeTrackingViewModel | DataService | GetProjectsAsync, GetCustomersAsync, GetServiceCatalogAsync (→ gefiltert per Projekt), GetTimeEntriesAsync, SaveAsync |
TimeEntryDetailViewModel | DataService | GetProjectsAsync, GetCustomersAsync, GetServiceCatalogAsync, GetTimeEntryAsync, SaveAsync, DeleteAsync |
InvoicesViewModel | DataService | GetInvoicesAsync, SaveAsync (status change) |
InvoiceDetailViewModel | DataService, SettingsService, PdfService | GetCustomersAsync, GetProjectsAsync, GetServiceCatalogAsync, GetTimeEntriesAsync (für Zeiten-Import), GetInvoiceAsync, SaveAsync, DeleteAsync |
QuotesViewModel | DataService | GetQuotesAsync, SaveAsync (status change) |
QuoteDetailViewModel | DataService, SettingsService, PdfService | GetCustomersAsync, GetProjectsAsync, GetQuoteAsync, SaveAsync, DeleteAsync |
PaymentsViewModel | DataService | GetPaymentsAsync |
PaymentDetailViewModel | DataService | GetPaymentAsync, SaveAsync, DeleteAsync |
ContractsViewModel | DataService | GetContractsAsync |
ContractDetailViewModel | DataService | GetCustomersAsync, GetContractAsync, SaveAsync, DeleteAsync |
ActivityReportsViewModel | DataService | GetActivityReportsAsync |
ActivityReportDetailViewModel | DataService | GetCustomersAsync, GetProjectsAsync, GetActivityReportAsync, SaveAsync, DeleteAsync |
ServiceCatalogViewModel | DataService | GetServiceCatalogAsync |
ServiceCatalogDetailViewModel | DataService | GetServiceEntryAsync, SaveAsync, DeleteAsync |
SettingsViewModel | SettingsService, FirebaseAuthService | (via SettingsService) |
BaseViewModel
Properties & Helpers
| Member | Was |
|---|---|
IsBusy / IsNotBusy | Loading-State für Spinner und CanExecute |
Title | Seitentitel für AppBar |
ShowErrorAsync(msg) | Toast (Long-Duration) auf Main Thread |
ShowSuccessAsync(msg) | Toast (Default) auf Main Thread |
GoToAsync(route) | Shell Navigation auf Main Thread |
GoToAsync(route, params) | Shell Navigation mit Query-Parametern |
GoBackAsync() | Shell.GoToAsync("..") |
Pattern in jedem LoadAsync
[RelayCommand]
private async Task LoadAsync()
{
if (IsBusy) return; // 1. double-fire guard
var err = _data.CheckPrerequisites();
if (err is not null) { await ShowErrorAsync(err); return; } // 2. network/auth guard
IsBusy = true;
try { /* Firestore-Laden */ }
finally { IsBusy = false; } // 3. immer freigeben
}
Behobene Bugs (diese Session)
Problem: Firestore REST gibt
integerValue als JSON-String zurück ("42"). System.Text.Json kann das nicht in long? deserialisieren → stille null → alle Integer-Felder (DurationMinutes, NextInvoiceNumber, ...) waren immer 0 oder null.Fix:
[JsonNumberHandling(AllowReadingFromString | WriteAsString)] auf long? integerValue in FirestoreModels.csProblem:
Connectivity.NetworkAccess != NetworkAccess.Internet schlägt auf Emulatoren und manchen Mobilfunkverbindungen fehl (liefern ConstrainedInternet), obwohl HTTP-Requests funktionieren → CheckPrerequisites() lieferte Fehler → keine Daten geladen.Fix: Nur
None und Unknown als "kein Internet" behandeln. ConstrainedInternet wird jetzt akzeptiert.Problem:
SyncService.cs (aufgehoben für später) referenzierte SyncCompletedMessage, die beim Refactoring entfernt wurde → Compile-Fehler.Fix:
SyncCompletedMessage : ValueChangedMessage<bool> direkt in SyncService.cs definiert.Problem:
FirestoreFieldBuilder.ForTimeEntry() speicherte startedAt als Fv.Ts() (Firestore timestampValue). Das Firebase Web SDK gibt timestampValue-Felder als Timestamp-Objekt zurück — nicht als String. Code wie e.startedAt.startsWith(today) im Dashboard crashte mit "startedAt.startsWith is not a function".Fix MAUI:
ForTimeEntry(): ["startedAt"] = Fv.Str(t.StartedAt) — jetzt als ISO-String gespeichert.Fix Web:
useCollection.ts: normalizeDoc() wandelt alle Timestamp-Objekte in ISO-Strings um, bevor Daten an React übergeben werden. Damit funktioniert auch zukünftiger Code robust.
// useCollection.ts — normalizeDoc()
snapshot.docs.map((d) => normalizeDoc<T>({ id: d.id, ...d.data() }))
// Wandelt Firestore Timestamp → ISO-String:
v instanceof Timestamp ? v.toDate().toISOString() : v
Debug-Checkliste — "Keine Daten"
- 🔲Eingeloggt? Preferences prüfen:
user_idundcompany_idmüssen gesetzt sein. Nach frischem Login:SetCompanyId(userId)inLoginViewModel. - 🔲Firestore-Pfad korrekt? Daten in Firestore müssen unter
companies/{uid}/customersetc. liegen. Web-App und MAUI-App müssen dieselbe Struktur verwenden! - 🔲Token gültig?
GetValidTokenAsync()— wenn Token abgelaufen UND kein RefreshToken → gibt null zurück → alle Requests silent fail. Fix: neu einloggen. - 🔲Firestore Security Rules? Erlauben authenticated User Lesezugriff auf
/companies/{uid}/**? Test im Firebase Console → Firestore → Rules. - 🔲Netzwerk? Debug-Logs:
GetCollection customers → 200, 0 docs= Netz OK aber Collection leer.GetCollection customers → 403= Security Rules.GetCollection customers → 404= Pfad falsch. - 🔲Emulator? Auf iOS-Simulator: Connectivity gibt manchmal
Unknownzurück. Bug #2 behebt das. Trotzdem auf Device testen. - 🔲CheckPrerequisites() Fehler sichtbar? Toast-Meldung erscheint kurz bei Fehler. In Konsole nach
[Warning] GetCollectionsuchen. - 🔲Web-App: Timestamp-Crash? Fehlermeldung
startedAt.startsWith is not a function→ ein Datumsfeld wird als FirestoreTimestamp-Objekt geliefert. Fix:normalizeDoc()inuseCollection.tskonvertiert alle Timestamps. MAUI-Seite:startedAtmuss alsFv.Str()gespeichert werden (nichtFv.Ts()). - 🔲Services/Tätigkeiten leer?
LocalProject.ServiceIdsJsonmuss ein valides JSON-Array enthalten (["id1","id2"]). Leerer String oder null → alle Services werden angezeigt (Fallback). Prüfen in Firestore Console: FeldserviceIdsJsonin den Projekt-Dokumenten. - 🔲Diagnose-Panel verwenden! (v2.2) In den Einstellungen → Sektion "Diagnose & Verbindung" → zeigt UserId, CompanyId, Token-Ablauf, Netzwerkstatus. Button "Verbindung testen" macht einen echten Firestore-Call und zeigt den HTTP-Status. Damit sofort sehen ob Token OK ist.
- 🔲welcome_seen Bug (v2.1 → v2.2 fix): Vor v2.2 wurde die
OnboardingSeen-Preference nach dem Login NIE gesetzt (falscher Preference-Key"welcome_seen"stattPrefKeys.OnboardingSeen). Fix: LoginViewModel setzt jetztPreferences.Set(PrefKeys.OnboardingSeen, true)nach erfolgreichem Login.
Logging — Was wo erscheint
Alle Services verwenden ILogger<T>. In DEBUG: Ausgabe via builder.Logging.AddDebug() im IDE-Output-Window sichtbar.
Wichtige Log-Zeilen
| Level | Message | Was |
|---|---|---|
| DEBUG | GetCollection customers: 5 docs | Erfolgreicher Request, 5 Dokumente geladen |
| WARN | GetCollection customers → 403: {error} | Security Rules blockieren den Zugriff |
| WARN | GetCollection customers → 404: {error} | Pfad existiert nicht (leere Collection oder falscher Pfad) |
| WARN | GetCollection customers → 401 | Token ungültig/abgelaufen |
| WARN | Token refresh failed | RefreshToken abgelaufen → neu einloggen |
| WARN | SignIn failed: {error} | Login-Fehler mit Firebase-Fehlermeldung |
| ERROR | LoadCollection failed: {Col} | Exception beim HTTP-Request |
| ERROR | Save failed: {Col}/{Id} | Exception beim Schreiben |
Logs anzeigen
// Visual Studio: Debug Output Window (Dropdown: "Debug")
// Rider: Run → Debug Output
// Android: adb logcat -s "myBusiness"
// iOS Simulator: Console.app → Filter "myBusiness"
Neue Features — v2.1
1. Kunden — Offene Posten
CustomerDetailViewModel lädt beim Öffnen eines Kunden parallel Rechnungen und Angebote und filtert auf offene Statuswerte.
| Property | Typ | Was |
|---|---|---|
OpenInvoices | ObservableCollection<LocalInvoice> | Status: draft, sent, overdue |
OpenQuotes | ObservableCollection<LocalQuote> | Status: draft, sent |
OpenItemCount | int | Summe beider Listen — für Badge im XAML |
HasOpenItems | bool | Für IsVisible-Binding |
XAML: Tab "Offene Posten" mit Zähler-Badge zeigen, wenn OpenItemCount > 0.
2. Projekte — Kunden-Filter
ProjectsViewModel hält die vollständige Liste intern (_allProjects) und exponiert FilteredProjects gefiltert nach SelectedCustomerFilter.
// Filter setzen (XAML: Picker mit Customers-Liste)
SelectedCustomerFilter = customer; // → Projects wird neu befüllt
// Filter zurücksetzen
ClearFilterCommand.Execute(null);
Kein extra Firestore-Request: Filter funktioniert rein in-memory auf der geladenen Liste.
3. Zeiterfassung — Tätigkeiten (Services) pro Projekt
TimeTrackingViewModel lädt alle Tätigkeiten aus dem Katalog in _allServices. Sobald der User ein Projekt auswählt, wird Services automatisch gefiltert:
// partial void wird automatisch von CommunityToolkit aufgerufen
partial void OnSelectedProjectChanged(LocalProject? value)
{
SelectedService = null;
var ids = JsonSerializer.Deserialize<List<string>>(value.ServiceIdsJson);
Services = new(..._allServices.Where(s => ids.Contains(s.Id)));
}
| Property | Was |
|---|---|
Services | Gefilterte Tätigkeit-Liste (nur die dem Projekt zugewiesenen) |
HasProjectServices | Services.Count > 0 — für IsVisible des Tätigkeit-Pickers |
SelectedService | Gewählte Tätigkeit — wird beim Projekt-Wechsel auf null gesetzt |
Beim Stopp des Timers: ServiceId = SelectedService?.Id und HourlyRate = SelectedService?.DefaultHourlyRate werden im TimeEntry gespeichert.
IsVisible="{Binding HasProjectServices}" nur anzeigen wenn Tätigkeiten verfügbar sind. Picker an SelectedService binden.4. Rechnungen — Zeiten importieren
InvoiceDetailViewModel lädt beim Öffnen auch Zeiteinträge, Projekte und Tätigkeiten. Sobald ein Kunde ausgewählt ist, werden importierbare Einträge berechnet.
| Property / Command | Was |
|---|---|
ImportableEntries | Abgeschlossene Zeiteinträge der Kunden-Projekte (isRunning=false) |
HasImportableEntries | Für IsVisible-Binding des Import-Buttons |
ShowImportPanel | Toggle für das Import-Panel |
ToggleImportPanelCommand | Öffnet/schließt das Panel |
ImportTimeEntriesCommand | Gruppiert alle ImportableEntries nach Tätigkeit → fügt als LineItems ein |
Gruppirungs-Logik: Einträge werden nach ServiceId (Fallback: ProjectId) gruppiert. Pro Gruppe entsteht eine LineItem:
description = $"{tätigkeitsName} ({Stunden}h {Minuten}m)"
quantity = Gesamtminuten / 60.0 // z.B. 2.5
unitPrice = DefaultHourlyRate der Tätigkeit
unit = "Std."
ImportTimeEntriesCommand importiert alle ImportableEntries ohne Selektion (MAUI-vereinfachte Version). Die Web-App bietet Checkbox-Selektion pro Eintrag.Neue Features — v2.2
1. Tablet-Layout — Persistente Sidebar-Navigation
Ab Fensterbreite ≥ 768 dp wechselt die App automatisch von der Bottom-TabBar (Phone) zur linken Sidebar-Navigation (Tablet/Desktop).
| Datei | Änderung |
|---|---|
AppShell.xaml | Shell.FlyoutContent mit 11 Navigations-Items (Border+TapGestureRecognizer), Shell.FlyoutHeader mit Branding, x:Name="MainTabBar" auf dem TabBar-Element |
AppShell.xaml.cs | OnHandlerChanged(): subscribed Window.SizeChanged → UpdateAdaptiveLayout() setzt FlyoutBehavior.Locked + MainTabBar.IsVisible=false auf ≥ 768dp |
Styles.xaml | Neue Styles: SidebarItem (Border) und SidebarItemLabel (Label) |
Aktives Element hervorheben
AppShell.Navigated-Event → UpdateActiveSidebarItem(location): Sucht den passenden Border per FindByName() und setzt BackgroundColor auf Primary @12% Opacity (hell) / @18% Opacity (dunkel).
// Breite-Trigger in AppShell.xaml.cs
private void UpdateAdaptiveLayout()
{
var isTablet = (Window?.Width ?? 0) >= 768;
FlyoutBehavior = isTablet ? FlyoutBehavior.Locked : FlyoutBehavior.Disabled;
MainTabBar.IsVisible = !isTablet;
}
GoToAsync(Routes.XYZ) — gleiche Routen wie MorePage und MoreViewModel. FlyoutBehavior.Locked = permanent sichtbar (kein Hamburger-Button). FlyoutBehavior.Disabled = komplett ausgeblendet.2. Diagnose-Panel in SettingsPage
SettingsViewModel wurde um Diagnose-Eigenschaften erweitert. Die SettingsPage zeigt jetzt eine "Diagnose & Verbindung" Sektion am Ende.
| Property | Typ | Was |
|---|---|---|
DiagUserId | string | UserId aus Preferences.Get(PrefKeys.UserId) |
DiagCompanyId | string | CompanyId aus Preferences.Get(PrefKeys.CompanyId) |
DiagTokenExpiry | string | Ablaufzeit formatiert: "Läuft ab in X min" oder "ABGELAUFEN" |
DiagConnectivity | string | Connectivity.NetworkAccess.ToString() |
DiagApiStatus | string | Ergebnis des letzten Verbindungstests (✅/⚠️/❌) |
DiagTesting | bool | True während Test läuft (deaktiviert Button) |
TestConnectionCommand | IAsyncRelayCommand | Prüft Voraussetzungen → Token → ruft GetSettingsAsync() auf |
RefreshDiagnostics() wird automatisch am Ende von LoadAsync() aufgerufen.
Konstruktor: SettingsViewModel(SettingsService, FirebaseAuthService, DataService) — DataService per DI injiziert (bereits als Singleton registriert, kein MauiProgram-Update nötig).
3. Bug-Fix: welcome_seen Preference-Key
In LoginViewModel.LoginAsync(): Der bisher verwendete Hardcode-Key "welcome_seen" wurde durch PrefKeys.OnboardingSeen ersetzt. Außerdem wird nach erfolgreichem Login Preferences.Set(PrefKeys.OnboardingSeen, true) gesetzt, damit der Welcome-Screen nur einmalig erscheint.
Tablet-Layout — Architektur-Details
Adaptive Navigation: Phone vs. Tablet
| Modus | Breite | FlyoutBehavior | TabBar | Navigation |
|---|---|---|---|---|
| Phone | < 768 dp | Disabled | Sichtbar (5 Tabs) | Tabs: Dashboard, Kunden, Projekte, Zeit, Mehr |
| Tablet | ≥ 768 dp | Locked | Ausgeblendet | Sidebar: alle 11 Menüpunkte |
Shell.FlyoutBehavior-Werte
| Wert | Verhalten |
|---|---|
Disabled | Flyout komplett deaktiviert — FlyoutContent unsichtbar, kein Hamburger |
Flyout | Flyout als Drawer (Hamburger öffnet/schließt es) |
Locked | Flyout permanent sichtbar — dient als persistente Sidebar ✓ |
Wichtig: Phone-Navigation beim Tablet-Modus
Wenn MainTabBar.IsVisible = false gesetzt ist, sind die TabBar-Seiten (Dashboard, Kunden, Projekte, Zeit) noch immer per GoToAsync(Routes.XYZ) erreichbar. Die Sidebar-Buttons verwenden dieselben Routes-Konstanten. Die "Mehr"-Seite (MorePage) ist auf Tablet nicht zugänglich — alle Menüpunkte sind direkt in der Sidebar.
Sidebar-Items
// Border mit x:Name für aktive Hervorhebung aus Code-behind
<Border x:Name="SidebarItemDashboard" Style="{StaticResource SidebarItem}">
<Grid ColumnDefinitions="28,*">
<Label Text="⊞" .../>
<Label Grid.Column="1" Text="Dashboard" Style="{StaticResource SidebarItemLabel}"/>
</Grid>
<Border.GestureRecognizers>
<TapGestureRecognizer Tapped="OnSidebarDashboard"/>
</Border.GestureRecognizers>
</Border>
Aktive Hervorhebung (Code-behind)
private static readonly Dictionary<string, string> RouteToSidebarName = new()
{
{ "dashboard", "SidebarItemDashboard" },
{ "customers", "SidebarItemCustomers" },
// ... alle 11 Items
};
private void UpdateActiveSidebarItem(string location)
{
// Reset vorheriges
if (_activeSidebarBorder is not null)
_activeSidebarBorder.BackgroundColor = Colors.Transparent;
foreach (var (keyword, elementName) in RouteToSidebarName)
{
if (!location.Contains(keyword)) continue;
if (FindByName(elementName) is Border border)
{
var isDark = Application.Current?.RequestedTheme == AppTheme.Dark;
border.BackgroundColor = isDark ? ActiveBgDark : ActiveBgLight;
_activeSidebarBorder = border;
}
break;
}
}
FirestoreFieldBuilder
Statische Hilfsklasse in Helpers/FirestoreFieldBuilder.cs. Wandelt jedes Local-Model in ein Dictionary<string, FirestoreValue> für den PATCH-Request um.
Wichtige Regel: String vs. Timestamp
Datum-/Zeitfelder die von der Web-App als String gelesen werden, dürfen NICHT als
Fv.Ts() gespeichert werden.Das Firebase Web SDK gibt
timestampValue als Timestamp-Objekt zurück (nicht als String) — das bricht alle String-Operationen in der Web-App.Regel:
Fv.Ts() nur für updatedAt/createdAt-Meta-Felder verwenden. Alle app-logischen Datums-Strings (startedAt, endedAt, date, etc.) als Fv.Str() speichern.| Methode | Felder |
|---|---|
ForCustomer() | type, name, email, phone, notes, street, postalCode, city, country, contactsJson, updatedAt |
ForProject() | customerId, title, description, status, serviceIdsJson, budgetHours?, fixedPrice?, updatedAt |
ForTimeEntry() | customerId, projectId, serviceId, startedAt (Str!), endedAt, isRunning, notes, durationMinutes?, hourlyRate?, updatedAt |
ForInvoice() | number, customerId, projectId, date, dueDate, status, taxRate, totalNet, totalGross, currency, notes, quoteId, lineItemsJson, updatedAt |
ForQuote() | number, customerId, projectId, date, validUntil, status, taxRate, totalNet, totalGross, currency, notes, invoiceId, lineItemsJson, updatedAt |
ForSettings() | name, street, postalCode, city, country, logoUrl, taxRate, currency, language, invoicePrefix, quotePrefix, nextInvoiceNumber, nextQuoteNumber, paymentDueDays |
Neue Features — v2.3
Auth-Fehler werden jetzt korrekt propagiert. Dashboard zeigt Stunden/Monat, offene Angebote und Budget-Warnungen. Projekte und Kunden wurden um gebuchte Stunden, Budget-Fortschritt und nicht abgerechnete Zeiteinträge erweitert. Filter wurden zu Projekten und Kunden hinzugefügt. Alle Dialoge / Popups sind deutlich größer.
1. Kritischer Fix — Auth-Fehler-Propagierung
Bisher wurden Auth-Fehler (abgelaufenes Token, kein Token) still verschluckt. Beide Stellen wurden gefixt:
| Datei | Änderung |
|---|---|
FirebaseRestService.cs | GetCollectionAsync() und GetDocumentAsync(): Wenn GetValidTokenAsync() null zurückgibt → wirft jetzt UnauthorizedAccessException("Sitzung abgelaufen...") statt leere Liste/null zurückzugeben |
DataService.cs | Äußeres try/catch in LoadCollectionAsync<T>() entfernt — Exceptions propagieren jetzt zum ViewModel. Inneres per-Dokument try/catch für Mapper-Fehler bleibt erhalten. |
DashboardViewModel.cs, ProjectsViewModel.cs | Fangen UnauthorizedAccessException in LoadAsync() und rufen ShowErrorAsync() auf |
// FirebaseRestService.cs — vorher
if (token is null) return [];
// FirebaseRestService.cs — nachher
if (token is null)
{
_log.LogWarning("GetCollection {Col}: kein Token — Sitzung abgelaufen", collection);
throw new UnauthorizedAccessException("Sitzung abgelaufen. Bitte melde dich neu an.");
}
2. Dashboard — Neue KPIs & Budget-Warnungen (MAUI + Web)
DashboardViewModel wurde um folgende Properties erweitert:
| Property | Typ | Beschreibung |
|---|---|---|
MonthMinutes | int | Summe aller Zeiteinträge im laufenden Monat (in Minuten) |
MonthHoursDisplay | string | Formatiert als "Xh Ym" |
OpenQuoteCount | int | Anzahl Angebote mit Status "Offen" |
BudgetWarnings | ObservableCollection<ProjectBudgetInfo> | Projekte mit Budgetauslastung ≥ 80% (max. 5, sortiert nach % absteigend) |
HasBudgetWarnings | bool | BudgetWarnings.Count > 0 — für IsVisible-Binding |
public record ProjectBudgetInfo(
string Title, string CustomerName,
double BudgetHours, double UsedHours,
double PercentUsed, bool IsExceeded);
Budget-Warnung wird ausgelöst wenn: UsedHours / BudgetHours >= 0.80. In der DashboardPage.xaml wird IsExceeded per DataTrigger für rote Farbgebung genutzt.
budgetCalc.ts. Gleiche Logik, gleicher Schwellenwert auf beiden Plattformen.3. Projekte — Budget-Fortschritt & Zugewiesene Tätigkeiten (MAUI + Web)
ProjectDetailViewModel lädt jetzt parallel Zeiteinträge und Tätigkeiten (Services) des Projekts:
| Property | Typ | Beschreibung |
|---|---|---|
BookedHours | double | Summe aller Zeiteinträge des Projekts (in Stunden) |
BudgetPercent | double | 0..100, geclampt auf 100 |
HasBudget | bool | BudgetHours > 0 |
IsOverBudget | bool | BookedHours > BudgetHours |
AssignedServices | ObservableCollection<LocalService> | Tätigkeiten mit ProjectId == CurrentProject.Id |
HasAssignedServices | bool | Für IsVisible-Binding |
Für die Web-App wurde in ProjectsPage.tsx ein Budget-Fortschrittsbalken in der Projekt-Detailansicht ergänzt (grün → amber bei ≥80% → rot bei Überschreitung).
Projekt-Status-Filter
ProjectsViewModel erhielt einen SelectedStatusFilter (string?) mit [NotifyPropertyChangedFor(nameof(FilteredProjects))]. StatusOptions liefert die Liste der wählbaren Status-Werte. ClearAllFiltersCommand setzt beide Filter zurück.
4. Kunden — Nicht abgerechnete Zeiteinträge (MAUI + Web)
CustomerDetailViewModel berechnet "Nicht abgerechnete Zeiteinträge" für den Kunden:
| Property | Typ | Beschreibung |
|---|---|---|
UnbilledTimeEntries | ObservableCollection<LocalTimeEntry> | Zeiteinträge ohne InvoiceId, deren ProjectId zu einem Projekt des Kunden gehört |
UnbilledHours | double | Summe der unbezahlten Stunden |
HasUnbilledTime | bool | Für IsVisible-Binding der "Nicht abgerechnet"-Karte |
projectId, keine direkte customerId. Deshalb wird erst die Liste der Kunden-Projekte ermittelt, dann alle Einträge gefiltert, deren ProjectId in dieser Liste liegt.Kunden-Typ-Filter
In CustomersViewModel (Web) und CustomersPage.xaml (MAUI) wurde ein Typ-Filter (Alle / Firma / Person) ergänzt.
5. Größere Dialoge / Popups (Web)
In allen Web-Dialog-Komponenten wurde die maximale Breite von sm:max-w-sm/md/lg auf sm:max-w-2xl angehoben:
| Datei | Betroffene Dialoge |
|---|---|
CustomersPage.tsx | Neuer Kunde, Kunde bearbeiten |
ProjectsPage.tsx | Neues Projekt, Projekt bearbeiten |
PaymentsPage.tsx | Neue Zahlung, Zahlung bearbeiten |
TimeTrackingPage.tsx | Neuer Zeiteintrag, Zeiteintrag bearbeiten |
MAUI: Popup-Größe wird über HeightRequest/WidthRequest auf dem RgPopupPage-Container gesteuert.
Konstanten
FirebaseConfig
ApiKey | AIzaSyBTU10a3_... |
ProjectId | mytime-7c3cd |
BaseUrl | Firestore Docs URL |
AuthUrl | identitytoolkit.googleapis.com/v1 |
PrefKeys
UserId | "user_id" |
CompanyId | "company_id" |
IdToken | "id_token" |
RefreshToken | "refresh_token" |
TokenExpiry | "token_expiry" |
Theme | "app_theme" |
PrimaryColor | "primary_color" |
Routes
Login | "//login" |
Dashboard | "//main/dashboard" |
Customers | "//main/customers" |
Invoices | "//main/invoices" |
Settings | "//main/settings" |
DI-Registrierung (MauiProgram.cs)
Services (Singleton)
s.AddSingleton<HttpClient>();
s.AddSingleton<FirebaseAuthService>(); // auth + token management
s.AddSingleton<FirebaseRestService>(); // HTTP wrapper
s.AddSingleton<DataService>(); // zentrale Datenschicht
s.AddSingleton<SettingsService>(); // thin wrapper für Settings
s.AddSingleton<PdfService>(); // Syncfusion PDF generation
s.AddSingleton<AppShell>(); // Shell als Singleton
ViewModels & Views (Transient)
Alle ViewModels und Views als AddTransient<T>() — neue Instanz bei jeder Navigation.
Auto-resolvierbar (kein explizites Register nötig)
ILogger<T> wird vom MAUI-Hosting-Framework automatisch bereitgestellt, sobald builder.Logging konfiguriert ist.
DatabaseService, SyncService — werden für offline-Erweiterung aufgehoben, sind nicht in DI registriert und werden von keinem ViewModel genutzt.App-Start-Flow
App.OnStart()
└─ NavigateInitialAsync() (200ms Delay, dann Main Thread)
├─ IsLoggedIn (UserId in Preferences)?
│ → ja: GoToAsync("//main/dashboard")
│ → DashboardViewModel.LoadAsync() via EventToCommandBehavior
│ → AppShell.OnHandlerChanged(): UpdateAdaptiveLayout() (Sidebar vs. TabBar)
│
└─ nein: PrefKeys.OnboardingSeen in Preferences?
→ ja: GoToAsync("//login")
→ nein: GoToAsync("//onboarding")
Login (LoginViewModel.LoginAsync):
SignInAsync() → SetCompanyId(userId) → Preferences.Set(PrefKeys.OnboardingSeen, true)
→ GoToAsync(Dashboard oder Welcome je nach OnboardingSeen)
FirebaseRestService.GetCollectionAsync() wirft seit v2.3 eine UnauthorizedAccessException wenn der Token fehlt — anstatt still eine leere Liste zurückzugeben. ViewModels fangen diese Exception und zeigen dem User eine Fehlermeldung an. Diagnose-Panel in Einstellungen → "Verbindung testen" bleibt als zusätzliches Diagnosetool verfügbar.Neue Features — v2.4
Benutzerprofil mit Anzeigename + Profilbild. Avatar-Bubble in Sidebar (Web + MAUI) mit Dropdown-Menü. Info-Seite mit App-Version. Kritischer Timestamp-Bug in Produktion behoben.
1. Benutzerprofil (Web + MAUI)
Profil wird in Firestore unter companies/{companyId}/profile/main gespeichert. Felder: displayName, photoUrl.
| Plattform | Datei | Beschreibung |
|---|---|---|
| Web | features/profile/useProfile.ts | Hook: liest/schreibt Profil + Upload zu Firebase Storage |
| Web | features/profile/ProfilePage.tsx | Formular: Anzeigename, E-Mail (read-only), Foto-Upload |
| MAUI | ViewModels/ProfileViewModel.cs | LoadAsync / SaveAsync / PickPhotoAsync + Storage-Upload |
| MAUI | Views/ProfilePage.xaml | Avatar-Kreis, Name-Entry, E-Mail read-only, Speichern-Button |
Foto wird in Firebase Storage unter companies/{companyId}/profile/photo abgelegt. Download-URL wird zurück in Firestore + Preferences gecacht.
2. Avatar-Sidebar + Dropdown-Menü (Web + MAUI)
Unterer Bereich der Sidebar zeigt Avatar (Foto → Initialen → "?") + Name/E-Mail.
| Plattform | Datei | Verhalten |
|---|---|---|
| Web | app/AppShell.tsx | DropdownMenu mit: Profil, Einstellungen, Info, Abmelden |
| MAUI | AppShell.xaml.cs → RefreshAvatarFromPreferences() | Liest Preferences-Cache, zeigt Foto oder Initialen. OnAvatarTapped → DisplayActionSheet |
// MAUI — Avatar-Refresh nach Login (LoginViewModel.cs)
Preferences.Set(PrefKeys.UserEmail, Email);
if (Shell.Current is AppShell appShell)
appShell.RefreshAvatarFromPreferences();
3. Info-Seite + App-Version
Zentrale Versionskonstanten in Constants/AppConstants.cs → AppInfo (MAUI) und shared/lib/appVersion.ts (Web).
| Konstante | Wert |
|---|---|
| Version | 2.5.0 |
| AppName | myBusiness |
| Developer | GellClan Software Products |
Web: Info wird als Dialog im AppShell-Dropdown angezeigt. MAUI: eigene Views/InfoPage.xaml (Route: info).
4. Bugfix — startedAt.startsWith Timestamp-Fehler
Firestore gibt Timestamp-Felder manchmal als Objekt {seconds, nanoseconds} zurück statt als ISO-String — wenn MAUI-App geschrieben hat oder nach Chunk-Grenze. Betraf DashboardPage.tsx in Produktion.
Fix in shared/hooks/useCollection.ts: Duck-Typing-Fallback in toIsoIfTimestamp():
function toIsoIfTimestamp(v: unknown): unknown {
if (v instanceof Timestamp) return v.toDate().toISOString();
// Duck-type für cross-chunk Szenarien
if (v !== null && typeof v === 'object' && 'seconds' in v && 'nanoseconds' in v) { ... }
return v;
}
Zusätzlich: defensive typeof e.startedAt === 'string' Guards in DashboardPage.tsx.
Neue Features — v2.5
Zeiteinträge wissen jetzt ob/wann sie verrechnet wurden. Verrechnung passiert automatisch bei Invoice-Erstellung (Import) und bei Tätigkeitsbericht-Transfer. Zeitübersicht zeigt Badge + Filter. MAUI-Initialen-Bug behoben.
1. TimeEntry — neue Billing-Felder
Datenmodell-Erweiterung in shared/types/models.ts (Web) und Models/LocalModels.cs (MAUI):
| Feld | Typ | Beschreibung |
|---|---|---|
invoiceId? | string | Rechnungsnummer wenn über Invoice verrechnet |
activityReportId? | string | Bericht-ID wenn über Tätigkeitsbericht verrechnet |
billedAt? | string (ISO) | Datum der Verrechnung |
MAUI: DataService.cs MapTimeEntry und FirestoreFieldBuilder.cs ForTimeEntry wurden entsprechend erweitert.
2. Automatische Markierung bei Verrechnung (Web)
Zwei Stellen setzen die Billing-Felder automatisch per Firestore writeBatch:
| Wo | Trigger | Felder gesetzt |
|---|---|---|
InvoicesPage.tsx → handleCreate() | Neue Rechnung speichern nach Zeiten-Import | invoiceId (= Rechnungsnummer), billedAt |
ActivityReportsPage.tsx → transferToInvoice() | Bericht → Rechnung übertragen | activityReportId (= Report-ID), billedAt |
InvoiceForm.tsx verfolgt importierte Entry-IDs in importedEntryIds-State und übergibt sie via erweiterter onSubmit(data, importedTimeEntryIds[])-Signatur. Importierbare Einträge schließen bereits verrechnete Einträge aus (!e.invoiceId && !e.activityReportId).
3. Zeitübersicht — Badge + Filter (Web + MAUI)
Web (TimeTrackingPage.tsx):
- Filter-Chips: Alle | Offen | Verrechnet — filtern
completedEntriesin-place - Badge "✓ Verrechnet" (grün) auf Einträgen mit
invoiceIdoderactivityReportId
MAUI (TimeTrackingViewModel.cs + TimeTrackingPage.xaml):
BillingFilterObservableProperty ("all" | "open" | "billed")ApplyBillingFilter()filtert_allEntries→EntriesSetBillingFilterCommand(string)— gebunden an 3 Buttons in der View- Badge "✓ Verrechnet" via
IsNotNullOrEmptyConverteraufInvoiceId/ActivityReportId
4. B2-Fix — MAUI ProfilePage Initialen
ProfileViewModel.cs: Neues berechnetes Property AvatarInitials — gibt ersten Buchstaben von DisplayName oder UserEmail zurück:
public string AvatarInitials =>
!string.IsNullOrEmpty(DisplayName) && DisplayName.Any(char.IsLetterOrDigit)
? DisplayName.First(char.IsLetterOrDigit).ToString().ToUpperInvariant()
: !string.IsNullOrEmpty(UserEmail) && UserEmail.Any(char.IsLetterOrDigit)
? UserEmail.First(char.IsLetterOrDigit).ToString().ToUpperInvariant()
: "?";
ProfilePage.xaml: Label-Binding von IsNullOrEmptyConverter auf AvatarInitials umgestellt. [NotifyPropertyChangedFor]-Attribute auf _displayName und _userEmail hinzugefügt.
Neue Features — v2.5 (P2 – P4)
Projekt→Angebot/Rechnung Import (Services + Zeiteinträge), Kalender-Ansicht, Payments v2 (Soll/Haben + Kategorien + Berichte) — Web & MAUI.
P2 — Projekt-Import in Angebot & Rechnung (Web + MAUI)
Angebote und Rechnungen können Positionen direkt aus dem Projekt-Kontext importieren:
| Tab | Quelle | Ergebnis |
|---|---|---|
| Tätigkeiten | ServiceCatalog-Einträge des Projekts | Je 1 Zeile mit Menge 1 + defaultHourlyRate |
| Zeiteinträge | Offene (unberechnete) TimeEntries des Kunden | Gruppiert nach Projekt, Minuten → Stunden |
Web: QuoteForm.tsx und InvoiceForm.tsx erweitert mit aufklappbarer "Projektdaten importieren"-Sektion und zwei Tabs. QuotesPage.tsx / InvoicesPage.tsx übergeben timeEntries + services Props.
MAUI: QuoteDetailViewModel.cs + InvoiceDetailViewModel.cs — neue Properties ImportableServices, ImportableEntries, ImportTab, HasImportData. Methoden ImportServicesCommand + ImportTimeEntriesCommand. XAML: Import-Panel mit Service-Zähler und Gruppen-Import-Button. Sichtbarkeit via IsPositiveConverter (neu in Converters.cs).
// IsPositiveConverter — für ObservableCollection.Count-Binding
public class IsPositiveConverter : IValueConverter {
public object Convert(object? value, ...) => value is int n && n > 0;
}
P3 — Kalender-Ansicht in der Zeiterfassung (Web + MAUI)
Zeiteinträge können in einer Wochenkalender-Ansicht visualisiert werden.
| Plattform | Implementierung | Details |
|---|---|---|
| Web | CalendarView.tsx (neu) | Pure-React, kein Lib-Dependency. 24h × 48px Grid, absolute positioning nach Startminuten. Projektfarb-Palette (8 Farben). Prev/Next/Today Navigation. |
| MAUI | TimeCalendarPage.xaml + TimeCalendarViewModel.cs (neu) | Syncfusion SfScheduler v32.1.22, View="Week". SchedulerAppointments mit Projektfarb-Palette. |
Route MAUI: Constants.Routes.TimeCalendar = "timeCalendar". Registriert in AppShell.xaml.cs + MauiProgram.cs. Kalender-Button 📅 in TimeTrackingPage.xaml.
Web: Toggle List/Kalender in TimeTrackingPage.tsx Header, viewMode State.
P4 — Payments v2: Soll/Haben (Web + MAUI)
Das Zahlungsmodul wurde auf ein vollständiges Doppelkontomodell (Einnahmen/Ausgaben) erweitert:
| Kollektion | Modell | Zweck |
|---|---|---|
payments | Payment / LocalPayment | Einnahmen (Haben-Buchungen) |
expenses | Expense / LocalExpense | Ausgaben (Soll-Buchungen) |
incomingCategories | IncomingCategory / LocalIncomingCategory | Kategorien für Einnahmen |
expenseCategories | ExpenseCategory / LocalExpenseCategory | Kategorien für Ausgaben |
MAUI — neue/geänderte Dateien
| Datei | Änderung |
|---|---|
Models/LocalModels.cs | + LocalExpense, LocalExpenseCategory, LocalIncomingCategory |
Constants/AppConstants.cs | + Collections: Expenses, ExpenseCategories, IncomingCategories; Routes: ExpenseDetail, CategoryDetail |
Services/DataService.cs | + GetExpensesAsync(), GetExpenseCategoriesAsync(), GetIncomingCategoriesAsync() + zugehörige Mapper + Einzel-Getter |
Helpers/FirestoreFieldBuilder.cs | + ForExpense(), ForExpenseCategory(), ForIncomingCategory() |
ViewModels/PaymentsViewModel.cs | Komplett neu: 4 Tabs (Einnahmen/Ausgaben/Berichte/Kategorien), Cashflow-Report, alle Navigation-Commands |
ViewModels/ExpenseDetailViewModel.cs | Neu: CRUD für Ausgaben, Kategorie-Picker via SelectedCategory |
ViewModels/CategoryDetailViewModel.cs | Neu: Shared ViewModel für Einnahmen- und Ausgaben-Kategorien (categoryType QueryProperty) |
Views/PaymentsPage.xaml | Komplett neu: 4-Tab-Layout via IndexEqualsConverter; Einnahmen/Ausgaben-Listen, KPI-Cards, Monats-Tabelle, Kategorie-Verwaltung |
Views/ExpenseDetailPage.xaml | Neu: Beschreibung, Betrag, Datum, Kategorie-Picker, Methode, Notizen |
Views/CategoryDetailPage.xaml | Neu: Name + Farbe (Hex) mit Live-BoxView-Preview |
Web — geänderte Dateien
| Datei | Änderung |
|---|---|
shared/types/models.ts | + Expense, ExpenseCategory, IncomingCategory Interfaces |
shared/hooks/usePayments.ts | + useExpenses(), useExpenseCategories() Hooks |
features/payments/PaymentsPage.tsx | Komplett neu: 4 Tabs, CategoryManager<T> generische Komponente, monatlicher Cashflow |
Berichte-Logik (MAUI PaymentsViewModel.BuildReport())
// Letzte 6 Monate mit laufendem Saldo
for (int i = 5; i >= 0; i--) {
var key = now.AddMonths(-i).ToString("yyyy-MM");
var inc = payments.Where(p => p.Date.StartsWith(key)).Sum(p => p.Amount);
var exp = expenses.Where(e => e.Date.StartsWith(key)).Sum(e => e.Amount);
running += inc - exp;
rows.Add(new MonthlyReportRow(label, inc, exp, running));
}
Neue Features — v2.6 (F2 – F4)
Web-only Release. Alle Änderungen betreffen ausschließlich die React/Firebase Web-App.
F2 — Kunden-Steuerdaten
Kunden können jetzt UID-Nummer (USt-IdNr.) und Steuernummer hinterlegen. Diese erscheinen automatisch in der Adresszeile auf Rechnungen, Angeboten und Tätigkeitsberichten.
| Datei | Änderung |
|---|---|
shared/types/models.ts | Customer: + vatId?: string, taxNumber?: string |
features/customers/CustomerForm.tsx | Neuer Abschnitt „Steuerdaten" mit UID-Nummer + Steuernummer |
features/invoices/InvoicePDF.tsx | Adressblock zeigt customer.vatId und customer.taxNumber |
features/quotes/QuotePDF.tsx | Analog InvoicePDF |
features/activity-reports/ActivityReportPDF.tsx | Analog InvoicePDF |
F3 — Firmen-Stammdaten (Kontakt, Steuer, Bank)
CompanySettings wurde um 9 Felder erweitert. Die Einstellungsseite hat drei neue Karten erhalten.
| Feld | Typ | Verwendung |
|---|---|---|
email | string? | Header-Block + PDF-Footer |
phone | string? | Header-Block + PDF-Footer |
website | string? | PDF-Footer |
vatId | string? | Header-Block + PDF-Footer: UID: ATU… |
taxId | string? | Einstellungen (kein PDF-Druck) |
kleinunternehmer | boolean? | MwSt.-Zeile ersetzt durch Hinweis; § 6-Disclaimer im PDF |
closingText | string? | Schlusstext auf allen PDFs nach Notizen |
iban / bic / bankName | string? | Zahlungsinfo-Box auf Rechnung; PDF-Footer |
Kleinunternehmer-Logik auf PDFs
const isKlein = company.kleinunternehmer === true;
// MwSt.-Zeile zeigt "Keine MwSt. (Kleinunternehmer)" statt Betrag
// Gesamtbetrag = totalNet (nicht totalGross)
// Disclaimer: "Gemäß § 6 Abs. 1 Z 27 UStG wird keine Umsatzsteuer berechnet."
Geändertes PDF-Footer-Format
Footer wird dynamisch aus allen ausgefüllten Feldern zusammengestellt (· getrennt):
Musterfirma GmbH · Musterstraße 1 · 1010 Wien · office@firma.at · +43 1 234 · UID: ATU12345678
| Datei | Änderung |
|---|---|
shared/types/models.ts | CompanySettings + 9 neue Felder |
shared/hooks/useSettings.ts | DEFAULT_SETTINGS aktualisiert |
features/settings/SettingsPage.tsx | Neue Cards: Kontaktdaten, Steuer & Rechtliches, Bankverbindung |
features/invoices/InvoicePDF.tsx | Dynamischer Footer, IBAN-Block, Kleinunternehmer-Support |
features/quotes/QuotePDF.tsx | Analog InvoicePDF |
features/activity-reports/ActivityReportPDF.tsx | Analog InvoicePDF |
F4 — PDF Report Designer (Template-Auswahl + Akzentfarbe)
Benutzer können in den Einstellungen ein PDF-Layout und eine Akzentfarbe wählen. Das gewählte Design wird auf alle erzeugten PDFs angewendet.
Templates
| Template | Beschreibung |
|---|---|
classic (Standard) | Weißer Hintergrund, hellgrauer Tabellenkopf (#f0f0f0), schwarzer Text |
modern | Farbiger Header-Banner (volle Breite, Akzentfarbe), weißer Text im Header, farbiger Tabellenkopf |
minimal | Keine Hintergründe, nur Trennlinien unter Tabellenköpfen, sehr clean |
Neue Felder in CompanySettings
| Feld | Typ | Default |
|---|---|---|
pdfTemplate | 'classic' | 'modern' | 'minimal' | 'classic' |
pdfAccentColor | string (Hex) | '#4f46e5' |
Architektur
| Datei | Zweck |
|---|---|
shared/lib/pdfTheme.ts | Zentraler Theme-Helper. getPdfTheme(company) gibt ein PdfTheme-Objekt zurück mit tableHeadBg, tableHeadColor, headerBg, accentLight, dividerColor etc. |
features/settings/SettingsPage.tsx | Neue Card „PDF Design": 3 Template-Buttons + nativer type="color" Picker |
InvoicePDF / QuotePDF / ActivityReportPDF | Importieren getPdfTheme(), wenden PdfTheme-Werte auf Styles an; modern rendert negativen Top-Margin Banner |
Modern-Banner Implementierung
// Negativer Margin zieht den Banner bis zum Seitenrand
modernBanner: {
marginTop: '-30mm',
marginLeft: '-20mm',
marginRight:'-20mm',
padding: '10mm 20mm 8mm 20mm',
backgroundColor: theme.headerBg
}
TypeScript-Hinweis: @react-pdf/renderer akzeptiert kein false in Style-Arrays. Statt condition && {style} immer condition ? {style} : undefined oder ternär verwenden.
myBusiness Developer Documentation · v2.6 · April 2026 · Für internen Entwickler-Einsatz