From 3069fcd8baa9f3a65b3c0dd35b01def65f4248ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:15:43 +0000 Subject: [PATCH 1/4] Initial plan From 976d494d9f845424fb09386d713b9d50003639bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:24:17 +0000 Subject: [PATCH 2/4] Add Android app implementation with core features Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com> --- .gitignore | 11 + README.md | 18 +- android-app/README.md | 156 ++++++++++++++ android-app/app/build.gradle.kts | 67 ++++++ android-app/app/proguard-rules.pro | 9 + android-app/app/src/main/AndroidManifest.xml | 31 +++ .../com/dienstplan/nrw/DutyEntryActivity.kt | 177 ++++++++++++++++ .../java/com/dienstplan/nrw/MainActivity.kt | 102 +++++++++ .../com/dienstplan/nrw/ResultsActivity.kt | 102 +++++++++ .../com/dienstplan/nrw/data/DutyDataStore.kt | 88 ++++++++ .../dienstplan/nrw/data/HolidayProvider.kt | 87 ++++++++ .../dienstplan/nrw/data/PayrollCalculator.kt | 133 ++++++++++++ .../com/dienstplan/nrw/model/DutyEntry.kt | 22 ++ .../java/com/dienstplan/nrw/model/Holiday.kt | 13 ++ .../com/dienstplan/nrw/model/PayrollResult.kt | 44 ++++ .../main/res/layout/activity_duty_entry.xml | 54 +++++ .../app/src/main/res/layout/activity_main.xml | 72 +++++++ .../src/main/res/layout/activity_results.xml | 34 +++ .../src/main/res/layout/dialog_add_duty.xml | 23 +++ .../src/main/res/layout/item_duty_entry.xml | 61 ++++++ .../app/src/main/res/layout/item_result.xml | 194 ++++++++++++++++++ .../app/src/main/res/values/colors.xml | 14 ++ .../app/src/main/res/values/strings.xml | 74 +++++++ .../app/src/main/res/values/themes.xml | 16 ++ .../dienstplan/nrw/PayrollCalculatorTest.kt | 157 ++++++++++++++ android-app/build.gradle.kts | 5 + android-app/gradle.properties | 5 + android-app/settings.gradle.kts | 18 ++ 28 files changed, 1784 insertions(+), 3 deletions(-) create mode 100644 android-app/README.md create mode 100644 android-app/app/build.gradle.kts create mode 100644 android-app/app/proguard-rules.pro create mode 100644 android-app/app/src/main/AndroidManifest.xml create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/DutyEntryActivity.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/ResultsActivity.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/data/DutyDataStore.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/data/HolidayProvider.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/data/PayrollCalculator.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/model/DutyEntry.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/model/Holiday.kt create mode 100644 android-app/app/src/main/java/com/dienstplan/nrw/model/PayrollResult.kt create mode 100644 android-app/app/src/main/res/layout/activity_duty_entry.xml create mode 100644 android-app/app/src/main/res/layout/activity_main.xml create mode 100644 android-app/app/src/main/res/layout/activity_results.xml create mode 100644 android-app/app/src/main/res/layout/dialog_add_duty.xml create mode 100644 android-app/app/src/main/res/layout/item_duty_entry.xml create mode 100644 android-app/app/src/main/res/layout/item_result.xml create mode 100644 android-app/app/src/main/res/values/colors.xml create mode 100644 android-app/app/src/main/res/values/strings.xml create mode 100644 android-app/app/src/main/res/values/themes.xml create mode 100644 android-app/app/src/test/java/com/dienstplan/nrw/PayrollCalculatorTest.kt create mode 100644 android-app/build.gradle.kts create mode 100644 android-app/gradle.properties create mode 100644 android-app/settings.gradle.kts 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/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/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 @@ + + + + + + + +