TaskSchema module
This commit is contained in:
@@ -27,6 +27,9 @@ web_extra_daemons:
|
||||
- name: vite
|
||||
command: "npm run dev"
|
||||
directory: /var/www/html/frontend
|
||||
- name: scheduler
|
||||
command: "php /var/www/html/backend/bin/console messenger:consume scheduler_default --time-limit=3600"
|
||||
directory: /var/www/html/backend
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import de.haushalt.app.ui.schema.SchemaAllScreen
|
||||
import de.haushalt.app.ui.schema.SchemaCreateScreen
|
||||
import de.haushalt.app.ui.schema.SchemaEditScreen
|
||||
import de.haushalt.app.ui.task.TaskAllScreen
|
||||
import de.haushalt.app.ui.task.TaskCreateScreen
|
||||
import de.haushalt.app.ui.task.TaskEditScreen
|
||||
import de.haushalt.app.ui.task.TaskScreen
|
||||
|
||||
@@ -35,16 +37,28 @@ private val trailMap: Map<String, List<Pair<String, String>>> = mapOf(
|
||||
"tasks" to "Tasks",
|
||||
"tasks/all" to "All",
|
||||
),
|
||||
"tasks/create" to listOf(
|
||||
"start" to "Haushalt",
|
||||
"tasks" to "Tasks",
|
||||
"tasks/create" to "Create",
|
||||
),
|
||||
"tasks/{id}" to listOf(
|
||||
"start" to "Haushalt",
|
||||
"tasks" to "Tasks",
|
||||
"tasks/{id}" to "Edit",
|
||||
),
|
||||
"schemas" to listOf(
|
||||
"start" to "Haushalt",
|
||||
"tasks" to "Tasks",
|
||||
"schemas" to "Schemas",
|
||||
),
|
||||
"schemas/create" to listOf(
|
||||
"start" to "Haushalt",
|
||||
"tasks" to "Tasks",
|
||||
"schemas" to "Schemas",
|
||||
"schemas/create" to "Create",
|
||||
),
|
||||
"schemas/{id}" to listOf(
|
||||
"start" to "Haushalt",
|
||||
"tasks" to "Tasks",
|
||||
"schemas" to "Schemas",
|
||||
"schemas/{id}" to "Edit",
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -70,9 +84,6 @@ fun MainScreen() {
|
||||
composable("tasks/all") {
|
||||
TaskAllScreen(navController = navController)
|
||||
}
|
||||
composable("tasks/create") {
|
||||
TaskCreateScreen(navController = navController)
|
||||
}
|
||||
composable(
|
||||
route = "tasks/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
||||
@@ -80,6 +91,19 @@ fun MainScreen() {
|
||||
val id = entry.arguments?.getInt("id") ?: return@composable
|
||||
TaskEditScreen(navController = navController, taskId = id)
|
||||
}
|
||||
composable("schemas") {
|
||||
SchemaAllScreen(navController = navController)
|
||||
}
|
||||
composable("schemas/create") {
|
||||
SchemaCreateScreen(navController = navController)
|
||||
}
|
||||
composable(
|
||||
route = "schemas/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
||||
) { entry ->
|
||||
val id = entry.arguments?.getInt("id") ?: return@composable
|
||||
SchemaEditScreen(navController = navController, schemaId = id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ object ApiClient {
|
||||
.build()
|
||||
|
||||
val taskApi: TaskApi = retrofit.create(TaskApi::class.java)
|
||||
val schemaApi: TaskSchemaApi = retrofit.create(TaskSchemaApi::class.java)
|
||||
|
||||
val appUpdateApi: AppUpdateApi = Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
|
||||
@@ -16,9 +16,6 @@ interface TaskApi {
|
||||
@GET("tasks/{id}")
|
||||
suspend fun get(@Path("id") id: Int): Task
|
||||
|
||||
@POST("tasks")
|
||||
suspend fun create(@Body body: TaskRequest): Task
|
||||
|
||||
@PUT("tasks/{id}")
|
||||
suspend fun update(@Path("id") id: Int, @Body body: TaskRequest): Task
|
||||
|
||||
|
||||
32
app/app/src/main/java/de/haushalt/app/data/TaskSchema.kt
Normal file
32
app/app/src/main/java/de/haushalt/app/data/TaskSchema.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package de.haushalt.app.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
enum class TaskSchemaStatus {
|
||||
active, inactive,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TaskSchema(
|
||||
val id: Int? = null,
|
||||
val name: String,
|
||||
val status: TaskSchemaStatus = TaskSchemaStatus.active,
|
||||
val taskStatus: TaskStatus = TaskStatus.active,
|
||||
val date: String? = null,
|
||||
val repeat: JsonObject? = null,
|
||||
val start: String? = null,
|
||||
val end: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TaskSchemaRequest(
|
||||
val name: String,
|
||||
val status: TaskSchemaStatus = TaskSchemaStatus.active,
|
||||
val taskStatus: TaskStatus = TaskStatus.active,
|
||||
val date: String? = null,
|
||||
val repeat: JsonObject? = null,
|
||||
val start: String? = null,
|
||||
val end: String? = null,
|
||||
)
|
||||
26
app/app/src/main/java/de/haushalt/app/data/TaskSchemaApi.kt
Normal file
26
app/app/src/main/java/de/haushalt/app/data/TaskSchemaApi.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package de.haushalt.app.data
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface TaskSchemaApi {
|
||||
@GET("task-schemas")
|
||||
suspend fun list(): List<TaskSchema>
|
||||
|
||||
@GET("task-schemas/{id}")
|
||||
suspend fun get(@Path("id") id: Int): TaskSchema
|
||||
|
||||
@POST("task-schemas")
|
||||
suspend fun create(@Body body: TaskSchemaRequest): TaskSchema
|
||||
|
||||
@PUT("task-schemas/{id}")
|
||||
suspend fun update(@Path("id") id: Int, @Body body: TaskSchemaRequest): TaskSchema
|
||||
|
||||
@DELETE("task-schemas/{id}")
|
||||
suspend fun delete(@Path("id") id: Int)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.haushalt.app.data.TaskSchema
|
||||
import de.haushalt.app.data.TaskSchemaStatus
|
||||
|
||||
@Composable
|
||||
fun SchemaAllScreen(
|
||||
navController: NavController,
|
||||
viewModel: SchemaAllViewModel = viewModel(),
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
viewModel.refresh()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = { navController.navigate("schemas/create") }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Neues Schema")
|
||||
}
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Neu laden")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.padding(4.dp))
|
||||
|
||||
when {
|
||||
viewModel.isLoading && viewModel.schemas.isEmpty() -> Text("Lädt…")
|
||||
viewModel.error != null -> Text(viewModel.error ?: "", color = MaterialTheme.colorScheme.error)
|
||||
viewModel.schemas.isEmpty() -> Text("Keine Schemas.")
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(viewModel.schemas, key = { it.id ?: 0 }) { schema ->
|
||||
SchemaRow(
|
||||
schema = schema,
|
||||
onEdit = { schema.id?.let { navController.navigate("schemas/$it") } },
|
||||
onDelete = { schema.id?.let(viewModel::delete) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SchemaRow(
|
||||
schema: TaskSchema,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val repeatLabel = when {
|
||||
schema.repeat == null -> "Einmalig"
|
||||
schema.repeat.containsKey("daily") -> "Täglich"
|
||||
schema.repeat.containsKey("weekly") -> "Wöchentlich"
|
||||
schema.repeat.containsKey("monthly") -> "Monatlich"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (schema.status == TaskSchemaStatus.inactive) 0.5f else 1f)
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = schema.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontStyle = if (schema.status == TaskSchemaStatus.inactive) FontStyle.Italic else null,
|
||||
)
|
||||
Text(
|
||||
text = repeatLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "Bearbeiten")
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = "Löschen")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.haushalt.app.data.ApiClient
|
||||
import de.haushalt.app.data.TaskSchema
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SchemaAllViewModel : ViewModel() {
|
||||
var schemas by mutableStateOf<List<TaskSchema>>(emptyList())
|
||||
private set
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
isLoading = true
|
||||
error = null
|
||||
try {
|
||||
schemas = ApiClient.schemaApi.list()
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(id: Int) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
ApiClient.schemaApi.delete(id)
|
||||
schemas = schemas.filter { it.id != id }
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.haushalt.app.data.TaskSchemaStatus
|
||||
import de.haushalt.app.data.TaskStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SchemaStatusDropdown(
|
||||
current: TaskSchemaStatus,
|
||||
onChange: (TaskSchemaStatus) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier) {
|
||||
OutlinedTextField(
|
||||
value = current.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Schema Status") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
TaskSchemaStatus.entries.forEach { status ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(status.name) },
|
||||
onClick = { onChange(status); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TaskStatusDropdown(
|
||||
current: TaskStatus,
|
||||
onChange: (TaskStatus) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectable = TaskStatus.entries.filter { it != TaskStatus.past }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier) {
|
||||
OutlinedTextField(
|
||||
value = current.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Task Status") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
selectable.forEach { status ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(status.name) },
|
||||
onClick = { onChange(status); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RepeatTypeDropdown(
|
||||
current: String,
|
||||
onChange: (String) -> Unit,
|
||||
) {
|
||||
val options = listOf("none" to "Keine (Einmalig)", "daily" to "Täglich", "weekly" to "Wöchentlich", "monthly" to "Monatlich")
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = options.firstOrNull { it.first == current }?.second ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Wiederholung") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { (key, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = { onChange(key); expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun WeekdaySelector(
|
||||
selected: List<Boolean>,
|
||||
onChange: (List<Boolean>) -> Unit,
|
||||
) {
|
||||
val days = listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
|
||||
Text("Wochentage", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
days.forEachIndexed { i, day ->
|
||||
val isSelected = selected[i]
|
||||
Surface(
|
||||
modifier = Modifier.clickable {
|
||||
onChange(selected.toMutableList().also { it[i] = !it[i] })
|
||||
},
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
border = BorderStroke(1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Text(
|
||||
text = day,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MonthdaySelector(
|
||||
selected: List<Boolean>,
|
||||
onChange: (List<Boolean>) -> Unit,
|
||||
) {
|
||||
Text("Monatstage", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
for (d in 1..31) {
|
||||
val isSelected = selected[d - 1]
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp).clickable {
|
||||
onChange(selected.toMutableList().also { it[d - 1] = !it[d - 1] })
|
||||
},
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
border = BorderStroke(1.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Text(
|
||||
text = "$d",
|
||||
modifier = Modifier.padding(4.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.haushalt.app.data.TaskSchemaStatus
|
||||
import de.haushalt.app.data.TaskStatus
|
||||
import de.haushalt.app.ui.task.DatePickerField
|
||||
|
||||
@Composable
|
||||
fun SchemaCreateScreen(
|
||||
navController: NavController,
|
||||
viewModel: SchemaCreateViewModel = viewModel(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.name,
|
||||
onValueChange = { viewModel.name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SchemaStatusDropdown(
|
||||
current = viewModel.status,
|
||||
onChange = { viewModel.status = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TaskStatusDropdown(
|
||||
current = viewModel.taskStatus,
|
||||
onChange = { viewModel.taskStatus = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
RepeatTypeDropdown(
|
||||
current = viewModel.repeatType,
|
||||
onChange = { viewModel.repeatType = it },
|
||||
)
|
||||
|
||||
if (viewModel.repeatType == "none") {
|
||||
DatePickerField(
|
||||
value = viewModel.date,
|
||||
onChange = { viewModel.date = it },
|
||||
label = "Datum",
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.repeatType == "weekly") {
|
||||
WeekdaySelector(
|
||||
selected = viewModel.weekly,
|
||||
onChange = { viewModel.weekly = it },
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.repeatType == "monthly") {
|
||||
MonthdaySelector(
|
||||
selected = viewModel.monthly,
|
||||
onChange = { viewModel.monthly = it },
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.repeatType != "none") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
DatePickerField(
|
||||
value = viewModel.start,
|
||||
onChange = { viewModel.start = it },
|
||||
label = "Start",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
DatePickerField(
|
||||
value = viewModel.end,
|
||||
onChange = { viewModel.end = it },
|
||||
label = "Ende",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
enabled = !viewModel.isSubmitting && viewModel.name.isNotBlank(),
|
||||
onClick = { viewModel.save { navController.popBackStack() } },
|
||||
) {
|
||||
Text("Erstellen")
|
||||
}
|
||||
OutlinedButton(onClick = { navController.popBackStack() }) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.haushalt.app.data.ApiClient
|
||||
import de.haushalt.app.data.TaskSchemaRequest
|
||||
import de.haushalt.app.data.TaskSchemaStatus
|
||||
import de.haushalt.app.data.TaskStatus
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
|
||||
class SchemaCreateViewModel : ViewModel() {
|
||||
var name by mutableStateOf("")
|
||||
var status by mutableStateOf(TaskSchemaStatus.active)
|
||||
var taskStatus by mutableStateOf(TaskStatus.active)
|
||||
var repeatType by mutableStateOf("none")
|
||||
var date by mutableStateOf("")
|
||||
var start by mutableStateOf("")
|
||||
var end by mutableStateOf("")
|
||||
var weekly by mutableStateOf(List(7) { false })
|
||||
var monthly by mutableStateOf(List(31) { false })
|
||||
var isSubmitting by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
fun save(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
isSubmitting = true
|
||||
error = null
|
||||
try {
|
||||
ApiClient.schemaApi.create(buildRequest())
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRequest(): TaskSchemaRequest {
|
||||
val repeat = when (repeatType) {
|
||||
"daily" -> JsonObject(mapOf("daily" to JsonPrimitive(true)))
|
||||
"weekly" -> JsonObject(mapOf("weekly" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } }))
|
||||
"monthly" -> JsonObject(mapOf("monthly" to buildJsonArray { monthly.forEach { add(JsonPrimitive(it)) } }))
|
||||
else -> null
|
||||
}
|
||||
|
||||
return TaskSchemaRequest(
|
||||
name = name,
|
||||
status = status,
|
||||
taskStatus = taskStatus,
|
||||
date = if (repeatType == "none" && date.isNotBlank()) date else null,
|
||||
repeat = repeat,
|
||||
start = if (repeatType != "none" && start.isNotBlank()) start else null,
|
||||
end = if (repeatType != "none" && end.isNotBlank()) end else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.haushalt.app.ui.task.DatePickerField
|
||||
|
||||
@Composable
|
||||
fun SchemaEditScreen(
|
||||
navController: NavController,
|
||||
schemaId: Int,
|
||||
viewModel: SchemaEditViewModel = viewModel(),
|
||||
) {
|
||||
LaunchedEffect(schemaId) {
|
||||
viewModel.load(schemaId)
|
||||
}
|
||||
|
||||
if (viewModel.isLoading) {
|
||||
Text("Lädt…", modifier = Modifier.padding(16.dp))
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.name,
|
||||
onValueChange = { viewModel.name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SchemaStatusDropdown(
|
||||
current = viewModel.status,
|
||||
onChange = { viewModel.status = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TaskStatusDropdown(
|
||||
current = viewModel.taskStatus,
|
||||
onChange = { viewModel.taskStatus = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
RepeatTypeDropdown(
|
||||
current = viewModel.repeatType,
|
||||
onChange = { viewModel.repeatType = it },
|
||||
)
|
||||
|
||||
if (viewModel.repeatType == "none") {
|
||||
DatePickerField(
|
||||
value = viewModel.date,
|
||||
onChange = { viewModel.date = it },
|
||||
label = "Datum",
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.repeatType == "weekly") {
|
||||
WeekdaySelector(
|
||||
selected = viewModel.weekly,
|
||||
onChange = { viewModel.weekly = it },
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.repeatType == "monthly") {
|
||||
MonthdaySelector(
|
||||
selected = viewModel.monthly,
|
||||
onChange = { viewModel.monthly = it },
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.repeatType != "none") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
DatePickerField(
|
||||
value = viewModel.start,
|
||||
onChange = { viewModel.start = it },
|
||||
label = "Start",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
DatePickerField(
|
||||
value = viewModel.end,
|
||||
onChange = { viewModel.end = it },
|
||||
label = "Ende",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
enabled = !viewModel.isSubmitting && viewModel.name.isNotBlank(),
|
||||
onClick = { viewModel.update(schemaId) { navController.popBackStack() } },
|
||||
) {
|
||||
Text("Aktualisieren")
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.reset() }) {
|
||||
Text("Zurücksetzen")
|
||||
}
|
||||
OutlinedButton(onClick = { navController.popBackStack() }) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package de.haushalt.app.ui.schema
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.haushalt.app.data.ApiClient
|
||||
import de.haushalt.app.data.TaskSchema
|
||||
import de.haushalt.app.data.TaskSchemaRequest
|
||||
import de.haushalt.app.data.TaskSchemaStatus
|
||||
import de.haushalt.app.data.TaskStatus
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
class SchemaEditViewModel : ViewModel() {
|
||||
var name by mutableStateOf("")
|
||||
var status by mutableStateOf(TaskSchemaStatus.active)
|
||||
var taskStatus by mutableStateOf(TaskStatus.active)
|
||||
var repeatType by mutableStateOf("none")
|
||||
var date by mutableStateOf("")
|
||||
var start by mutableStateOf("")
|
||||
var end by mutableStateOf("")
|
||||
var weekly by mutableStateOf(List(7) { false })
|
||||
var monthly by mutableStateOf(List(31) { false })
|
||||
var isLoading by mutableStateOf(false)
|
||||
private set
|
||||
var isSubmitting by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
private var original: TaskSchema? = null
|
||||
|
||||
fun load(id: Int) {
|
||||
viewModelScope.launch {
|
||||
isLoading = true
|
||||
error = null
|
||||
try {
|
||||
val schema = ApiClient.schemaApi.get(id)
|
||||
original = schema
|
||||
applySchema(schema)
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(id: Int, onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
isSubmitting = true
|
||||
error = null
|
||||
try {
|
||||
ApiClient.schemaApi.update(id, buildRequest())
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
original?.let { applySchema(it) }
|
||||
}
|
||||
|
||||
private fun applySchema(schema: TaskSchema) {
|
||||
name = schema.name
|
||||
status = schema.status
|
||||
taskStatus = schema.taskStatus
|
||||
date = schema.date?.take(10) ?: ""
|
||||
start = schema.start?.take(10) ?: ""
|
||||
end = schema.end?.take(10) ?: ""
|
||||
|
||||
when {
|
||||
schema.repeat == null -> {
|
||||
repeatType = "none"
|
||||
weekly = List(7) { false }
|
||||
monthly = List(31) { false }
|
||||
}
|
||||
schema.repeat.containsKey("daily") -> {
|
||||
repeatType = "daily"
|
||||
weekly = List(7) { false }
|
||||
monthly = List(31) { false }
|
||||
}
|
||||
schema.repeat.containsKey("weekly") -> {
|
||||
repeatType = "weekly"
|
||||
weekly = schema.repeat["weekly"]!!.jsonArray.map { it.jsonPrimitive.boolean }
|
||||
monthly = List(31) { false }
|
||||
}
|
||||
schema.repeat.containsKey("monthly") -> {
|
||||
repeatType = "monthly"
|
||||
weekly = List(7) { false }
|
||||
monthly = schema.repeat["monthly"]!!.jsonArray.map { it.jsonPrimitive.boolean }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRequest(): TaskSchemaRequest {
|
||||
val repeat = when (repeatType) {
|
||||
"daily" -> JsonObject(mapOf("daily" to JsonPrimitive(true)))
|
||||
"weekly" -> JsonObject(mapOf("weekly" to buildJsonArray { weekly.forEach { add(JsonPrimitive(it)) } }))
|
||||
"monthly" -> JsonObject(mapOf("monthly" to buildJsonArray { monthly.forEach { add(JsonPrimitive(it)) } }))
|
||||
else -> null
|
||||
}
|
||||
|
||||
return TaskSchemaRequest(
|
||||
name = name,
|
||||
status = status,
|
||||
taskStatus = taskStatus,
|
||||
date = if (repeatType == "none" && date.isNotBlank()) date else null,
|
||||
repeat = repeat,
|
||||
start = if (repeatType != "none" && start.isNotBlank()) start else null,
|
||||
end = if (repeatType != "none" && end.isNotBlank()) end else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
@@ -65,8 +66,11 @@ fun TaskAllScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = { navController.navigate("tasks/create") }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Neuer Task")
|
||||
IconButton(onClick = { navController.navigate("schemas") }) {
|
||||
Icon(Icons.Filled.DateRange, contentDescription = "Schemas")
|
||||
}
|
||||
IconButton(onClick = { navController.navigate("schemas/create") }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Neues Schema")
|
||||
}
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Neu laden")
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package de.haushalt.app.ui.task
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun TaskCreateScreen(
|
||||
navController: NavController,
|
||||
viewModel: TaskCreateViewModel = viewModel(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.name,
|
||||
onValueChange = { viewModel.name = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
DatePickerField(
|
||||
value = viewModel.date,
|
||||
onChange = { viewModel.date = it },
|
||||
)
|
||||
|
||||
StatusDropdown(
|
||||
current = viewModel.status,
|
||||
selectable = viewModel.availableStatuses,
|
||||
onChange = { viewModel.status = it },
|
||||
)
|
||||
|
||||
viewModel.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
enabled = !viewModel.isSubmitting && viewModel.name.isNotBlank(),
|
||||
onClick = { viewModel.save { navController.popBackStack() } },
|
||||
) {
|
||||
Text("Speichern")
|
||||
}
|
||||
OutlinedButton(onClick = { navController.popBackStack() }) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package de.haushalt.app.ui.task
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.haushalt.app.data.ApiClient
|
||||
import de.haushalt.app.data.TaskRequest
|
||||
import de.haushalt.app.data.TaskStatus
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TaskCreateViewModel : ViewModel() {
|
||||
var name by mutableStateOf("")
|
||||
var date by mutableStateOf("")
|
||||
var status by mutableStateOf(TaskStatus.active)
|
||||
var availableStatuses by mutableStateOf<List<TaskStatus>>(emptyList())
|
||||
private set
|
||||
var isSubmitting by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
availableStatuses = ApiClient.taskApi.statuses()
|
||||
} catch (e: Exception) {
|
||||
availableStatuses = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(onSuccess: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
isSubmitting = true
|
||||
error = null
|
||||
try {
|
||||
ApiClient.taskApi.create(
|
||||
TaskRequest(
|
||||
name = name,
|
||||
date = date.ifBlank { null },
|
||||
status = status,
|
||||
)
|
||||
)
|
||||
onSuccess()
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
} finally {
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -66,8 +67,11 @@ fun TaskScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
IconButton(onClick = { navController.navigate("tasks/create") }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Neuer Task")
|
||||
IconButton(onClick = { navController.navigate("schemas") }) {
|
||||
Icon(Icons.Filled.DateRange, contentDescription = "Schemas")
|
||||
}
|
||||
IconButton(onClick = { navController.navigate("schemas/create") }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Neues Schema")
|
||||
}
|
||||
IconButton(onClick = { navController.navigate("tasks/all") }) {
|
||||
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Alle Tasks")
|
||||
|
||||
@@ -38,3 +38,10 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1|haushalt\.ddev\.site)(:[0-9
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
# Choose one of the transports below
|
||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
"symfony/dotenv": "7.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/messenger": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
"symfony/property-info": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/scheduler": "7.4.*",
|
||||
"symfony/serializer": "7.4.*",
|
||||
"symfony/validator": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
|
||||
307
backend/composer.lock
generated
307
backend/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "36cb2b820400a518223222a28461ff75",
|
||||
"content-hash": "3faf0714de84c701cf5b3c444ce28e8b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "doctrine/collections",
|
||||
@@ -1456,6 +1456,54 @@
|
||||
},
|
||||
"time": "2021-02-03T23:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/clock.git",
|
||||
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
|
||||
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.0 || ^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Clock\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for reading the clock.",
|
||||
"homepage": "https://github.com/php-fig/clock",
|
||||
"keywords": [
|
||||
"clock",
|
||||
"now",
|
||||
"psr",
|
||||
"psr-20",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-fig/clock/issues",
|
||||
"source": "https://github.com/php-fig/clock/tree/1.0.0"
|
||||
},
|
||||
"time": "2022-11-25T14:36:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/container",
|
||||
"version": "2.0.2",
|
||||
@@ -1789,6 +1837,84 @@
|
||||
],
|
||||
"time": "2025-03-13T15:25:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v7.4.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/clock.git",
|
||||
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111",
|
||||
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/clock": "^1.0",
|
||||
"symfony/polyfill-php83": "^1.28"
|
||||
},
|
||||
"provide": {
|
||||
"psr/clock-implementation": "1.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Resources/now.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Clock\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Decouples applications from the system clock",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"clock",
|
||||
"psr20",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/clock/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-24T13:12:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/config",
|
||||
"version": "v7.4.7",
|
||||
@@ -3121,6 +3247,100 @@
|
||||
],
|
||||
"time": "2026-03-06T16:33:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/messenger",
|
||||
"version": "v7.4.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/messenger.git",
|
||||
"reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/ddf5ab29bc0329ece30e16f01c86abb6241e92d8",
|
||||
"reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<7.2",
|
||||
"symfony/event-dispatcher": "<6.4",
|
||||
"symfony/event-dispatcher-contracts": "<2.5",
|
||||
"symfony/framework-bundle": "<6.4",
|
||||
"symfony/http-kernel": "<7.3",
|
||||
"symfony/lock": "<7.4",
|
||||
"symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/cache": "^1.0|^2.0|^3.0",
|
||||
"symfony/console": "^7.2|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^7.3|^8.0",
|
||||
"symfony/lock": "^7.4|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
|
||||
"symfony/routing": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0",
|
||||
"symfony/validator": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Messenger\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Samuel Roze",
|
||||
"email": "samuel.roze@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/messenger/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T12:55:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-grapheme",
|
||||
"version": "v1.33.0",
|
||||
@@ -3952,6 +4172,91 @@
|
||||
],
|
||||
"time": "2025-12-05T14:04:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/scheduler",
|
||||
"version": "v7.4.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/scheduler.git",
|
||||
"reference": "f95e696edaad466db9b087a6480ef936c766c3de"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/scheduler/zipball/f95e696edaad466db9b087a6480ef936c766c3de",
|
||||
"reference": "f95e696edaad466db9b087a6480ef936c766c3de",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dragonmantank/cron-expression": "^3.1",
|
||||
"symfony/cache": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/lock": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.1|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Scheduler\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sergey Rabochiy",
|
||||
"email": "upyx.00@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides scheduling through Symfony Messenger",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"cron",
|
||||
"schedule",
|
||||
"scheduler"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/scheduler/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-24T13:12:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/serializer",
|
||||
"version": "v7.4.7",
|
||||
|
||||
8
backend/config/packages/messenger.yaml
Normal file
8
backend/config/packages/messenger.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
sync: 'sync://'
|
||||
scheduler_default: 'scheduler://default'
|
||||
|
||||
routing:
|
||||
App\Message\GenerateTasksMessage: scheduler_default
|
||||
@@ -426,7 +426,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* resources?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
@@ -468,7 +468,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* }>,
|
||||
* },
|
||||
* scheduler?: bool|array{ // Scheduler configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true
|
||||
* http_client?: bool|array{ // HTTP Client configuration
|
||||
|
||||
37
backend/migrations/Version20260412094958.php
Normal file
37
backend/migrations/Version20260412094958.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260412094958 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE task_schema (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, task_status VARCHAR(255) NOT NULL, date DATE DEFAULT NULL, `repeat` JSON DEFAULT NULL, start DATE DEFAULT NULL, end DATE DEFAULT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE task ADD schema_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25EA1BEF35 FOREIGN KEY (schema_id) REFERENCES task_schema (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX IDX_527EDB25EA1BEF35 ON task (schema_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE task_schema');
|
||||
$this->addSql('ALTER TABLE task DROP FOREIGN KEY FK_527EDB25EA1BEF35');
|
||||
$this->addSql('DROP INDEX IDX_527EDB25EA1BEF35 ON task');
|
||||
$this->addSql('ALTER TABLE task DROP schema_id');
|
||||
}
|
||||
}
|
||||
BIN
backend/public/app/haushalt.apk
Normal file
BIN
backend/public/app/haushalt.apk
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionCode": 1,
|
||||
"versionCode": 2,
|
||||
"apkFile": "haushalt.apk"
|
||||
}
|
||||
|
||||
29
backend/src/Collection/TaskSchemaCollection.php
Normal file
29
backend/src/Collection/TaskSchemaCollection.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Collection;
|
||||
|
||||
use App\Entity\TaskSchema;
|
||||
|
||||
/**
|
||||
* @implements \IteratorAggregate<int, TaskSchema>
|
||||
*/
|
||||
final class TaskSchemaCollection implements \IteratorAggregate
|
||||
{
|
||||
/** @var list<TaskSchema> */
|
||||
private array $schemas = [];
|
||||
|
||||
/**
|
||||
* @param list<TaskSchema> $schemas
|
||||
*/
|
||||
public function __construct(array $schemas = [])
|
||||
{
|
||||
foreach ($schemas as $schema) {
|
||||
$this->schemas[] = $schema;
|
||||
}
|
||||
}
|
||||
|
||||
public function getIterator(): \Iterator
|
||||
{
|
||||
return new \ArrayIterator($this->schemas);
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,6 @@ class TaskController extends AbstractController
|
||||
return $this->json($task, 200, [], ['groups' => ['task:read']]);
|
||||
}
|
||||
|
||||
#[Route('', methods: ['POST'])]
|
||||
public function create(#[Payload] TaskRequest $dto): JsonResponse
|
||||
{
|
||||
$task = $this->manager->create($dto);
|
||||
|
||||
return $this->json($task, 201, [], ['groups' => ['task:read']]);
|
||||
}
|
||||
|
||||
#[Route('/{id}', methods: ['PUT'], requirements: ['id' => '\d+'])]
|
||||
public function update(Task $task, #[Payload] TaskRequest $dto): JsonResponse
|
||||
{
|
||||
|
||||
60
backend/src/Controller/Api/TaskSchemaController.php
Normal file
60
backend/src/Controller/Api/TaskSchemaController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\DTO\TaskSchemaRequest;
|
||||
use App\Entity\TaskSchema;
|
||||
use App\Repository\TaskSchemaRepository;
|
||||
use App\Service\TaskSchemaManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload as Payload;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/task-schemas')]
|
||||
class TaskSchemaController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskSchemaRepository $repo,
|
||||
private readonly TaskSchemaManager $manager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', methods: ['GET'])]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$schemas = $this->repo->allSchemas();
|
||||
|
||||
return $this->json($schemas, 200, [], ['groups' => ['task_schema:read']]);
|
||||
}
|
||||
|
||||
#[Route('/{id}', methods: ['GET'], requirements: ['id' => '\d+'])]
|
||||
public function show(TaskSchema $schema): JsonResponse
|
||||
{
|
||||
return $this->json($schema, 200, [], ['groups' => ['task_schema:read']]);
|
||||
}
|
||||
|
||||
#[Route('', methods: ['POST'])]
|
||||
public function create(#[Payload] TaskSchemaRequest $dto): JsonResponse
|
||||
{
|
||||
$this->manager->create($dto);
|
||||
|
||||
return new JsonResponse(null, 201);
|
||||
}
|
||||
|
||||
#[Route('/{id}', methods: ['PUT'], requirements: ['id' => '\d+'])]
|
||||
public function update(TaskSchema $schema, #[Payload] TaskSchemaRequest $dto): JsonResponse
|
||||
{
|
||||
$this->manager->update($schema, $dto);
|
||||
|
||||
return $this->json($schema, 200, [], ['groups' => ['task_schema:read']]);
|
||||
}
|
||||
|
||||
#[Route('/{id}', methods: ['DELETE'], requirements: ['id' => '\d+'])]
|
||||
public function delete(TaskSchema $schema): JsonResponse
|
||||
{
|
||||
$this->manager->delete($schema);
|
||||
|
||||
return new JsonResponse(null, 204);
|
||||
}
|
||||
}
|
||||
36
backend/src/DTO/TaskSchemaRequest.php
Normal file
36
backend/src/DTO/TaskSchemaRequest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use App\Enum\TaskSchemaStatus;
|
||||
use App\Enum\TaskStatus;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class TaskSchemaRequest
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 255)]
|
||||
public readonly string $name,
|
||||
|
||||
#[Assert\NotNull]
|
||||
public readonly TaskSchemaStatus $status = TaskSchemaStatus::Active,
|
||||
|
||||
#[Assert\NotNull]
|
||||
public readonly TaskStatus $taskStatus = TaskStatus::Active,
|
||||
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||
public readonly ?\DateTimeImmutable $date = null,
|
||||
|
||||
public readonly ?array $repeat = null,
|
||||
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||
public readonly ?\DateTimeImmutable $start = null,
|
||||
|
||||
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
||||
public readonly ?\DateTimeImmutable $end = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Enum\TaskStatus;
|
||||
use App\Repository\TaskRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TaskRepository::class)]
|
||||
class Task
|
||||
@@ -27,6 +28,11 @@ class Task
|
||||
#[ORM\Column(enumType: TaskStatus::class)]
|
||||
private TaskStatus $status = TaskStatus::Active;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TaskSchema::class, inversedBy: 'tasks')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Ignore]
|
||||
private ?TaskSchema $schema = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -77,4 +83,16 @@ class Task
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSchema(): ?TaskSchema
|
||||
{
|
||||
return $this->schema;
|
||||
}
|
||||
|
||||
public function setSchema(?TaskSchema $schema): self
|
||||
{
|
||||
$this->schema = $schema;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
158
backend/src/Entity/TaskSchema.php
Normal file
158
backend/src/Entity/TaskSchema.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\TaskSchemaStatus;
|
||||
use App\Enum\TaskStatus;
|
||||
use App\Repository\TaskSchemaRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TaskSchemaRepository::class)]
|
||||
class TaskSchema
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(enumType: TaskSchemaStatus::class)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private TaskSchemaStatus $status = TaskSchemaStatus::Active;
|
||||
|
||||
#[ORM\Column(enumType: TaskStatus::class)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private TaskStatus $taskStatus = TaskStatus::Active;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private ?\DateTimeImmutable $date = null;
|
||||
|
||||
#[ORM\Column(name: '`repeat`', type: 'json', nullable: true)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private ?array $repeat = null;
|
||||
|
||||
#[ORM\Column(name: '`start`', type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private ?\DateTimeImmutable $start = null;
|
||||
|
||||
#[ORM\Column(name: '`end`', type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['task_schema:read'])]
|
||||
private ?\DateTimeImmutable $end = null;
|
||||
|
||||
/** @var Collection<int, Task> */
|
||||
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'schema')]
|
||||
private Collection $tasks;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tasks = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): TaskSchemaStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(TaskSchemaStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTaskStatus(): TaskStatus
|
||||
{
|
||||
return $this->taskStatus;
|
||||
}
|
||||
|
||||
public function setTaskStatus(TaskStatus $taskStatus): self
|
||||
{
|
||||
$this->taskStatus = $taskStatus;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDate(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function setDate(?\DateTimeImmutable $date): self
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRepeat(): ?array
|
||||
{
|
||||
return $this->repeat;
|
||||
}
|
||||
|
||||
public function getRepeatType(): ?string
|
||||
{
|
||||
return $this->repeat !== null ? array_key_first($this->repeat) : null;
|
||||
}
|
||||
|
||||
public function setRepeat(?array $repeat): self
|
||||
{
|
||||
$this->repeat = $repeat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStart(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
public function setStart(?\DateTimeImmutable $start): self
|
||||
{
|
||||
$this->start = $start;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnd(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
public function setEnd(?\DateTimeImmutable $end): self
|
||||
{
|
||||
$this->end = $end;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Task> */
|
||||
public function getTasks(): Collection
|
||||
{
|
||||
return $this->tasks;
|
||||
}
|
||||
}
|
||||
9
backend/src/Enum/TaskSchemaStatus.php
Normal file
9
backend/src/Enum/TaskSchemaStatus.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum TaskSchemaStatus: string
|
||||
{
|
||||
case Active = 'active';
|
||||
case Inactive = 'inactive';
|
||||
}
|
||||
7
backend/src/Message/GenerateTasksMessage.php
Normal file
7
backend/src/Message/GenerateTasksMessage.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final class GenerateTasksMessage
|
||||
{
|
||||
}
|
||||
20
backend/src/MessageHandler/GenerateTasksMessageHandler.php
Normal file
20
backend/src/MessageHandler/GenerateTasksMessageHandler.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Message\GenerateTasksMessage;
|
||||
use App\Service\TaskGenerator;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final class GenerateTasksMessageHandler
|
||||
{
|
||||
public function __construct(private TaskGenerator $generator)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(GenerateTasksMessage $message): void
|
||||
{
|
||||
$this->generator->generateNewTasks();
|
||||
}
|
||||
}
|
||||
36
backend/src/Repository/TaskSchemaRepository.php
Normal file
36
backend/src/Repository/TaskSchemaRepository.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Collection\TaskSchemaCollection;
|
||||
use App\Entity\TaskSchema;
|
||||
use App\Enum\TaskSchemaStatus;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TaskSchema>
|
||||
*/
|
||||
class TaskSchemaRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TaskSchema::class);
|
||||
}
|
||||
|
||||
public function allSchemas(): TaskSchemaCollection
|
||||
{
|
||||
return new TaskSchemaCollection(parent::findAll());
|
||||
}
|
||||
|
||||
/** @return list<TaskSchema> */
|
||||
public function findActiveWithRepeat(): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->andWhere('s.status = :status')
|
||||
->andWhere('s.repeat IS NOT NULL')
|
||||
->setParameter('status', TaskSchemaStatus::Active)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
27
backend/src/Schedule.php
Normal file
27
backend/src/Schedule.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Message\GenerateTasksMessage;
|
||||
use Symfony\Component\Scheduler\Attribute\AsSchedule;
|
||||
use Symfony\Component\Scheduler\RecurringMessage;
|
||||
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
|
||||
use Symfony\Component\Scheduler\ScheduleProviderInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
#[AsSchedule]
|
||||
class Schedule implements ScheduleProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private CacheInterface $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSchedule(): SymfonySchedule
|
||||
{
|
||||
return (new SymfonySchedule())
|
||||
->stateful($this->cache)
|
||||
->processOnlyLastMissedRun(true)
|
||||
->add(RecurringMessage::cron('0 3 * * *', new GenerateTasksMessage()));
|
||||
}
|
||||
}
|
||||
87
backend/src/Service/TaskGenerator.php
Normal file
87
backend/src/Service/TaskGenerator.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskSchema;
|
||||
use App\Enum\TaskStatus;
|
||||
use App\Repository\TaskSchemaRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class TaskGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private TaskSchemaRepository $schemaRepo,
|
||||
) {
|
||||
}
|
||||
|
||||
public function generateNewTasks(): void
|
||||
{
|
||||
$schemas = $this->schemaRepo->findActiveWithRepeat();
|
||||
|
||||
foreach ($schemas as $schema) {
|
||||
$this->removeTasks($schema);
|
||||
$this->generateTasks($schema);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function removeTasks(TaskSchema $schema): void
|
||||
{
|
||||
foreach ($schema->getTasks() as $task) {
|
||||
if ($task->getStatus() === TaskStatus::Past) continue;
|
||||
$this->em->remove($task);
|
||||
}
|
||||
}
|
||||
|
||||
public function generateTasks(TaskSchema $schema): void
|
||||
{
|
||||
$dates = $this->getDates($schema);
|
||||
|
||||
foreach ($dates as $date) {
|
||||
$task = new Task();
|
||||
$task->setName($schema->getName());
|
||||
$task->setDate($date);
|
||||
$task->setStatus($schema->getTaskStatus());
|
||||
$task->setSchema($schema);
|
||||
$this->em->persist($task);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{\DateTimeImmutable, \DateTimeImmutable} */
|
||||
private function getDateRange(TaskSchema $schema): array
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
$from = max($today, $schema->getStart() ?? $today);
|
||||
$end = min($today->modify('+14 days'), $schema->getEnd() ?? $today->modify('+14 days'));
|
||||
|
||||
return [$from, $end];
|
||||
}
|
||||
|
||||
/** @return list<\DateTimeImmutable> */
|
||||
private function getDates(TaskSchema $schema): array
|
||||
{
|
||||
[$from, $end] = $this->getDateRange($schema);
|
||||
$type = $schema->getRepeatType();
|
||||
$repeat = $schema->getRepeat();
|
||||
|
||||
$dates = [];
|
||||
for ($date = $from; $date <= $end; $date = $date->modify('+1 day')) {
|
||||
|
||||
if ($type === 'weekly') {
|
||||
$weekday = (int) $date->format('N') - 1;
|
||||
if(!$repeat['weekly'][$weekday]) continue;
|
||||
}
|
||||
if ($type === 'monthly') {
|
||||
$monthday = (int) $date->format('j') - 1;
|
||||
if(!$repeat['monthly'][$monthday]) continue;
|
||||
}
|
||||
|
||||
$dates[] = $date;
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
}
|
||||
@@ -13,19 +13,6 @@ class TaskManager
|
||||
{
|
||||
}
|
||||
|
||||
public function create(TaskRequest $req): Task
|
||||
{
|
||||
$task = new Task();
|
||||
$task->setName($req->name);
|
||||
$task->setDate($req->date);
|
||||
$task->setStatus($req->status);
|
||||
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
public function update(Task $task, TaskRequest $req): Task
|
||||
{
|
||||
$task->setName($req->name);
|
||||
|
||||
79
backend/src/Service/TaskSchemaManager.php
Normal file
79
backend/src/Service/TaskSchemaManager.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\DTO\TaskSchemaRequest;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskSchema;
|
||||
use App\Enum\TaskSchemaStatus;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class TaskSchemaManager
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private TaskGenerator $generator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(TaskSchemaRequest $req): void
|
||||
{
|
||||
if ($req->repeat === null) {
|
||||
$task = new Task();
|
||||
$task->setName($req->name);
|
||||
$task->setDate($req->date);
|
||||
$task->setStatus($req->taskStatus);
|
||||
$this->em->persist($task);
|
||||
$this->em->flush();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = new TaskSchema();
|
||||
$schema->setName($req->name);
|
||||
$schema->setStatus($req->status);
|
||||
$schema->setTaskStatus($req->taskStatus);
|
||||
$schema->setDate($req->date);
|
||||
$schema->setRepeat($req->repeat);
|
||||
$schema->setStart($req->start);
|
||||
$schema->setEnd($req->end);
|
||||
$this->em->persist($schema);
|
||||
$this->em->flush();
|
||||
|
||||
if ($schema->getStatus() === TaskSchemaStatus::Inactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->generator->generateTasks($schema);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function update(TaskSchema $schema, TaskSchemaRequest $req): void
|
||||
{
|
||||
$schema->setName($req->name);
|
||||
$schema->setStatus($req->status);
|
||||
$schema->setTaskStatus($req->taskStatus);
|
||||
$schema->setDate($req->date);
|
||||
$schema->setRepeat($req->repeat);
|
||||
$schema->setStart($req->start);
|
||||
$schema->setEnd($req->end);
|
||||
|
||||
if ($schema->getStatus() === TaskSchemaStatus::Inactive) {
|
||||
$this->em->flush();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->generator->removeTasks($schema);
|
||||
$this->generator->generateTasks($schema);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function delete(TaskSchema $schema): void
|
||||
{
|
||||
$this->generator->removeTasks($schema);
|
||||
$this->em->remove($schema);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -101,6 +101,18 @@
|
||||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/messenger": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.0",
|
||||
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/messenger.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
@@ -126,6 +138,18 @@
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/scheduler": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.2",
|
||||
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
|
||||
},
|
||||
"files": [
|
||||
"src/Schedule.php"
|
||||
]
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
|
||||
3
backend/vendor/composer/autoload_files.php
vendored
3
backend/vendor/composer/autoload_files.php
vendored
@@ -13,6 +13,7 @@ return array(
|
||||
'8825ede83f2f289127722d4e842cf7e8' => $vendorDir . '/symfony/polyfill-intl-grapheme/bootstrap.php',
|
||||
'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php',
|
||||
'b6b991a57620e2fb6b2f66f03fe9ddc2' => $vendorDir . '/symfony/string/Resources/functions.php',
|
||||
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
|
||||
'662a729f963d39afe703c9d9b7ab4a8c' => $vendorDir . '/symfony/polyfill-php83/bootstrap.php',
|
||||
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
|
||||
'2203a247e6fda86070a5e4e07aed533a' => $vendorDir . '/symfony/clock/Resources/now.php',
|
||||
);
|
||||
|
||||
4
backend/vendor/composer/autoload_psr4.php
vendored
4
backend/vendor/composer/autoload_psr4.php
vendored
@@ -28,11 +28,13 @@ return array(
|
||||
'Symfony\\Component\\String\\' => array($vendorDir . '/symfony/string'),
|
||||
'Symfony\\Component\\Stopwatch\\' => array($vendorDir . '/symfony/stopwatch'),
|
||||
'Symfony\\Component\\Serializer\\' => array($vendorDir . '/symfony/serializer'),
|
||||
'Symfony\\Component\\Scheduler\\' => array($vendorDir . '/symfony/scheduler'),
|
||||
'Symfony\\Component\\Runtime\\' => array($vendorDir . '/symfony/runtime'),
|
||||
'Symfony\\Component\\Routing\\' => array($vendorDir . '/symfony/routing'),
|
||||
'Symfony\\Component\\PropertyInfo\\' => array($vendorDir . '/symfony/property-info'),
|
||||
'Symfony\\Component\\PropertyAccess\\' => array($vendorDir . '/symfony/property-access'),
|
||||
'Symfony\\Component\\Process\\' => array($vendorDir . '/symfony/process'),
|
||||
'Symfony\\Component\\Messenger\\' => array($vendorDir . '/symfony/messenger'),
|
||||
'Symfony\\Component\\HttpKernel\\' => array($vendorDir . '/symfony/http-kernel'),
|
||||
'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'),
|
||||
'Symfony\\Component\\Finder\\' => array($vendorDir . '/symfony/finder'),
|
||||
@@ -43,6 +45,7 @@ return array(
|
||||
'Symfony\\Component\\DependencyInjection\\' => array($vendorDir . '/symfony/dependency-injection'),
|
||||
'Symfony\\Component\\Console\\' => array($vendorDir . '/symfony/console'),
|
||||
'Symfony\\Component\\Config\\' => array($vendorDir . '/symfony/config'),
|
||||
'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'),
|
||||
'Symfony\\Component\\Cache\\' => array($vendorDir . '/symfony/cache'),
|
||||
'Symfony\\Bundle\\MakerBundle\\' => array($vendorDir . '/symfony/maker-bundle/src'),
|
||||
'Symfony\\Bundle\\FrameworkBundle\\' => array($vendorDir . '/symfony/framework-bundle'),
|
||||
@@ -50,6 +53,7 @@ return array(
|
||||
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
|
||||
'Psr\\EventDispatcher\\' => array($vendorDir . '/psr/event-dispatcher/src'),
|
||||
'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
|
||||
'Psr\\Clock\\' => array($vendorDir . '/psr/clock/src'),
|
||||
'Psr\\Cache\\' => array($vendorDir . '/psr/cache/src'),
|
||||
'PhpParser\\' => array($vendorDir . '/nikic/php-parser/lib/PhpParser'),
|
||||
'PHPStan\\PhpDocParser\\' => array($vendorDir . '/phpstan/phpdoc-parser/src'),
|
||||
|
||||
23
backend/vendor/composer/autoload_static.php
vendored
23
backend/vendor/composer/autoload_static.php
vendored
@@ -14,8 +14,9 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
'8825ede83f2f289127722d4e842cf7e8' => __DIR__ . '/..' . '/symfony/polyfill-intl-grapheme/bootstrap.php',
|
||||
'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php',
|
||||
'b6b991a57620e2fb6b2f66f03fe9ddc2' => __DIR__ . '/..' . '/symfony/string/Resources/functions.php',
|
||||
'9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
|
||||
'662a729f963d39afe703c9d9b7ab4a8c' => __DIR__ . '/..' . '/symfony/polyfill-php83/bootstrap.php',
|
||||
'9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
|
||||
'2203a247e6fda86070a5e4e07aed533a' => __DIR__ . '/..' . '/symfony/clock/Resources/now.php',
|
||||
);
|
||||
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
@@ -49,11 +50,13 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
'Symfony\\Component\\String\\' => 25,
|
||||
'Symfony\\Component\\Stopwatch\\' => 28,
|
||||
'Symfony\\Component\\Serializer\\' => 29,
|
||||
'Symfony\\Component\\Scheduler\\' => 28,
|
||||
'Symfony\\Component\\Runtime\\' => 26,
|
||||
'Symfony\\Component\\Routing\\' => 26,
|
||||
'Symfony\\Component\\PropertyInfo\\' => 31,
|
||||
'Symfony\\Component\\PropertyAccess\\' => 33,
|
||||
'Symfony\\Component\\Process\\' => 26,
|
||||
'Symfony\\Component\\Messenger\\' => 28,
|
||||
'Symfony\\Component\\HttpKernel\\' => 29,
|
||||
'Symfony\\Component\\HttpFoundation\\' => 33,
|
||||
'Symfony\\Component\\Finder\\' => 25,
|
||||
@@ -64,6 +67,7 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
'Symfony\\Component\\DependencyInjection\\' => 38,
|
||||
'Symfony\\Component\\Console\\' => 26,
|
||||
'Symfony\\Component\\Config\\' => 25,
|
||||
'Symfony\\Component\\Clock\\' => 24,
|
||||
'Symfony\\Component\\Cache\\' => 24,
|
||||
'Symfony\\Bundle\\MakerBundle\\' => 27,
|
||||
'Symfony\\Bundle\\FrameworkBundle\\' => 31,
|
||||
@@ -74,6 +78,7 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
'Psr\\Log\\' => 8,
|
||||
'Psr\\EventDispatcher\\' => 20,
|
||||
'Psr\\Container\\' => 14,
|
||||
'Psr\\Clock\\' => 10,
|
||||
'Psr\\Cache\\' => 10,
|
||||
'PhpParser\\' => 10,
|
||||
'PHPStan\\PhpDocParser\\' => 21,
|
||||
@@ -196,6 +201,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/serializer',
|
||||
),
|
||||
'Symfony\\Component\\Scheduler\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/scheduler',
|
||||
),
|
||||
'Symfony\\Component\\Runtime\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/runtime',
|
||||
@@ -216,6 +225,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/process',
|
||||
),
|
||||
'Symfony\\Component\\Messenger\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/messenger',
|
||||
),
|
||||
'Symfony\\Component\\HttpKernel\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/http-kernel',
|
||||
@@ -256,6 +269,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/config',
|
||||
),
|
||||
'Symfony\\Component\\Clock\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/clock',
|
||||
),
|
||||
'Symfony\\Component\\Cache\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/cache',
|
||||
@@ -284,6 +301,10 @@ class ComposerStaticInit75e7f8d848176580e9902a32f6f14640
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/psr/container/src',
|
||||
),
|
||||
'Psr\\Clock\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/psr/clock/src',
|
||||
),
|
||||
'Psr\\Cache\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/psr/cache/src',
|
||||
|
||||
317
backend/vendor/composer/installed.json
vendored
317
backend/vendor/composer/installed.json
vendored
@@ -1568,6 +1568,57 @@
|
||||
},
|
||||
"install-path": "../psr/cache"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
"version_normalized": "1.0.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/clock.git",
|
||||
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
|
||||
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.0 || ^8.0"
|
||||
},
|
||||
"time": "2022-11-25T14:36:26+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Clock\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for reading the clock.",
|
||||
"homepage": "https://github.com/php-fig/clock",
|
||||
"keywords": [
|
||||
"clock",
|
||||
"now",
|
||||
"psr",
|
||||
"psr-20",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-fig/clock/issues",
|
||||
"source": "https://github.com/php-fig/clock/tree/1.0.0"
|
||||
},
|
||||
"install-path": "../psr/clock"
|
||||
},
|
||||
{
|
||||
"name": "psr/container",
|
||||
"version": "2.0.2",
|
||||
@@ -1916,6 +1967,87 @@
|
||||
],
|
||||
"install-path": "../symfony/cache-contracts"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v7.4.8",
|
||||
"version_normalized": "7.4.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/clock.git",
|
||||
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111",
|
||||
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/clock": "^1.0",
|
||||
"symfony/polyfill-php83": "^1.28"
|
||||
},
|
||||
"provide": {
|
||||
"psr/clock-implementation": "1.0"
|
||||
},
|
||||
"time": "2026-03-24T13:12:05+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Resources/now.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Clock\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Decouples applications from the system clock",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"clock",
|
||||
"psr20",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/clock/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"install-path": "../symfony/clock"
|
||||
},
|
||||
{
|
||||
"name": "symfony/config",
|
||||
"version": "v7.4.7",
|
||||
@@ -3395,6 +3527,103 @@
|
||||
],
|
||||
"install-path": "../symfony/maker-bundle"
|
||||
},
|
||||
{
|
||||
"name": "symfony/messenger",
|
||||
"version": "v7.4.8",
|
||||
"version_normalized": "7.4.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/messenger.git",
|
||||
"reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/ddf5ab29bc0329ece30e16f01c86abb6241e92d8",
|
||||
"reference": "ddf5ab29bc0329ece30e16f01c86abb6241e92d8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/log": "^1|^2|^3",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<7.2",
|
||||
"symfony/event-dispatcher": "<6.4",
|
||||
"symfony/event-dispatcher-contracts": "<2.5",
|
||||
"symfony/framework-bundle": "<6.4",
|
||||
"symfony/http-kernel": "<7.3",
|
||||
"symfony/lock": "<7.4",
|
||||
"symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"psr/cache": "^1.0|^2.0|^3.0",
|
||||
"symfony/console": "^7.2|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^7.3|^8.0",
|
||||
"symfony/lock": "^7.4|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
|
||||
"symfony/routing": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/stopwatch": "^6.4|^7.0|^8.0",
|
||||
"symfony/validator": "^6.4|^7.0|^8.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"time": "2026-03-30T12:55:43+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Messenger\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Samuel Roze",
|
||||
"email": "samuel.roze@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/messenger/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"install-path": "../symfony/messenger"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-grapheme",
|
||||
"version": "v1.33.0",
|
||||
@@ -4324,6 +4553,94 @@
|
||||
],
|
||||
"install-path": "../symfony/runtime"
|
||||
},
|
||||
{
|
||||
"name": "symfony/scheduler",
|
||||
"version": "v7.4.8",
|
||||
"version_normalized": "7.4.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/scheduler.git",
|
||||
"reference": "f95e696edaad466db9b087a6480ef936c766c3de"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/scheduler/zipball/f95e696edaad466db9b087a6480ef936c766c3de",
|
||||
"reference": "f95e696edaad466db9b087a6480ef936c766c3de",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/clock": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dragonmantank/cron-expression": "^3.1",
|
||||
"symfony/cache": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
|
||||
"symfony/lock": "^6.4|^7.0|^8.0",
|
||||
"symfony/messenger": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.1|^8.0"
|
||||
},
|
||||
"time": "2026-03-24T13:12:05+00:00",
|
||||
"type": "library",
|
||||
"installation-source": "dist",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Scheduler\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sergey Rabochiy",
|
||||
"email": "upyx.00@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides scheduling through Symfony Messenger",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"cron",
|
||||
"schedule",
|
||||
"scheduler"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/scheduler/tree/v7.4.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"install-path": "../symfony/scheduler"
|
||||
},
|
||||
{
|
||||
"name": "symfony/serializer",
|
||||
"version": "v7.4.7",
|
||||
|
||||
46
backend/vendor/composer/installed.php
vendored
46
backend/vendor/composer/installed.php
vendored
@@ -3,7 +3,7 @@
|
||||
'name' => 'symfony/skeleton',
|
||||
'pretty_version' => 'dev-main',
|
||||
'version' => 'dev-main',
|
||||
'reference' => '2f96caaa233f92fb18ffcdc3f13805d0e7e41369',
|
||||
'reference' => '4e81cea8317097bcd2f8ce85decd6b78e5156e8a',
|
||||
'type' => 'project',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
@@ -196,6 +196,21 @@
|
||||
0 => '2.0|3.0',
|
||||
),
|
||||
),
|
||||
'psr/clock' => array(
|
||||
'pretty_version' => '1.0.0',
|
||||
'version' => '1.0.0.0',
|
||||
'reference' => 'e41a24703d4560fd0acb709162f73b8adfc3aa0d',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/clock',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'psr/clock-implementation' => array(
|
||||
'dev_requirement' => false,
|
||||
'provided' => array(
|
||||
0 => '1.0',
|
||||
),
|
||||
),
|
||||
'psr/container' => array(
|
||||
'pretty_version' => '2.0.2',
|
||||
'version' => '2.0.2.0',
|
||||
@@ -271,6 +286,15 @@
|
||||
0 => '1.1|2.0|3.0',
|
||||
),
|
||||
),
|
||||
'symfony/clock' => array(
|
||||
'pretty_version' => 'v7.4.8',
|
||||
'version' => '7.4.8.0',
|
||||
'reference' => '674fa3b98e21531dd040e613479f5f6fa8f32111',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/clock',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/config' => array(
|
||||
'pretty_version' => 'v7.4.7',
|
||||
'version' => '7.4.7.0',
|
||||
@@ -421,6 +445,15 @@
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => true,
|
||||
),
|
||||
'symfony/messenger' => array(
|
||||
'pretty_version' => 'v7.4.8',
|
||||
'version' => '7.4.8.0',
|
||||
'reference' => 'ddf5ab29bc0329ece30e16f01c86abb6241e92d8',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/messenger',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-ctype' => array(
|
||||
'dev_requirement' => false,
|
||||
'replaced' => array(
|
||||
@@ -568,6 +601,15 @@
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/scheduler' => array(
|
||||
'pretty_version' => 'v7.4.8',
|
||||
'version' => '7.4.8.0',
|
||||
'reference' => 'f95e696edaad466db9b087a6480ef936c766c3de',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/scheduler',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/serializer' => array(
|
||||
'pretty_version' => 'v7.4.7',
|
||||
'version' => '7.4.7.0',
|
||||
@@ -595,7 +637,7 @@
|
||||
'symfony/skeleton' => array(
|
||||
'pretty_version' => 'dev-main',
|
||||
'version' => 'dev-main',
|
||||
'reference' => '2f96caaa233f92fb18ffcdc3f13805d0e7e41369',
|
||||
'reference' => '4e81cea8317097bcd2f8ce85decd6b78e5156e8a',
|
||||
'type' => 'project',
|
||||
'install_path' => __DIR__ . '/../../',
|
||||
'aliases' => array(),
|
||||
|
||||
@@ -48,6 +48,12 @@ const props = defineProps({
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar'">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</template>
|
||||
<template v-else-if="name === 'refresh'">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
|
||||
@@ -27,16 +27,40 @@ const router = createRouter({
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/tasks/create',
|
||||
name: 'tasks-create',
|
||||
component: () => import('../views/TaskCreate.vue'),
|
||||
path: '/schemas',
|
||||
name: 'schemas',
|
||||
component: () => import('../views/SchemaAll.vue'),
|
||||
meta: {
|
||||
breadcrumb: [
|
||||
{ label: 'Tasks', to: '/tasks' },
|
||||
{ label: 'Schemas' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/schemas/create',
|
||||
name: 'schemas-create',
|
||||
component: () => import('../views/SchemaCreate.vue'),
|
||||
meta: {
|
||||
breadcrumb: [
|
||||
{ label: 'Tasks', to: '/tasks' },
|
||||
{ label: 'Schemas', to: '/schemas' },
|
||||
{ label: 'Create' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/schemas/:id(\\d+)',
|
||||
name: 'schemas-edit',
|
||||
component: () => import('../views/SchemaEdit.vue'),
|
||||
meta: {
|
||||
breadcrumb: [
|
||||
{ label: 'Tasks', to: '/tasks' },
|
||||
{ label: 'Schemas', to: '/schemas' },
|
||||
{ label: 'Edit' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/tasks/:id(\\d+)',
|
||||
name: 'tasks-edit',
|
||||
|
||||
@@ -12,10 +12,17 @@ async function request(path, opts = {}) {
|
||||
return res.status === 204 ? null : res.json()
|
||||
}
|
||||
|
||||
export const schemaApi = {
|
||||
list: () => request('/task-schemas'),
|
||||
get: (id) => request(`/task-schemas/${id}`),
|
||||
create: (data) => request('/task-schemas', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => request(`/task-schemas/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
remove: (id) => request(`/task-schemas/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
export const taskApi = {
|
||||
list: (filter) => request(`/tasks${filter ? `?filter=${filter}` : ''}`),
|
||||
get: (id) => request(`/tasks/${id}`),
|
||||
create: (data) => request('/tasks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id, data) => request(`/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
remove: (id) => request(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
toggle: (id) => request(`/tasks/${id}/toggle`, { method: 'PATCH' }),
|
||||
|
||||
44
frontend/src/stores/schemas.js
Normal file
44
frontend/src/stores/schemas.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { schemaApi } from '../services/api'
|
||||
|
||||
export const useSchemasStore = defineStore('schemas', {
|
||||
state: () => ({
|
||||
schemas: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchAll() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.schemas = await schemaApi.list()
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
return schemaApi.get(id)
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const result = await schemaApi.create(data)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const updated = await schemaApi.update(id, data)
|
||||
this.schemas = this.schemas.map((s) => (s.id === id ? updated : s))
|
||||
return updated
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
await schemaApi.remove(id)
|
||||
this.schemas = this.schemas.filter((s) => s.id !== id)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -44,12 +44,6 @@ export const useTasksStore = defineStore('tasks', {
|
||||
this.availableStatuses = await taskApi.statuses()
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const task = await taskApi.create(data)
|
||||
this.tasks.push(task)
|
||||
return task
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const updated = await taskApi.update(id, data)
|
||||
this.replaceLocal(updated)
|
||||
|
||||
145
frontend/src/views/SchemaAll.vue
Normal file
145
frontend/src/views/SchemaAll.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSchemasStore } from '../stores/schemas'
|
||||
import Icon from '../components/Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useSchemasStore()
|
||||
|
||||
function repeatLabel(schema) {
|
||||
if (!schema.repeat) return 'Einmalig'
|
||||
if (schema.repeat.daily) return 'Täglich'
|
||||
if (schema.repeat.weekly) return 'Wöchentlich'
|
||||
if (schema.repeat.monthly) return 'Monatlich'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function onDelete(id) {
|
||||
await store.remove(id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-all-view">
|
||||
<div class="actions">
|
||||
<button class="icon-btn" @click="router.push('/schemas/create')" title="Neues Schema">
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="store.fetchAll()" title="Neu laden">
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading && store.schemas.length === 0" class="hint">Lädt…</p>
|
||||
<p v-else-if="store.error" class="error">{{ store.error }}</p>
|
||||
<p v-else-if="store.schemas.length === 0" class="hint">Keine Schemas.</p>
|
||||
|
||||
<ul v-else class="schema-list">
|
||||
<li
|
||||
v-for="schema in store.schemas"
|
||||
:key="schema.id"
|
||||
class="schema-item"
|
||||
:class="{ inactive: schema.status === 'inactive' }"
|
||||
>
|
||||
<div class="schema-info">
|
||||
<span class="schema-name">{{ schema.name }}</span>
|
||||
<span class="schema-repeat">{{ repeatLabel(schema) }}</span>
|
||||
</div>
|
||||
<div class="schema-actions">
|
||||
<button class="icon-btn" @click="router.push(`/schemas/${schema.id}`)" title="Bearbeiten">
|
||||
<Icon name="pencil" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="onDelete(schema.id)" title="Löschen">
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--breadcrumb-bg);
|
||||
}
|
||||
|
||||
.schema-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.schema-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.schema-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.schema-item:hover {
|
||||
background: var(--breadcrumb-bg);
|
||||
}
|
||||
|
||||
.schema-item.inactive {
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.schema-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.schema-repeat {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.schema-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
249
frontend/src/views/SchemaCreate.vue
Normal file
249
frontend/src/views/SchemaCreate.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSchemasStore } from '../stores/schemas'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useSchemasStore()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
status: 'active',
|
||||
taskStatus: 'active',
|
||||
repeatType: 'none',
|
||||
date: '',
|
||||
start: '',
|
||||
end: '',
|
||||
weekly: Array(7).fill(false),
|
||||
monthly: Array(31).fill(false),
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
function buildPayload() {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
status: form.value.status,
|
||||
taskStatus: form.value.taskStatus,
|
||||
}
|
||||
|
||||
if (form.value.repeatType === 'none') {
|
||||
data.date = form.value.date || null
|
||||
} else {
|
||||
data.start = form.value.start || null
|
||||
data.end = form.value.end || null
|
||||
|
||||
if (form.value.repeatType === 'daily') {
|
||||
data.repeat = { daily: true }
|
||||
} else if (form.value.repeatType === 'weekly') {
|
||||
data.repeat = { weekly: [...form.value.weekly] }
|
||||
} else if (form.value.repeatType === 'monthly') {
|
||||
data.repeat = { monthly: [...form.value.monthly] }
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await store.create(buildPayload())
|
||||
router.push('/schemas')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="schema-form" @submit.prevent="onSave">
|
||||
<div class="field">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="status">Schema Status</label>
|
||||
<select id="status" v-model="form.status">
|
||||
<option value="active">active</option>
|
||||
<option value="inactive">inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="taskStatus">Task Status</label>
|
||||
<select id="taskStatus" v-model="form.taskStatus">
|
||||
<option value="active">active</option>
|
||||
<option value="done">done</option>
|
||||
<option value="inactive">inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeatType">Wiederholung</label>
|
||||
<select id="repeatType" v-model="form.repeatType">
|
||||
<option value="none">Keine (Einmalig)</option>
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType === 'none'" class="field">
|
||||
<label for="date">Datum</label>
|
||||
<input id="date" v-model="form.date" type="date" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType === 'weekly'" class="field">
|
||||
<label>Wochentage</label>
|
||||
<div class="day-grid">
|
||||
<label v-for="(day, i) in weekdays" :key="i" class="day-label" :class="{ selected: form.weekly[i] }">
|
||||
<input type="checkbox" v-model="form.weekly[i]" class="sr-only" />
|
||||
{{ day }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType === 'monthly'" class="field">
|
||||
<label>Monatstage</label>
|
||||
<div class="day-grid">
|
||||
<label v-for="d in 31" :key="d" class="day-label" :class="{ selected: form.monthly[d - 1] }">
|
||||
<input type="checkbox" v-model="form.monthly[d - 1]" class="sr-only" />
|
||||
{{ d }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType !== 'none'" class="field-row">
|
||||
<div class="field">
|
||||
<label for="start">Start</label>
|
||||
<input id="start" v-model="form.start" type="date" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="end">Ende</label>
|
||||
<input id="end" v-model="form.end" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="submitting || !form.name">Erstellen</button>
|
||||
<button type="button" @click="router.back()">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schema-form {
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-row .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.day-label.selected {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--breadcrumb-bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
302
frontend/src/views/SchemaEdit.vue
Normal file
302
frontend/src/views/SchemaEdit.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSchemasStore } from '../stores/schemas'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useSchemasStore()
|
||||
|
||||
const id = Number(route.params.id)
|
||||
const original = ref(null)
|
||||
const form = ref({
|
||||
name: '',
|
||||
status: 'active',
|
||||
taskStatus: 'active',
|
||||
repeatType: 'none',
|
||||
date: '',
|
||||
start: '',
|
||||
end: '',
|
||||
weekly: Array(7).fill(false),
|
||||
monthly: Array(31).fill(false),
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
function toFormDate(iso) {
|
||||
if (!iso) return ''
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
|
||||
function detectRepeatType(schema) {
|
||||
if (!schema.repeat) return 'none'
|
||||
if (schema.repeat.daily) return 'daily'
|
||||
if (schema.repeat.weekly) return 'weekly'
|
||||
if (schema.repeat.monthly) return 'monthly'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function loadFromSchema(schema) {
|
||||
const repeatType = detectRepeatType(schema)
|
||||
return {
|
||||
name: schema.name,
|
||||
status: schema.status,
|
||||
taskStatus: schema.taskStatus,
|
||||
repeatType,
|
||||
date: toFormDate(schema.date),
|
||||
start: toFormDate(schema.start),
|
||||
end: toFormDate(schema.end),
|
||||
weekly: schema.repeat?.weekly ? [...schema.repeat.weekly] : Array(7).fill(false),
|
||||
monthly: schema.repeat?.monthly ? [...schema.repeat.monthly] : Array(31).fill(false),
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const data = {
|
||||
name: form.value.name,
|
||||
status: form.value.status,
|
||||
taskStatus: form.value.taskStatus,
|
||||
}
|
||||
|
||||
if (form.value.repeatType === 'none') {
|
||||
data.date = form.value.date || null
|
||||
} else {
|
||||
data.start = form.value.start || null
|
||||
data.end = form.value.end || null
|
||||
|
||||
if (form.value.repeatType === 'daily') {
|
||||
data.repeat = { daily: true }
|
||||
} else if (form.value.repeatType === 'weekly') {
|
||||
data.repeat = { weekly: [...form.value.weekly] }
|
||||
} else if (form.value.repeatType === 'monthly') {
|
||||
data.repeat = { monthly: [...form.value.monthly] }
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const schema = await store.get(id)
|
||||
original.value = schema
|
||||
form.value = loadFromSchema(schema)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
})
|
||||
|
||||
async function onUpdate() {
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await store.update(id, buildPayload())
|
||||
router.push('/schemas')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
if (original.value) {
|
||||
form.value = loadFromSchema(original.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-if="original" class="schema-form" @submit.prevent="onUpdate">
|
||||
<div class="field">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="status">Schema Status</label>
|
||||
<select id="status" v-model="form.status">
|
||||
<option value="active">active</option>
|
||||
<option value="inactive">inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="taskStatus">Task Status</label>
|
||||
<select id="taskStatus" v-model="form.taskStatus">
|
||||
<option value="active">active</option>
|
||||
<option value="done">done</option>
|
||||
<option value="inactive">inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeatType">Wiederholung</label>
|
||||
<select id="repeatType" v-model="form.repeatType">
|
||||
<option value="none">Keine (Einmalig)</option>
|
||||
<option value="daily">Täglich</option>
|
||||
<option value="weekly">Wöchentlich</option>
|
||||
<option value="monthly">Monatlich</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType === 'none'" class="field">
|
||||
<label for="date">Datum</label>
|
||||
<input id="date" v-model="form.date" type="date" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType === 'weekly'" class="field">
|
||||
<label>Wochentage</label>
|
||||
<div class="day-grid">
|
||||
<label v-for="(day, i) in weekdays" :key="i" class="day-label" :class="{ selected: form.weekly[i] }">
|
||||
<input type="checkbox" v-model="form.weekly[i]" class="sr-only" />
|
||||
{{ day }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType === 'monthly'" class="field">
|
||||
<label>Monatstage</label>
|
||||
<div class="day-grid">
|
||||
<label v-for="d in 31" :key="d" class="day-label" :class="{ selected: form.monthly[d - 1] }">
|
||||
<input type="checkbox" v-model="form.monthly[d - 1]" class="sr-only" />
|
||||
{{ d }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.repeatType !== 'none'" class="field-row">
|
||||
<div class="field">
|
||||
<label for="start">Start</label>
|
||||
<input id="start" v-model="form.start" type="date" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="end">Ende</label>
|
||||
<input id="end" v-model="form.end" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="submitting || !form.name">Aktualisieren</button>
|
||||
<button type="button" @click="onReset">Zurücksetzen</button>
|
||||
<button type="button" @click="router.back()">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else-if="error" class="error">{{ error }}</p>
|
||||
<p v-else class="hint">Lädt…</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.schema-form {
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-row .field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.day-label.selected {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--breadcrumb-bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,10 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="task-view">
|
||||
<div class="actions">
|
||||
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
|
||||
<button class="icon-btn" @click="router.push('/schemas')" title="Schemas">
|
||||
<Icon name="calendar" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="router.push('/schemas/create')" title="Neues Schema">
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="router.push('/tasks/all')" title="Alle Tasks">
|
||||
|
||||
@@ -24,7 +24,10 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="task-all-view">
|
||||
<div class="actions">
|
||||
<button class="icon-btn" @click="router.push('/tasks/create')" title="Neuer Task">
|
||||
<button class="icon-btn" @click="router.push('/schemas')" title="Schemas">
|
||||
<Icon name="calendar" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="router.push('/schemas/create')" title="Neues Schema">
|
||||
<Icon name="plus" />
|
||||
</button>
|
||||
<button class="icon-btn" @click="store.fetchAll()" title="Neu laden">
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTasksStore } from '../stores/tasks'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useTasksStore()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
date: '',
|
||||
status: 'active',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchStatuses()
|
||||
})
|
||||
|
||||
async function onSave() {
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await store.create({
|
||||
name: form.value.name,
|
||||
date: form.value.date || null,
|
||||
status: form.value.status,
|
||||
})
|
||||
router.push('/tasks')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAbort() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="task-form" @submit.prevent="onSave">
|
||||
<div class="field">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="date">Datum</label>
|
||||
<input id="date" v-model="form.date" type="date" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" v-model="form.status">
|
||||
<option v-for="key in store.availableStatuses" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="submitting">Speichern</button>
|
||||
<button type="button" @click="onAbort">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-form {
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--breadcrumb-bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
37
module.md
37
module.md
@@ -91,21 +91,28 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
|
||||
|
||||
# TaskSchema module
|
||||
## Backend
|
||||
- TaskSchema - TaskSchema entity
|
||||
- id, name, status, date, type, repeat, start, end
|
||||
- TaskType - Enum for schema type
|
||||
- single (create one task on date or null, delete schema after date or now if null)
|
||||
- repeat (create tasks depending on repeat in start-end range, delete after enddate)
|
||||
- TaskRepeat - Enum for schema repeat
|
||||
- daily, weekly (array with weekdays), monthly (array with monthdays)
|
||||
- TaskSchemaController - TaskSchema routes
|
||||
- index, show, create, update, delete
|
||||
- TaskSchemaManager - TaskSchema CRUD
|
||||
- create, update, delete
|
||||
- TaskSchemaRepository - TaskSchema queries
|
||||
- TaskGenerator - Create tasks from schema
|
||||
- generate
|
||||
|
||||
- Task - add schema (n:1)
|
||||
- TaskSchema - id, name, status, taskStatus, date, repeat (json), start, end
|
||||
- repeat=null → single, repeat={"daily"/"weekly"/"monthly":...} → repeating
|
||||
- TaskSchemaStatus - active, inactive
|
||||
- TaskController - remove create route
|
||||
- TaskManager - remove create
|
||||
- TaskSchemaController - index, show, create, update, delete
|
||||
- TaskSchemaManager - create (single=task only, repeat=schema+generate), update (remove+generate), delete (remove+schema)
|
||||
- TaskGenerator - generateTasks, removeTasks, generateNewTasks (scheduler)
|
||||
- Scheduler - daily at 03:00, messenger:consume via DDEV daemon
|
||||
- Migration - task_schema table + schema_id FK
|
||||
## Frontend
|
||||
- TaskCreate.vue removed, SchemaCreate/SchemaEdit/SchemaAll added
|
||||
- Task.vue + TaskAll.vue - calendar + plus icons → /schemas, /schemas/create
|
||||
- Form: name, (status + taskStatus), repeat, weekday/monthday grid, (start + end)
|
||||
## App
|
||||
- same changes as Frontend
|
||||
## Features
|
||||
- Single schema: task directly, no schema persisted
|
||||
- Repeat schema: tasks for period (max 14 days), scheduler fills daily
|
||||
- Update: remove non-past tasks + regenerate
|
||||
- Delete: remove non-past tasks + schema
|
||||
|
||||
# Category module
|
||||
## Backend
|
||||
|
||||
Reference in New Issue
Block a user