diff --git a/.gitignore b/.gitignore index fbe1afd..6106a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,17 @@ output/*.xlsx templates/*.xlsx ~$*.xlsx +# Android +android-app/.gradle/ +android-app/build/ +android-app/app/build/ +android-app/local.properties +android-app/.gradle +android-app/.idea/ +android-app/*.iml +android-app/captures/ +android-app/app/release/ + # IDE .vscode/ .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d6513..bb01259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog +## 2025-11-14 - Android App Implementation + +### Feature +Added native Android mobile app for duty roster management with the same NRW Variante 2 (streng) payroll calculation logic as the Python/Excel version. + +### Details + +**New Android App:** +- Location: `android-app/` directory +- Language: Kotlin +- Min SDK: Android 7.0 (API 24) +- Target SDK: Android 14 (API 34) + +**Features:** +- Month selection interface (2025-2030) +- Duty entry with employee name and share (Anteil) +- Automatic payroll calculation +- Results display with detailed breakdown per employee +- In-memory data storage + +**Business Logic:** +- Same NRW holidays (2025-2026) +- Same WE-Tag detection (Friday, Saturday, Sunday, holidays, day before holiday) +- Same WT-Tag classification +- Same compensation rates (WT: 250€, WE: 450€) +- Same threshold logic (≥ 2.0 WE units) +- Same deduction rules (1.0 unit, Friday priority) +- Same Variante 2 behavior (no WE compensation below threshold) + +**Testing:** +- Comprehensive unit tests for PayrollCalculator +- All test cases passed (under threshold, at threshold, over threshold, Friday priority, multiple employees) + +**Documentation:** +- Android-specific README with setup instructions +- Main README updated to mention Android app +- .gitignore updated for Android build artifacts + +### Usage + +See [android-app/README.md](android-app/README.md) for detailed Android setup and usage instructions. + +### Known Limitations +- Data is not persisted (in-memory only) +- No data export/import functionality +- German language only + +--- + ## 2025-11-14 - Fix Excel Formula Syntax Error ### Issue diff --git a/README.md b/README.md index e41d467..920383b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ # Dienstplan Generator (NRW - Variante 2) -Python-Projekt zum automatischen Erstellen von Dienstplänen mit Vergütungsberechnung nach NRW-Regeln (Variante 2 "streng"). +Projekt zum automatischen Erstellen von Dienstplänen mit Vergütungsberechnung nach NRW-Regeln (Variante 2 "streng"). -## Features +## Verfügbare Versionen + +### Python/Excel Version (Desktop) +Python-basierter Generator für Excel-Dienstpläne. + +### Android App (Mobile) 🆕 +Native Android-App für mobiles Dienstplan-Management. Siehe [android-app/README.md](android-app/README.md) für Details. + +## Features (Python/Excel Version) - ✅ Automatische Erkennung von Wochenenden (Fr–So), Feiertagen und Vortagen - ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten) @@ -55,10 +63,14 @@ Die Datei landet in `output/Dienstplan_YYYY_MM_NRW.xlsx`. ```text . -├── src/ +├── src/ # Python source code │ ├── build_template.py # Erstellt die Basis-Vorlage │ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen │ └── read_excel.py # Liest xlsx-Dateien aus +├── android-app/ # Android mobile app +│ ├── app/ # Android app source code +│ ├── build.gradle.kts # Build configuration +│ └── README.md # Android app documentation ├── output/ # Generierte Monatspläne ├── templates/ # Basis-Vorlage ├── requirements.txt # Python-Abhängigkeiten (openpyxl) diff --git a/android-app/IMPLEMENTATION_SUMMARY.md b/android-app/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..55e510f --- /dev/null +++ b/android-app/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,239 @@ +# Android App Implementation Summary + +## Overview + +Successfully implemented a native Android mobile application for the Dienstplan NRW project, providing the same duty roster management and payroll calculation functionality as the existing Python/Excel solution, but in a mobile-friendly format. + +## What Was Implemented + +### 1. Complete Android Project Structure +- **Package**: `com.dienstplan.nrw` +- **Language**: Kotlin +- **Min SDK**: Android 7.0 (API 24) - covers ~98% of active Android devices +- **Target SDK**: Android 14 (API 34) +- **Build System**: Gradle with Kotlin DSL +- **Architecture**: Simple MVVM-inspired pattern with clear separation of concerns + +### 2. Core Business Logic (100% Parity with Python/Excel) + +#### PayrollCalculator +Implements NRW Variante 2 (streng) rules: +- WT compensation: 250€ per unit (always paid) +- WE compensation: 450€ per unit (only if threshold ≥ 2.0 reached) +- Threshold: 2.0 WE units per employee per month +- Deduction: Exactly 1.0 WE unit after threshold reached +- Deduction priority: Friday first, then other WE days +- Below threshold: WE shifts = 0€ (NOT converted to WT) + +#### HolidayProvider +- NRW public holidays for 2025-2026 +- Same holiday dates as Python implementation +- Holiday detection for threshold classification +- Day-before-holiday detection (Vortag) + +#### Day Classification +- **WE-Tag** (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before holiday +- **WT-Tag** (Weekday): All other days +- Uses Calendar.DAY_OF_WEEK for reliable detection + +### 3. User Interface + +#### MainActivity +- Month selection (dropdown for 1-12) +- Year selection (2025-2030) +- Navigation to duty entry and results screens +- Material Design components + +#### DutyEntryActivity +- RecyclerView-based duty list +- Date picker dialog for selecting duty dates +- Employee name and share input (0.0-1.0) +- Add/delete duty functionality +- Validation for valid share values + +#### ResultsActivity +- RecyclerView-based results list +- Per-employee payroll breakdown showing: + - WT units + - WE Friday units + - WE Other units + - WE Total + - Threshold reached (JA/NEIN) + - Payout WT (€) + - Payout WE (€) + - Total payout (€) + +### 4. Data Management + +#### DutyDataStore +- Simple in-memory data storage +- CRUD operations for duty entries +- Month-based filtering +- Date generation for month selection + +**Note**: Production implementation should use Room database for persistence. + +### 5. Testing + +#### Unit Tests (PayrollCalculatorTest.kt) +Comprehensive test coverage including: +1. **Under threshold test**: 1.75 WE + 1.0 WT → WE payout 0€, WT payout 250€ +2. **Exactly at threshold test**: 2.0 WE → WE payout 450€ (1.0 unit after deduction) +3. **Over threshold test**: 3.5 WE → WE payout 1125€ (2.5 units after deduction) +4. **Friday deduction priority test**: Verifies deduction comes from Friday first +5. **Multiple employees test**: Separate calculations per employee + +All tests pass and validate the business logic matches the specification. + +### 6. Resource Files + +#### Layouts +- `activity_main.xml`: Month selection screen +- `activity_duty_entry.xml`: Duty entry list screen +- `activity_results.xml`: Results display screen +- `item_duty_entry.xml`: Duty entry list item (CardView) +- `item_result.xml`: Result list item (CardView with detailed breakdown) +- `dialog_add_duty.xml`: Dialog for adding a new duty + +#### Strings (German) +- All UI text in German (matching the domain) +- Month names array +- Error messages +- Labels and titles + +#### Themes & Colors +- Material Design theme +- Primary color: #4472C4 (matching Excel template header color) +- Consistent color scheme throughout + +### 7. Documentation + +#### android-app/README.md +Complete documentation including: +- Feature list +- Business rules explanation +- Installation instructions (Android Studio and command line) +- Usage guide +- Project structure +- Customization guide +- Known limitations +- Future enhancements + +#### Main README.md Updates +- Added section about available versions (Python/Excel and Android) +- Updated project structure to include android-app + +#### CHANGELOG.md +- Detailed entry for Android app implementation +- Features, testing, and limitations documented + +## Technical Decisions + +### Why Kotlin? +- Modern Android development standard +- Concise, expressive syntax +- Null safety built-in +- Excellent interop with Java libraries + +### Why In-Memory Storage? +- Simplifies initial implementation +- Easy to replace with Room database later +- Sufficient for proof-of-concept +- Documented as known limitation + +### Why Material Design? +- Android standard UI framework +- Consistent user experience +- Built-in accessibility features +- Professional appearance + +## Validation + +### Business Logic Verification +✅ All payroll calculation test cases pass +✅ Holiday detection matches Python implementation +✅ WE-Tag classification matches specification +✅ Threshold logic matches Variante 2 (streng) rules +✅ Deduction priority (Friday first) implemented correctly + +### Python/Excel Compatibility +✅ Python template generation still works +✅ No changes to existing Python code +✅ Business rules identical between implementations + +### Code Quality +✅ No CodeQL security issues detected +✅ Clean separation of concerns +✅ Well-documented code with comments +✅ Consistent naming conventions +✅ Proper error handling and validation + +## Security Summary + +**Security Scan**: No vulnerabilities detected by CodeQL +**Data Storage**: In-memory only (no sensitive data persisted) +**Input Validation**: Share values validated (0.0-1.0 range) +**No External Dependencies**: Only official Android/Google libraries used + +## Known Limitations + +1. **Data Persistence**: Data is lost when app closes (in-memory only) +2. **No Export**: Cannot export results to Excel/PDF +3. **No Import**: Cannot import existing Excel files +4. **Limited Holiday Data**: Only NRW 2025-2026 +5. **Single Bundesland**: Only NRW supported +6. **No Offline Sync**: No cloud backup/restore +7. **German Only**: No internationalization + +## Future Enhancements (Recommended) + +1. **Data Persistence**: Implement Room database +2. **Export/Import**: Excel and PDF export, Excel import +3. **Multi-Bundesland**: Support other German states +4. **Extended Holiday Data**: Add years beyond 2026 +5. **Cloud Sync**: Optional cloud backup +6. **Localization**: Add English translation +7. **Dark Mode**: Theme support +8. **Tablet Layout**: Optimized for larger screens + +## Files Added + +``` +android-app/ +├── README.md (Documentation) +├── build.gradle.kts (Root build config) +├── settings.gradle.kts (Project settings) +├── gradle.properties (Gradle properties) +├── app/ +│ ├── build.gradle.kts (App build config) +│ ├── proguard-rules.pro (ProGuard rules) +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── AndroidManifest.xml (App manifest) +│ │ │ ├── java/com/dienstplan/nrw/ +│ │ │ │ ├── MainActivity.kt (Month selection) +│ │ │ │ ├── DutyEntryActivity.kt (Duty entry) +│ │ │ │ ├── ResultsActivity.kt (Results display) +│ │ │ │ ├── model/ +│ │ │ │ │ ├── DutyEntry.kt (Data model) +│ │ │ │ │ ├── Holiday.kt (Data model) +│ │ │ │ │ └── PayrollResult.kt (Data model) +│ │ │ │ └── data/ +│ │ │ │ ├── DutyDataStore.kt (Data storage) +│ │ │ │ ├── HolidayProvider.kt (Holiday data) +│ │ │ │ └── PayrollCalculator.kt (Business logic) +│ │ │ └── res/ +│ │ │ ├── layout/ (6 XML layouts) +│ │ │ └── values/ (strings, colors, themes) +│ │ └── test/ +│ │ └── java/com/dienstplan/nrw/ +│ │ └── PayrollCalculatorTest.kt (Unit tests) +``` + +Total: 27 new files + +## Conclusion + +The Android app successfully brings the Dienstplan NRW functionality to mobile devices while maintaining 100% business logic parity with the Python/Excel implementation. The app is ready for use with the understanding that data is not persisted between sessions (by design for this initial implementation). + +The implementation is clean, well-tested, documented, and follows Android best practices. It provides a solid foundation for future enhancements such as data persistence, export/import functionality, and additional features. diff --git a/android-app/README.md b/android-app/README.md new file mode 100644 index 0000000..7694129 --- /dev/null +++ b/android-app/README.md @@ -0,0 +1,156 @@ +# Android Dienstplan NRW App + +Android mobile app for managing duty rosters (Dienstplan) with automatic payroll calculations according to NRW rules (Variante 2 - streng). + +## Features + +- ✅ Month selection interface +- ✅ Simple duty entry with employee name and share (Anteil) +- ✅ Automatic payroll calculation +- ✅ NRW holiday recognition +- ✅ Weekend/Holiday shift classification +- ✅ Threshold-based WE compensation (Variante 2 - streng) +- ✅ Results display with detailed breakdown + +## Business Rules (Variante 2 - streng) + +Same as the Python/Excel implementation: + +- **WE-Tag** (Weekend/Holiday): Friday, Saturday, Sunday, public holidays, day before public holiday +- **WT-Tag** (Weekday): All other days +- **WT compensation**: Always 250€ per unit +- **WE compensation**: Only paid if monthly total ≥ 2.0 WE units + - If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit + - Deduction priority: Friday first, then other WE days + - Below threshold: 0€ for WE shifts + +## Requirements + +- Android Studio Arctic Fox or later +- Android SDK 24+ (Android 7.0 Nougat) +- Kotlin 1.9.10 +- Gradle 8.1.4 + +## Installation + +### Option 1: Android Studio + +1. Open Android Studio +2. Select "Open an Existing Project" +3. Navigate to the `android-app` directory +4. Wait for Gradle sync to complete +5. Click "Run" or press Shift+F10 + +### Option 2: Command Line + +```bash +cd android-app +./gradlew build +./gradlew installDebug +``` + +## Usage + +1. **Select Month**: Choose year and month from the dropdowns on the main screen +2. **Enter Duties**: Click "Dienste eintragen" to add duty entries + - Select a date from the month + - Enter employee name + - Enter share/portion (Anteil) between 0.0 and 1.0 + - Save the duty +3. **View Results**: Click "Auswertung anzeigen" to see payroll calculations + - Shows breakdown per employee + - Displays WT units, WE units, threshold status, and payouts + +## Project Structure + +``` +android-app/ +├── app/ +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/com/dienstplan/nrw/ +│ │ │ │ ├── MainActivity.kt # Main screen with month selection +│ │ │ │ ├── DutyEntryActivity.kt # Duty entry screen +│ │ │ │ ├── ResultsActivity.kt # Results/Auswertung screen +│ │ │ │ ├── model/ +│ │ │ │ │ ├── DutyEntry.kt # Duty entry data model +│ │ │ │ │ ├── Holiday.kt # Holiday data model +│ │ │ │ │ └── PayrollResult.kt # Payroll calculation result +│ │ │ │ └── data/ +│ │ │ │ ├── DutyDataStore.kt # In-memory data storage +│ │ │ │ ├── HolidayProvider.kt # NRW holidays data +│ │ │ │ └── PayrollCalculator.kt # Business logic engine +│ │ │ ├── res/ # Resources (layouts, strings, colors) +│ │ │ └── AndroidManifest.xml +│ │ └── test/ # Unit tests +│ └── build.gradle.kts # App build configuration +├── build.gradle.kts # Project build configuration +├── settings.gradle.kts # Project settings +└── README.md # This file +``` + +## Data Storage + +Currently uses in-memory storage (`DutyDataStore`). Data is lost when the app is closed. + +For production use, this should be replaced with: +- Room database for persistent local storage +- Or backend API integration for cloud storage + +## Testing + +### Unit Tests + +Run unit tests with: + +```bash +./gradlew test +``` + +### UI Tests + +Run instrumented tests with: + +```bash +./gradlew connectedAndroidTest +``` + +## Customization + +### Changing Payroll Rules + +Edit `PayrollCalculator.kt` and modify the constants: +- `RATE_WT`: Weekday rate (default 250€) +- `RATE_WE`: Weekend rate (default 450€) +- `WE_THRESHOLD`: Threshold for WE compensation (default 2.0) +- `DEDUCTION_AFTER_THRESHOLD`: Deduction amount (default 1.0) + +### Adding Holidays + +Edit `HolidayProvider.kt` and add entries to the holiday lists for additional years. + +## Known Limitations + +- Data is not persisted (in-memory only) +- No data export/import functionality +- No multi-user support +- Only NRW holidays (2025-2026) +- German language only + +## Future Enhancements + +- [ ] Room database for data persistence +- [ ] Export to Excel/PDF +- [ ] Import from Excel +- [ ] Multi-Bundesland support +- [ ] Data backup/restore +- [ ] Dark mode support +- [ ] Tablet layout optimization + +## License + +MIT + +## Credits + +Based on the Python/Excel Dienstplan Generator implementation by Kenearos. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts new file mode 100644 index 0000000..906e2d0 --- /dev/null +++ b/android-app/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.dienstplan.nrw" + compileSdk = 34 + + defaultConfig { + applicationId = "com.dienstplan.nrw" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + // Room database + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + annotationProcessor("androidx.room:room-compiler:$roomVersion") + + // Lifecycle + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") + + // RecyclerView + implementation("androidx.recyclerview:recyclerview:1.3.2") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..ef3d48b --- /dev/null +++ b/android-app/app/proguard-rules.pro @@ -0,0 +1,9 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt + +# Keep data classes +-keep class com.dienstplan.nrw.model.** { *; } + +# Keep ViewBinding classes +-keep class com.dienstplan.nrw.databinding.** { *; } diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..67ff5ab --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt b/android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt new file mode 100644 index 0000000..fa3d39a --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt @@ -0,0 +1,177 @@ +package com.dienstplan.nrw + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.dienstplan.nrw.data.DutyDataStore +import com.dienstplan.nrw.databinding.ActivityDutyEntryBinding +import com.dienstplan.nrw.databinding.ItemDutyEntryBinding +import com.dienstplan.nrw.model.DutyEntry +import java.text.SimpleDateFormat +import java.util.* + +class DutyEntryActivity : AppCompatActivity() { + + private lateinit var binding: ActivityDutyEntryBinding + private lateinit var adapter: DutyEntryAdapter + private var year: Int = 0 + private var month: Int = 0 + private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDutyEntryBinding.inflate(layoutInflater) + setContentView(binding.root) + + year = intent.getIntExtra("year", 0) + month = intent.getIntExtra("month", 0) + + if (year == 0 || month == 0) { + finish() + return + } + + setupUI() + loadDuties() + } + + private fun setupUI() { + val monthName = getMonthName(month) + binding.tvSelectedMonth.text = String.format("%s %d", monthName, year) + + adapter = DutyEntryAdapter { duty -> + deleteDuty(duty) + } + binding.rvDuties.layoutManager = LinearLayoutManager(this) + binding.rvDuties.adapter = adapter + + binding.btnAddDuty.setOnClickListener { + showAddDutyDialog() + } + + binding.btnSave.setOnClickListener { + Toast.makeText(this, "Dienste gespeichert", Toast.LENGTH_SHORT).show() + finish() + } + } + + private fun loadDuties() { + val duties = DutyDataStore.getDutiesForMonth(year, month) + adapter.submitList(duties) + } + + private fun showAddDutyDialog() { + val dates = DutyDataStore.getDatesInMonth(year, month) + val dateStrings = dates.map { dateFormat.format(it) }.toTypedArray() + + AlertDialog.Builder(this) + .setTitle("Datum auswählen") + .setItems(dateStrings) { _, which -> + val selectedDate = dates[which] + showEmployeeDialog(selectedDate) + } + .show() + } + + private fun showEmployeeDialog(date: Date) { + val builder = AlertDialog.Builder(this) + val inflater = layoutInflater + val dialogView = inflater.inflate(R.layout.dialog_add_duty, null) + + val etName = dialogView.findViewById(R.id.etEmployeeName) + val etShare = dialogView.findViewById(R.id.etShare) + + builder.setView(dialogView) + .setTitle("Dienst hinzufügen - ${dateFormat.format(date)}") + .setPositiveButton("Hinzufügen") { _, _ -> + val name = etName.text.toString().trim() + val shareStr = etShare.text.toString().trim() + + if (name.isEmpty()) { + Toast.makeText(this, R.string.error_empty_name, Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + + val share = shareStr.toDoubleOrNull() ?: 0.0 + if (share <= 0.0 || share > 1.0) { + Toast.makeText(this, R.string.error_invalid_share, Toast.LENGTH_SHORT).show() + return@setPositiveButton + } + + val monthKey = DutyDataStore.getMonthKey(year, month) + val duty = DutyEntry( + date = date, + employeeName = name, + share = share, + monthKey = monthKey + ) + + DutyDataStore.addDuty(duty) + loadDuties() + } + .setNegativeButton("Abbrechen", null) + .show() + } + + private fun deleteDuty(duty: DutyEntry) { + DutyDataStore.deleteDuty(duty.id) + loadDuties() + } + + private fun getMonthName(month: Int): String { + val months = resources.getStringArray(R.array.months) + return if (month in 1..12) months[month - 1] else "" + } +} + +class DutyEntryAdapter( + private val onDelete: (DutyEntry) -> Unit +) : RecyclerView.Adapter() { + + private var duties = listOf() + private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY) + + fun submitList(newDuties: List) { + duties = newDuties.sortedBy { it.date } + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemDutyEntryBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(duties[position]) + } + + override fun getItemCount() = duties.size + + inner class ViewHolder( + private val binding: ItemDutyEntryBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(duty: DutyEntry) { + binding.tvDate.text = dateFormat.format(duty.date) + binding.etEmployeeName.setText(duty.employeeName) + binding.etShare.setText(duty.share.toString()) + + binding.btnDelete.setOnClickListener { + onDelete(duty) + } + + // Disable editing in the list view + binding.etEmployeeName.isEnabled = false + binding.etShare.isEnabled = false + } + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt b/android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt new file mode 100644 index 0000000..46d9486 --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt @@ -0,0 +1,102 @@ +package com.dienstplan.nrw + +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.dienstplan.nrw.databinding.ActivityMainBinding +import java.util.* + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private var selectedYear: Int = 0 + private var selectedMonth: Int = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupMonthSpinner() + setupYearSpinner() + setupButtons() + + // Set default to current month + val calendar = Calendar.getInstance() + selectedYear = calendar.get(Calendar.YEAR) + selectedMonth = calendar.get(Calendar.MONTH) + 1 // Calendar.MONTH is 0-based + } + + private fun setupMonthSpinner() { + val months = resources.getStringArray(R.array.months) + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, months) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.spinnerMonth.adapter = adapter + + binding.spinnerMonth.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + selectedMonth = position + 1 // Months are 1-based + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // Do nothing + } + } + + // Set to current month + val currentMonth = Calendar.getInstance().get(Calendar.MONTH) + binding.spinnerMonth.setSelection(currentMonth) + } + + private fun setupYearSpinner() { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val years = (2025..2030).map { it.toString() } + val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, years) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.spinnerYear.adapter = adapter + + binding.spinnerYear.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + selectedYear = 2025 + position + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + // Do nothing + } + } + + // Set to current year + val yearIndex = currentYear - 2025 + if (yearIndex >= 0 && yearIndex < years.size) { + binding.spinnerYear.setSelection(yearIndex) + } + } + + private fun setupButtons() { + binding.btnEnterDuties.setOnClickListener { + if (selectedYear > 0 && selectedMonth > 0) { + val intent = Intent(this, DutyEntryActivity::class.java) + intent.putExtra("year", selectedYear) + intent.putExtra("month", selectedMonth) + startActivity(intent) + } else { + Toast.makeText(this, R.string.error_no_month_selected, Toast.LENGTH_SHORT).show() + } + } + + binding.btnViewResults.setOnClickListener { + if (selectedYear > 0 && selectedMonth > 0) { + val intent = Intent(this, ResultsActivity::class.java) + intent.putExtra("year", selectedYear) + intent.putExtra("month", selectedMonth) + startActivity(intent) + } else { + Toast.makeText(this, R.string.error_no_month_selected, Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt b/android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt new file mode 100644 index 0000000..f3199cc --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt @@ -0,0 +1,102 @@ +package com.dienstplan.nrw + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.dienstplan.nrw.data.DutyDataStore +import com.dienstplan.nrw.data.PayrollCalculator +import com.dienstplan.nrw.databinding.ActivityResultsBinding +import com.dienstplan.nrw.databinding.ItemResultBinding +import com.dienstplan.nrw.model.PayrollResult + +class ResultsActivity : AppCompatActivity() { + + private lateinit var binding: ActivityResultsBinding + private lateinit var adapter: ResultsAdapter + private var year: Int = 0 + private var month: Int = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityResultsBinding.inflate(layoutInflater) + setContentView(binding.root) + + year = intent.getIntExtra("year", 0) + month = intent.getIntExtra("month", 0) + + if (year == 0 || month == 0) { + finish() + return + } + + setupUI() + calculateAndDisplayResults() + } + + private fun setupUI() { + val monthName = getMonthName(month) + binding.tvSelectedMonth.text = String.format("%s %d", monthName, year) + + adapter = ResultsAdapter() + binding.rvResults.layoutManager = LinearLayoutManager(this) + binding.rvResults.adapter = adapter + } + + private fun calculateAndDisplayResults() { + val duties = DutyDataStore.getDutiesForMonth(year, month) + val calculator = PayrollCalculator() + val results = calculator.calculatePayroll(duties, year, month) + + adapter.submitList(results) + } + + private fun getMonthName(month: Int): String { + val months = resources.getStringArray(R.array.months) + return if (month in 1..12) months[month - 1] else "" + } +} + +class ResultsAdapter : RecyclerView.Adapter() { + + private var results = listOf() + + fun submitList(newResults: List) { + results = newResults + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemResultBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(results[position]) + } + + override fun getItemCount() = results.size + + class ViewHolder( + private val binding: ItemResultBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(result: PayrollResult) { + binding.tvEmployeeName.text = result.employeeName + binding.tvWTUnits.text = String.format("%.2f", result.wtUnits) + binding.tvWEFriday.text = String.format("%.2f", result.weFriday) + binding.tvWEOther.text = String.format("%.2f", result.weOther) + binding.tvWETotal.text = String.format("%.2f", result.weTotal) + binding.tvThresholdReached.text = if (result.thresholdReached) "JA" else "NEIN" + binding.tvPayoutWT.text = String.format("%.2f €", result.payoutWT) + binding.tvPayoutWE.text = String.format("%.2f €", result.payoutWE) + binding.tvPayoutTotal.text = String.format("%.2f €", result.payoutTotal) + } + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt new file mode 100644 index 0000000..91d5819 --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt @@ -0,0 +1,88 @@ +package com.dienstplan.nrw.data + +import com.dienstplan.nrw.model.DutyEntry +import java.text.SimpleDateFormat +import java.util.* + +/** + * Simple in-memory data store for duty entries. + * In a production app, this would be replaced with a Room database. + */ +object DutyDataStore { + + private val duties = mutableListOf() + private var nextId = 1L + + /** + * Add a new duty entry. + */ + fun addDuty(duty: DutyEntry): DutyEntry { + val dutyWithId = duty.copy(id = nextId++) + duties.add(dutyWithId) + return dutyWithId + } + + /** + * Update an existing duty entry. + */ + fun updateDuty(duty: DutyEntry) { + val index = duties.indexOfFirst { it.id == duty.id } + if (index >= 0) { + duties[index] = duty + } + } + + /** + * Delete a duty entry. + */ + fun deleteDuty(dutyId: Long) { + duties.removeAll { it.id == dutyId } + } + + /** + * Get all duties for a specific month. + */ + fun getDutiesForMonth(year: Int, month: Int): List { + val monthKey = getMonthKey(year, month) + return duties.filter { it.monthKey == monthKey } + } + + /** + * Get all duties. + */ + fun getAllDuties(): List = duties.toList() + + /** + * Clear all duties. + */ + fun clearAll() { + duties.clear() + nextId = 1L + } + + /** + * Get month key in YYYYMM format. + */ + fun getMonthKey(year: Int, month: Int): String { + return String.format("%04d%02d", year, month) + } + + /** + * Get all dates in a month. + */ + fun getDatesInMonth(year: Int, month: Int): List { + val calendar = Calendar.getInstance() + calendar.set(year, month - 1, 1, 0, 0, 0) + calendar.set(Calendar.MILLISECOND, 0) + + val dates = mutableListOf() + val maxDay = calendar.getActualMaximum(Calendar.DAY_OF_MONTH) + + for (day in 1..maxDay) { + calendar.set(Calendar.DAY_OF_MONTH, day) + dates.add(calendar.time) + } + + return dates + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt new file mode 100644 index 0000000..cb5b70a --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt @@ -0,0 +1,87 @@ +package com.dienstplan.nrw.data + +import com.dienstplan.nrw.model.Holiday +import java.text.SimpleDateFormat +import java.util.* + +/** + * Provides NRW public holidays for years 2025-2026. + * This matches the holiday data in the Python implementation. + */ +object HolidayProvider { + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY) + + private val nrwHolidays2025 = listOf( + Holiday(parseDate("2025-01-01"), "Neujahr", "NRW"), + Holiday(parseDate("2025-04-18"), "Karfreitag", "NRW"), + Holiday(parseDate("2025-04-21"), "Ostermontag", "NRW"), + Holiday(parseDate("2025-05-01"), "Tag der Arbeit", "NRW"), + Holiday(parseDate("2025-05-29"), "Christi Himmelfahrt", "NRW"), + Holiday(parseDate("2025-06-09"), "Pfingstmontag", "NRW"), + Holiday(parseDate("2025-06-19"), "Fronleichnam", "NRW"), + Holiday(parseDate("2025-10-03"), "Tag der Deutschen Einheit", "NRW"), + Holiday(parseDate("2025-11-01"), "Allerheiligen", "NRW"), + Holiday(parseDate("2025-12-25"), "1. Weihnachtstag", "NRW"), + Holiday(parseDate("2025-12-26"), "2. Weihnachtstag", "NRW") + ) + + private val nrwHolidays2026 = listOf( + Holiday(parseDate("2026-01-01"), "Neujahr", "NRW"), + Holiday(parseDate("2026-04-03"), "Karfreitag", "NRW"), + Holiday(parseDate("2026-04-06"), "Ostermontag", "NRW"), + Holiday(parseDate("2026-05-01"), "Tag der Arbeit", "NRW"), + Holiday(parseDate("2026-05-14"), "Christi Himmelfahrt", "NRW"), + Holiday(parseDate("2026-05-25"), "Pfingstmontag", "NRW"), + Holiday(parseDate("2026-06-04"), "Fronleichnam", "NRW"), + Holiday(parseDate("2026-10-03"), "Tag der Deutschen Einheit", "NRW"), + Holiday(parseDate("2026-11-01"), "Allerheiligen", "NRW"), + Holiday(parseDate("2026-12-25"), "1. Weihnachtstag", "NRW"), + Holiday(parseDate("2026-12-26"), "2. Weihnachtstag", "NRW") + ) + + private val allHolidays = nrwHolidays2025 + nrwHolidays2026 + + /** + * Get all NRW holidays. + */ + fun getAllHolidays(): List = allHolidays + + /** + * Check if a date is a public holiday. + */ + fun isHoliday(date: Date): Boolean { + val calendar = Calendar.getInstance() + calendar.time = date + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) + val normalizedDate = calendar.time + + return allHolidays.any { + val holidayCalendar = Calendar.getInstance() + holidayCalendar.time = it.date + holidayCalendar.set(Calendar.HOUR_OF_DAY, 0) + holidayCalendar.set(Calendar.MINUTE, 0) + holidayCalendar.set(Calendar.SECOND, 0) + holidayCalendar.set(Calendar.MILLISECOND, 0) + + normalizedDate.equals(holidayCalendar.time) + } + } + + /** + * Check if a date is the day before a public holiday. + */ + fun isDayBeforeHoliday(date: Date): Boolean { + val calendar = Calendar.getInstance() + calendar.time = date + calendar.add(Calendar.DAY_OF_MONTH, 1) + return isHoliday(calendar.time) + } + + private fun parseDate(dateString: String): Date { + return dateFormat.parse(dateString) ?: throw IllegalArgumentException("Invalid date: $dateString") + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt new file mode 100644 index 0000000..9323553 --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt @@ -0,0 +1,133 @@ +package com.dienstplan.nrw.data + +import com.dienstplan.nrw.model.DutyEntry +import com.dienstplan.nrw.model.PayrollResult +import java.util.* +import kotlin.math.max +import kotlin.math.min + +/** + * Payroll calculator implementing NRW Variante 2 (streng) rules. + * + * Business rules: + * - WE-Tag (Weekend/Holiday): Friday, Saturday, Sunday, public holiday, day before public holiday + * - WT-Tag (Weekday): All other days + * - WT compensation: Always 250€ per unit + * - WE compensation: Only paid if monthly total >= 2.0 WE units (threshold) + * - If threshold reached: 450€ per WE unit, then deduct exactly 1.0 WE unit + * - Deduction priority: Friday first, then other WE days + * - Below threshold: 0€ for WE shifts (NOT converted to WT) + */ +class PayrollCalculator { + + companion object { + private const val RATE_WT = 250.0 // Satz_WT + private const val RATE_WE = 450.0 // Satz_WE + private const val WE_THRESHOLD = 2.0 // WE_Schwelle + private const val DEDUCTION_AFTER_THRESHOLD = 1.0 // Abzug_nach_WE_Schwelle + private const val TOLERANCE = 0.0001 // For floating-point comparisons + } + + /** + * Calculate payroll results for all employees in the given month. + */ + fun calculatePayroll(duties: List, year: Int, month: Int): List { + // Group duties by employee + val dutiesByEmployee = duties.groupBy { it.employeeName } + + return dutiesByEmployee.map { (employeeName, employeeDuties) -> + calculateForEmployee(employeeName, employeeDuties) + }.sortedBy { it.employeeName } + } + + /** + * Calculate payroll result for a single employee. + */ + private fun calculateForEmployee(employeeName: String, duties: List): PayrollResult { + var wtUnits = 0.0 + var weFriday = 0.0 + var weOther = 0.0 + + for (duty in duties) { + val date = duty.date + + if (isWETag(date)) { + if (isFriday(date)) { + weFriday += duty.share + } else { + weOther += duty.share + } + } else { + // WT-Tag (weekday, not weekend) + wtUnits += duty.share + } + } + + val weTotal = weFriday + weOther + + // Check if threshold is reached + val thresholdReached = weTotal >= (WE_THRESHOLD - TOLERANCE) + + // Calculate deduction (only if threshold reached) + val deductionTotal = if (thresholdReached) DEDUCTION_AFTER_THRESHOLD else 0.0 + val deductionFriday = min(deductionTotal, weFriday) + val deductionOther = max(0.0, deductionTotal - deductionFriday) + + // Calculate paid WE units (Variante 2: only if threshold reached) + val wePaid = if (weTotal < (WE_THRESHOLD - TOLERANCE)) { + 0.0 // Below threshold: no WE compensation + } else { + (weFriday - deductionFriday) + (weOther - deductionOther) + } + + // Calculate payouts + val payoutWT = wtUnits * RATE_WT + val payoutWE = wePaid * RATE_WE + val payoutTotal = payoutWT + payoutWE + + return PayrollResult( + employeeName = employeeName, + wtUnits = wtUnits, + weFriday = weFriday, + weOther = weOther, + weTotal = weTotal, + thresholdReached = thresholdReached, + deductionTotal = deductionTotal, + deductionFriday = deductionFriday, + deductionOther = deductionOther, + wePaid = wePaid, + payoutWT = payoutWT, + payoutWE = payoutWE, + payoutTotal = payoutTotal + ) + } + + /** + * Check if a date is a WE-Tag (Weekend/Holiday). + * WE-Tag = Friday, Saturday, Sunday, public holiday, or day before public holiday. + */ + private fun isWETag(date: Date): Boolean { + val calendar = Calendar.getInstance() + calendar.time = date + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + + // Friday, Saturday, Sunday (Calendar.FRIDAY = 6, SATURDAY = 7, SUNDAY = 1) + val isWeekend = dayOfWeek == Calendar.FRIDAY || + dayOfWeek == Calendar.SATURDAY || + dayOfWeek == Calendar.SUNDAY + + val isHoliday = HolidayProvider.isHoliday(date) + val isDayBeforeHoliday = HolidayProvider.isDayBeforeHoliday(date) + + return isWeekend || isHoliday || isDayBeforeHoliday + } + + /** + * Check if a date is a Friday. + */ + private fun isFriday(date: Date): Boolean { + val calendar = Calendar.getInstance() + calendar.time = date + return calendar.get(Calendar.DAY_OF_WEEK) == Calendar.FRIDAY + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt b/android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt new file mode 100644 index 0000000..e8dc6d0 --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt @@ -0,0 +1,22 @@ +package com.dienstplan.nrw.model + +import java.util.Date + +/** + * Represents a single duty entry in the plan. + * Maps to the tblPlan in the Excel implementation. + */ +data class DutyEntry( + val id: Long = 0, + val date: Date, + val employeeName: String, + val share: Double, // Anteil (0.0 - 1.0) + val monthKey: String // YYYYMM format +) { + /** + * Check if this entry is valid. + */ + fun isValid(): Boolean { + return employeeName.isNotBlank() && share in 0.0..1.0 + } +} diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt b/android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt new file mode 100644 index 0000000..19067df --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt @@ -0,0 +1,13 @@ +package com.dienstplan.nrw.model + +import java.util.Date + +/** + * Represents a public holiday in NRW. + * Maps to the tblFeiertage in the Excel implementation. + */ +data class Holiday( + val date: Date, + val name: String, + val bundesland: String = "NRW" +) diff --git a/android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt b/android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt new file mode 100644 index 0000000..337d30d --- /dev/null +++ b/android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt @@ -0,0 +1,44 @@ +package com.dienstplan.nrw.model + +/** + * Payroll calculation result for an employee for a specific month. + * Maps to the Auswertung sheet in the Excel implementation. + */ +data class PayrollResult( + val employeeName: String, + val wtUnits: Double, // WT_Einheiten (weekday units) + val weFriday: Double, // WE_Freitag (Friday weekend units) + val weOther: Double, // WE_Andere (other weekend units) + val weTotal: Double, // WE_Gesamt (total weekend units) + val thresholdReached: Boolean, // Schwelle_erreicht + val deductionTotal: Double, // Abzug_gesamt + val deductionFriday: Double, // Abzug_Freitag + val deductionOther: Double, // Abzug_Andere + val wePaid: Double, // WE_bezahlt (paid weekend units after threshold check) + val payoutWT: Double, // Auszahlung_WT + val payoutWE: Double, // Auszahlung_WE + val payoutTotal: Double // Auszahlung_Gesamt +) { + companion object { + /** + * Create an empty result for an employee with no duties. + */ + fun empty(employeeName: String): PayrollResult { + return PayrollResult( + employeeName = employeeName, + wtUnits = 0.0, + weFriday = 0.0, + weOther = 0.0, + weTotal = 0.0, + thresholdReached = false, + deductionTotal = 0.0, + deductionFriday = 0.0, + deductionOther = 0.0, + wePaid = 0.0, + payoutWT = 0.0, + payoutWE = 0.0, + payoutTotal = 0.0 + ) + } + } +} diff --git a/android-app/app/src/main/res/layout/activity_duty_entry.xml b/android-app/app/src/main/res/layout/activity_duty_entry.xml new file mode 100644 index 0000000..b85d093 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_duty_entry.xml @@ -0,0 +1,54 @@ + + + + + + + +