Compare commits

...

57 Commits

Author SHA1 Message Date
b551365fb5 Merge pull request 'Membership-System und Bug Fixing (inkl. Nutrition)' (#8) from develop into main
Some checks failed
Deploy Production / deploy (push) Failing after 1s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Reviewed-on: #8
2026-03-21 08:48:56 +01:00
0ab13c282e docs: update CLAUDE.md for v9c completion
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Changes:
- Version: v9c-dev → v9c (komplett)
- Added nutrition module enhancements (manual entry, edit/delete, filters, import history)
- Documented bug fixes (BUG-001, BUG-002)
- Moved open items to v9d
- Two-level tab layout documented

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:47:04 +01:00
1f1100c289 refactor: restructure nutrition page with two-level tabs
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Layout changes:
- Input tabs at top: ✏️ Einzelerfassung (default) | 📥 Import
- Single entry form shown by default (was hidden in data tab)
- Import panel + history only visible in Import tab
- Analysis section below (unchanged): OverviewCards + Analysis tabs

Benefits:
- Cleaner separation of input methods vs analysis
- Manual entry more discoverable (was buried in data tab)
- Import history only shown when relevant
- Reduces clutter on initial view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:43:55 +01:00
02ca9772d6 feat: add manual nutrition entry form with auto-detect
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Features:
- Manual entry form above data list
- Date picker with auto-load existing entries
- Upsert logic: creates new or updates existing entry
- Smart button text: "Hinzufügen" vs "Aktualisieren"
- Prevents duplicate entries per day
- Feature enforcement for nutrition_entries

Backend:
- POST /nutrition - Create or update entry (upsert)
- GET /nutrition/by-date/{date} - Load entry by date
- Auto-detects existing entry and switches to UPDATE mode
- Increments usage counter only on INSERT

Frontend:
- EntryForm component with date picker + macros inputs
- Auto-loads data when date changes
- Shows info message when entry exists
- Success/error feedback
- Disabled state while loading/saving

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:37:01 +01:00
873f08042e feat: add date filter to nutrition data tab
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s
Added dropdown filter with options:
- Letzte 7 Tage
- Letzte 30 Tage (default)
- Letzte 90 Tage
- Letztes Jahr
- Alle anzeigen

Shows filtered count vs total count in title.
Handles large datasets (7+ years) efficiently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:33:03 +01:00
0f072f4735 feat: add nutrition entry editing and import history
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Features:
- Import history panel showing all CSV imports with date, count, and range
- Edit/delete functionality for nutrition entries (inline editing)
- New backend endpoints: GET /import-history, PUT /{id}, DELETE /{id}

UI Changes:
- Import history displayed under import panel
- "Daten" tab now has edit/delete buttons per entry
- Inline form for editing macros (kcal, protein, fat, carbs)
- Confirmation dialog for deletion

Backend:
- nutrition.py: Added import_history, update_nutrition, delete_nutrition endpoints
- Groups imports by created date to show history

Frontend:
- NutritionPage: New DataTab and ImportHistory components
- api.js: Added nutritionImportHistory, updateNutrition, deleteNutrition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:26:47 +01:00
d833a60ad4 fix: [BUG-002] add missing Daten tab to show nutrition entries
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Problem: Imported nutrition data not visible in UI
Root Cause: NutritionPage only had analysis tabs, no raw data view
Solution: Added "Daten" tab with entries list showing date, kcal, macros
Tested: Entries now visible after CSV import

Closes: BUG-002

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:06:01 +01:00
4d9c59ccf7 fix: [BUG-001] TypeError in nutrition_weekly endpoint
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Problem:
- /api/nutrition/weekly crashed with 500 Internal Server Error
- TypeError: strptime() argument 1 must be str, not datetime.date

Root Cause:
- d['date'] from PostgreSQL is already datetime.date object
- datetime.strptime() expects string input
- Line 156: wk=datetime.strptime(d['date'],'%Y-%m-%d').strftime('%Y-W%V')

Solution:
- Added type check before strptime()
- If date already has strftime method → use directly
- Else → parse as string first
- Works with both datetime.date objects and strings

Tested:
- /nutrition page loads without error
- Weekly aggregation works correctly
- Chart displays nutrition data

Closes: BUG-001

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:58:37 +01:00
f2f089a223 docs: add pending features and known issues tracking
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
CLAUDE.md erweitert:
- Verweis auf PENDING_FEATURES.md (ausstehende Enforcement-Items)
- Verweis auf KNOWN_ISSUES.md (Bug-Tracking)

Lokal erstellt (in .claude/):

.claude/docs/PENDING_FEATURES.md:
- Dashboard-Assistent (keine Badges)
- Import-Endpoints ohne Enforcement (Activity CSV, Nutrition CSV)
- Weitere potenzielle Limitierungen (Export-Wiederholungen, etc.)
- Implementierungs-Richtlinien für späteres Nachziehen

.claude/docs/KNOWN_ISSUES.md:
- BUG-001: Nutrition Import-Seite zeigt keine bisherigen Importe
  (Daten vorhanden in Verlauf, aber Import-Panel zeigt keine Historie)
- Technische Schulden (alte AI-Limit-Checks, deprecated export_enabled)
- Bug-Meldung-Prozess dokumentiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:55:04 +01:00
fed51453e4 docs: update CLAUDE.md with completed Phase 3+4 status
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s
Feature-Enforcement komplett:
-  Phase 1-4 alle abgeschlossen
- 11 Features mit Monitoring, UI-Badges + Blocking
- Verweis auf neue FEATURE_ENFORCEMENT.md Dokumentation

Lokale Dokumentation erstellt:
- .claude/docs/architecture/FEATURE_ENFORCEMENT.md
- Vollständiger Guide für neue Feature-Integration
- Backend + Frontend Pattern mit Beispielen
- Checkliste + Debugging-Tipps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:44:51 +01:00
ed057fe545 feat: complete Phase 4 enforcement UI for all features (frontend)
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Alle verbleibenden Screens mit proaktiver Limit-Anzeige:

- ActivityPage: Manuelle Einträge mit Badge + deaktiviertem Button
- Analysis: AI-Analysen (Pipeline + Einzelanalysen) mit Hover-Tooltip
- NutritionPage: Hat bereits Error-Handling (bulk import)

Konsistentes Pattern:
- Usage-Badge im Titel
- Button deaktiviert + Hover-Tooltip bei Limit
- "🔒 Limit erreicht" Button-Text
- Error-Handling für API-Fehler
- Usage reload nach erfolgreichem Speichern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:42:50 +01:00
4b8e6755dc feat: complete Phase 4 enforcement for all features (backend)
Alle 11 Features blockieren jetzt bei Limit-Überschreitung:

Batch 1 (bereits erledigt):
- weight_entries, circumference_entries, caliper_entries

Batch 2:
- activity_entries
- nutrition_entries (CSV import)
- photos

Batch 3:
- ai_calls (einzelne Analysen)
- ai_pipeline (3-stufige Gesamtanalyse)
- data_export (CSV, JSON, ZIP)
- data_import (ZIP)

Entfernt: Alte check_ai_limit() Calls (ersetzt durch neue Feature-Limits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:40:37 +01:00
d13c2c7e25 fix: add dashboard weight enforcement and fix hover tooltips
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Dashboard QuickWeight: Feature limit enforcement hinzugefügt
- Hover-Tooltip Fix: Button in div wrapper (disabled buttons zeigen keine nativen tooltips)
- Error handling für Dashboard weight input
- Konsistentes UX über alle Eingabe-Screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:25:47 +01:00
0f019f87a4 feat: add feature limit enforcement UI (Phase 4 Batch 1)
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Implementiert User-freundliches Limit-Feedback für Daten-Einträge:
- Button wird deaktiviert wenn Limit erreicht
- Hover-Tooltip erklärt warum ("Limit erreicht X/Y")
- Button-Text zeigt "🔒 Limit erreicht"
- Error-Handling für alle API-Fehler
- Usage-Badge wird nach Speichern aktualisiert

Betrifft: Weight, Circumference, Caliper Screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:14:34 +01:00
cf522190c6 fix: correct indentation in auth.py _check_impl function
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Behebt IndentationError in Zeile 204 der _check_impl() Funktion.
Die Funktion wurde beim Connection-Pool-Fix erstellt, hatte aber
inkonsistente Einrückungen (8 statt 4 Spaces nach der ersten Zeile).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:06:53 +01:00
329daaef1c fix: prevent connection pool exhaustion in features/usage
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
- Add optional conn parameter to get_effective_tier()
- Add optional conn parameter to check_feature_access()
- Pass existing connection in features.py loop
- Prevents opening 20+ connections simultaneously
- Fixes "connection pool exhausted" error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:02:42 +01:00
cbcb6a2a34 feat: Phase 4 Batch 1 - enable enforcement for data entries
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Weight, Circumference, Caliper now BLOCK on limit exceeded
- Raise HTTPException(403) with user-friendly message
- Show used/limit and suggest contacting admin
- Phase 2 → Phase 4 transition

Phase 4: Enforcement (Batch 1/3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:57:05 +01:00
baad096ead refactor: consolidate badge styling to CSS classes
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Move all positioning logic from inline styles to CSS
- New classes: .badge-container-right, .badge-button-layout
- All badge styling now in UsageBadge.css (single source)
- Easier to maintain and adjust globally
- Mobile responsive adjustments in one place

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:54:45 +01:00
30df150b6f refactor: make UsageBadge more subtle and better positioned
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Smaller font (0.65rem), more spacing (10px margin)
- Reduced opacity (0.6), hover effect (0.9)
- OK status now gray instead of green (less prominent)
- Position: right-aligned in headings (flex space-between)
- Buttons: badge on right side of main text, description below
- Much more discreet overall appearance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:50:12 +01:00
c59c71a1c7 feat: add UsageBadge to action buttons (Phase 3)
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
- Weight page: badge on "Eintrag hinzufügen" heading
- Settings: badges on export buttons (ZIP/JSON)
- Analysis: badges on pipeline and individual analysis titles
- Shows real-time usage status (e.g., "7/5" with red color)

Phase 3: Frontend Display complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:43:10 +01:00
405abc1973 feat: add feature usage UI components (Phase 3)
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
- Add api.getFeatureUsage() endpoint call
- Create UsageBadge component (inline indicators)
- Create FeatureUsageOverview component (Settings table)
- Add "Kontingente" section to Settings page
- Color-coded status (green/yellow/red)
- Grouped by category
- Shows reset period and next reset date

Phase 3: Frontend Display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:39:52 +01:00
d10f605d66 feat: add GET /api/features/usage endpoint (Phase 3)
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Add user-facing usage overview endpoint
- Returns all features with usage, limits, reset info
- Fully dynamic - automatically includes new features
- Phase 3: Frontend Display preparation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:32:43 +01:00
4e846605e9 docs: update CLAUDE.md - Phase 2 complete
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s
- Mark Feature-Enforcement Phase 2 as complete
- Add 4-phase model status overview
- Document feature_logger.py and JSON logging
- Update DB schema section with user_feature_usage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:43:29 +01:00
32d53b447d fix: pipeline typo and add features diagnostic script
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Fix NameError in insights.py pipeline endpoint (access -> access_calls)
- Add check_features.py diagnostic script for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:32:09 +01:00
1298bd235f feat: add structured JSON logging for all feature usage (Phase 2)
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
- Create feature_logger.py with JSON logging infrastructure
- Add log_feature_usage() calls to all 9 routers after check_feature_access()
- Logs written to /app/logs/feature-usage.log
- Tracks all usage (not just violations) for future analysis
- Phase 2: Non-blocking monitoring complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 22:18:12 +01:00
ddcd2f4350 feat: v9c Phase 2 - Backend Non-Blocking Logging (12 Endpoints)
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s
PHASE 2: Backend Non-Blocking Logging - KOMPLETT

Instrumentierte Endpoints (12):
- Data: weight, circumference, caliper, nutrition, activity, photos (6)
- AI: insights/run/{slug}, insights/pipeline (2)
- Export: csv, json, zip (3)
- Import: zip (1)

Pattern implementiert:
- check_feature_access() VOR Operation (non-blocking)
- [FEATURE-LIMIT] Logging wenn Limit überschritten
- increment_feature_usage() NACH Operation
- Alte Permission-Checks bleiben aktiv

Features geprüft:
- weight_entries, circumference_entries, caliper_entries
- nutrition_entries, activity_entries, photos
- ai_calls, ai_pipeline
- data_export, data_import

Monitoring: 1-2 Wochen Log-Only-Phase
Logs zeigen: Wie oft würde blockiert werden?
Nächste Phase: Frontend Display (Usage-Counter)

Phase 1 (Cleanup) + Phase 2 (Logging) vollständig!

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:59:33 +01:00
73bea5ee86 feat: v9c Phase 1 - Feature consolidation & cleanup migration
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
PHASE 1: Cleanup & Analyse
- Feature-Konsolidierung: export_csv/json/zip → data_export (1 Feature)
- Umbenennung: csv_import → data_import
- Auto-Migration bei Container-Start (apply_v9c_migration.py)
- Diagnose-Script (check_features.sql)

Lessons Learned angewendet:
- Ein Feature für Export, nicht drei
- Migration ist idempotent (kann mehrfach laufen)
- Zeigt BEFORE/AFTER State im Log

Finaler Feature-Katalog (10 statt 13):
- Data: weight, circumference, caliper, nutrition, activity, photos
- AI: ai_calls, ai_pipeline
- Export/Import: data_export, data_import

Tier Limits:
- FREE: 30 data entries, 0 AI/export/import
- BASIC: unlimited data, 3 AI/month, 5 export/month, 3 import/month
- PREMIUM/SELFHOSTED: unlimited

Migration läuft automatisch auf dev UND prod beim Container-Start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:57:39 +01:00
7040931816 claude.md überarbeitet
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
2026-03-20 18:22:45 +01:00
ef8008a75d docs: update CLAUDE.md and add comprehensive membership system documentation
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Updates:
- CLAUDE.md: Reflect current v9c-dev status (enforcement disabled, history working)
- CLAUDE.md: Document simple AI limit system currently active
- CLAUDE.md: Update implementation status (admin UI complete, enforcement rolled back)

New Documentation:
- docs/MEMBERSHIP_SYSTEM.md: Complete v9c architecture documentation
  - Design decisions and rationale
  - Complete database schema (11 tables)
  - Backend API overview (7 routers, 30+ endpoints)
  - Frontend components (6 admin pages)
  - Feature enforcement rollback analysis
  - Lessons learned and next steps
  - Testing strategy
  - Deployment notes
  - Troubleshooting guide

The new doc provides complete reference for:
- Feature-Registry-Pattern implementation
- Tier system architecture
- Coupon system (3 types with stacking logic)
- User-Override system
- Access-Grant mechanics
- What went wrong with enforcement attempt
- Roadmap for v9d/v9e

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:44:29 +01:00
e4f49c0351 fix: enable AI analysis history and correct pipeline scope
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Fixes two critical bugs in AI analysis storage:

1. History now works - analyses are saved, not overwritten
   - Removed DELETE statements before INSERT in insights.py
   - All analyses are now preserved per scope
   - Displayed in descending order by creation date

2. Pipeline saves under correct scope 'pipeline' instead of 'gesamt'
   - Changed scope from 'gesamt' to 'pipeline' in pipeline endpoint
   - Pipeline results now appear under correct category in history

3. Fixed pipeline appearing twice in UI
   - Filter now excludes both 'pipeline_*' and 'pipeline' from individual list
   - Pipeline only appears in dedicated section at top

Changes:
- backend/routers/insights.py: Removed DELETE, changed scope to 'pipeline'
- frontend/src/pages/Analysis.jsx: Fixed filter to exclude 'pipeline'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:35:33 +01:00
4fcde4abfb ROLLBACK: complete removal of broken feature enforcement system
All checks were successful
Deploy Development / deploy (push) Successful in 32s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Reverts all feature enforcement changes (commits 3745ebd, cbad50a, cd4d912, 8415509)
to restore original working functionality.

Issues caused by feature enforcement implementation:
- Export buttons disappeared and never reappeared
- KI analysis counter not incrementing
- New analyses not saving
- Pipeline appearing twice
- Many core features broken

Restored files to working state before enforcement implementation (commit 0210844):
- Backend: auth.py, insights.py, exportdata.py, importdata.py, nutrition.py, activity.py
- Frontend: Analysis.jsx, SettingsPage.jsx, api.js
- Removed: FeatureGate.jsx, useFeatureAccess.js

The original simple AI limit system (ai_enabled, ai_limit_day) is now active again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:19:56 +01:00
8415509f4c fix: monthly reset now updates reset_at correctly
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Critical bug: usage limits were never resetting after first month because
reset_at timestamp was not updated during ON CONFLICT UPDATE.

This caused users to stay permanently blocked after reaching monthly limit once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:14:35 +01:00
cd4d9124b0 fix: auto-apply feature fixes migration on startup
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
2026-03-20 12:58:07 +01:00
cbad50a987 fix: add missing feature check endpoint and features
Some checks failed
Build Test / lint-backend (push) Waiting to run
Build Test / build-frontend (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled
Critical fixes for feature enforcement:
- Add GET /api/features/{feature_id}/check-access endpoint (was missing!)
- Add migration for missing features: data_export, csv_import
- These features were used in frontend but didn't exist in DB

This fixes:
- "No analysis available" when setting KI limit
- Export features not working
- Frontend calling non-existent API endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:57:29 +01:00
3745ebd6cd feat: implement v9c feature enforcement system
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Backend:
- Add feature access checks to insights, export, import endpoints
- Enforce ai_calls, ai_pipeline, data_export, csv_import limits
- Return HTTP 403 (disabled) or 429 (limit exceeded)

Frontend:
- Create useFeatureAccess hook for feature checking
- Create FeatureGate/FeatureBadge components
- Gate KI-Analysen in Analysis page
- Gate Export/Import in Settings page
- Show usage counters (e.g. "3/10")

Docs:
- Update CLAUDE.md with implementation status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:43:41 +01:00
0210844522 docs: CRITICAL - document missing feature enforcement
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
⚠️ MAJOR GAP IDENTIFIED: Feature limits don't work!
- Admin UI exists to configure limits
- But actual enforcement (check_feature_access) is NOT called in endpoints
- Users can exceed limits, use disabled features

Backend TODO (CRITICAL):
- Add feature checks to insights.py (AI analysis)
- Add feature checks to exportdata.py, importdata.py
- Add feature checks to nutrition.py, activity.py (imports)
- Add feature checks to photos.py, data entry endpoints

Frontend TODO (UX):
- Implement useFeatureAccess() hook
- Create <FeatureGate> component
- Hide disabled features
- Show limit counters & upgrade prompts

Estimated work: 2-3 hours

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:25:31 +01:00
5da18de708 docs: update CLAUDE.md - v9c Phase 3 status and lessons learned
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s
- Mark Phase 3 as "MOSTLY DONE" (core features complete)
- Document all implemented admin/user pages
- Add AdminUserRestrictionsPage solution to "Bekannte Probleme"
- Detail effective value system, auto-remove redundant overrides
- List remaining v9c tasks: self-registration, trial UI, app settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:14:45 +01:00
4e592dddc5 fix: AdminUserRestrictionsPage - show effective values, auto-remove redundant overrides
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Major UX improvements:
- Display effective value in input (override if set, otherwise tier limit)
- Format NULL as "unlimited" (easy to type, no special char needed)
- Auto-remove override when value equals tier default
- "Zurück" button resets to tier default value
- Wider input field (120px) for "unlimited" text

This solves:
- User can now see and edit current effective values
- "unlimited" can be typed and saved
- Redundant overrides (value = tier default) are prevented
- No more confusion with empty fields vs actual values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:08:29 +01:00
adfa9ec139 fix: AdminUserRestrictionsPage - use same tier limits fallback as TierLimitsPage
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Use `null` (unlimited) instead of `feature.default_limit` when no
tier_limits entry exists. This fixes Selfhosted tier showing 0
instead of ∞ for features like AI analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:57:26 +01:00
85f5938d7d fix: AdminUserRestrictionsPage - use exact TierLimitsPage input system
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
- formatValue: NULL → '' (empty field with placeholder ∞)
- handleChange: Accept ONLY '∞' or 'unlimited' (no other formats)
- Input styling: Green only for '∞', empty fields normal color
- Simplified legend: Only ∞ or unlimited accepted
- Boolean features: Toggle buttons with 1/0 values
- Add package-lock.json to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:34:48 +01:00
917c8937cf feat: accept multiple formats for unlimited in user overrides
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s
User can now input unlimited with:
- "unbegrenzt" (German)
- "unlimited" (English)
- "inf"
- "999999"
- "∞" (infinity symbol)

All map to NULL (unlimited) in database.

Updated legend to show:
- "unbegrenzt, inf, 999999" = Unbegrenzt
- Clear documentation for users

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:40:56 +01:00
0c0b1ee811 fix: add missing Link import in SettingsPage
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s
Critical bug fix:
- Added missing "import { Link } from 'react-router-dom'"
- Caused Settings page to crash on render
- Route /settings now works again

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:36:00 +01:00
a27f090616 feat: add SubscriptionPage - user-facing subscription info
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
User can now view:
- Current tier (Free, Basic, Premium, Selfhosted) with icon
- Trial status and end date
- Access expiration date
- Feature limits with usage bars
- Progress indicators (green/orange/red based on usage)
- Reset period info (daily/monthly/never)

Coupon redemption:
- Input field for coupon code
- Auto-uppercase, monospace display
- Enter key support
- Success/error feedback
- Auto-refresh after redemption

Features:
- Clean card-based layout
- Visual tier badges with colors
- Progress bars for count limits
- Trial and access warnings
- Integrated in Settings page

Link added to SettingsPage:
- "👑 Abo-Status, Limits & Coupon einlösen"
- Easy access for all users

Phase 3 complete - all user-facing subscription features done!

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:31:04 +01:00
3eae7eb43f refactor: remove legacy permission system, use only feature-overrides
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
AdminPanel.jsx:
- Removed ai_enabled, ai_limit_day, export_enabled UI
- Kept only role selection (Admin/User)
- Added link to Feature-Overrides page
- Simplified perms state to only role
- Changed display to show tier and email

AdminUserRestrictionsPage.jsx:
- Removed legacy system warning
- Clean interface, no confusion

Result:
- ONE consistent permission system (feature-overrides)
- Clear separation: role in AdminPanel, limits in Feature-Overrides
- No data migration needed (no old users exist)
- System ready for production

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 08:51:49 +01:00
b1a1925360 fix: move buttons to header and add legacy system warning
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Button position fixed:
- Moved from fixed bottom bar to header (like TierLimitsPage)
- No longer covers bottom navigation menu
- Always visible when user selected
- "Abbrechen" only shown when changes exist

Legacy system warning added:
- Yellow warning box explaining old permission system
- Old system: ai_enabled, ai_limit_day, export_enabled in profiles table
- New system: feature_restrictions table with overrides
- Warning: both systems can conflict, new system has priority
- Recommendation: use only feature-overrides going forward

This addresses:
1. UI overlap issue (buttons covering navigation)
2. System architecture confusion (two permission systems)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 08:45:06 +01:00
ac56974e83 fix: make buttons always visible in AdminUserRestrictionsPage
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Bottom bar changes:
- Always visible when user selected (not hidden)
- Buttons disabled when no changes (clearer state)
- Moved outside inner block to prevent hiding

Action column changes:
- "↺ Zurück" button always visible per feature
- Disabled when no override exists (grayed out)
- Consistent button presence improves UX

This fixes the issue where buttons were not shown
because they were conditionally rendered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 08:26:18 +01:00
5ef6a80a1f fix: add tier limits display and improve buttons in AdminUserRestrictionsPage
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Added tier limits column:
- Shows current tier-limit value for each feature
- Loads from tier-limits matrix based on user's tier
- Visual display for boolean (✓ AN / ✗ AUS) and count features
- Clear comparison: Tier-Limit vs Override-Wert

Added per-feature reset button:
- "↺ Zurück zu Standard" button per feature
- Only shown when override exists
- Removes override with single click

Improved bottom bar buttons:
- Renamed "Zurücksetzen" to "Abbrechen" (clearer)
- Always visible (not hidden when no changes)
- Disabled state when no changes
- Shows "Keine Änderungen" when nothing to save

Better UX:
- Tier-Limit column shows what user gets without override
- Override input highlighted when active (accent-light background)
- Clear action buttons per row
- Global save/cancel at bottom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 08:13:11 +01:00
365fe3d068 fix: complete rewrite of AdminUserRestrictionsPage
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Fixed all reported bugs:
1. Initial values now correct (empty = no override, not defaults)
2. Save/Reset buttons always visible (fixed bottom bar)
3. Toggle buttons work correctly (can be toggled multiple times)
4. Simplified table columns (removed confusing Tier-Limit/Aktiv/Aktion)

New logic:
- Empty input = no override (user uses tier standard)
- Value entered = override set
- Change tracking with 3 actions: set, remove, toggle
- Clear status display: "Override aktiv" vs "Tier-Standard"

Simplified table structure:
- Feature (name + type)
- Override-Wert (input/toggle)
- Status (has override yes/no)

Better UX:
- Placeholder text explains empty = tier standard
- Status badge shows if override is active
- Fixed bottom bar always present
- Buttons disabled only when no changes
- Legend explains all input options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 08:08:02 +01:00
72d8dd8df7 feat: add AdminUserRestrictionsPage for individual user overrides
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 12s
Per-user feature limit overrides:
- Select user from dropdown (shows tier)
- View all features with tier limits
- Set individual overrides that supersede tier limits
- Toggle buttons for boolean features
- Text inputs for count features
- Remove overrides to revert to tier limits

Features:
- User info card (avatar, name, email, tier)
- Feature table grouped by category
- Visual indicators for active overrides
- Change tracking with fixed bottom save bar
- Conditional rendering based on limit type
- Info box explaining override priority

UX improvements:
- Clear "Tier-Limit" vs "Override" columns
- Active/Inactive status per feature
- Batch save with change counter
- Confirmation before removing overrides
- Legend for input values

Use cases:
- Beta testers with extended limits
- Support requests for special access
- Temporary feature grants
- Custom enterprise configurations

Integrated in AdminPanel navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 07:59:49 +01:00
18991025bf feat: add AdminCouponsPage for coupon management
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s
Full CRUD interface for coupons:
- Create, edit, delete coupons
- Three coupon types supported:
  - Single-Use: one-time redemption per user
  - Multi-Use Period: unlimited redemptions in timeframe (Wellpass)
  - Gift: bonus system coupons

Features:
- Auto-generate random coupon codes
- Configure tier, duration, validity period
- Set max redemptions (or unlimited)
- View redemption history per coupon (modal)
- Active/inactive state management
- Card-based layout with visual type indicators

Form improvements:
- Conditional fields based on coupon type
- Date pickers for period coupons
- Duration config for single-use/gift
- Help text for each field
- Labels above inputs (consistent with other pages)

Integrated in AdminPanel navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 07:53:47 +01:00
bc4db19190 refactor: improve AdminFeaturesPage form layout and UX
All checks were successful
Deploy Development / deploy (push) Successful in 1m0s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
Layout improvements:
- Labels now above inputs (not beside)
- Inputs use full width for better readability
- Better spacing and visual hierarchy

Field changes:
- Removed "Einheit" field (unused, confusing)
- "Sortierung" renamed to "Anzeigereihenfolge" with help text
- Added help text under inputs for clarity

Conditional rendering:
- Boolean features: hide Reset-Periode and Standard-Limit
- Show info box explaining Boolean features
- Count features: show all relevant fields

Better UX:
- Clear explanations what each field does
- Visual feedback for different limit types
- Cleaner, more focused interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 07:47:00 +01:00
69b6f38c89 refactor: change AdminFeaturesPage to configuration-only interface
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s
Philosophy change:
- Features are registered via code/migrations, not UI
- AdminFeaturesPage now only configures existing features
- No create/delete functionality

Changes:
- Removed "Neues Feature" button and create form
- Removed delete functionality
- Feature ID now read-only in edit mode
- Added info box explaining feature registration
- Improved status display (Aktiv/Inaktiv)
- Added legend for limit types and reset periods
- Focus on configuration: limit type, reset period, defaults, active state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 06:46:04 +01:00
07a802dff6 feat: add admin pages for Features and Tiers management
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
AdminFeaturesPage:
- Full CRUD for features registry
- Add/edit features with all properties
- Category, limit type, reset period configuration
- Default limits and sorting

AdminTiersPage:
- Full CRUD for subscription tiers
- Pricing configuration (monthly/yearly in cents)
- Active/inactive state management
- Card-based layout with edit/delete actions

Both pages:
- Form validation
- Success/error messaging
- Clean table/card layouts
- Integrated in AdminPanel navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 06:35:13 +01:00
7d6d9dabf2 feat: add toggle buttons for boolean features in matrix editor
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Boolean features now show as visual toggle buttons (AN/AUS)
- Desktop: compact toggle (✓ AN / ✗ AUS)
- Mobile: full-width toggle (✓ Aktiviert / ✗ Deaktiviert)
- Prevents invalid values for boolean features
- Green when enabled, gray when disabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 06:28:31 +01:00
8bb5d85c16 fix: show all tiers in admin matrix editor including selfhosted
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Remove active=true filter - admins need to configure all tiers
- Add reset_period to features query for frontend display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 06:19:32 +01:00
759d5e5162 fix: improve AdminTierLimitsPage UX with responsive design
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
- Fix input bug: cells now editable after deletion (temp value tracking)
- Add responsive design: mobile card view, desktop table view
- Mobile: accordion-style FeatureMobileCard with fixed bottom bar
- Desktop: enhanced table with better visual feedback
- Maintains PWA compatibility (no media query conflicts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 06:17:52 +01:00
9438b5d617 feat: add Tier Limits Matrix Editor (Admin UI)
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 12s
Phase 3 Frontend - First Component: Matrix Editor

New page: AdminTierLimitsPage
- Displays Tier x Feature matrix (editable table)
- Inline editing for all limit values
- Visual feedback for changes (highlighted cells)
- Batch save with validation
- Category grouping (data, ai, export, integration)
- Legend: ∞ = unlimited (NULL),  = disabled (0), 1-999 = limit
- Responsive table with sticky column headers

Features:
- GET /api/tier-limits - Load matrix
- PUT /api/tier-limits/batch - Save all changes
- Change tracking (shows unsaved count)
- Reset button to discard changes
- Success/error messages

API helpers added (api.js):
- v9c subscription endpoints (user + admin)
- listFeatures, listTiers, getTierLimitsMatrix
- updateTierLimit, updateTierLimitsBatch
- listCoupons, redeemCoupon
- User restrictions, access grants

Navigation:
- Link in AdminPanel (Settings Page)
- Route: /admin/tier-limits

Ready for testing on Dev!

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:21:52 +01:00
43 changed files with 13219 additions and 1347 deletions

2
.gitignore vendored
View File

@ -61,4 +61,4 @@ tmp/
#.claude Konfiguration #.claude Konfiguration
.claude/ .claude/
.claude/settings.local.json .claude/settings.local.jsonfrontend/package-lock.json

1282
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,9 @@ def apply_migration():
if not migration_needed(conn): if not migration_needed(conn):
print("[v9c Migration] Already applied, skipping.") print("[v9c Migration] Already applied, skipping.")
conn.close() conn.close()
# Even if main migration is done, check cleanup
apply_cleanup_migration()
return return
print("[v9c Migration] Applying subscription system migration...") print("[v9c Migration] Applying subscription system migration...")
@ -83,6 +86,26 @@ def apply_migration():
print("[v9c Migration] ✅ Migration completed successfully!") print("[v9c Migration] ✅ Migration completed successfully!")
# Apply fix migration if exists
fix_migration_path = os.path.join(
os.path.dirname(__file__),
"migrations",
"v9c_fix_features.sql"
)
if os.path.exists(fix_migration_path):
print("[v9c Migration] Applying feature fixes...")
with open(fix_migration_path, 'r', encoding='utf-8') as f:
fix_sql = f.read()
conn = get_db_connection()
cur = conn.cursor()
cur.execute(fix_sql)
conn.commit()
cur.close()
conn.close()
print("[v9c Migration] ✅ Feature fixes applied!")
# Verify tables created # Verify tables created
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor() cur = conn.cursor()
@ -108,10 +131,123 @@ def apply_migration():
cur.close() cur.close()
conn.close() conn.close()
# After successful migration, apply cleanup
apply_cleanup_migration()
except Exception as e: except Exception as e:
print(f"[v9c Migration] ❌ Error: {e}") print(f"[v9c Migration] ❌ Error: {e}")
raise raise
def cleanup_features_needed(conn):
"""Check if feature cleanup migration is needed."""
cur = conn.cursor()
# Check if old export features still exist
cur.execute("""
SELECT COUNT(*) as count FROM features
WHERE id IN ('export_csv', 'export_json', 'export_zip')
""")
old_exports = cur.fetchone()['count']
# Check if csv_import needs to be renamed
cur.execute("""
SELECT COUNT(*) as count FROM features
WHERE id = 'csv_import'
""")
old_import = cur.fetchone()['count']
cur.close()
# Cleanup needed if old features exist
return old_exports > 0 or old_import > 0
def apply_cleanup_migration():
"""Apply v9c feature cleanup migration."""
print("[v9c Cleanup] Checking if cleanup migration is needed...")
try:
conn = get_db_connection()
if not cleanup_features_needed(conn):
print("[v9c Cleanup] Already applied, skipping.")
conn.close()
return
print("[v9c Cleanup] Applying feature consolidation...")
# Show BEFORE state
cur = conn.cursor()
cur.execute("SELECT id, name FROM features ORDER BY category, id")
features_before = [f"{r['id']} ({r['name']})" for r in cur.fetchall()]
print(f"[v9c Cleanup] Features BEFORE: {len(features_before)} features")
for f in features_before:
print(f" - {f}")
cur.close()
# Read cleanup migration SQL
cleanup_path = os.path.join(
os.path.dirname(__file__),
"migrations",
"v9c_cleanup_features.sql"
)
if not os.path.exists(cleanup_path):
print(f"[v9c Cleanup] ⚠️ Cleanup migration file not found: {cleanup_path}")
conn.close()
return
with open(cleanup_path, 'r', encoding='utf-8') as f:
cleanup_sql = f.read()
# Execute cleanup migration
cur = conn.cursor()
cur.execute(cleanup_sql)
conn.commit()
cur.close()
# Show AFTER state
cur = conn.cursor()
cur.execute("SELECT id, name, category FROM features ORDER BY category, id")
features_after = cur.fetchall()
print(f"[v9c Cleanup] Features AFTER: {len(features_after)} features")
# Group by category
categories = {}
for f in features_after:
cat = f['category'] or 'other'
if cat not in categories:
categories[cat] = []
categories[cat].append(f"{f['id']} ({f['name']})")
for cat, feats in sorted(categories.items()):
print(f" {cat.upper()}:")
for f in feats:
print(f" - {f}")
# Verify tier_limits updated
cur.execute("""
SELECT tier_id, feature_id, limit_value
FROM tier_limits
WHERE feature_id IN ('data_export', 'data_import')
ORDER BY tier_id, feature_id
""")
limits = cur.fetchall()
print(f"[v9c Cleanup] Tier limits for data_export/data_import:")
for lim in limits:
limit_str = 'unlimited' if lim['limit_value'] is None else lim['limit_value']
print(f" {lim['tier_id']}.{lim['feature_id']} = {limit_str}")
cur.close()
conn.close()
print("[v9c Cleanup] ✅ Feature cleanup completed successfully!")
except Exception as e:
print(f"[v9c Cleanup] ❌ Error: {e}")
raise
if __name__ == "__main__": if __name__ == "__main__":
apply_migration() apply_migration()

View File

@ -121,17 +121,22 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
# Feature Access Control (v9c) # Feature Access Control (v9c)
# ============================================================================ # ============================================================================
def get_effective_tier(profile_id: str) -> str: def get_effective_tier(profile_id: str, conn=None) -> str:
""" """
Get the effective tier for a profile. Get the effective tier for a profile.
Checks for active access_grants first (from coupons, trials, etc.), Checks for active access_grants first (from coupons, trials, etc.),
then falls back to profile.tier. then falls back to profile.tier.
Args:
profile_id: User profile ID
conn: Optional existing DB connection (to avoid pool exhaustion)
Returns: Returns:
tier_id (str): 'free', 'basic', 'premium', or 'selfhosted' tier_id (str): 'free', 'basic', 'premium', or 'selfhosted'
""" """
with get_db() as conn: # Use existing connection if provided, otherwise open new one
if conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Check for active access grants (highest priority) # Check for active access grants (highest priority)
@ -154,9 +159,13 @@ def get_effective_tier(profile_id: str) -> str:
cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,)) cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,))
profile = cur.fetchone() profile = cur.fetchone()
return profile['tier'] if profile else 'free' return profile['tier'] if profile else 'free'
else:
# Open new connection if none provided
with get_db() as conn:
return get_effective_tier(profile_id, conn)
def check_feature_access(profile_id: str, feature_id: str) -> dict: def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
""" """
Check if a profile has access to a feature. Check if a profile has access to a feature.
@ -165,6 +174,11 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
2. Tier limit (tier_limits) 2. Tier limit (tier_limits)
3. Feature default (features.default_limit) 3. Feature default (features.default_limit)
Args:
profile_id: User profile ID
feature_id: Feature ID to check
conn: Optional existing DB connection (to avoid pool exhaustion)
Returns: Returns:
dict: { dict: {
'allowed': bool, 'allowed': bool,
@ -174,118 +188,127 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled' 'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
} }
""" """
with get_db() as conn: # Use existing connection if provided
cur = get_cursor(conn) if conn:
return _check_impl(profile_id, feature_id, conn)
else:
with get_db() as conn:
return _check_impl(profile_id, feature_id, conn)
# Get feature info
cur.execute("""
SELECT limit_type, reset_period, default_limit
FROM features
WHERE id = %s AND active = true
""", (feature_id,))
feature = cur.fetchone()
if not feature: def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
return { """Internal implementation of check_feature_access."""
'allowed': False, cur = get_cursor(conn)
'limit': None,
'used': 0,
'remaining': None,
'reason': 'feature_not_found'
}
# Priority 1: Check user-specific restriction # Get feature info
cur.execute("""
SELECT limit_type, reset_period, default_limit
FROM features
WHERE id = %s AND active = true
""", (feature_id,))
feature = cur.fetchone()
if not feature:
return {
'allowed': False,
'limit': None,
'used': 0,
'remaining': None,
'reason': 'feature_not_found'
}
# Priority 1: Check user-specific restriction
cur.execute("""
SELECT limit_value
FROM user_feature_restrictions
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
restriction = cur.fetchone()
if restriction is not None:
limit = restriction['limit_value']
else:
# Priority 2: Check tier limit
tier_id = get_effective_tier(profile_id, conn)
cur.execute(""" cur.execute("""
SELECT limit_value SELECT limit_value
FROM user_feature_restrictions FROM tier_limits
WHERE profile_id = %s AND feature_id = %s WHERE tier_id = %s AND feature_id = %s
""", (profile_id, feature_id)) """, (tier_id, feature_id))
restriction = cur.fetchone() tier_limit = cur.fetchone()
if restriction is not None: if tier_limit is not None:
limit = restriction['limit_value'] limit = tier_limit['limit_value']
else: else:
# Priority 2: Check tier limit # Priority 3: Feature default
tier_id = get_effective_tier(profile_id) limit = feature['default_limit']
cur.execute("""
SELECT limit_value
FROM tier_limits
WHERE tier_id = %s AND feature_id = %s
""", (tier_id, feature_id))
tier_limit = cur.fetchone()
if tier_limit is not None:
limit = tier_limit['limit_value']
else:
# Priority 3: Feature default
limit = feature['default_limit']
# For boolean features (limit 0 = disabled, 1 = enabled)
if feature['limit_type'] == 'boolean':
allowed = limit == 1
return {
'allowed': allowed,
'limit': limit,
'used': 0,
'remaining': None,
'reason': 'enabled' if allowed else 'feature_disabled'
}
# For count-based features
# Check current usage
cur.execute("""
SELECT usage_count, reset_at
FROM user_feature_usage
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
usage = cur.fetchone()
used = usage['usage_count'] if usage else 0
# Check if reset is needed
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
# Reset usage
used = 0
next_reset = _calculate_next_reset(feature['reset_period'])
cur.execute("""
UPDATE user_feature_usage
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
WHERE profile_id = %s AND feature_id = %s
""", (next_reset, profile_id, feature_id))
conn.commit()
# NULL limit = unlimited
if limit is None:
return {
'allowed': True,
'limit': None,
'used': used,
'remaining': None,
'reason': 'unlimited'
}
# 0 limit = disabled
if limit == 0:
return {
'allowed': False,
'limit': 0,
'used': used,
'remaining': 0,
'reason': 'feature_disabled'
}
# Check if within limit
allowed = used < limit
remaining = limit - used if limit else None
# For boolean features (limit 0 = disabled, 1 = enabled)
if feature['limit_type'] == 'boolean':
allowed = limit == 1
return { return {
'allowed': allowed, 'allowed': allowed,
'limit': limit, 'limit': limit,
'used': used, 'used': 0,
'remaining': remaining, 'remaining': None,
'reason': 'within_limit' if allowed else 'limit_exceeded' 'reason': 'enabled' if allowed else 'feature_disabled'
} }
# For count-based features
# Check current usage
cur.execute("""
SELECT usage_count, reset_at
FROM user_feature_usage
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
usage = cur.fetchone()
used = usage['usage_count'] if usage else 0
# Check if reset is needed
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
# Reset usage
used = 0
next_reset = _calculate_next_reset(feature['reset_period'])
cur.execute("""
UPDATE user_feature_usage
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
WHERE profile_id = %s AND feature_id = %s
""", (next_reset, profile_id, feature_id))
conn.commit()
# NULL limit = unlimited
if limit is None:
return {
'allowed': True,
'limit': None,
'used': used,
'remaining': None,
'reason': 'unlimited'
}
# 0 limit = disabled
if limit == 0:
return {
'allowed': False,
'limit': 0,
'used': used,
'remaining': 0,
'reason': 'feature_disabled'
}
# Check if within limit
allowed = used < limit
remaining = limit - used if limit else None
return {
'allowed': allowed,
'limit': limit,
'used': used,
'remaining': remaining,
'reason': 'within_limit' if allowed else 'limit_exceeded'
}
def increment_feature_usage(profile_id: str, feature_id: str) -> None: def increment_feature_usage(profile_id: str, feature_id: str) -> None:
""" """

36
backend/check_features.py Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Quick diagnostic script to check features table."""
from db import get_db, get_cursor
with get_db() as conn:
cur = get_cursor(conn)
print("\n=== FEATURES TABLE ===")
cur.execute("SELECT id, name, active, limit_type, reset_period FROM features ORDER BY id")
features = cur.fetchall()
if not features:
print("❌ NO FEATURES FOUND! Migration failed!")
else:
for r in features:
print(f" {r['id']:30} {r['name']:40} active={r['active']} type={r['limit_type']:8} reset={r['reset_period']}")
print(f"\nTotal features: {len(features)}")
print("\n=== USER_FEATURE_USAGE (recent) ===")
cur.execute("""
SELECT profile_id, feature_id, usage_count, reset_at
FROM user_feature_usage
ORDER BY updated DESC
LIMIT 10
""")
usages = cur.fetchall()
if not usages:
print(" (no usage records yet)")
else:
for r in usages:
print(f" {r['profile_id'][:8]}... -> {r['feature_id']:30} used={r['usage_count']} reset_at={r['reset_at']}")
print(f"\nTotal usage records: {len(usages)}")

76
backend/feature_logger.py Normal file
View File

@ -0,0 +1,76 @@
"""
Feature Usage Logger for Mitai Jinkendo
Logs all feature access checks to a separate JSON log file for analysis.
Phase 2: Non-blocking monitoring of feature usage.
"""
import logging
import json
from datetime import datetime
from pathlib import Path
# ── Setup Feature Usage Logger ───────────────────────────────────────────────
feature_usage_logger = logging.getLogger('feature_usage')
feature_usage_logger.setLevel(logging.INFO)
feature_usage_logger.propagate = False # Don't propagate to root logger
# Ensure logs directory exists
LOG_DIR = Path('/app/logs')
LOG_DIR.mkdir(parents=True, exist_ok=True)
# FileHandler for JSON logs
log_file = LOG_DIR / 'feature-usage.log'
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter('%(message)s')) # JSON only
feature_usage_logger.addHandler(file_handler)
# Also log to console in dev (optional)
# console_handler = logging.StreamHandler()
# console_handler.setFormatter(logging.Formatter('[FEATURE-USAGE] %(message)s'))
# feature_usage_logger.addHandler(console_handler)
# ── Logging Function ──────────────────────────────────────────────────────────
def log_feature_usage(user_id: str, feature_id: str, access: dict, action: str):
"""
Log feature usage in structured JSON format.
Args:
user_id: Profile UUID
feature_id: Feature identifier (e.g., 'weight_entries', 'ai_calls')
access: Result from check_feature_access() containing:
- allowed: bool
- limit: int | None
- used: int
- remaining: int | None
- reason: str
action: Type of action (e.g., 'create', 'export', 'analyze')
Example log entry:
{
"timestamp": "2026-03-20T15:30:45.123456",
"user_id": "abc-123",
"feature": "weight_entries",
"action": "create",
"used": 5,
"limit": 100,
"remaining": 95,
"allowed": true,
"reason": "within_limit"
}
"""
entry = {
"timestamp": datetime.now().isoformat(),
"user_id": user_id,
"feature": feature_id,
"action": action,
"used": access.get('used', 0),
"limit": access.get('limit'), # None for unlimited
"remaining": access.get('remaining'), # None for unlimited
"allowed": access.get('allowed', True),
"reason": access.get('reason', 'unknown')
}
feature_usage_logger.info(json.dumps(entry))

View File

@ -0,0 +1,50 @@
-- ============================================================================
-- Feature Check Script - Diagnose vor/nach Migration
-- ============================================================================
-- Usage: psql -U mitai_dev -d mitai_dev -f check_features.sql
-- ============================================================================
\echo '=== CURRENT FEATURES ==='
SELECT id, name, category, limit_type, reset_period, default_limit, active
FROM features
ORDER BY category, id;
\echo ''
\echo '=== TIER LIMITS MATRIX ==='
SELECT
f.id as feature,
f.category,
MAX(CASE WHEN tl.tier_id = 'free' THEN COALESCE(tl.limit_value::text, '') END) as free,
MAX(CASE WHEN tl.tier_id = 'basic' THEN COALESCE(tl.limit_value::text, '') END) as basic,
MAX(CASE WHEN tl.tier_id = 'premium' THEN COALESCE(tl.limit_value::text, '') END) as premium,
MAX(CASE WHEN tl.tier_id = 'selfhosted' THEN COALESCE(tl.limit_value::text, '') END) as selfhosted
FROM features f
LEFT JOIN tier_limits tl ON f.id = tl.feature_id
GROUP BY f.id, f.category
ORDER BY f.category, f.id;
\echo ''
\echo '=== FEATURE COUNT BY CATEGORY ==='
SELECT category, COUNT(*) as count
FROM features
WHERE active = true
GROUP BY category
ORDER BY category;
\echo ''
\echo '=== ORPHANED TIER LIMITS (feature not exists) ==='
SELECT tl.tier_id, tl.feature_id, tl.limit_value
FROM tier_limits tl
LEFT JOIN features f ON tl.feature_id = f.id
WHERE f.id IS NULL;
\echo ''
\echo '=== USER FEATURE USAGE (current usage tracking) ==='
SELECT
p.name as user,
ufu.feature_id,
ufu.usage_count,
ufu.reset_at
FROM user_feature_usage ufu
JOIN profiles p ON ufu.profile_id = p.id
ORDER BY p.name, ufu.feature_id;

View File

@ -0,0 +1,141 @@
-- ============================================================================
-- v9c Cleanup: Feature-Konsolidierung
-- ============================================================================
-- Created: 2026-03-20
-- Purpose: Konsolidiere Export-Features (export_csv/json/zip → data_export)
-- und Import-Features (csv_import → data_import)
--
-- Idempotent: Kann mehrfach ausgeführt werden
--
-- Lessons Learned:
-- "Ein Feature für Export, nicht drei (csv/json/zip)"
-- ============================================================================
-- ============================================================================
-- 1. Rename csv_import to data_import
-- ============================================================================
UPDATE features
SET
id = 'data_import',
name = 'Daten importieren',
description = 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import'
WHERE id = 'csv_import';
-- Update tier_limits references
UPDATE tier_limits
SET feature_id = 'data_import'
WHERE feature_id = 'csv_import';
-- Update user_feature_restrictions references
UPDATE user_feature_restrictions
SET feature_id = 'data_import'
WHERE feature_id = 'csv_import';
-- Update user_feature_usage references
UPDATE user_feature_usage
SET feature_id = 'data_import'
WHERE feature_id = 'csv_import';
-- ============================================================================
-- 2. Remove old export_csv/json/zip features
-- ============================================================================
-- Remove tier_limits for old features
DELETE FROM tier_limits
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
-- Remove user restrictions for old features
DELETE FROM user_feature_restrictions
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
-- Remove usage tracking for old features
DELETE FROM user_feature_usage
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
-- Remove old feature definitions
DELETE FROM features
WHERE id IN ('export_csv', 'export_json', 'export_zip');
-- ============================================================================
-- 3. Ensure data_export exists and is properly configured
-- ============================================================================
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
VALUES ('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
limit_type = EXCLUDED.limit_type,
reset_period = EXCLUDED.reset_period;
-- ============================================================================
-- 4. Ensure data_import exists and is properly configured
-- ============================================================================
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
VALUES ('data_import', 'Daten importieren', 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import', 'import', 'count', 'monthly', 0, true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
limit_type = EXCLUDED.limit_type,
reset_period = EXCLUDED.reset_period;
-- ============================================================================
-- 5. Update tier_limits for data_export (consolidate from old features)
-- ============================================================================
-- FREE tier: no export
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('free', 'data_export', 0)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- BASIC tier: 5 exports/month
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('basic', 'data_export', 5)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- PREMIUM tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('premium', 'data_export', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- SELFHOSTED tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('selfhosted', 'data_export', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- ============================================================================
-- 6. Update tier_limits for data_import
-- ============================================================================
-- FREE tier: no import
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('free', 'data_import', 0)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- BASIC tier: 3 imports/month
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('basic', 'data_import', 3)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- PREMIUM tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('premium', 'data_import', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- SELFHOSTED tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('selfhosted', 'data_import', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- ============================================================================
-- Cleanup complete
-- ============================================================================
-- Final feature list:
-- Data: weight_entries, circumference_entries, caliper_entries,
-- nutrition_entries, activity_entries, photos
-- AI: ai_calls, ai_pipeline
-- Export/Import: data_export, data_import
--
-- Total: 10 features (down from 13)
-- ============================================================================

View File

@ -0,0 +1,33 @@
-- Fix missing features for v9c feature enforcement
-- 2026-03-20
-- Add missing features
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES
('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true),
('csv_import', 'CSV importieren', 'FDDB/Apple Health CSV Import + ZIP Backup Import', 'import', 'count', 'monthly', 0, true)
ON CONFLICT (id) DO NOTHING;
-- Add tier limits for new features
-- FREE tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('free', 'data_export', 0), -- Kein Export
('free', 'csv_import', 0) -- Kein Import
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- BASIC tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('basic', 'data_export', 5), -- 5 Exporte/Monat
('basic', 'csv_import', 3) -- 3 Imports/Monat
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- PREMIUM tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('premium', 'data_export', NULL), -- Unbegrenzt
('premium', 'csv_import', NULL) -- Unbegrenzt
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- SELFHOSTED tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('selfhosted', 'data_export', NULL), -- Unbegrenzt
('selfhosted', 'csv_import', NULL) -- Unbegrenzt
ON CONFLICT (tier_id, feature_id) DO NOTHING;

View File

@ -6,16 +6,19 @@ Handles workout/activity logging, statistics, and Apple Health CSV import.
import csv import csv
import io import io
import uuid import uuid
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from models import ActivityEntry from models import ActivityEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/activity", tags=["activity"]) router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
@router.get("") @router.get("")
@ -33,6 +36,22 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create new activity entry.""" """Create new activity entry."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'activity_entries')
log_feature_usage(pid, 'activity_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
eid = str(uuid.uuid4()) eid = str(uuid.uuid4())
d = e.model_dump() d = e.model_dump()
with get_db() as conn: with get_db() as conn:
@ -44,6 +63,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'], (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'], d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
d['rpe'],d['source'],d['notes'])) d['rpe'],d['source'],d['notes']))
# Phase 2: Increment usage counter (always for new entries)
increment_feature_usage(pid, 'activity_entries')
return {"id":eid,"date":e.date} return {"id":eid,"date":e.date}

View File

@ -4,16 +4,19 @@ Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo
Handles body fat measurements via skinfold caliper (4 methods supported). Handles body fat measurements via skinfold caliper (4 methods supported).
""" """
import uuid import uuid
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Header, Depends from fastapi import APIRouter, Header, Depends, HTTPException
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from models import CaliperEntry from models import CaliperEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/caliper", tags=["caliper"]) router = APIRouter(prefix="/api/caliper", tags=["caliper"])
logger = logging.getLogger(__name__)
@router.get("") @router.get("")
@ -31,17 +34,37 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None
def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update caliper entry (upsert by date).""" """Create or update caliper entry (upsert by date)."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'caliper_entries')
log_feature_usage(pid, 'caliper_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Caliper-Einträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date)) cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
ex = cur.fetchone() ex = cur.fetchone()
d = e.model_dump() d = e.model_dump()
is_new_entry = not ex
if ex: if ex:
# UPDATE existing entry
eid = ex['id'] eid = ex['id']
sets = ', '.join(f"{k}=%s" for k in d if k!='date') sets = ', '.join(f"{k}=%s" for k in d if k!='date')
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s", cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
[v for k,v in d.items() if k!='date']+[eid]) [v for k,v in d.items() if k!='date']+[eid])
else: else:
# INSERT new entry
eid = str(uuid.uuid4()) eid = str(uuid.uuid4())
cur.execute("""INSERT INTO caliper_log cur.execute("""INSERT INTO caliper_log
(id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac, (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
@ -50,6 +73,10 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N
(eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'], (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'], d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes'])) d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
# Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'caliper_entries')
return {"id":eid,"date":e.date} return {"id":eid,"date":e.date}

View File

@ -4,16 +4,19 @@ Circumference Tracking Endpoints for Mitai Jinkendo
Handles body circumference measurements (8 measurement points). Handles body circumference measurements (8 measurement points).
""" """
import uuid import uuid
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Header, Depends from fastapi import APIRouter, Header, Depends, HTTPException
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from models import CircumferenceEntry from models import CircumferenceEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/circumferences", tags=["circumference"]) router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
logger = logging.getLogger(__name__)
@router.get("") @router.get("")
@ -31,23 +34,47 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None),
def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update circumference entry (upsert by date).""" """Create or update circumference entry (upsert by date)."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'circumference_entries')
log_feature_usage(pid, 'circumference_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Umfangs-Einträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date)) cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
ex = cur.fetchone() ex = cur.fetchone()
d = e.model_dump() d = e.model_dump()
is_new_entry = not ex
if ex: if ex:
# UPDATE existing entry
eid = ex['id'] eid = ex['id']
sets = ', '.join(f"{k}=%s" for k in d if k!='date') sets = ', '.join(f"{k}=%s" for k in d if k!='date')
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s", cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
[v for k,v in d.items() if k!='date']+[eid]) [v for k,v in d.items() if k!='date']+[eid])
else: else:
# INSERT new entry
eid = str(uuid.uuid4()) eid = str(uuid.uuid4())
cur.execute("""INSERT INTO circumference_log cur.execute("""INSERT INTO circumference_log
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created) (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'], (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id'])) d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
# Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'circumference_entries')
return {"id":eid,"date":e.date} return {"id":eid,"date":e.date}

View File

@ -7,6 +7,7 @@ import os
import csv import csv
import io import io
import json import json
import logging
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -17,10 +18,12 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from fastapi.responses import StreamingResponse, Response from fastapi.responses import StreamingResponse, Response
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/export", tags=["export"]) router = APIRouter(prefix="/api/export", tags=["export"])
logger = logging.getLogger(__name__)
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
@ -30,13 +33,20 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
"""Export all data as CSV.""" """Export all data as CSV."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Check export permission # Phase 4: Check feature access and ENFORCE
with get_db() as conn: access = check_feature_access(pid, 'data_export')
cur = get_cursor(conn) log_feature_usage(pid, 'data_export', access, 'export_csv')
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone() if not access['allowed']:
if not prof or not prof['export_enabled']: logger.warning(
raise HTTPException(403, "Export ist für dieses Profil deaktiviert") f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Build CSV # Build CSV
output = io.StringIO() output = io.StringIO()
@ -74,6 +84,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
output.seek(0) output.seek(0)
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_export')
return StreamingResponse( return StreamingResponse(
iter([output.getvalue()]), iter([output.getvalue()]),
media_type="text/csv", media_type="text/csv",
@ -86,13 +100,20 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
"""Export all data as JSON.""" """Export all data as JSON."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Check export permission # Phase 4: Check feature access and ENFORCE
with get_db() as conn: access = check_feature_access(pid, 'data_export')
cur = get_cursor(conn) log_feature_usage(pid, 'data_export', access, 'export_json')
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone() if not access['allowed']:
if not prof or not prof['export_enabled']: logger.warning(
raise HTTPException(403, "Export ist für dieses Profil deaktiviert") f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Collect all data # Collect all data
data = {} data = {}
@ -126,6 +147,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
return str(obj) return str(obj)
json_str = json.dumps(data, indent=2, default=decimal_handler) json_str = json.dumps(data, indent=2, default=decimal_handler)
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_export')
return Response( return Response(
content=json_str, content=json_str,
media_type="application/json", media_type="application/json",
@ -138,13 +163,26 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
"""Export all data as ZIP (CSV + JSON + photos) per specification.""" """Export all data as ZIP (CSV + JSON + photos) per specification."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Check export permission & get profile # Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_zip')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Get profile
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = r2d(cur.fetchone()) prof = r2d(cur.fetchone())
if not prof or not prof.get('export_enabled'):
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Helper: CSV writer with UTF-8 BOM + semicolon # Helper: CSV writer with UTF-8 BOM + semicolon
def write_csv(zf, filename, rows, columns): def write_csv(zf, filename, rows, columns):
@ -297,6 +335,10 @@ Datumsformat: YYYY-MM-DD
zip_buffer.seek(0) zip_buffer.seek(0)
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip" filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_export')
return StreamingResponse( return StreamingResponse(
iter([zip_buffer.getvalue()]), iter([zip_buffer.getvalue()]),
media_type="application/zip", media_type="application/zip",

View File

@ -2,11 +2,16 @@
Feature Management Endpoints for Mitai Jinkendo Feature Management Endpoints for Mitai Jinkendo
Admin-only CRUD for features registry. Admin-only CRUD for features registry.
User endpoint for feature usage overview (Phase 3).
""" """
from fastapi import APIRouter, HTTPException, Depends from typing import Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_admin from auth import require_admin, require_auth, check_feature_access
from routers.profiles import get_pid
router = APIRouter(prefix="/api/features", tags=["features"]) router = APIRouter(prefix="/api/features", tags=["features"])
@ -119,3 +124,100 @@ def delete_feature(feature_id: str, session: dict = Depends(require_admin)):
cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,)) cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,))
conn.commit() conn.commit()
return {"ok": True} return {"ok": True}
@router.get("/{feature_id}/check-access")
def check_access(feature_id: str, session: dict = Depends(require_auth)):
"""
User: Check if current user can access a feature.
Returns:
- allowed: bool - whether user can use the feature
- limit: int|null - total limit (null = unlimited)
- used: int - current usage
- remaining: int|null - remaining uses (null = unlimited)
- reason: str - why access is granted/denied
"""
profile_id = session['profile_id']
result = check_feature_access(profile_id, feature_id)
return result
@router.get("/usage")
def get_feature_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""
User: Get usage overview for all active features (Phase 3: Frontend Display).
Returns list of all features with current usage, limits, and reset info.
Automatically includes new features from database - no code changes needed.
Response:
[
{
"feature_id": "weight_entries",
"name": "Gewichtseinträge",
"description": "Anzahl der Gewichtseinträge",
"category": "data",
"limit_type": "count",
"reset_period": "never",
"used": 5,
"limit": 10,
"remaining": 5,
"allowed": true,
"reset_at": null
},
...
]
"""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Get all active features (dynamic - picks up new features automatically)
cur.execute("""
SELECT id, name, description, category, limit_type, reset_period
FROM features
WHERE active = true
ORDER BY category, name
""")
features = [r2d(r) for r in cur.fetchall()]
result = []
for feature in features:
# Use existing check_feature_access to get usage and limits
# This respects user overrides, tier limits, and feature defaults
# Pass connection to avoid pool exhaustion
access = check_feature_access(pid, feature['id'], conn)
# Get reset date from user_feature_usage
cur.execute("""
SELECT reset_at
FROM user_feature_usage
WHERE profile_id = %s AND feature_id = %s
""", (pid, feature['id']))
usage_row = cur.fetchone()
# Format reset_at as ISO string
reset_at = None
if usage_row and usage_row['reset_at']:
if isinstance(usage_row['reset_at'], datetime):
reset_at = usage_row['reset_at'].isoformat()
else:
reset_at = str(usage_row['reset_at'])
result.append({
'feature_id': feature['id'],
'name': feature['name'],
'description': feature.get('description'),
'category': feature.get('category'),
'limit_type': feature['limit_type'],
'reset_period': feature['reset_period'],
'used': access['used'],
'limit': access['limit'],
'remaining': access['remaining'],
'allowed': access['allowed'],
'reset_at': reset_at
})
return result

View File

@ -8,6 +8,7 @@ import csv
import io import io
import json import json
import uuid import uuid
import logging
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -16,10 +17,12 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor from db import get_db, get_cursor
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/import", tags=["import"]) router = APIRouter(prefix="/api/import", tags=["import"])
logger = logging.getLogger(__name__)
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
@ -41,6 +44,21 @@ async def import_zip(
""" """
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'data_import')
log_feature_usage(pid, 'data_import', access, 'import_zip')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Read uploaded file # Read uploaded file
content = await file.read() content = await file.read()
zip_buffer = io.BytesIO(content) zip_buffer = io.BytesIO(content)
@ -254,6 +272,9 @@ async def import_zip(
conn.rollback() conn.rollback()
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}") raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_import')
return { return {
"ok": True, "ok": True,
"message": "Import erfolgreich", "message": "Import erfolgreich",

View File

@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking.
import os import os
import json import json
import uuid import uuid
import logging
import httpx import httpx
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@ -13,10 +14,12 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api", tags=["insights"]) router = APIRouter(prefix="/api", tags=["insights"])
logger = logging.getLogger(__name__)
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4") OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
@ -251,7 +254,21 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Run AI analysis with specified prompt template.""" """Run AI analysis with specified prompt template."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
check_ai_limit(pid)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access, 'analyze')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Get prompt template # Get prompt template
with get_db() as conn: with get_db() as conn:
@ -294,14 +311,18 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
else: else:
raise HTTPException(500, "Keine KI-API konfiguriert") raise HTTPException(500, "Keine KI-API konfiguriert")
# Save insight # Save insight (with history - no DELETE)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug))
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(str(uuid.uuid4()), pid, slug, content)) (str(uuid.uuid4()), pid, slug, content))
# Phase 2: Increment new feature usage counter
increment_feature_usage(pid, 'ai_calls')
# Old usage tracking (keep for now)
inc_ai_usage(pid) inc_ai_usage(pid)
return {"scope": slug, "content": content} return {"scope": slug, "content": content}
@ -309,7 +330,35 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Run 3-stage pipeline analysis.""" """Run 3-stage pipeline analysis."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
check_ai_limit(pid)
# Phase 4: Check pipeline feature access (boolean - enabled/disabled)
access_pipeline = check_feature_access(pid, 'ai_pipeline')
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
if not access_pipeline['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"ai_pipeline {access_pipeline['reason']}"
)
raise HTTPException(
status_code=403,
detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin."
)
# Also check ai_calls (pipeline uses API calls too)
access_calls = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access_calls, 'pipeline_calls')
if not access_calls['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
data = _get_profile_data(pid) data = _get_profile_data(pid)
vars = _prepare_template_vars(data) vars = _prepare_template_vars(data)
@ -431,15 +480,20 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
if goals_text: if goals_text:
final_content += "\n\n" + goals_text final_content += "\n\n" + goals_text
# Save as 'gesamt' scope # Save as 'pipeline' scope (with history - no DELETE)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,)) cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)",
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
(str(uuid.uuid4()), pid, final_content)) (str(uuid.uuid4()), pid, final_content))
# Phase 2: Increment ai_calls usage (pipeline uses multiple API calls)
# Note: We increment once per pipeline run, not per individual call
increment_feature_usage(pid, 'ai_calls')
# Old usage tracking (keep for now)
inc_ai_usage(pid) inc_ai_usage(pid)
return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
return {"scope": "pipeline", "content": final_content, "stage1": stage1_results}
@router.get("/ai/usage") @router.get("/ai/usage")

View File

@ -6,16 +6,19 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
import csv import csv
import io import io
import uuid import uuid
import logging
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__)
# ── Helper ──────────────────────────────────────────────────────────────────── # ── Helper ────────────────────────────────────────────────────────────────────
@ -30,6 +33,23 @@ def _pf(s):
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Import FDDB nutrition CSV.""" """Import FDDB nutrition CSV."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
# Note: CSV import can create many entries - we check once before import
access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
raw = await file.read() raw = await file.read()
try: text = raw.decode('utf-8') try: text = raw.decode('utf-8')
except: text = raw.decode('latin-1') except: text = raw.decode('latin-1')
@ -52,23 +72,88 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
days[iso]['protein_g'] += _pf(row.get('protein_g',0)) days[iso]['protein_g'] += _pf(row.get('protein_g',0))
count+=1 count+=1
inserted=0 inserted=0
new_entries=0
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
for iso,vals in days.items(): for iso,vals in days.items():
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1) kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1) carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso)) cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
if cur.fetchone(): is_new = not cur.fetchone()
if not is_new:
# UPDATE existing
cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s", cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
(kcal,prot,fat,carbs,pid,iso)) (kcal,prot,fat,carbs,pid,iso))
else: else:
# INSERT new
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)", cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs)) (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
new_entries += 1
inserted+=1 inserted+=1
return {"rows_parsed":count,"days_imported":inserted,
# Phase 2: Increment usage counter for each new entry created
for _ in range(new_entries):
increment_feature_usage(pid, 'nutrition_entries')
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
@router.post("")
def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update nutrition entry for a specific date."""
pid = get_pid(x_profile_id)
# Validate date format
try:
datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise HTTPException(400, "Ungültiges Datumsformat. Erwartet: YYYY-MM-DD")
with get_db() as conn:
cur = get_cursor(conn)
# Check if entry exists
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
existing = cur.fetchone()
if existing:
# UPDATE existing entry
cur.execute("""
UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual'
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
return {"success": True, "mode": "updated", "id": existing['id']}
else:
# Phase 4: Check feature access before INSERT
access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# INSERT new entry
new_id = str(uuid.uuid4())
cur.execute("""
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP)
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'nutrition_entries')
return {"success": True, "mode": "created", "id": new_id}
@router.get("") @router.get("")
def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition entries for current profile.""" """Get nutrition entries for current profile."""
@ -80,6 +165,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
return [r2d(r) for r in cur.fetchall()] return [r2d(r) for r in cur.fetchall()]
@router.get("/by-date/{date}")
def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition entry for a specific date."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
row = cur.fetchone()
return r2d(row) if row else None
@router.get("/correlations") @router.get("/correlations")
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition data correlated with weight and body fat.""" """Get nutrition data correlated with weight and body fat."""
@ -123,7 +219,9 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
if not rows: return [] if not rows: return []
wm={} wm={}
for d in rows: for d in rows:
wk=datetime.strptime(d['date'],'%Y-%m-%d').strftime('%Y-W%V') # Handle both datetime.date objects (from DB) and strings
date_obj = d['date'] if hasattr(d['date'], 'strftime') else datetime.strptime(d['date'],'%Y-%m-%d')
wk = date_obj.strftime('%Y-W%V')
wm.setdefault(wk,[]).append(d) wm.setdefault(wk,[]).append(d)
result=[] result=[]
for wk in sorted(wm): for wk in sorted(wm):
@ -131,3 +229,61 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1) def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1)
result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')}) result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')})
return result return result
@router.get("/import-history")
def import_history(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get import history by grouping entries by created timestamp."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT
DATE(created) as import_date,
COUNT(*) as count,
MIN(date) as date_from,
MAX(date) as date_to,
MAX(created) as last_created
FROM nutrition_log
WHERE profile_id=%s AND source='csv'
GROUP BY DATE(created)
ORDER BY DATE(created) DESC
""", (pid,))
return [r2d(r) for r in cur.fetchall()]
@router.put("/{entry_id}")
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Update nutrition entry macros."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Verify ownership
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
cur.execute("""
UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
return {"success": True}
@router.delete("/{entry_id}")
def delete_nutrition(entry_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Delete nutrition entry."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Verify ownership
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
cur.execute("DELETE FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
return {"success": True}

View File

@ -5,6 +5,7 @@ Handles progress photo uploads and retrieval.
""" """
import os import os
import uuid import uuid
import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -13,10 +14,12 @@ from fastapi.responses import FileResponse
import aiofiles import aiofiles
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, require_auth_flexible from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/photos", tags=["photos"]) router = APIRouter(prefix="/api/photos", tags=["photos"])
logger = logging.getLogger(__name__)
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
PHOTOS_DIR.mkdir(parents=True, exist_ok=True) PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
@ -27,6 +30,22 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Upload progress photo.""" """Upload progress photo."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'photos')
log_feature_usage(pid, 'photos', access, 'upload')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
fid = str(uuid.uuid4()) fid = str(uuid.uuid4())
ext = Path(file.filename).suffix or '.jpg' ext = Path(file.filename).suffix or '.jpg'
path = PHOTOS_DIR / f"{fid}{ext}" path = PHOTOS_DIR / f"{fid}{ext}"
@ -35,6 +54,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(fid,pid,date,str(path))) (fid,pid,date,str(path)))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'photos')
return {"id":fid,"date":date} return {"id":fid,"date":date}

View File

@ -29,13 +29,13 @@ def get_tier_limits_matrix(session: dict = Depends(require_admin)):
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Get all tiers # Get all tiers (including inactive - admin needs to configure all)
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order") cur.execute("SELECT id, name, sort_order FROM tiers ORDER BY sort_order")
tiers = [r2d(r) for r in cur.fetchall()] tiers = [r2d(r) for r in cur.fetchall()]
# Get all features # Get all features
cur.execute(""" cur.execute("""
SELECT id, name, category, limit_type, default_limit SELECT id, name, category, limit_type, default_limit, reset_period
FROM features FROM features
WHERE active = true WHERE active = true
ORDER BY category, name ORDER BY category, name

View File

@ -4,16 +4,19 @@ Weight Tracking Endpoints for Mitai Jinkendo
Handles weight log CRUD operations and statistics. Handles weight log CRUD operations and statistics.
""" """
import uuid import uuid
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Header, Depends from fastapi import APIRouter, Header, Depends, HTTPException
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth, check_feature_access, increment_feature_usage
from models import WeightEntry from models import WeightEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/weight", tags=["weight"]) router = APIRouter(prefix="/api/weight", tags=["weight"])
logger = logging.getLogger(__name__)
@router.get("") @router.get("")
@ -31,17 +34,44 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None)
def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update weight entry (upsert by date).""" """Create or update weight entry (upsert by date)."""
pid = get_pid(x_profile_id) pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'weight_entries')
# Structured logging (always)
log_feature_usage(pid, 'weight_entries', access, 'create')
# BLOCK if limit exceeded
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"weight_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Gewichtseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date)) cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
ex = cur.fetchone() ex = cur.fetchone()
is_new_entry = not ex
if ex: if ex:
# UPDATE existing entry
cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id'])) cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
wid = ex['id'] wid = ex['id']
else: else:
# INSERT new entry
wid = str(uuid.uuid4()) wid = str(uuid.uuid4())
cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)", cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(wid,pid,e.date,e.weight,e.note)) (wid,pid,e.date,e.weight,e.note))
# Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'weight_entries')
return {"id":wid,"date":e.date,"weight":e.weight} return {"id":wid,"date":e.date,"weight":e.weight}

1058
docs/MEMBERSHIP_SYSTEM.md Normal file

File diff suppressed because it is too large Load Diff

6766
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,12 @@ import ActivityPage from './pages/ActivityPage'
import Analysis from './pages/Analysis' import Analysis from './pages/Analysis'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import GuidePage from './pages/GuidePage' import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage'
import AdminTiersPage from './pages/AdminTiersPage'
import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import SubscriptionPage from './pages/SubscriptionPage'
import './app.css' import './app.css'
function Nav() { function Nav() {
@ -115,6 +121,12 @@ function AppShell() {
<Route path="/analysis" element={<Analysis/>}/> <Route path="/analysis" element={<Analysis/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
<Route path="/guide" element={<GuidePage/>}/> <Route path="/guide" element={<GuidePage/>}/>
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
<Route path="/admin/features" element={<AdminFeaturesPage/>}/>
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes> </Routes>
</main> </main>
<Nav/> <Nav/>

View File

@ -0,0 +1,163 @@
/**
* FeatureUsageOverview Styles
* Phase 3: Frontend Display
*/
.feature-usage-overview {
display: flex;
flex-direction: column;
gap: 16px;
}
.feature-usage-loading,
.feature-usage-error,
.feature-usage-empty {
padding: 20px;
text-align: center;
color: var(--text2);
font-size: 14px;
}
.feature-usage-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.feature-usage-error {
background: rgba(216, 90, 48, 0.1);
color: var(--danger);
border-radius: 8px;
}
/* Category grouping */
.feature-category {
display: flex;
flex-direction: column;
gap: 8px;
}
.feature-category-label {
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Feature item */
.feature-item {
padding: 12px;
background: var(--surface2);
border-radius: 8px;
border: 1px solid var(--border);
transition: all 0.2s;
}
.feature-item--exceeded {
border-color: var(--danger);
background: rgba(216, 90, 48, 0.05);
}
.feature-item--warning {
border-color: #d97706;
background: rgba(217, 119, 6, 0.05);
}
.feature-item--ok {
border-color: var(--border);
}
.feature-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feature-name {
font-size: 14px;
font-weight: 500;
color: var(--text1);
}
.feature-usage {
font-size: 14px;
white-space: nowrap;
}
.usage-unlimited {
color: var(--accent);
font-weight: 500;
}
.usage-boolean {
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.usage-boolean.enabled {
color: var(--accent);
background: rgba(29, 158, 117, 0.1);
}
.usage-boolean.disabled {
color: var(--text3);
background: rgba(136, 135, 128, 0.1);
}
.usage-count {
color: var(--text1);
font-variant-numeric: tabular-nums;
}
.usage-count strong {
font-weight: 600;
}
.feature-item--exceeded .usage-count {
color: var(--danger);
}
.feature-item--warning .usage-count {
color: #d97706;
}
/* Meta info */
.feature-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
font-size: 11px;
color: var(--text3);
}
.meta-reset,
.meta-next-reset {
padding: 2px 6px;
background: var(--surface);
border-radius: 4px;
}
/* Responsive */
@media (max-width: 640px) {
.feature-main {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.feature-usage {
width: 100%;
}
}

View File

@ -0,0 +1,142 @@
/**
* FeatureUsageOverview - Full feature usage table for Settings page
*
* Shows all features with usage, limits, reset period, and next reset date
* Phase 3: Frontend Display
*/
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import './FeatureUsageOverview.css'
const RESET_PERIOD_LABELS = {
'never': 'Niemals',
'daily': 'Täglich',
'monthly': 'Monatlich'
}
const CATEGORY_LABELS = {
'data': 'Daten',
'ai': 'KI',
'export': 'Export',
'import': 'Import',
'integration': 'Integration'
}
export default function FeatureUsageOverview() {
const [features, setFeatures] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
loadFeatures()
}, [])
const loadFeatures = async () => {
try {
setLoading(true)
const data = await api.getFeatureUsage()
setFeatures(data)
setError(null)
} catch (err) {
console.error('Failed to load feature usage:', err)
setError('Fehler beim Laden der Kontingente')
} finally {
setLoading(false)
}
}
const formatResetDate = (resetAt) => {
if (!resetAt) return '—'
try {
const date = new Date(resetAt)
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
return '—'
}
}
const getStatusClass = (feature) => {
if (!feature.allowed || feature.remaining < 0) return 'exceeded'
if (feature.limit && feature.remaining <= Math.ceil(feature.limit * 0.2)) return 'warning'
return 'ok'
}
// Group by category
const byCategory = features.reduce((acc, f) => {
const cat = f.category || 'other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(f)
return acc
}, {})
if (loading) {
return (
<div className="feature-usage-loading">
<div className="spinner" />
Lade Kontingente...
</div>
)
}
if (error) {
return (
<div className="feature-usage-error">
{error}
</div>
)
}
if (features.length === 0) {
return (
<div className="feature-usage-empty">
Keine Features gefunden
</div>
)
}
return (
<div className="feature-usage-overview">
{Object.entries(byCategory).map(([category, items]) => (
<div key={category} className="feature-category">
<div className="feature-category-label">
{CATEGORY_LABELS[category] || category}
</div>
<div className="feature-list">
{items.map(feature => (
<div key={feature.feature_id} className={`feature-item feature-item--${getStatusClass(feature)}`}>
<div className="feature-main">
<div className="feature-name">{feature.name}</div>
<div className="feature-usage">
{feature.limit === null ? (
<span className="usage-unlimited">Unbegrenzt</span>
) : feature.limit_type === 'boolean' ? (
<span className={`usage-boolean ${feature.allowed ? 'enabled' : 'disabled'}`}>
{feature.allowed ? '✓ Aktiviert' : '✗ Deaktiviert'}
</span>
) : (
<span className="usage-count">
<strong>{feature.used}</strong> / {feature.limit}
</span>
)}
</div>
</div>
{feature.limit_type === 'count' && feature.limit !== null && (
<div className="feature-meta">
<span className="meta-reset">
Reset: {RESET_PERIOD_LABELS[feature.reset_period] || feature.reset_period}
</span>
{feature.reset_at && (
<span className="meta-next-reset">
Nächster Reset: {formatResetDate(feature.reset_at)}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,103 @@
/**
* UsageBadge Styles - Dezente Version
*
* Sehr kleine, subtile Badge für Usage-Anzeige
* Phase 3: Frontend Display
*/
.usage-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 500;
padding: 2px 5px;
border-radius: 3px;
margin-left: 10px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transition: opacity 0.2s;
}
.usage-badge:hover {
opacity: 0.9;
}
.usage-badge--ok {
color: var(--text3, #888);
background: rgba(136, 135, 128, 0.08);
}
.usage-badge--warning {
color: #d97706;
background: rgba(217, 119, 6, 0.08);
}
.usage-badge--exceeded {
color: var(--danger, #D85A30);
background: rgba(216, 90, 48, 0.1);
font-weight: 600;
opacity: 0.75;
}
.usage-badge--exceeded:hover {
opacity: 1;
}
/* Responsive: Even smaller on mobile */
@media (max-width: 640px) {
.usage-badge {
font-size: 0.6rem;
padding: 1px 4px;
margin-left: 8px;
}
}
/* ============================================================================
Badge Container Styles - Positioning
============================================================================
Zentrale Konfiguration für Badge-Platzierung in verschiedenen Kontexten.
Alle Anpassungen an Layout/Spacing hier vornehmen!
*/
/* Badge rechts außen (für Headings/Titles) */
.badge-container-right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* Badge rechts im Button (mit Beschreibung darunter) */
.badge-button-layout {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
}
.badge-button-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.badge-button-description {
font-size: 11px;
opacity: 0.8;
display: block;
margin-top: 2px;
text-align: left;
}
/* Mobile Anpassungen */
@media (max-width: 640px) {
.badge-container-right {
gap: 8px;
}
.badge-button-description {
font-size: 10px;
}
}

View File

@ -0,0 +1,31 @@
/**
* UsageBadge - Small inline usage indicator
*
* Shows usage quota in format: (used/limit)
* Color-coded: green (ok), yellow (warning), red (exceeded)
*
* Phase 3: Frontend Display
*/
import './UsageBadge.css'
export default function UsageBadge({ used, limit, remaining, allowed }) {
// Don't show badge if unlimited
if (limit === null || limit === undefined) {
return null
}
// Determine status for color coding
let status = 'ok'
if (!allowed || remaining < 0) {
status = 'exceeded'
} else if (limit > 0 && remaining <= Math.ceil(limit * 0.2)) {
// Warning at 20% or less remaining
status = 'warning'
}
return (
<span className={`usage-badge usage-badge--${status}`}>
({used}/{limit})
</span>
)
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api' import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
@ -79,7 +80,7 @@ function ImportPanel({ onImported }) {
} }
// Manual Entry // Manual Entry
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) { function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
const set = (k,v) => setForm(f=>({...f,[k]:v})) const set = (k,v) => setForm(f=>({...f,[k]:v}))
return ( return (
<div> <div>
@ -130,8 +131,25 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/> value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/> <span className="form-unit"/>
</div> </div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}> <div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button> <div
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={onSave}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>} {onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div> </div>
</div> </div>
@ -145,25 +163,51 @@ export default function ActivityPage() {
const [tab, setTab] = useState('list') const [tab, setTab] = useState('list')
const [form, setForm] = useState(empty()) const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const load = async () => { const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s) setEntries(e); setStats(s)
} }
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
setActivityUsage(activityFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const handleSave = async () => { const handleSave = async () => {
const payload = {...form} setSaving(true)
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min) setError(null)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active) try {
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg) const payload = {...form}
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max) if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
if(payload.rpe) payload.rpe = parseInt(payload.rpe) if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
payload.source = 'manual' if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
await api.createActivity(payload) if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
setSaved(true); await load() if(payload.rpe) payload.rpe = parseInt(payload.rpe)
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500) payload.source = 'manual'
await api.createActivity(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
} }
const handleUpdate = async () => { const handleUpdate = async () => {
@ -225,9 +269,13 @@ export default function ActivityPage() {
{tab==='add' && ( {tab==='add' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Training eintragen</div> <div className="card-title badge-container-right">
<span>Training eintragen</span>
{activityUsage && <UsageBadge {...activityUsage} />}
</div>
<EntryForm form={form} setForm={setForm} <EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/> onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={activityUsage}/>
</div> </div>
)} )}

View File

@ -0,0 +1,523 @@
import { useState, useEffect } from 'react'
import { Save, Plus, Edit2, Trash2, X, Eye, Gift, Ticket } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminCouponsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [coupons, setCoupons] = useState([])
const [editingId, setEditingId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [viewingRedemptions, setViewingRedemptions] = useState(null)
const [redemptions, setRedemptions] = useState([])
const [formData, setFormData] = useState({
code: '',
type: 'single_use',
valid_from: '',
valid_until: '',
grants_tier: 'premium',
duration_days: 30,
max_redemptions: 1,
notes: '',
active: true
})
useEffect(() => {
loadCoupons()
}, [])
async function loadCoupons() {
try {
setLoading(true)
const data = await api.listCoupons()
setCoupons(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function loadRedemptions(couponId) {
try {
const data = await api.getCouponRedemptions(couponId)
setRedemptions(data)
setViewingRedemptions(couponId)
} catch (e) {
setError(e.message)
}
}
function resetForm() {
setFormData({
code: '',
type: 'single_use',
valid_from: '',
valid_until: '',
grants_tier: 'premium',
duration_days: 30,
max_redemptions: 1,
notes: '',
active: true
})
setEditingId(null)
setShowAddForm(false)
}
function startEdit(coupon) {
setFormData({
code: coupon.code,
type: coupon.type,
valid_from: coupon.valid_from ? coupon.valid_from.split('T')[0] : '',
valid_until: coupon.valid_until ? coupon.valid_until.split('T')[0] : '',
grants_tier: coupon.grants_tier,
duration_days: coupon.duration_days || 30,
max_redemptions: coupon.max_redemptions || 1,
notes: coupon.notes || '',
active: coupon.active
})
setEditingId(coupon.id)
setShowAddForm(false)
}
function startAdd() {
// Generate random code
const randomCode = `${formData.type === 'gift' ? 'GIFT' : 'PROMO'}-${Math.random().toString(36).substr(2, 8).toUpperCase()}`
setFormData({ ...formData, code: randomCode })
setShowAddForm(true)
setEditingId(null)
}
async function handleSave() {
try {
setError('')
setSuccess('')
if (!formData.code.trim()) {
setError('Code erforderlich')
return
}
const payload = {
code: formData.code.trim().toUpperCase(),
type: formData.type,
valid_from: formData.valid_from || null,
valid_until: formData.valid_until || null,
grants_tier: formData.grants_tier,
duration_days: parseInt(formData.duration_days) || null,
max_redemptions: formData.type === 'single_use' ? 1 : (parseInt(formData.max_redemptions) || null),
notes: formData.notes.trim(),
active: formData.active
}
if (editingId) {
await api.updateCoupon(editingId, payload)
setSuccess('Coupon aktualisiert')
} else {
await api.createCoupon(payload)
setSuccess('Coupon erstellt')
}
await loadCoupons()
resetForm()
} catch (e) {
setError(e.message)
}
}
async function handleDelete(couponId) {
if (!confirm('Coupon wirklich löschen?')) return
try {
setError('')
await api.deleteCoupon(couponId)
setSuccess('Coupon gelöscht')
await loadCoupons()
} catch (e) {
setError(e.message)
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('de-DE')
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const couponTypes = [
{ value: 'single_use', label: 'Single-Use (einmalig)', icon: '🎟️' },
{ value: 'multi_use_period', label: 'Multi-Use Period (Zeitraum)', icon: '🔄' },
{ value: 'gift', label: 'Geschenk-Coupon', icon: '🎁' }
]
const tierOptions = [
{ value: 'free', label: 'Free' },
{ value: 'basic', label: 'Basic' },
{ value: 'premium', label: 'Premium' }
]
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20
}}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Coupon-Verwaltung
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Trial-Codes, Wellpass-Integration, Geschenk-Coupons
</div>
</div>
{!showAddForm && !editingId && (
<button className="btn btn-primary" onClick={startAdd}>
<Plus size={16} /> Neuer Coupon
</button>
)}
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Add/Edit Form */}
{(showAddForm || editingId) && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{editingId ? 'Coupon bearbeiten' : 'Neuer Coupon'}
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 16 }}>
{/* Code */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Coupon-Code *
</label>
<input
className="form-input"
style={{ width: '100%', fontFamily: 'monospace', textTransform: 'uppercase' }}
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="Z.B. PROMO-2026"
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wird automatisch in Großbuchstaben konvertiert
</div>
</div>
{/* Type */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Coupon-Typ
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
>
{couponTypes.map(t => (
<option key={t.value} value={t.value}>
{t.icon} {t.label}
</option>
))}
</select>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
{formData.type === 'single_use' && 'Kann nur einmal pro User eingelöst werden'}
{formData.type === 'multi_use_period' && 'Unbegrenzte Einlösungen im Gültigkeitszeitraum (z.B. Wellpass)'}
{formData.type === 'gift' && 'Geschenk-Coupon für Bonus-System'}
</div>
</div>
{/* Tier */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gewährt Tier
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.grants_tier}
onChange={(e) => setFormData({ ...formData, grants_tier: e.target.value })}
>
{tierOptions.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Duration (only for single_use and gift) */}
{(formData.type === 'single_use' || formData.type === 'gift') && (
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültigkeitsdauer (Tage)
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.duration_days}
onChange={(e) => setFormData({ ...formData, duration_days: e.target.value })}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wie lange ist der gewährte Zugriff gültig?
</div>
</div>
)}
{/* Valid From/Until (for multi_use_period) */}
{formData.type === 'multi_use_period' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültig ab
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="date"
value={formData.valid_from}
onChange={(e) => setFormData({ ...formData, valid_from: e.target.value })}
/>
</div>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültig bis
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="date"
value={formData.valid_until}
onChange={(e) => setFormData({ ...formData, valid_until: e.target.value })}
/>
</div>
</div>
)}
{/* Max Redemptions (not for single_use) */}
{formData.type !== 'single_use' && (
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Maximale Einlösungen (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.max_redemptions}
onChange={(e) => setFormData({ ...formData, max_redemptions: e.target.value })}
placeholder="Leer = unbegrenzt"
/>
</div>
)}
{/* Notes */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Notizen (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Z.B. 'Für Marketing-Kampagne März 2026'"
/>
</div>
{/* Active */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Coupon aktiviert (kann eingelöst werden)
</label>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
</button>
<button className="btn btn-secondary" onClick={resetForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Redemptions Modal */}
{viewingRedemptions && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20
}} onClick={() => setViewingRedemptions(null)}>
<div className="card" style={{ padding: 20, maxWidth: 600, width: '100%', maxHeight: '80vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ fontSize: 16, fontWeight: 600 }}>Einlösungen</div>
<button className="btn btn-secondary" onClick={() => setViewingRedemptions(null)} style={{ padding: '6px 12px' }}>
<X size={16} />
</button>
</div>
{redemptions.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Noch keine Einlösungen
</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{redemptions.map(r => (
<div key={r.id} className="card" style={{ padding: 12, background: 'var(--surface)' }}>
<div style={{ fontWeight: 500 }}>{r.profile_name || `User #${r.profile_id}`}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>
Eingelöst am {formatDate(r.redeemed_at)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Coupons List */}
<div style={{ display: 'grid', gap: 12 }}>
{coupons.length === 0 && (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
Keine Coupons vorhanden
</div>
)}
{coupons.map(coupon => (
<div
key={coupon.id}
className="card"
style={{
padding: 16,
opacity: coupon.active ? 1 : 0.6,
border: coupon.active ? '1px solid var(--border)' : '1px dashed var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{
fontSize: 16, fontWeight: 700, color: 'var(--text1)',
fontFamily: 'monospace', background: 'var(--surface2)',
padding: '4px 8px', borderRadius: 4
}}>
{coupon.code}
</div>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
}}>
{couponTypes.find(t => t.value === coupon.type)?.icon} {couponTypes.find(t => t.value === coupon.type)?.label}
</span>
{!coupon.active && (
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--danger)', color: 'white', fontWeight: 600
}}>
INAKTIV
</span>
)}
</div>
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
Gewährt: <strong>{coupon.grants_tier}</strong>
{coupon.duration_days && ` für ${coupon.duration_days} Tage`}
</div>
{coupon.notes && (
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>
📝 {coupon.notes}
</div>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
{coupon.valid_from && (
<div><strong>Gültig ab:</strong> {formatDate(coupon.valid_from)}</div>
)}
{coupon.valid_until && (
<div><strong>Gültig bis:</strong> {formatDate(coupon.valid_until)}</div>
)}
<div>
<strong>Einlösungen:</strong> {coupon.current_redemptions || 0}
{coupon.max_redemptions ? ` / ${coupon.max_redemptions}` : ' / ∞'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button
className="btn btn-secondary"
onClick={() => loadRedemptions(coupon.id)}
style={{ padding: '6px 12px', fontSize: 12 }}
title="Einlösungen anzeigen"
>
<Eye size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => startEdit(coupon)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => handleDelete(coupon.id)}
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,480 @@
import { useState, useEffect } from 'react'
import { Save, Edit2, X, Info } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminFeaturesPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [features, setFeatures] = useState([])
const [editingId, setEditingId] = useState(null)
const [formData, setFormData] = useState({
name: '',
category: 'data',
description: '',
limit_type: 'count',
default_limit: '',
reset_period: 'never',
visible_in_admin: true,
sort_order: 50,
active: true
})
useEffect(() => {
loadFeatures()
}, [])
async function loadFeatures() {
try {
setLoading(true)
const data = await api.listFeatures()
setFeatures(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
function resetForm() {
setFormData({
name: '',
category: 'data',
description: '',
limit_type: 'count',
default_limit: '',
reset_period: 'never',
visible_in_admin: true,
sort_order: 50,
active: true
})
setEditingId(null)
}
function startEdit(feature) {
setFormData({
name: feature.name,
category: feature.category,
description: feature.description || '',
limit_type: feature.limit_type,
default_limit: feature.default_limit === null ? '' : feature.default_limit,
reset_period: feature.reset_period,
visible_in_admin: feature.visible_in_admin,
sort_order: feature.sort_order || 50,
active: feature.active
})
setEditingId(feature.id)
}
async function handleSave() {
try {
setError('')
setSuccess('')
if (!formData.name.trim()) {
setError('Name erforderlich')
return
}
const payload = {
name: formData.name.trim(),
category: formData.category,
description: formData.description.trim(),
limit_type: formData.limit_type,
default_limit: formData.default_limit === '' ? null : parseInt(formData.default_limit),
reset_period: formData.reset_period,
visible_in_admin: formData.visible_in_admin,
sort_order: formData.sort_order,
active: formData.active
}
await api.updateFeature(editingId, payload)
setSuccess('Feature aktualisiert')
await loadFeatures()
resetForm()
} catch (e) {
setError(e.message)
}
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const categoryOptions = [
{ value: 'data', label: 'Daten' },
{ value: 'ai', label: 'KI' },
{ value: 'export', label: 'Export' },
{ value: 'integration', label: 'Integrationen' }
]
const resetPeriodOptions = [
{ value: 'never', label: 'Nie (akkumuliert)' },
{ value: 'daily', label: 'Täglich' },
{ value: 'monthly', label: 'Monatlich' }
]
const limitTypeOptions = [
{ value: 'count', label: 'Anzahl (Count)' },
{ value: 'boolean', label: 'Ja/Nein (Boolean)' }
]
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Feature-Konfiguration
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Limitierungs-Einstellungen für registrierte Features
</div>
</div>
{/* Info Box */}
<div style={{
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
display: 'flex', gap: 8, alignItems: 'flex-start'
}}>
<Info size={16} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<strong>Hinweis:</strong> Features werden automatisch via Code registriert.
Hier können nur Basis-Einstellungen (Limit-Typ, Reset-Periode, Standards) angepasst werden.
Neue Features hinzuzufügen erfordert Code-Änderungen im Backend.
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Edit Form */}
{editingId && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
Feature konfigurieren
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 16 }}>
{/* Feature ID (read-only) */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Feature ID
</label>
<input
className="form-input"
value={editingId}
disabled
style={{
width: '100%',
background: 'var(--surface2)',
color: 'var(--text3)',
cursor: 'not-allowed',
fontFamily: 'monospace'
}}
/>
</div>
{/* Name */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Name *
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Gewichtseinträge"
/>
</div>
{/* Description */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Beschreibung (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kurze Erklärung was dieses Feature limitiert"
/>
</div>
{/* Category + Limit Type */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Kategorie
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
>
{categoryOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Limit-Typ
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.limit_type}
onChange={(e) => setFormData({ ...formData, limit_type: e.target.value })}
>
{limitTypeOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{/* Count-specific fields (only for limit_type='count') */}
{formData.limit_type === 'count' && (
<>
{/* Reset Period */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Reset-Periode
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.reset_period}
onChange={(e) => setFormData({ ...formData, reset_period: e.target.value })}
>
{resetPeriodOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wann wird der Nutzungszähler zurückgesetzt?
</div>
</div>
{/* Default Limit */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Standard-Limit
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.default_limit}
onChange={(e) => setFormData({ ...formData, default_limit: e.target.value })}
placeholder="Leer = unbegrenzt"
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Fallback-Wert wenn kein Tier-spezifisches Limit gesetzt ist
</div>
</div>
</>
)}
{/* Boolean info */}
{formData.limit_type === 'boolean' && (
<div style={{
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
fontSize: 12, color: 'var(--accent-dark)'
}}>
<strong>Boolean-Feature:</strong> Ist entweder verfügbar (AN) oder nicht verfügbar (AUS).
Keine Zähler oder Reset-Perioden notwendig.
</div>
)}
{/* Sort Order */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Anzeigereihenfolge
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Niedrigere Werte erscheinen weiter oben in Listen (Standard: 50)
</div>
</div>
{/* Checkboxes */}
<div style={{ display: 'flex', gap: 16, paddingTop: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.visible_in_admin}
onChange={(e) => setFormData({ ...formData, visible_in_admin: e.target.checked })}
/>
Im Admin sichtbar
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Feature aktiviert
</label>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={14} /> Speichern
</button>
<button className="btn btn-secondary" onClick={resetForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Features List */}
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Feature</th>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Kategorie</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Limit-Typ</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Reset</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Standard</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Status</th>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>Aktion</th>
</tr>
</thead>
<tbody>
{features.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Keine Features registriert
</td>
</tr>
)}
{features.map((feature, idx) => (
<tr
key={feature.id}
style={{
borderBottom: idx === features.length - 1 ? 'none' : '1px solid var(--border)',
background: feature.active ? 'transparent' : 'var(--surface)',
opacity: feature.active ? 1 : 0.6
}}
>
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2, fontFamily: 'monospace' }}>
{feature.id}
</div>
{feature.description && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.description}
</div>
)}
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
}}>
{feature.category}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: feature.limit_type === 'boolean' ? 'var(--surface2)' : 'var(--surface2)',
fontWeight: 500
}}>
{feature.limit_type === 'boolean' ? '✓/✗' : '123'}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', fontSize: 11, color: 'var(--text3)' }}>
{feature.reset_period === 'never' ? '∞' : feature.reset_period === 'daily' ? '1d' : '1m'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', fontWeight: 500 }}>
{feature.default_limit === null ? '∞' : feature.default_limit}
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.active ? (
<span style={{ color: 'var(--accent)', fontWeight: 600 }}> Aktiv</span>
) : (
<span style={{ color: 'var(--text3)' }}> Inaktiv</span>
)}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<button
className="btn btn-secondary"
onClick={() => startEdit(feature)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} /> Konfigurieren
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Limit-Typ:</strong>
<div style={{ marginTop: 4 }}>
<strong>Boolean (/):</strong> Feature ist entweder verfügbar oder nicht (z.B. "KI aktiviert")
</div>
<div style={{ marginTop: 2 }}>
<strong>Count (123):</strong> Feature hat ein Nutzungs-Limit (z.B. "max. 50 Einträge")
</div>
<div style={{ marginTop: 8 }}>
<strong>Reset-Periode:</strong> = nie, 1d = täglich, 1m = monatlich
</div>
</div>
</div>
)
}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Shield, Key } from 'lucide-react' import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { api } from '../utils/api' import { api } from '../utils/api'
@ -142,10 +143,7 @@ function EmailEditor({ profileId, currentEmail, onSaved }) {
function ProfileCard({ profile, currentId, onRefresh }) { function ProfileCard({ profile, currentId, onRefresh }) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [perms, setPerms] = useState({ const [perms, setPerms] = useState({
ai_enabled: profile.ai_enabled ?? 1, role: profile.role || 'user',
ai_limit_day: profile.ai_limit_day || '',
export_enabled: profile.export_enabled ?? 1,
role: profile.role || 'user',
}) })
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [newPin, setNewPin] = useState('') const [newPin, setNewPin] = useState('')
@ -156,10 +154,7 @@ function ProfileCard({ profile, currentId, onRefresh }) {
setSaving(true) setSaving(true)
try { try {
await api.adminSetPermissions(profile.id, { await api.adminSetPermissions(profile.id, {
ai_enabled: perms.ai_enabled, role: perms.role,
ai_limit_day: perms.ai_limit_day ? parseInt(perms.ai_limit_day) : null,
export_enabled: perms.export_enabled,
role: perms.role,
}) })
await onRefresh() await onRefresh()
} finally { setSaving(false) } } finally { setSaving(false) }
@ -195,9 +190,8 @@ function ProfileCard({ profile, currentId, onRefresh }) {
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>} {isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
</div> </div>
<div style={{fontSize:11,color:'var(--text3)'}}> <div style={{fontSize:11,color:'var(--text3)'}}>
KI: {profile.ai_enabled?`${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} · Tier: {profile.tier || 'free'} ·
Export: {profile.export_enabled?'✓':'✗'} · Email: {profile.email || 'nicht gesetzt'}
Calls heute: {profile.ai_calls_today||0}
</div> </div>
</div> </div>
<div style={{display:'flex',gap:6}}> <div style={{display:'flex',gap:6}}>
@ -232,23 +226,19 @@ function ProfileCard({ profile, currentId, onRefresh }) {
</button> </button>
))} ))}
</div> </div>
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Rolle speichern'}
</button>
</div> </div>
<Toggle value={!!perms.ai_enabled} onChange={v=>setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/> {/* Feature-Overrides */}
{!!perms.ai_enabled && ( <div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
<div className="form-row" style={{paddingTop:6}}> <strong>Feature-Limits:</strong> Nutze die neue{' '}
<label className="form-label" style={{fontSize:12}}>Max. KI-Calls/Tag</label> <Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
<input type="number" className="form-input" style={{width:70}} min={1} max={100} User Feature-Overrides
placeholder="∞" value={perms.ai_limit_day} </Link>{' '}
onChange={e=>setPerms(p=>({...p,ai_limit_day:e.target.value}))}/> Seite um individuelle Limits zu setzen.
<span className="form-unit" style={{fontSize:11}}>/Tag</span> </div>
</div>
)}
<Toggle value={!!perms.export_enabled} onChange={v=>setPerms(p=>({...p,export_enabled:v?1:0}))} label="Daten-Export erlaubt"/>
<button className="btn btn-primary btn-full" style={{marginTop:10}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Berechtigungen speichern'}
</button>
{/* Email */} {/* Email */}
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}> <div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
@ -397,6 +387,43 @@ export default function AdminPanel() {
{/* Email Settings */} {/* Email Settings */}
<EmailSettings/> <EmailSettings/>
{/* v9c Subscription Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Subscription-System (v9c)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Tiers, Features und Limits für das neue Freemium-System.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/tiers">
<button className="btn btn-secondary btn-full">
🎯 Tiers verwalten
</button>
</Link>
<Link to="/admin/features">
<button className="btn btn-secondary btn-full">
🔧 Feature-Registry verwalten
</button>
</Link>
<Link to="/admin/tier-limits">
<button className="btn btn-secondary btn-full">
📊 Tier Limits Matrix bearbeiten
</button>
</Link>
<Link to="/admin/coupons">
<button className="btn btn-secondary btn-full">
🎟 Coupons verwalten
</button>
</Link>
<Link to="/admin/user-restrictions">
<button className="btn btn-secondary btn-full">
👤 User Feature-Overrides
</button>
</Link>
</div>
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,499 @@
import { useState, useEffect } from 'react'
import { Save, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminTierLimitsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [matrix, setMatrix] = useState({ tiers: [], features: [], limits: {} })
const [changes, setChanges] = useState({})
const [saving, setSaving] = useState(false)
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
useEffect(() => {
loadMatrix()
const handleResize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
async function loadMatrix() {
try {
setLoading(true)
const data = await api.getTierLimitsMatrix()
setMatrix(data)
setChanges({})
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
function handleChange(tierId, featureId, value) {
const key = `${tierId}:${featureId}`
const newChanges = { ...changes }
// Allow temporary empty input for better UX
if (value === '') {
newChanges[key] = { tierId, featureId, value: '', tempValue: '' }
setChanges(newChanges)
return
}
// Parse value
let parsedValue = null
if (value === 'unlimited' || value === '∞') {
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
} else {
const num = parseInt(value)
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid input, ignore
}
}
newChanges[key] = { tierId, featureId, value: parsedValue, tempValue: value }
setChanges(newChanges)
}
async function saveChanges() {
// Filter out empty temporary values
const validChanges = Object.values(changes).filter(c => c.value !== '')
if (validChanges.length === 0) {
setSuccess('Keine Änderungen')
return
}
try {
setSaving(true)
setError('')
setSuccess('')
const updates = validChanges.map(c => ({
tier_id: c.tierId,
feature_id: c.featureId,
limit_value: c.value
}))
await api.updateTierLimitsBatch(updates)
setSuccess(`${updates.length} Limits gespeichert`)
await loadMatrix()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function getCurrentValue(tierId, featureId) {
const key = `${tierId}:${featureId}`
if (key in changes) {
// Return temp value for display
return changes[key].tempValue !== undefined ? changes[key].tempValue : changes[key].value
}
return matrix.limits[key] ?? null
}
function formatValue(val) {
if (val === '' || val === null || val === undefined) return ''
if (val === '∞' || val === 'unlimited') return '∞'
if (val === 0 || val === '0') return '0'
return val.toString()
}
function groupFeaturesByCategory() {
const groups = {}
matrix.features.forEach(f => {
if (!groups[f.category]) groups[f.category] = []
groups[f.category].push(f)
})
return groups
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const hasChanges = Object.keys(changes).filter(k => changes[k].value !== '').length > 0
const categoryGroups = groupFeaturesByCategory()
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
// Mobile: Card-based view
if (isMobile) {
return (
<div style={{ paddingBottom: 100 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Tier Limits
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Limits pro Tier konfigurieren
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 13
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 13
}}>
{success}
</div>
)}
{/* Mobile: Feature Cards */}
{Object.entries(categoryGroups).map(([category, features]) => (
<div key={category} style={{ marginBottom: 20 }}>
{/* Category Header */}
<div style={{
padding: '6px 12px', background: 'var(--accent-light)', borderRadius: 8,
fontWeight: 600, fontSize: 11, textTransform: 'uppercase',
color: 'var(--accent-dark)', marginBottom: 8
}}>
{categoryIcons[category]} {categoryNames[category] || category}
</div>
{/* Features */}
{features.map(feature => (
<FeatureMobileCard
key={feature.id}
feature={feature}
tiers={matrix.tiers}
getCurrentValue={getCurrentValue}
handleChange={handleChange}
changes={changes}
/>
))}
</div>
))}
{/* Fixed Bottom Bar */}
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
background: 'var(--bg)', borderTop: '1px solid var(--border)',
padding: 16, display: 'flex', gap: 8, zIndex: 100,
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
}}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
disabled={saving}
style={{ flex: 1 }}
>
<RotateCcw size={14}/> Zurück
</button>
)}
<button
className="btn btn-primary"
onClick={saveChanges}
disabled={saving || !hasChanges}
style={{ flex: hasChanges ? 2 : 1 }}
>
{saving ? '...' : hasChanges ? <><Save size={14}/> {Object.keys(changes).filter(k=>changes[k].value!=='').length} Speichern</> : 'Keine Änderungen'}
</button>
</div>
</div>
)
}
// Desktop: Table view
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20
}}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Tier Limits Matrix
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Feature-Limits pro Tier (leer = unbegrenzt, 0 = deaktiviert)
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
disabled={saving}
>
<RotateCcw size={14}/> Zurücksetzen
</button>
)}
<button
className="btn btn-primary"
onClick={saveChanges}
disabled={saving || !hasChanges}
>
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).filter(k=>changes[k].value!=='').length} Änderungen speichern` : <><Save size={14}/> Speichern</>}
</button>
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Matrix Table */}
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{
width: '100%', borderCollapse: 'collapse', fontSize: 13,
minWidth: 800
}}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{
textAlign: 'left', padding: '12px 16px', fontWeight: 600,
position: 'sticky', left: 0, background: 'var(--surface2)', zIndex: 10,
borderRight: '1px solid var(--border)'
}}>
Feature
</th>
{matrix.tiers.map(tier => (
<th key={tier.id} style={{
textAlign: 'center', padding: '12px 16px', fontWeight: 600,
minWidth: 100
}}>
<div>{tier.name}</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text3)', marginTop: 2 }}>
{tier.id}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(categoryGroups).map(([category, features]) => (
<>
{/* Category Header */}
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
<td colSpan={matrix.tiers.length + 1} style={{
padding: '8px 16px', fontWeight: 600, fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.5px',
color: 'var(--accent-dark)'
}}>
{categoryIcons[category]} {categoryNames[category] || category}
</td>
</tr>
{/* Feature Rows */}
{features.map((feature, idx) => (
<tr key={feature.id} style={{
borderBottom: idx === features.length - 1 ? '2px solid var(--border)' : '1px solid var(--border)'
}}>
<td style={{
padding: '8px 16px', fontWeight: 500,
position: 'sticky', left: 0, background: 'var(--bg)', zIndex: 5,
borderRight: '1px solid var(--border)'
}}>
<div>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(count, reset: ${feature.reset_period})`}
</div>
</td>
{matrix.tiers.map(tier => {
const currentValue = getCurrentValue(tier.id, feature.id)
const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== ''
// Boolean features: Toggle button
if (feature.limit_type === 'boolean') {
const isEnabled = currentValue !== 0 && currentValue !== '0'
return (
<td key={`${tier.id}-${feature.id}`} style={{
textAlign: 'center', padding: 8,
background: isChanged ? 'var(--accent-light)' : 'transparent'
}}>
<button
onClick={() => handleChange(tier.id, feature.id, isEnabled ? '0' : '1')}
style={{
padding: '6px 16px',
border: `2px solid ${isEnabled ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20,
background: isEnabled ? 'var(--accent)' : 'var(--surface)',
color: isEnabled ? 'white' : 'var(--text3)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
minWidth: 70
}}
>
{isEnabled ? '✓ AN' : '✗ AUS'}
</button>
</td>
)
}
// Count features: Text input
return (
<td key={`${tier.id}-${feature.id}`} style={{
textAlign: 'center', padding: 8,
background: isChanged ? 'var(--accent-light)' : 'transparent'
}}>
<input
type="text"
value={formatValue(currentValue)}
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
placeholder="∞"
style={{
width: '80px',
padding: '6px 8px',
border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6,
textAlign: 'center',
fontSize: 13,
fontWeight: isChanged ? 600 : 400,
background: 'var(--bg)',
color: currentValue === 0 || currentValue === '0' ? 'var(--danger)' :
currentValue === null || currentValue === '' || currentValue === '∞' ? 'var(--accent)' : 'var(--text1)'
}}
/>
</td>
)
})}
</tr>
))}
</>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Eingabe:</strong>
<div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span><strong style={{ color: 'var(--accent)' }}>leer oder </strong> = Unbegrenzt</span>
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Deaktiviert</span>
<span><strong>1-999999</strong> = Limit-Wert</span>
</div>
</div>
</div>
)
}
// Mobile Card Component
function FeatureMobileCard({ feature, tiers, getCurrentValue, handleChange, changes }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="card" style={{ marginBottom: 8, padding: 12 }}>
{/* Feature Header */}
<div
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', padding: '4px 0'
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
</div>
</div>
{expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}
</div>
{/* Tier Inputs (Expanded) */}
{expanded && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{tiers.map(tier => {
const currentValue = getCurrentValue(tier.id, feature.id)
const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== ''
// Boolean features: Toggle button
if (feature.limit_type === 'boolean') {
const isEnabled = currentValue !== 0 && currentValue !== '0'
return (
<div key={tier.id} className="form-row" style={{ marginBottom: 8, alignItems: 'center' }}>
<label className="form-label" style={{ fontSize: 12 }}>{tier.name}</label>
<button
onClick={() => handleChange(tier.id, feature.id, isEnabled ? '0' : '1')}
style={{
flex: 1,
padding: '8px 16px',
border: `2px solid ${isEnabled ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20,
background: isEnabled ? 'var(--accent)' : 'var(--surface)',
color: isEnabled ? 'white' : 'var(--text3)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
{isEnabled ? '✓ Aktiviert' : '✗ Deaktiviert'}
</button>
</div>
)
}
// Count features: Text input
return (
<div key={tier.id} className="form-row" style={{ marginBottom: 8 }}>
<label className="form-label" style={{ fontSize: 12 }}>{tier.name}</label>
<input
type="text"
className="form-input"
value={currentValue === null || currentValue === undefined ? '' : currentValue.toString()}
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
placeholder="∞"
style={{
border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
background: isChanged ? 'var(--accent-light)' : 'var(--bg)',
color: currentValue === 0 ? 'var(--danger)' :
currentValue === null || currentValue === '' ? 'var(--accent)' : 'var(--text1)',
fontWeight: isChanged ? 600 : 400
}}
/>
<span className="form-unit" style={{ fontSize: 11 }}>
{currentValue === null || currentValue === '' ? '∞' : currentValue === 0 ? '❌' : '✓'}
</span>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,392 @@
import { useState, useEffect } from 'react'
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminTiersPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [tiers, setTiers] = useState([])
const [editingId, setEditingId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState({
id: '',
name: '',
description: '',
price_monthly_cents: '',
price_yearly_cents: '',
sort_order: 50,
active: true
})
useEffect(() => {
loadTiers()
}, [])
async function loadTiers() {
try {
setLoading(true)
const data = await api.listTiers()
setTiers(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
function resetForm() {
setFormData({
id: '',
name: '',
description: '',
price_monthly_cents: '',
price_yearly_cents: '',
sort_order: 50,
active: true
})
setEditingId(null)
setShowAddForm(false)
}
function startEdit(tier) {
setFormData({
id: tier.id,
name: tier.name,
description: tier.description || '',
price_monthly_cents: tier.price_monthly_cents === null ? '' : tier.price_monthly_cents,
price_yearly_cents: tier.price_yearly_cents === null ? '' : tier.price_yearly_cents,
sort_order: tier.sort_order || 50,
active: tier.active
})
setEditingId(tier.id)
setShowAddForm(false)
}
async function handleSave() {
try {
setError('')
setSuccess('')
// Validation
if (!formData.name.trim()) {
setError('Name erforderlich')
return
}
const payload = {
name: formData.name.trim(),
description: formData.description.trim(),
price_monthly_cents: formData.price_monthly_cents === '' ? null : parseInt(formData.price_monthly_cents),
price_yearly_cents: formData.price_yearly_cents === '' ? null : parseInt(formData.price_yearly_cents),
sort_order: formData.sort_order,
active: formData.active
}
if (editingId) {
// Update existing
await api.updateTier(editingId, payload)
setSuccess('Tier aktualisiert')
} else {
// Create new
if (!formData.id.trim()) {
setError('ID erforderlich')
return
}
payload.id = formData.id.trim()
await api.createTier(payload)
setSuccess('Tier erstellt')
}
await loadTiers()
resetForm()
} catch (e) {
setError(e.message)
}
}
async function handleDelete(tierId) {
if (!confirm('Tier wirklich deaktivieren?')) return
try {
setError('')
await api.deleteTier(tierId)
setSuccess('Tier deaktiviert')
await loadTiers()
} catch (e) {
setError(e.message)
}
}
function formatPrice(cents) {
if (cents === null || cents === undefined) return 'Kostenlos'
return `${(cents / 100).toFixed(2)}`
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20
}}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Tier-Verwaltung
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Subscription-Tiers konfigurieren
</div>
</div>
{!showAddForm && !editingId && (
<button
className="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
<Plus size={16} /> Neuer Tier
</button>
)}
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Add/Edit Form */}
{(showAddForm || editingId) && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{editingId ? 'Tier bearbeiten' : 'Neuen Tier erstellen'}
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 12 }}>
{/* ID (nur bei Neuanlage) */}
{!editingId && (
<div className="form-row">
<label className="form-label">ID (Slug) *</label>
<input
className="form-input"
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
placeholder="z.B. enterprise"
/>
<span className="form-unit" style={{ fontSize: 11, color: 'var(--text3)' }}>
Kleinbuchstaben, keine Leerzeichen
</span>
</div>
)}
{/* Name */}
<div className="form-row">
<label className="form-label">Name *</label>
<input
className="form-input"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Enterprise"
/>
</div>
{/* Description */}
<div className="form-row">
<label className="form-label">Beschreibung</label>
<input
className="form-input"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="z.B. Für Teams und Unternehmen"
/>
</div>
{/* Pricing */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">Monatspreis (Cent)</label>
<input
className="form-input"
type="number"
value={formData.price_monthly_cents}
onChange={(e) => setFormData({ ...formData, price_monthly_cents: e.target.value })}
placeholder="Leer = kostenlos"
/>
<span className="form-unit" style={{ fontSize: 11 }}>
{formData.price_monthly_cents ? formatPrice(parseInt(formData.price_monthly_cents)) : '-'}
</span>
</div>
<div className="form-row">
<label className="form-label">Jahrespreis (Cent)</label>
<input
className="form-input"
type="number"
value={formData.price_yearly_cents}
onChange={(e) => setFormData({ ...formData, price_yearly_cents: e.target.value })}
placeholder="Leer = kostenlos"
/>
<span className="form-unit" style={{ fontSize: 11 }}>
{formData.price_yearly_cents ? formatPrice(parseInt(formData.price_yearly_cents)) : '-'}
</span>
</div>
</div>
{/* Sort Order + Active */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end' }}>
<div className="form-row">
<label className="form-label">Sortierung</label>
<input
className="form-input"
type="number"
value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, paddingBottom: 8 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Aktiv
</label>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
</button>
<button className="btn btn-secondary" onClick={resetForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Tiers List */}
<div style={{ display: 'grid', gap: 12 }}>
{tiers.length === 0 && (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
Keine Tiers vorhanden
</div>
)}
{tiers.map(tier => (
<div
key={tier.id}
className="card"
style={{
padding: 16,
opacity: tier.active ? 1 : 0.6,
border: tier.active ? '1px solid var(--border)' : '1px dashed var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>
{tier.name}
</div>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--surface2)', color: 'var(--text3)', fontFamily: 'monospace'
}}>
{tier.id}
</span>
{!tier.active && (
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--danger)', color: 'white', fontWeight: 600
}}>
INAKTIV
</span>
)}
</div>
{tier.description && (
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
{tier.description}
</div>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
<div>
<strong>Monatlich:</strong> {formatPrice(tier.price_monthly_cents)}
</div>
<div>
<strong>Jährlich:</strong> {formatPrice(tier.price_yearly_cents)}
</div>
<div>
<strong>Sortierung:</strong> {tier.sort_order}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button
className="btn btn-secondary"
onClick={() => startEdit(tier)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => handleDelete(tier.id)}
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
disabled={!tier.active}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
{/* Info */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Hinweis:</strong> Limits für jeden Tier können in der{' '}
<a href="/admin/tier-limits" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Tier Limits Matrix
</a>{' '}
konfiguriert werden.
</div>
</div>
)
}

View File

@ -0,0 +1,538 @@
import { useState, useEffect } from 'react'
import { Save, AlertCircle, X, RotateCcw } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminUserRestrictionsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [users, setUsers] = useState([])
const [features, setFeatures] = useState([])
const [selectedUserId, setSelectedUserId] = useState('')
const [selectedUser, setSelectedUser] = useState(null)
const [restrictions, setRestrictions] = useState([])
const [tierLimits, setTierLimits] = useState({})
const [changes, setChanges] = useState({})
const [saving, setSaving] = useState(false)
useEffect(() => {
loadInitialData()
}, [])
useEffect(() => {
if (selectedUserId) {
loadUserData(selectedUserId)
} else {
setSelectedUser(null)
setRestrictions([])
setChanges({})
}
}, [selectedUserId])
async function loadInitialData() {
try {
setLoading(true)
const [usersData, featuresData] = await Promise.all([
api.adminListProfiles(),
api.listFeatures()
])
setUsers(usersData)
setFeatures(featuresData.filter(f => f.active))
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function loadUserData(userId) {
try {
const [user, restrictionsData, limitsMatrix] = await Promise.all([
api.adminListProfiles().then(users => users.find(u => u.id === userId)),
api.listUserRestrictions(userId),
api.getTierLimitsMatrix()
])
setSelectedUser(user)
setRestrictions(restrictionsData)
// Build tier limits lookup for this user's tier
const userTier = user.tier || 'free'
const limits = {}
features.forEach(feature => {
const key = `${userTier}:${feature.id}`
// Use same fallback logic as TierLimitsPage: undefined null (unlimited)
limits[feature.id] = limitsMatrix.limits[key] ?? null
})
setTierLimits(limits)
setChanges({})
setError('')
setSuccess('')
} catch (e) {
setError(e.message)
}
}
function handleChange(featureId, value) {
const newChanges = { ...changes }
const tierLimit = tierLimits[featureId]
// Parse value (EXACTLY like TierLimitsPage)
let parsedValue = null
if (value === 'unlimited' || value === '∞') {
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
} else if (value === '') {
parsedValue = null // empty unlimited
} else {
const num = parseInt(value)
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid input, ignore
}
}
// Check if value equals tier limit remove override
if (parsedValue === tierLimit) {
newChanges[featureId] = { action: 'remove', tempValue: value }
} else {
// Different from tier default set override
newChanges[featureId] = { action: 'set', value: parsedValue, tempValue: value }
}
setChanges(newChanges)
}
function handleToggle(featureId) {
// Get current state
const restriction = restrictions.find(r => r.feature_id === featureId)
let currentValue = restriction?.limit_value ?? null
// Check if there's a pending change
if (featureId in changes && changes[featureId].action === 'set') {
currentValue = changes[featureId].value
}
// Toggle between 1 (enabled) and 0 (disabled)
const isCurrentlyEnabled = currentValue !== 0 && currentValue !== '0'
const newValue = isCurrentlyEnabled ? 0 : 1
const newChanges = { ...changes }
newChanges[featureId] = { action: 'set', value: newValue, tempValue: newValue.toString() }
setChanges(newChanges)
}
async function handleSave() {
if (!selectedUserId) return
try {
setSaving(true)
setError('')
setSuccess('')
let changeCount = 0
for (const [featureId, change] of Object.entries(changes)) {
const existingRestriction = restrictions.find(r => r.feature_id === featureId)
if (change.action === 'remove') {
// Remove restriction if exists
if (existingRestriction) {
await api.deleteUserRestriction(existingRestriction.id)
changeCount++
}
} else if (change.action === 'set') {
// Create or update
if (existingRestriction) {
await api.updateUserRestriction(existingRestriction.id, {
limit_value: change.value,
enabled: true
})
} else {
await api.createUserRestriction({
profile_id: selectedUserId,
feature_id: featureId,
limit_value: change.value,
enabled: true,
reason: 'Admin override'
})
}
changeCount++
}
}
setSuccess(`${changeCount} Änderung(en) gespeichert`)
await loadUserData(selectedUserId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function getDisplayValue(featureId) {
// Check pending changes first
if (featureId in changes) {
const change = changes[featureId]
if (change.action === 'remove') {
// Returning to tier default
return formatValue(tierLimits[featureId])
}
if (change.action === 'set') {
// Use tempValue for display if available, otherwise format the value
return change.tempValue !== undefined ? change.tempValue : formatValue(change.value)
}
}
// Show override if exists, otherwise tier limit (= effective value)
const restriction = restrictions.find(r => r.feature_id === featureId)
if (restriction) {
return formatValue(restriction.limit_value)
}
// No override: show tier limit as default
return formatValue(tierLimits[featureId])
}
function formatValue(val) {
if (val === null || val === undefined) return 'unlimited'
if (val === '' ) return ''
if (val === '∞' || val === 'unlimited') return 'unlimited'
if (val === 0 || val === '0') return '0'
return val.toString()
}
function getToggleState(featureId) {
// Check pending changes first
if (featureId in changes && changes[featureId].action === 'set') {
const val = changes[featureId].value
return val !== 0 && val !== '0'
}
// Check existing restriction
const restriction = restrictions.find(r => r.feature_id === featureId)
if (!restriction) {
// No override: use tier default
const tierLimit = tierLimits[featureId]
return tierLimit !== 0 && tierLimit !== '0'
}
// For boolean features: limit_value determines state
return restriction.limit_value !== 0 && restriction.limit_value !== '0'
}
function hasOverride(featureId) {
return restrictions.some(r => r.feature_id === featureId)
}
function isChanged(featureId) {
return featureId in changes
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const hasChanges = Object.keys(changes).length > 0
const categoryGroups = {}
features.forEach(f => {
if (!categoryGroups[f.category]) categoryGroups[f.category] = []
categoryGroups[f.category].push(f)
})
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
User Feature-Overrides
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Individuelle Feature-Limits für einzelne User setzen
</div>
</div>
{/* Info Box */}
<div style={{
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
display: 'flex', gap: 8, alignItems: 'flex-start'
}}>
<AlertCircle size={16} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<strong>Hinweis:</strong> Felder zeigen effektive Werte (Override falls gesetzt, sonst Tier-Standard).
Wert ändern Override wird gesetzt. Wert = Tier-Standard Override wird entfernt.
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* User Selection */}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<label className="form-label" style={{ display: 'block', marginBottom: 8 }}>
User auswählen
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
>
<option value="">-- User auswählen --</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.name} ({u.email || u.id}) - Tier: {u.tier || 'free'}
</option>
))}
</select>
</div>
{/* User Info + Features */}
{selectedUser && (
<>
{/* Action Buttons */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
Feature-Overrides für {selectedUser.name}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => setChanges({})}
disabled={saving}
>
<X size={14} /> Abbrechen
</button>
)}
<button
className="btn btn-primary"
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderung(en) speichern` : 'Keine Änderungen'}
</button>
</div>
</div>
{/* User Info Card */}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: selectedUser.avatar_color || 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 18
}}>
{selectedUser.name?.charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{selectedUser.name}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{selectedUser.email || `ID: ${selectedUser.id}`}
</div>
</div>
<div style={{
padding: '4px 12px', borderRadius: 6,
background: 'var(--accent-light)', color: 'var(--accent-dark)',
fontSize: 12, fontWeight: 600
}}>
Tier: {selectedUser.tier || 'free'}
</div>
</div>
</div>
{/* Features Table */}
<div className="card" style={{ padding: 0, overflow: 'auto', marginBottom: 16 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>
Feature
</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
Tier-Limit
</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
Override-Wert
</th>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>
Aktion
</th>
</tr>
</thead>
<tbody>
{Object.entries(categoryGroups).map(([category, categoryFeatures]) => (
<>
{/* Category Header */}
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
<td colSpan={4} style={{
padding: '8px 16px', fontWeight: 600, fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.5px',
color: 'var(--accent-dark)'
}}>
{categoryIcons[category]} {categoryNames[category] || category}
</td>
</tr>
{/* Feature Rows */}
{categoryFeatures.map(feature => {
const displayValue = getDisplayValue(feature.id)
const toggleState = getToggleState(feature.id)
const override = hasOverride(feature.id)
const changed = isChanged(feature.id)
return (
<tr key={feature.id} style={{
borderBottom: '1px solid var(--border)',
background: changed ? 'var(--accent-light)' : 'transparent'
}}>
{/* Feature Name */}
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
</div>
</td>
{/* Tier-Limit */}
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? (
<span style={{
padding: '6px 12px', borderRadius: 20,
background: tierLimits[feature.id] !== 0 ? 'var(--accent-light)' : 'var(--surface2)',
color: tierLimits[feature.id] !== 0 ? 'var(--accent-dark)' : 'var(--text3)',
fontSize: 12, fontWeight: 600
}}>
{tierLimits[feature.id] !== 0 ? '✓ AN' : '✗ AUS'}
</span>
) : (
<span style={{ fontWeight: 500, color: 'var(--text2)' }}>
{tierLimits[feature.id] === null ? '∞' : tierLimits[feature.id]}
</span>
)}
</td>
{/* Override Input */}
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? (
<button
onClick={() => handleToggle(feature.id)}
style={{
padding: '6px 16px',
border: `2px solid ${toggleState ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20,
background: toggleState ? 'var(--accent)' : 'var(--surface)',
color: toggleState ? 'white' : 'var(--text3)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
minWidth: 80
}}
>
{toggleState ? '✓ AN' : '✗ AUS'}
</button>
) : (
<input
type="text"
value={displayValue}
onChange={(e) => handleChange(feature.id, e.target.value)}
placeholder=""
style={{
width: '120px',
padding: '6px 8px',
border: `1.5px solid ${changed ? 'var(--accent)' : override ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6,
textAlign: 'center',
fontSize: 13,
fontWeight: override || changed ? 600 : 400,
background: override || changed ? 'var(--accent-light)' : 'var(--bg)',
color: displayValue === '0' ? 'var(--danger)' :
displayValue === 'unlimited' ? 'var(--accent)' : 'var(--text1)'
}}
/>
)}
</td>
{/* Action */}
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<button
className="btn btn-secondary"
onClick={() => {
// Reset to tier default
const tierValue = tierLimits[feature.id]
handleChange(feature.id, formatValue(tierValue))
}}
disabled={!override}
style={{
padding: '4px 8px',
fontSize: 11,
opacity: override ? 1 : 0.4,
cursor: override ? 'pointer' : 'not-allowed'
}}
>
Zurück
</button>
</td>
</tr>
)
})}
</>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Eingabe:</strong>
<div style={{ marginTop: 8, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span><strong>unlimited</strong> = Unbegrenzt</span>
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Feature deaktiviert</span>
<span><strong>1+</strong> = Limit-Wert</span>
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.8 }}>
Feld zeigt effektiven Wert (Override falls gesetzt, sonst Tier-Standard)<br />
Wert ändern Override wird gesetzt<br />
Wert = Tier-Standard Override wird entfernt
</div>
</div>
</>
)}
</div>
)
}

View File

@ -3,6 +3,7 @@ import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-
import { api } from '../utils/api' import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
@ -114,6 +115,7 @@ export default function Analysis() {
const [tab, setTab] = useState('run') const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null) const [newResult, setNewResult] = useState(null)
const [pipelineLoading, setPipelineLoading] = useState(false) const [pipelineLoading, setPipelineLoading] = useState(false)
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
const loadAll = async () => { const loadAll = async () => {
const [p, i] = await Promise.all([ const [p, i] = await Promise.all([
@ -123,7 +125,15 @@ export default function Analysis() {
setPrompts(Array.isArray(p)?p:[]) setPrompts(Array.isArray(p)?p:[])
setAllInsights(Array.isArray(i)?i:[]) setAllInsights(Array.isArray(i)?i:[])
} }
useEffect(()=>{ loadAll() },[])
useEffect(()=>{
loadAll()
// Load feature usage for badges
api.getFeatureUsage().then(features => {
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
setAiUsage(aiFeature)
}).catch(err => console.error('Failed to load usage:', err))
},[])
const runPipeline = async () => { const runPipeline = async () => {
setPipelineLoading(true); setError(null); setNewResult(null) setPipelineLoading(true); setError(null); setNewResult(null)
@ -177,7 +187,7 @@ export default function Analysis() {
grouped[key].push(ins) grouped[key].push(ins)
}) })
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_')) const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
// Pipeline is available if the "pipeline" prompt is active // Pipeline is available if the "pipeline" prompt is active
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline') const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
@ -230,7 +240,10 @@ export default function Analysis() {
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}> <div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12}}> <div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div> <div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>🔬 Mehrstufige Gesamtanalyse</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}> <div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität), 3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
dann Synthese + Zielabgleich. Detaillierteste Auswertung. dann Synthese + Zielabgleich. Detaillierteste Auswertung.
@ -241,12 +254,22 @@ export default function Analysis() {
</div> </div>
)} )}
</div> </div>
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}} <div
onClick={runPipeline} disabled={!!loading||pipelineLoading}> title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
{pipelineLoading style={{display:'inline-block'}}
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</> >
: <><Brain size={13}/> Starten</>} <button
</button> className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={runPipeline}
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>} {!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
</div> </div>
{pipelineLoading && ( {pipelineLoading && (
@ -282,7 +305,10 @@ export default function Analysis() {
<div key={p.id} className="card section-gap"> <div key={p.id} className="card section-gap">
<div style={{display:'flex',alignItems:'flex-start',gap:12}}> <div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}> <div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:15}}>{SLUG_LABELS[p.slug]||p.name}</div> <div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
<span>{SLUG_LABELS[p.slug]||p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>} {p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
{existing && ( {existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}> <div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
@ -290,12 +316,22 @@ export default function Analysis() {
</div> </div>
)} )}
</div> </div>
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}} <div
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}> title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
{loading===p.slug style={{display:'inline-block'}}
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</> >
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>} <button
</button> className="btn btn-primary"
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
</div>
</div> </div>
{/* Show existing result collapsed */} {/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && ( {existing && newResult?.id !== existing.id && (

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api' import { api } from '../utils/api'
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc' import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData' import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
function emptyForm() { function emptyForm() {
@ -15,7 +16,7 @@ function emptyForm() {
} }
} }
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern' }) { function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
const sex = profile?.sex||'m' const sex = profile?.sex||'m'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30 const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const weight = form.weight || 80 const weight = form.weight || 80
@ -65,8 +66,25 @@ function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Spei
<input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/> <input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/> <span className="form-unit"/>
</div> </div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}> <div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(bfPct, sex)}>{saveLabel}</button> <div
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>onSave(bfPct, sex)}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>} {onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div> </div>
</div> </div>
@ -78,12 +96,26 @@ export default function CaliperScreen() {
const [profile, setProfile] = useState(null) const [profile, setProfile] = useState(null)
const [form, setForm] = useState(emptyForm()) const [form, setForm] = useState(emptyForm())
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [caliperUsage, setCaliperUsage] = useState(null) // Phase 4: Usage badge
const nav = useNavigate() const nav = useNavigate()
const load = () => Promise.all([api.listCaliper(), api.getProfile()]) const load = () => Promise.all([api.listCaliper(), api.getProfile()])
.then(([e,p])=>{ setEntries(e); setProfile(p) }) .then(([e,p])=>{ setEntries(e); setProfile(p) })
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const caliperFeature = features.find(f => f.feature_id === 'caliper_entries')
setCaliperUsage(caliperFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const buildPayload = (f, bfPct, sex) => { const buildPayload = (f, bfPct, sex) => {
const weight = profile?.weight || null const weight = profile?.weight || null
@ -97,11 +129,23 @@ export default function CaliperScreen() {
} }
const handleSave = async (bfPct, sex) => { const handleSave = async (bfPct, sex) => {
const payload = buildPayload(form, bfPct, sex) setSaving(true)
await api.upsertCaliper(payload) setError(null)
setSaved(true); await load() try {
setTimeout(()=>setSaved(false),2000) const payload = buildPayload(form, bfPct, sex)
setForm(emptyForm()) await api.upsertCaliper(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false),2000)
setForm(emptyForm())
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
} }
const handleUpdate = async (bfPct, sex) => { const handleUpdate = async (bfPct, sex) => {
@ -125,9 +169,13 @@ export default function CaliperScreen() {
</div> </div>
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Neue Messung</div> <div className="card-title badge-container-right">
<span>Neue Messung</span>
{caliperUsage && <UsageBadge {...caliperUsage} />}
</div>
<CaliperForm form={form} setForm={setForm} profile={profile} <CaliperForm form={form} setForm={setForm} profile={profile}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/> onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={caliperUsage}/>
</div> </div>
<div className="section-gap"> <div className="section-gap">

View File

@ -3,6 +3,7 @@ import { Pencil, Trash2, Check, X, Camera, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api' import { api } from '../utils/api'
import { CIRCUMFERENCE_POINTS } from '../utils/guideData' import { CIRCUMFERENCE_POINTS } from '../utils/guideData'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm'] const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm']
@ -16,18 +17,32 @@ export default function CircumScreen() {
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [photoFile, setPhotoFile] = useState(null) const [photoFile, setPhotoFile] = useState(null)
const [photoPreview, setPhotoPreview] = useState(null) const [photoPreview, setPhotoPreview] = useState(null)
const [circumUsage, setCircumUsage] = useState(null) // Phase 4: Usage badge
const fileRef = useRef() const fileRef = useRef()
const nav = useNavigate() const nav = useNavigate()
const load = () => api.listCirc().then(setEntries) const load = () => api.listCirc().then(setEntries)
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const circumFeature = features.find(f => f.feature_id === 'circumference_entries')
setCircumUsage(circumFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const set = (k,v) => setForm(f=>({...f,[k]:v})) const set = (k,v) => setForm(f=>({...f,[k]:v}))
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
setError(null)
try { try {
const payload = {} const payload = {}
payload.date = form.date payload.date = form.date
@ -38,10 +53,18 @@ export default function CircumScreen() {
payload.photo_id = pr.id payload.photo_id = pr.id
} }
await api.upsertCirc(payload) await api.upsertCirc(payload)
setSaved(true); await load() setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false),2000) setTimeout(()=>setSaved(false),2000)
setForm(empty()); setPhotoFile(null); setPhotoPreview(null) setForm(empty()); setPhotoFile(null); setPhotoPreview(null)
} finally { setSaving(false) } } catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
} }
const startEdit = (e) => setEditing({...e}) const startEdit = (e) => setEditing({...e})
@ -72,7 +95,10 @@ export default function CircumScreen() {
{/* Eingabe */} {/* Eingabe */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Neue Messung</div> <div className="card-title badge-container-right">
<span>Neue Messung</span>
{circumUsage && <UsageBadge {...circumUsage} />}
</div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Datum</label> <label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/> <input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
@ -99,9 +125,27 @@ export default function CircumScreen() {
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}> <button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
<Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'} <Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'}
</button> </button>
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={handleSave} disabled={saving}> {error && (
{saved ? <><Check size={14}/> Gespeichert!</> : saving ? '…' : 'Speichern'} <div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginTop:8}}>
</button> {error}
</div>
)}
<div
title={circumUsage && !circumUsage.allowed ? `Limit erreicht (${circumUsage.used}/${circumUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block',width:'100%',marginTop:8}}
>
<button
className="btn btn-primary btn-full"
style={{cursor: (circumUsage && !circumUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={handleSave}
disabled={saving || (circumUsage && !circumUsage.allowed)}
>
{saved ? <><Check size={14}/> Gespeichert!</>
: saving ? '…'
: (circumUsage && !circumUsage.allowed) ? '🔒 Limit erreicht'
: 'Speichern'}
</button>
</div>
</div> </div>
{/* Liste */} {/* Liste */}

View File

@ -27,32 +27,75 @@ function QuickWeight({ onSaved }) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [weightUsage, setWeightUsage] = useState(null)
const today = dayjs().format('YYYY-MM-DD') const today = dayjs().format('YYYY-MM-DD')
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const weightFeature = features.find(f => f.feature_id === 'weight_entries')
setWeightUsage(weightFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{ useEffect(()=>{
api.weightStats().then(s=>{ api.weightStats().then(s=>{
if(s?.latest?.date===today) setInput(String(s.latest.weight)) if(s?.latest?.date===today) setInput(String(s.latest.weight))
}) })
loadUsage()
},[]) },[])
const handleSave = async () => { const handleSave = async () => {
const w=parseFloat(input); if(!w||w<20||w>300) return const w=parseFloat(input); if(!w||w<20||w>300) return
setSaving(true) setSaving(true)
try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) } setError(null)
finally{ setSaving(false) } try{
await api.upsertWeight(today,w)
setSaved(true)
await loadUsage() // Reload usage after save
onSaved?.()
setTimeout(()=>setSaved(false),2000)
} catch(err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
} }
const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed)
const tooltipText = weightUsage && !weightUsage.allowed
? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.`
: ''
return ( return (
<div style={{display:'flex',gap:8,alignItems:'center'}}> <div>
<input type="number" min={20} max={300} step={0.1} className="form-input" {error && (
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}} <div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)} {error}
onKeyDown={e=>e.key==='Enter'&&handleSave()}/> </div>
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span> )}
<button className="btn btn-primary" style={{padding:'8px 14px'}} <div style={{display:'flex',gap:8,alignItems:'center'}}>
onClick={handleSave} disabled={saving||!input}> <input type="number" min={20} max={300} step={0.1} className="form-input"
{saved?<Check size={15}/>:saving?<div className="spinner" style={{width:14,height:14}}/>:'Speichern'} style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
</button> placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
<div title={tooltipText} style={{display:'inline-block'}}>
<button
className="btn btn-primary"
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
onClick={handleSave}
disabled={isDisabled}
>
{saved ? <Check size={15}/>
: saving ? <div className="spinner" style={{width:14,height:14}}/>
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
: 'Speichern'}
</button>
</div>
</div>
</div> </div>
) )
} }

View File

@ -18,6 +18,419 @@ function rollingAvg(arr, key, window=7) {
}) })
} }
// Entry Form (Create/Update)
function EntryForm({ onSaved }) {
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
const [existingId, setExistingId] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
// Load data for selected date
useEffect(() => {
const load = async () => {
if (!date) return
setLoading(true)
setError(null)
try {
const data = await nutritionApi.getNutritionByDate(date)
if (data) {
setValues({
kcal: data.kcal || '',
protein_g: data.protein_g || '',
fat_g: data.fat_g || '',
carbs_g: data.carbs_g || ''
})
setExistingId(data.id)
} else {
setValues({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
setExistingId(null)
}
} catch(e) {
console.error('Failed to load entry:', e)
} finally {
setLoading(false)
}
}
load()
}, [date])
const handleSave = async () => {
if (!date || !values.kcal) {
setError('Datum und Kalorien sind Pflichtfelder')
return
}
setSaving(true)
setError(null)
setSuccess(null)
try {
const result = await nutritionApi.createNutrition(
date,
parseFloat(values.kcal) || 0,
parseFloat(values.protein_g) || 0,
parseFloat(values.fat_g) || 0,
parseFloat(values.carbs_g) || 0
)
setSuccess(result.mode === 'created' ? 'Eintrag hinzugefügt' : 'Eintrag aktualisiert')
setTimeout(() => setSuccess(null), 3000)
onSaved()
} catch(e) {
if (e.message.includes('Limit erreicht')) {
setError(e.message)
} else {
setError('Speichern fehlgeschlagen: ' + e.message)
}
setTimeout(() => setError(null), 5000)
} finally {
setSaving(false)
}
}
return (
<div className="card section-gap">
<div className="card-title">Eintrag hinzufügen / bearbeiten</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{success && (
<div style={{padding:'8px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)',marginBottom:12}}>
{success}
</div>
)}
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:12,marginBottom:12}}>
<div style={{gridColumn:'1 / -1'}}>
<label className="form-label">Datum</label>
<input
type="date"
className="form-input"
value={date}
onChange={e => setDate(e.target.value)}
max={dayjs().format('YYYY-MM-DD')}
style={{width:'100%'}}
/>
{existingId && !loading && (
<div style={{fontSize:11,color:'var(--accent)',marginTop:4}}>
Eintrag existiert bereits wird beim Speichern aktualisiert
</div>
)}
</div>
<div>
<label className="form-label">Kalorien *</label>
<input
type="number"
className="form-input"
value={values.kcal}
onChange={e => setValues({...values, kcal: e.target.value})}
placeholder="z.B. 2000"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Protein (g)</label>
<input
type="number"
className="form-input"
value={values.protein_g}
onChange={e => setValues({...values, protein_g: e.target.value})}
placeholder="z.B. 150"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Fett (g)</label>
<input
type="number"
className="form-input"
value={values.fat_g}
onChange={e => setValues({...values, fat_g: e.target.value})}
placeholder="z.B. 80"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Kohlenhydrate (g)</label>
<input
type="number"
className="form-input"
value={values.carbs_g}
onChange={e => setValues({...values, carbs_g: e.target.value})}
placeholder="z.B. 200"
disabled={loading}
style={{width:'100%'}}
/>
</div>
</div>
<button
className="btn btn-primary btn-full"
onClick={handleSave}
disabled={saving || loading || !date || !values.kcal}>
{saving ? (
<><div className="spinner" style={{width:14,height:14}}/> Speichere</>
) : existingId ? (
'📝 Eintrag aktualisieren'
) : (
' Eintrag hinzufügen'
)}
</button>
</div>
)
}
// Data Tab (Editable Entry List)
function DataTab({ entries, onUpdate }) {
const [editId, setEditId] = useState(null)
const [editValues, setEditValues] = useState({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('30') // days to show (7, 30, 90, 'all')
const startEdit = (e) => {
setEditId(e.id)
setEditValues({
kcal: e.kcal || 0,
protein_g: e.protein_g || 0,
fat_g: e.fat_g || 0,
carbs_g: e.carbs_g || 0
})
}
const cancelEdit = () => {
setEditId(null)
setEditValues({})
setError(null)
}
const saveEdit = async (id) => {
setSaving(true)
setError(null)
try {
await nutritionApi.updateNutrition(
id,
editValues.kcal,
editValues.protein_g,
editValues.fat_g,
editValues.carbs_g
)
setEditId(null)
setEditValues({})
onUpdate()
} catch(e) {
setError('Speichern fehlgeschlagen: ' + e.message)
} finally {
setSaving(false)
}
}
const deleteEntry = async (id, date) => {
if (!confirm(`Eintrag vom ${dayjs(date).format('DD.MM.YYYY')} wirklich löschen?`)) return
try {
await nutritionApi.deleteNutrition(id)
onUpdate()
} catch(e) {
setError('Löschen fehlgeschlagen: ' + e.message)
}
}
// Filter entries by date range
const filteredEntries = filter === 'all'
? entries
: entries.filter(e => {
const daysDiff = dayjs().diff(dayjs(e.date), 'day')
return daysDiff <= parseInt(filter)
})
if (entries.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Alle Einträge (0)</div>
<p className="muted">Noch keine Ernährungsdaten. Importiere FDDB CSV oben.</p>
</div>
)
}
return (
<div className="card section-gap">
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
<div className="card-title" style={{margin:0}}>
Alle Einträge ({filteredEntries.length}{filteredEntries.length !== entries.length ? ` von ${entries.length}` : ''})
</div>
<select
value={filter}
onChange={e => setFilter(e.target.value)}
style={{
padding:'6px 10px',fontSize:12,borderRadius:8,border:'1.5px solid var(--border2)',
background:'var(--surface)',color:'var(--text2)',cursor:'pointer',fontFamily:'var(--font)'
}}>
<option value="7">Letzte 7 Tage</option>
<option value="30">Letzte 30 Tage</option>
<option value="90">Letzte 90 Tage</option>
<option value="365">Letztes Jahr</option>
<option value="all">Alle anzeigen</option>
</select>
</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{filteredEntries.map((e, i) => {
const isEditing = editId === e.id
return (
<div key={e.id || i} style={{
borderBottom: i < filteredEntries.length - 1 ? '1px solid var(--border)' : 'none',
padding: '12px 0'
}}>
{!isEditing ? (
<>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:6}}>
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
<div style={{display:'flex',gap:6}}>
<button onClick={() => startEdit(e)}
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid var(--border2)',
background:'var(--surface)',color:'var(--text2)',cursor:'pointer'}}>
Bearbeiten
</button>
<button onClick={() => deleteEntry(e.id, e.date)}
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid #D85A30',
background:'#FCEBEB',color:'#D85A30',cursor:'pointer'}}>
🗑
</button>
</div>
</div>
<div style={{fontSize:13, fontWeight:600, color:'#EF9F27',marginBottom:6}}>
{Math.round(e.kcal || 0)} kcal
</div>
<div style={{display:'flex', gap:12, fontSize:12, color:'var(--text2)'}}>
<span>🥩 Protein: <strong>{Math.round(e.protein_g || 0)}g</strong></span>
<span>🫙 Fett: <strong>{Math.round(e.fat_g || 0)}g</strong></span>
<span>🍞 Kohlenhydrate: <strong>{Math.round(e.carbs_g || 0)}g</strong></span>
</div>
{e.source && (
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
Quelle: {e.source}
</div>
)}
</>
) : (
<>
<div style={{marginBottom:8}}>
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kalorien</label>
<input type="number" className="form-input" value={editValues.kcal}
onChange={e => setEditValues({...editValues, kcal: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Protein (g)</label>
<input type="number" className="form-input" value={editValues.protein_g}
onChange={e => setEditValues({...editValues, protein_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Fett (g)</label>
<input type="number" className="form-input" value={editValues.fat_g}
onChange={e => setEditValues({...editValues, fat_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kohlenhydrate (g)</label>
<input type="number" className="form-input" value={editValues.carbs_g}
onChange={e => setEditValues({...editValues, carbs_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={() => saveEdit(e.id)} disabled={saving}
className="btn btn-primary" style={{flex:1}}>
{saving ? 'Speichere…' : '✓ Speichern'}
</button>
<button onClick={cancelEdit} disabled={saving}
className="btn btn-secondary" style={{flex:1}}>
Abbrechen
</button>
</div>
</>
)}
</div>
)
})}
</div>
)
}
// Import History
function ImportHistory() {
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
const data = await nutritionApi.nutritionImportHistory()
setHistory(Array.isArray(data) ? data : [])
} catch(e) {
console.error('Failed to load import history:', e)
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) return null
if (!history.length) return null
return (
<div className="card section-gap">
<div className="card-title">Import-Historie</div>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{history.map((h, i) => (
<div key={i} style={{
padding: '10px 12px',
background: 'var(--surface2)',
borderRadius: 8,
borderLeft: '3px solid var(--accent)',
fontSize: 13
}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:4}}>
<strong>{dayjs(h.import_date).format('DD.MM.YYYY')}</strong>
<span style={{color:'var(--text3)',fontSize:11}}>
{dayjs(h.last_created).format('HH:mm')} Uhr
</span>
</div>
<div style={{color:'var(--text2)',fontSize:12}}>
<span>{h.count} {h.count === 1 ? 'Eintrag' : 'Einträge'}</span>
{h.date_from && h.date_to && (
<span style={{marginLeft:8,color:'var(--text3)'}}>
({dayjs(h.date_from).format('DD.MM.YY')} {dayjs(h.date_to).format('DD.MM.YY')})
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
// Import Panel // Import Panel
function ImportPanel({ onImported }) { function ImportPanel({ onImported }) {
const fileRef = useRef() const fileRef = useRef()
@ -322,9 +735,11 @@ function CalorieBalance({ data, profile }) {
// Main Page // Main Page
export default function NutritionPage() { export default function NutritionPage() {
const [tab, setTab] = useState('overview') const [inputTab, setInputTab] = useState('entry') // 'entry' or 'import'
const [analysisTab,setAnalysisTab] = useState('data')
const [corrData, setCorr] = useState([]) const [corrData, setCorr] = useState([])
const [weekly, setWeekly] = useState([]) const [weekly, setWeekly] = useState([])
const [entries, setEntries]= useState([])
const [profile, setProf] = useState(null) const [profile, setProf] = useState(null)
const [loading, setLoad] = useState(true) const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false) const [hasData, setHasData]= useState(false)
@ -332,13 +747,15 @@ export default function NutritionPage() {
const load = async () => { const load = async () => {
setLoad(true) setLoad(true)
try { try {
const [corr, wkly, prof] = await Promise.all([ const [corr, wkly, ent, prof] = await Promise.all([
nutritionApi.nutritionCorrelations(), nutritionApi.nutritionCorrelations(),
nutritionApi.nutritionWeekly(16), nutritionApi.nutritionWeekly(16),
api.getActiveProfile(), nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
nutritionApi.getActiveProfile(),
]) ])
setCorr(Array.isArray(corr)?corr:[]) setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[]) setWeekly(Array.isArray(wkly)?wkly:[])
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
setProf(prof) setProf(prof)
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal)) setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
} catch(e) { console.error('load error:', e) } } catch(e) { console.error('load error:', e) }
@ -351,29 +768,52 @@ export default function NutritionPage() {
<div> <div>
<h1 className="page-title">Ernährung</h1> <h1 className="page-title">Ernährung</h1>
<ImportPanel onImported={load}/> {/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}>
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
Einzelerfassung
</button>
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
📥 Import
</button>
</div>
{/* Entry Form */}
{inputTab==='entry' && <EntryForm onSaved={load}/>}
{/* Import Panel + History */}
{inputTab==='import' && (
<>
<ImportPanel onImported={load}/>
<ImportHistory/>
</>
)}
{loading && <div className="empty-state"><div className="spinner"/></div>} {loading && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && ( {!loading && !hasData && (
<div className="empty-state"> <div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3> <h3>Noch keine Ernährungsdaten</h3>
<p>Importiere deinen FDDB-Export oben um Auswertungen zu sehen.</p> <p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
</div> </div>
)} )}
{/* Analysis Section */}
{!loading && hasData && ( {!loading && hasData && (
<> <>
<OverviewCards data={corrData}/> <OverviewCards data={corrData}/>
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}> <div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button> <button className={'tab'+(analysisTab==='data'?' active':'')} onClick={()=>setAnalysisTab('data')}>Daten</button>
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button> <button className={'tab'+(analysisTab==='overview'?' active':'')} onClick={()=>setAnalysisTab('overview')}>Übersicht</button>
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button> <button className={'tab'+(analysisTab==='weight'?' active':'')} onClick={()=>setAnalysisTab('weight')}>Kcal vs. Gewicht</button>
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</button> <button className={'tab'+(analysisTab==='protein'?' active':'')} onClick={()=>setAnalysisTab('protein')}>Protein vs. Mager</button>
<button className={'tab'+(analysisTab==='balance'?' active':'')} onClick={()=>setAnalysisTab('balance')}>Bilanz</button>
</div> </div>
{tab==='overview' && ( {analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
{analysisTab==='overview' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div> <div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
<WeeklyMacros weekly={weekly}/> <WeeklyMacros weekly={weekly}/>
@ -385,7 +825,7 @@ export default function NutritionPage() {
</div> </div>
)} )}
{tab==='weight' && ( {analysisTab==='weight' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Kalorien vs. Gewichtsverlauf</div> <div className="card-title">Kalorien vs. Gewichtsverlauf</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}> <div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
@ -401,7 +841,7 @@ export default function NutritionPage() {
</div> </div>
)} )}
{tab==='protein' && ( {analysisTab==='protein' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Protein vs. Magermasse</div> <div className="card-title">Protein vs. Magermasse</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}> <div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
@ -417,7 +857,7 @@ export default function NutritionPage() {
</div> </div>
)} )}
{tab==='balance' && ( {analysisTab==='balance' && (
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Kaloriendefizit / -überschuss</div> <div className="card-title">Kaloriendefizit / -überschuss</div>
<CalorieBalance data={corrData} profile={profile}/> <CalorieBalance data={corrData} profile={profile}/>

View File

@ -1,10 +1,12 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react' import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
import { useProfile } from '../context/ProfileContext' import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect' import { Avatar } from './ProfileSelect'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPanel from './AdminPanel' import AdminPanel from './AdminPanel'
import FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780'] const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
@ -99,6 +101,15 @@ export default function SettingsPage() {
const [pinOpen, setPinOpen] = useState(false) const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('') const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null) const [pinMsg, setPinMsg] = useState(null)
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
// Load feature usage for export badges
useEffect(() => {
api.getFeatureUsage().then(features => {
const exportFeature = features.find(f => f.feature_id === 'data_export')
setExportUsage(exportFeature)
}).catch(err => console.error('Failed to load usage:', err))
}, [])
const handleLogout = async () => { const handleLogout = async () => {
if (!confirm('Ausloggen?')) return if (!confirm('Ausloggen?')) return
@ -326,6 +337,17 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Feature Usage Overview (Phase 3) */}
<div className="card section-gap">
<div className="card-title" style={{display:'flex',alignItems:'center',gap:6}}>
<BarChart3 size={15} color="var(--accent)"/> Kontingente
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Übersicht über deine Feature-Nutzung und verfügbare Kontingente.
</p>
<FeatureUsageOverview />
</div>
{/* Admin Panel */} {/* Admin Panel */}
{isAdmin && ( {isAdmin && (
<div className="card section-gap"> <div className="card section-gap">
@ -359,13 +381,23 @@ export default function SettingsPage() {
{canExport && <> {canExport && <>
<button className="btn btn-primary btn-full" <button className="btn btn-primary btn-full"
onClick={()=>api.exportZip()}> onClick={()=>api.exportZip()}>
<Download size={14}/> ZIP exportieren <div className="badge-button-layout">
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}> je eine CSV pro Kategorie</span> <div className="badge-button-header">
<span><Download size={14}/> ZIP exportieren</span>
{exportUsage && <UsageBadge {...exportUsage} />}
</div>
<span className="badge-button-description">je eine CSV pro Kategorie</span>
</div>
</button> </button>
<button className="btn btn-secondary btn-full" <button className="btn btn-secondary btn-full"
onClick={()=>api.exportJson()}> onClick={()=>api.exportJson()}>
<Download size={14}/> JSON exportieren <div className="badge-button-layout">
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}> maschinenlesbar, alles in einer Datei</span> <div className="badge-button-header">
<span><Download size={14}/> JSON exportieren</span>
{exportUsage && <UsageBadge {...exportUsage} />}
</div>
<span className="badge-button-description">maschinenlesbar, alles in einer Datei</span>
</div>
</button> </button>
</>} </>}
</div> </div>

View File

@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { Gift, AlertCircle, TrendingUp, Award } from 'lucide-react'
import { api } from '../utils/api'
export default function SubscriptionPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [subscription, setSubscription] = useState(null)
const [usage, setUsage] = useState([])
const [limits, setLimits] = useState([])
const [couponCode, setCouponCode] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [couponSuccess, setCouponSuccess] = useState('')
useEffect(() => {
loadData()
}, [])
async function loadData() {
try {
setLoading(true)
const [subData, usageData, limitsData] = await Promise.all([
api.getMySubscription(),
api.getMyUsage(),
api.getMyLimits()
])
setSubscription(subData)
setUsage(usageData)
setLimits(limitsData)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function handleRedeemCoupon() {
if (!couponCode.trim()) return
try {
setRedeeming(true)
setError('')
setCouponSuccess('')
await api.redeemCoupon(couponCode.trim().toUpperCase())
setCouponSuccess('Coupon erfolgreich eingelöst!')
setCouponCode('')
await loadData()
} catch (e) {
setError(e.message)
} finally {
setRedeeming(false)
}
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const tierColors = {
free: { bg: 'var(--surface2)', color: 'var(--text2)', icon: '🆓' },
basic: { bg: '#E3F2FD', color: '#1565C0', icon: '⭐' },
premium: { bg: '#F3E5F5', color: '#6A1B9A', icon: '👑' },
selfhosted: { bg: 'var(--accent-light)', color: 'var(--accent-dark)', icon: '🏠' }
}
const currentTier = subscription?.current_tier || 'free'
const tierStyle = tierColors[currentTier] || tierColors.free
return (
<div style={{ paddingBottom: 40 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Mein Abo
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Tier, Limits und Nutzung
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{couponSuccess && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{couponSuccess}
</div>
)}
{/* Current Tier Card */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: tierStyle.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24
}}>
{tierStyle.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', fontWeight: 600 }}>
Aktueller Tier
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: tierStyle.color }}>
{currentTier.charAt(0).toUpperCase() + currentTier.slice(1)}
</div>
</div>
</div>
{subscription?.trial_ends_at && new Date(subscription.trial_ends_at) > new Date() && (
<div style={{
padding: 10, background: '#FFF3CD', borderRadius: 8,
fontSize: 13, color: '#856404', display: 'flex', gap: 8, alignItems: 'center'
}}>
<AlertCircle size={16} />
<div>
<strong>Trial aktiv:</strong> Endet am {new Date(subscription.trial_ends_at).toLocaleDateString('de-DE')}
</div>
</div>
)}
{subscription?.access_until && (
<div style={{
padding: 10, background: 'var(--accent-light)', borderRadius: 8,
fontSize: 13, color: 'var(--accent-dark)', display: 'flex', gap: 8, alignItems: 'center',
marginTop: 12
}}>
<Award size={16} />
<div>
<strong>Zugriff bis:</strong> {new Date(subscription.access_until).toLocaleDateString('de-DE')}
</div>
</div>
)}
</div>
{/* Feature Limits */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{
fontSize: 14, fontWeight: 600, marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6
}}>
<TrendingUp size={16} color="var(--accent)" />
Feature-Limits & Nutzung
</div>
{limits.length === 0 ? (
<div style={{ textAlign: 'center', padding: 20, color: 'var(--text3)', fontSize: 13 }}>
Keine Limits konfiguriert
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{limits.map(limit => {
const usageEntry = usage.find(u => u.feature_id === limit.feature_id)
const used = usageEntry?.usage_count || 0
const limitValue = limit.limit_value
const percentage = limitValue ? Math.min((used / limitValue) * 100, 100) : 0
return (
<div key={limit.feature_id} style={{
padding: 12, background: 'var(--surface)', borderRadius: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontWeight: 500, fontSize: 13 }}>{limit.feature_name}</div>
<div style={{ fontSize: 13, color: 'var(--text3)' }}>
{used} / {limitValue === null ? '∞' : limitValue}
</div>
</div>
{limitValue !== null && (
<div style={{
height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden'
}}>
<div style={{
width: `${percentage}%`,
height: '100%',
background: percentage > 90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)',
transition: 'width 0.3s'
}} />
</div>
)}
{limit.reset_period !== 'never' && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Reset: {limit.reset_period === 'daily' ? 'Täglich' : 'Monatlich'}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Coupon Redemption */}
<div className="card" style={{ padding: 20 }}>
<div style={{
fontSize: 14, fontWeight: 600, marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6
}}>
<Gift size={16} color="var(--accent)" />
Coupon einlösen
</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
Hast du einen Coupon-Code? Löse ihn hier ein um Zugriff auf Premium-Features zu erhalten.
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="form-input"
style={{ flex: 1, textTransform: 'uppercase', fontFamily: 'monospace' }}
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder="Z.B. PROMO-2026"
onKeyPress={(e) => e.key === 'Enter' && handleRedeemCoupon()}
/>
<button
className="btn btn-primary"
onClick={handleRedeemCoupon}
disabled={!couponCode.trim() || redeeming}
>
{redeeming ? 'Prüfen...' : 'Einlösen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Pencil, Trash2, Check, X } from 'lucide-react' import { Pencil, Trash2, Check, X } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts' import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
@ -21,19 +22,42 @@ export default function WeightScreen() {
const [newNote, setNewNote] = useState('') const [newNote, setNewNote] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [weightUsage, setWeightUsage] = useState(null) // Phase 3: Usage badge
const load = () => api.listWeight(365).then(data => setEntries(data)) const load = () => api.listWeight(365).then(data => setEntries(data))
useEffect(()=>{ load() },[])
const loadUsage = () => {
// Load feature usage for badge
api.getFeatureUsage().then(features => {
const weightFeature = features.find(f => f.feature_id === 'weight_entries')
setWeightUsage(weightFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const handleSave = async () => { const handleSave = async () => {
if (!newWeight) return if (!newWeight) return
setSaving(true) setSaving(true)
setError(null)
try { try {
await api.upsertWeight(newDate, parseFloat(newWeight), newNote) await api.upsertWeight(newDate, parseFloat(newWeight), newNote)
setSaved(true); await load() setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false), 2000) setTimeout(()=>setSaved(false), 2000)
setNewWeight(''); setNewNote('') setNewWeight(''); setNewNote('')
} finally { setSaving(false) } } catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
} }
const handleUpdate = async () => { const handleUpdate = async () => {
@ -59,7 +83,10 @@ export default function WeightScreen() {
{/* Eingabe */} {/* Eingabe */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Eintrag hinzufügen</div> <div className="card-title badge-container-right">
<span>Eintrag hinzufügen</span>
{weightUsage && <UsageBadge {...weightUsage} />}
</div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Datum</label> <label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} <input type="date" className="form-input" style={{width:140}}
@ -79,11 +106,27 @@ export default function WeightScreen() {
value={newNote} onChange={e=>setNewNote(e.target.value)}/> value={newNote} onChange={e=>setNewNote(e.target.value)}/>
<span className="form-unit"/> <span className="form-unit"/>
</div> </div>
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||!newWeight}> {error && (
{saved ? <><Check size={15}/> Gespeichert!</> <div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:12}}>
: saving ? <><div className="spinner" style={{width:14,height:14}}/> </> {error}
: 'Speichern'} </div>
</button> )}
<div
title={weightUsage && !weightUsage.allowed ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block',width:'100%'}}
>
<button
className="btn btn-primary btn-full"
onClick={handleSave}
disabled={saving || !newWeight || (weightUsage && !weightUsage.allowed)}
style={{cursor: (weightUsage && !weightUsage.allowed) ? 'not-allowed' : 'pointer'}}
>
{saved ? <><Check size={15}/> Gespeichert!</>
: saving ? <><div className="spinner" style={{width:14,height:14}}/> </>
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit erreicht'
: 'Speichern'}
</button>
</div>
</div> </div>
{/* Chart */} {/* Chart */}

View File

@ -82,6 +82,11 @@ export const api = {
listNutrition: (l=365) => req(`/nutrition?limit=${l}`), listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
nutritionCorrelations: () => req('/nutrition/correlations'), nutritionCorrelations: () => req('/nutrition/correlations'),
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`), nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
nutritionImportHistory: () => req('/nutrition/import-history'),
getNutritionByDate: (date) => req(`/nutrition/by-date/${date}`),
createNutrition: (date,kcal,protein,fat,carbs) => req(`/nutrition?date=${date}&kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'POST'}),
updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}),
deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}),
// Stats & AI // Stats & AI
getStats: () => req('/stats'), getStats: () => req('/stats'),
@ -137,4 +142,48 @@ export const api = {
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}), adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)), adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
changePin: (pin) => req('/auth/pin',json({pin})), changePin: (pin) => req('/auth/pin',json({pin})),
// v9c Subscription System
// User-facing
getMySubscription: () => req('/subscription/me'),
getMyUsage: () => req('/subscription/usage'),
getMyLimits: () => req('/subscription/limits'),
redeemCoupon: (code) => req('/coupons/redeem',json({code})),
getFeatureUsage: () => req('/features/usage'), // Phase 3: Usage overview
// Admin: Features
listFeatures: () => req('/features'),
createFeature: (d) => req('/features',json(d)),
updateFeature: (id,d) => req(`/features/${id}`,jput(d)),
deleteFeature: (id) => req(`/features/${id}`,{method:'DELETE'}),
// Admin: Tiers
listTiers: () => req('/tiers'),
createTier: (d) => req('/tiers',json(d)),
updateTier: (id,d) => req(`/tiers/${id}`,jput(d)),
deleteTier: (id) => req(`/tiers/${id}`,{method:'DELETE'}),
// Admin: Tier Limits (Matrix)
getTierLimitsMatrix: () => req('/tier-limits'),
updateTierLimit: (d) => req('/tier-limits',jput(d)),
updateTierLimitsBatch:(updates) => req('/tier-limits/batch',jput({updates})),
// Admin: User Restrictions
listUserRestrictions: (pid) => req(`/user-restrictions${pid?'?profile_id='+pid:''}`),
createUserRestriction:(d) => req('/user-restrictions',json(d)),
updateUserRestriction:(id,d) => req(`/user-restrictions/${id}`,jput(d)),
deleteUserRestriction:(id) => req(`/user-restrictions/${id}`,{method:'DELETE'}),
// Admin: Coupons
listCoupons: () => req('/coupons'),
createCoupon: (d) => req('/coupons',json(d)),
updateCoupon: (id,d) => req(`/coupons/${id}`,jput(d)),
deleteCoupon: (id) => req(`/coupons/${id}`,{method:'DELETE'}),
getCouponRedemptions: (id) => req(`/coupons/${id}/redemptions`),
// Admin: Access Grants
listAccessGrants: (pid,active)=> req(`/access-grants${pid?'?profile_id='+pid:''}${active?'&active_only=true':''}`),
createAccessGrant: (d) => req('/access-grants',json(d)),
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
} }