Add Android app implementation with core features
Co-authored-by: Kenearos <86194771+Kenearos@users.noreply.github.com>
This commit is contained in:
parent
3069fcd8ba
commit
976d494d9f
28 changed files with 1784 additions and 3 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -12,6 +12,17 @@ output/*.xlsx
|
||||||
templates/*.xlsx
|
templates/*.xlsx
|
||||||
~$*.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
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -1,8 +1,16 @@
|
||||||
# Dienstplan Generator (NRW - Variante 2)
|
# 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
|
- ✅ Automatische Erkennung von Wochenenden (Fr–So), Feiertagen und Vortagen
|
||||||
- ✅ Vergütungslogik: WT 250€, WE 450€ (nur ab Schwelle ≥ 2,0 WE-Einheiten)
|
- ✅ 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
|
```text
|
||||||
.
|
.
|
||||||
├── src/
|
├── src/ # Python source code
|
||||||
│ ├── build_template.py # Erstellt die Basis-Vorlage
|
│ ├── build_template.py # Erstellt die Basis-Vorlage
|
||||||
│ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen
|
│ ├── fill_plan_dates.py # Füllt Monate mit Datumszeilen
|
||||||
│ └── read_excel.py # Liest xlsx-Dateien aus
|
│ └── 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
|
├── output/ # Generierte Monatspläne
|
||||||
├── templates/ # Basis-Vorlage
|
├── templates/ # Basis-Vorlage
|
||||||
├── requirements.txt # Python-Abhängigkeiten (openpyxl)
|
├── requirements.txt # Python-Abhängigkeiten (openpyxl)
|
||||||
|
|
|
||||||
156
android-app/README.md
Normal file
156
android-app/README.md
Normal file
|
|
@ -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.
|
||||||
67
android-app/app/build.gradle.kts
Normal file
67
android-app/app/build.gradle.kts
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
9
android-app/app/proguard-rules.pro
vendored
Normal file
9
android-app/app/proguard-rules.pro
vendored
Normal file
|
|
@ -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.** { *; }
|
||||||
31
android-app/app/src/main/AndroidManifest.xml
Normal file
31
android-app/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.DienstplanNRW"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".DutyEntryActivity"
|
||||||
|
android:label="@string/duty_entry_title"
|
||||||
|
android:parentActivityName=".MainActivity" />
|
||||||
|
<activity
|
||||||
|
android:name=".ResultsActivity"
|
||||||
|
android:label="@string/results_title"
|
||||||
|
android:parentActivityName=".MainActivity" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -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<android.widget.EditText>(R.id.etEmployeeName)
|
||||||
|
val etShare = dialogView.findViewById<android.widget.EditText>(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<DutyEntryAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var duties = listOf<DutyEntry>()
|
||||||
|
private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
|
||||||
|
|
||||||
|
fun submitList(newDuties: List<DutyEntry>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt
Normal file
102
android-app/app/src/main/java/com/dienstplan/nrw/MainActivity.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ResultsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var results = listOf<PayrollResult>()
|
||||||
|
|
||||||
|
fun submitList(newResults: List<PayrollResult>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DutyEntry>()
|
||||||
|
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<DutyEntry> {
|
||||||
|
val monthKey = getMonthKey(year, month)
|
||||||
|
return duties.filter { it.monthKey == monthKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all duties.
|
||||||
|
*/
|
||||||
|
fun getAllDuties(): List<DutyEntry> = 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<Date> {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.set(year, month - 1, 1, 0, 0, 0)
|
||||||
|
calendar.set(Calendar.MILLISECOND, 0)
|
||||||
|
|
||||||
|
val dates = mutableListOf<Date>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Holiday> = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<DutyEntry>, year: Int, month: Int): List<PayrollResult> {
|
||||||
|
// 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<DutyEntry>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
android-app/app/src/main/res/layout/activity_duty_entry.xml
Normal file
54
android-app/app/src/main/res/layout/activity_duty_entry.xml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".DutyEntryActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSelectedMonth"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
tools:text="November 2025" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvDuties"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvSelectedMonth"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/btnAddDuty"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
tools:listitem="@layout/item_duty_entry" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnAddDuty"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/add_duty"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/btnSave"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSave"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/save_duties"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
72
android-app/app/src/main/res/layout/activity_main.xml
Normal file
72
android-app/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/main_title"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSelectMonth"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/select_month"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:layout_marginTop="48dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvTitle"
|
||||||
|
app:layout_constraintStart_toStartOf="parent" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinnerMonth"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvSelectMonth"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/spinnerYear"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinnerYear"
|
||||||
|
android:layout_width="120dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvSelectMonth"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnEnterDuties"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/enter_duties"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/spinnerMonth"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnViewResults"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/view_results"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/btnEnterDuties"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
34
android-app/app/src/main/res/layout/activity_results.xml
Normal file
34
android-app/app/src/main/res/layout/activity_results.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="16dp"
|
||||||
|
tools:context=".ResultsActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSelectedMonth"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
tools:text="November 2025" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvResults"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvSelectedMonth"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
tools:listitem="@layout/item_result" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
23
android-app/app/src/main/res/layout/dialog_add_duty.xml
Normal file
23
android-app/app/src/main/res/layout/dialog_add_duty.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etEmployeeName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/employee_name"
|
||||||
|
android:inputType="textPersonName" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etShare"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/duty_share"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:layout_marginTop="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
61
android-app/app/src/main/res/layout/item_duty_entry.xml
Normal file
61
android-app/app/src/main/res/layout/item_duty_entry.xml
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.cardview.widget.CardView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="16sp"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
tools:text="01.11.2025" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etEmployeeName"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/employee_name"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:inputType="textPersonName"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvDate"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/etShare"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etShare"
|
||||||
|
android:layout_width="100dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/duty_share"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/tvDate"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/delete"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/etEmployeeName"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.TextButton" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
</androidx.cardview.widget.CardView>
|
||||||
194
android-app/app/src/main/res/layout/item_result.xml
Normal file
194
android-app/app/src/main/res/layout/item_result.xml
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.cardview.widget.CardView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
app:cardCornerRadius="8dp"
|
||||||
|
app:cardElevation="4dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEmployeeName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="18sp"
|
||||||
|
tools:text="Max Mustermann" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/wt_units" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWTUnits"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="5.0" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/we_friday" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWEFriday"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="1.0" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/we_other" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWEOther"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="1.0" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/we_total"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWETotal"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="2.0" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/threshold_reached" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvThresholdReached"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="JA" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/gray"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/payout_wt" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPayoutWT"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="1250.00 €" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/payout_we" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPayoutWE"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:text="450.00 €" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/payout_total"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPayoutTotal"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:text="1700.00 €" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.cardview.widget.CardView>
|
||||||
14
android-app/app/src/main/res/values/colors.xml
Normal file
14
android-app/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="primary">#4472C4</color>
|
||||||
|
<color name="primary_dark">#2952A3</color>
|
||||||
|
<color name="secondary">#03DAC5</color>
|
||||||
|
<color name="secondary_dark">#018786</color>
|
||||||
|
<color name="white">#FFFFFF</color>
|
||||||
|
<color name="black">#000000</color>
|
||||||
|
<color name="gray_light">#F5F5F5</color>
|
||||||
|
<color name="gray">#9E9E9E</color>
|
||||||
|
<color name="gray_dark">#616161</color>
|
||||||
|
<color name="red">#F44336</color>
|
||||||
|
<color name="green">#4CAF50</color>
|
||||||
|
</resources>
|
||||||
74
android-app/app/src/main/res/values/strings.xml
Normal file
74
android-app/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Dienstplan NRW</string>
|
||||||
|
|
||||||
|
<!-- Main Activity -->
|
||||||
|
<string name="main_title">Dienstplan Generator</string>
|
||||||
|
<string name="select_month">Monat auswählen</string>
|
||||||
|
<string name="enter_duties">Dienste eintragen</string>
|
||||||
|
<string name="view_results">Auswertung anzeigen</string>
|
||||||
|
<string name="month_year_format">%s %d</string>
|
||||||
|
|
||||||
|
<!-- Duty Entry Activity -->
|
||||||
|
<string name="duty_entry_title">Dienste eintragen</string>
|
||||||
|
<string name="employee_name">Mitarbeiter</string>
|
||||||
|
<string name="duty_share">Anteil (0.0 - 1.0)</string>
|
||||||
|
<string name="add_duty">Dienst hinzufügen</string>
|
||||||
|
<string name="save_duties">Dienste speichern</string>
|
||||||
|
<string name="date_label">Datum</string>
|
||||||
|
|
||||||
|
<!-- Results Activity -->
|
||||||
|
<string name="results_title">Auswertung</string>
|
||||||
|
<string name="wt_units">WT Einheiten</string>
|
||||||
|
<string name="we_friday">WE Freitag</string>
|
||||||
|
<string name="we_other">WE Andere</string>
|
||||||
|
<string name="we_total">WE Gesamt</string>
|
||||||
|
<string name="threshold_reached">Schwelle erreicht</string>
|
||||||
|
<string name="payout_wt">Auszahlung WT</string>
|
||||||
|
<string name="payout_we">Auszahlung WE</string>
|
||||||
|
<string name="payout_total">Auszahlung Gesamt</string>
|
||||||
|
<string name="yes">JA</string>
|
||||||
|
<string name="no">NEIN</string>
|
||||||
|
<string name="euro">€</string>
|
||||||
|
|
||||||
|
<!-- Common -->
|
||||||
|
<string name="cancel">Abbrechen</string>
|
||||||
|
<string name="ok">OK</string>
|
||||||
|
<string name="delete">Löschen</string>
|
||||||
|
<string name="edit">Bearbeiten</string>
|
||||||
|
|
||||||
|
<!-- Months -->
|
||||||
|
<string name="january">Januar</string>
|
||||||
|
<string name="february">Februar</string>
|
||||||
|
<string name="march">März</string>
|
||||||
|
<string name="april">April</string>
|
||||||
|
<string name="may">Mai</string>
|
||||||
|
<string name="june">Juni</string>
|
||||||
|
<string name="july">Juli</string>
|
||||||
|
<string name="august">August</string>
|
||||||
|
<string name="september">September</string>
|
||||||
|
<string name="october">Oktober</string>
|
||||||
|
<string name="november">November</string>
|
||||||
|
<string name="december">Dezember</string>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<string name="error_invalid_share">Anteil muss zwischen 0.0 und 1.0 liegen</string>
|
||||||
|
<string name="error_empty_name">Bitte Mitarbeiternamen eingeben</string>
|
||||||
|
<string name="error_no_month_selected">Bitte wählen Sie einen Monat aus</string>
|
||||||
|
|
||||||
|
<!-- String Arrays -->
|
||||||
|
<string-array name="months">
|
||||||
|
<item>@string/january</item>
|
||||||
|
<item>@string/february</item>
|
||||||
|
<item>@string/march</item>
|
||||||
|
<item>@string/april</item>
|
||||||
|
<item>@string/may</item>
|
||||||
|
<item>@string/june</item>
|
||||||
|
<item>@string/july</item>
|
||||||
|
<item>@string/august</item>
|
||||||
|
<item>@string/september</item>
|
||||||
|
<item>@string/october</item>
|
||||||
|
<item>@string/november</item>
|
||||||
|
<item>@string/december</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
16
android-app/app/src/main/res/values/themes.xml
Normal file
16
android-app/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Base application theme. -->
|
||||||
|
<style name="Theme.DienstplanNRW" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<!-- Primary brand color. -->
|
||||||
|
<item name="colorPrimary">@color/primary</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/primary_dark</item>
|
||||||
|
<item name="colorOnPrimary">@color/white</item>
|
||||||
|
<!-- Secondary brand color. -->
|
||||||
|
<item name="colorSecondary">@color/secondary</item>
|
||||||
|
<item name="colorSecondaryVariant">@color/secondary_dark</item>
|
||||||
|
<item name="colorOnSecondary">@color/black</item>
|
||||||
|
<!-- Status bar color. -->
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
package com.dienstplan.nrw
|
||||||
|
|
||||||
|
import com.dienstplan.nrw.data.PayrollCalculator
|
||||||
|
import com.dienstplan.nrw.model.DutyEntry
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for PayrollCalculator.
|
||||||
|
* Tests the business logic of Variante 2 (streng) payroll calculations.
|
||||||
|
*/
|
||||||
|
class PayrollCalculatorTest {
|
||||||
|
|
||||||
|
private lateinit var calculator: PayrollCalculator
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
calculator = PayrollCalculator()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 1: Under threshold (1.75 WE + 1.0 WT)
|
||||||
|
* Expected: WE payout = 0€, WT payout = 250€
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testUnderThreshold() {
|
||||||
|
val duties = listOf(
|
||||||
|
// Weekday duty
|
||||||
|
DutyEntry(date = parseDate("2025-11-03"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Monday
|
||||||
|
// Weekend duties (below threshold)
|
||||||
|
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 0.75, monthKey = "202511"), // Friday
|
||||||
|
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 1.0, monthKey = "202511") // Saturday
|
||||||
|
)
|
||||||
|
|
||||||
|
val results = calculator.calculatePayroll(duties, 2025, 11)
|
||||||
|
assertEquals(1, results.size)
|
||||||
|
|
||||||
|
val result = results[0]
|
||||||
|
assertEquals("Test", result.employeeName)
|
||||||
|
assertEquals(1.0, result.wtUnits, 0.001)
|
||||||
|
assertEquals(1.75, result.weTotal, 0.001)
|
||||||
|
assertFalse(result.thresholdReached)
|
||||||
|
assertEquals(0.0, result.wePaid, 0.001)
|
||||||
|
assertEquals(250.0, result.payoutWT, 0.001)
|
||||||
|
assertEquals(0.0, result.payoutWE, 0.001)
|
||||||
|
assertEquals(250.0, result.payoutTotal, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 2: Exactly at threshold (2.0 WE)
|
||||||
|
* Expected: WE payout = 450€ (1.0 unit after deduction), threshold reached
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testExactlyAtThreshold() {
|
||||||
|
val duties = listOf(
|
||||||
|
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Friday
|
||||||
|
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 1.0, monthKey = "202511") // Saturday
|
||||||
|
)
|
||||||
|
|
||||||
|
val results = calculator.calculatePayroll(duties, 2025, 11)
|
||||||
|
assertEquals(1, results.size)
|
||||||
|
|
||||||
|
val result = results[0]
|
||||||
|
assertEquals(2.0, result.weTotal, 0.001)
|
||||||
|
assertTrue(result.thresholdReached)
|
||||||
|
assertEquals(1.0, result.deductionTotal, 0.001)
|
||||||
|
assertEquals(1.0, result.wePaid, 0.001)
|
||||||
|
assertEquals(450.0, result.payoutWE, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 3: Over threshold (3.5 WE)
|
||||||
|
* Expected: WE payout = 1125€ (2.5 units after deduction)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testOverThreshold() {
|
||||||
|
val duties = listOf(
|
||||||
|
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Friday
|
||||||
|
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Saturday
|
||||||
|
DutyEntry(date = parseDate("2025-11-09"), employeeName = "Test", share = 1.0, monthKey = "202511"), // Sunday
|
||||||
|
DutyEntry(date = parseDate("2025-11-14"), employeeName = "Test", share = 0.5, monthKey = "202511") // Friday
|
||||||
|
)
|
||||||
|
|
||||||
|
val results = calculator.calculatePayroll(duties, 2025, 11)
|
||||||
|
assertEquals(1, results.size)
|
||||||
|
|
||||||
|
val result = results[0]
|
||||||
|
assertEquals(3.5, result.weTotal, 0.001)
|
||||||
|
assertTrue(result.thresholdReached)
|
||||||
|
assertEquals(2.5, result.wePaid, 0.001)
|
||||||
|
assertEquals(1125.0, result.payoutWE, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 4: Friday deduction priority
|
||||||
|
* Expected: Deduction comes from Friday first
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testFridayDeductionPriority() {
|
||||||
|
val duties = listOf(
|
||||||
|
DutyEntry(date = parseDate("2025-11-07"), employeeName = "Test", share = 0.4, monthKey = "202511"), // Friday
|
||||||
|
DutyEntry(date = parseDate("2025-11-08"), employeeName = "Test", share = 0.6, monthKey = "202511"), // Saturday
|
||||||
|
DutyEntry(date = parseDate("2025-11-09"), employeeName = "Test", share = 1.0, monthKey = "202511") // Sunday
|
||||||
|
)
|
||||||
|
|
||||||
|
val results = calculator.calculatePayroll(duties, 2025, 11)
|
||||||
|
assertEquals(1, results.size)
|
||||||
|
|
||||||
|
val result = results[0]
|
||||||
|
assertEquals(2.0, result.weTotal, 0.001)
|
||||||
|
assertEquals(0.4, result.weFriday, 0.001)
|
||||||
|
assertEquals(1.6, result.weOther, 0.001)
|
||||||
|
assertEquals(0.4, result.deductionFriday, 0.001) // All Friday deducted first
|
||||||
|
assertEquals(0.6, result.deductionOther, 0.001) // Rest from other
|
||||||
|
assertEquals(1.0, result.wePaid, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Case 5: Multiple employees
|
||||||
|
* Expected: Separate calculations for each employee
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun testMultipleEmployees() {
|
||||||
|
val duties = listOf(
|
||||||
|
// Employee A: under threshold
|
||||||
|
DutyEntry(date = parseDate("2025-11-07"), employeeName = "A", share = 1.0, monthKey = "202511"),
|
||||||
|
// Employee B: over threshold
|
||||||
|
DutyEntry(date = parseDate("2025-11-07"), employeeName = "B", share = 1.5, monthKey = "202511"),
|
||||||
|
DutyEntry(date = parseDate("2025-11-08"), employeeName = "B", share = 1.0, monthKey = "202511")
|
||||||
|
)
|
||||||
|
|
||||||
|
val results = calculator.calculatePayroll(duties, 2025, 11)
|
||||||
|
assertEquals(2, results.size)
|
||||||
|
|
||||||
|
// Results are sorted by name
|
||||||
|
val resultA = results.find { it.employeeName == "A" }!!
|
||||||
|
val resultB = results.find { it.employeeName == "B" }!!
|
||||||
|
|
||||||
|
// A: below threshold
|
||||||
|
assertFalse(resultA.thresholdReached)
|
||||||
|
assertEquals(0.0, resultA.payoutWE, 0.001)
|
||||||
|
|
||||||
|
// B: above threshold
|
||||||
|
assertTrue(resultB.thresholdReached)
|
||||||
|
assertEquals(2.5, resultB.weTotal, 0.001)
|
||||||
|
assertEquals(1.5, resultB.wePaid, 0.001)
|
||||||
|
assertEquals(675.0, resultB.payoutWE, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDate(dateString: String): Date {
|
||||||
|
return dateFormat.parse(dateString) ?: throw IllegalArgumentException("Invalid date")
|
||||||
|
}
|
||||||
|
}
|
||||||
5
android-app/build.gradle.kts
Normal file
5
android-app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.1.4" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.10" apply false
|
||||||
|
}
|
||||||
5
android-app/gradle.properties
Normal file
5
android-app/gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Project-wide Gradle settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
kotlin.code.style=official
|
||||||
18
android-app/settings.gradle.kts
Normal file
18
android-app/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Dienstplan NRW"
|
||||||
|
include(":app")
|
||||||
Reference in a new issue