Add Deck workflow automation Nextcloud app
Ports the standalone due-date cron script into a proper Nextcloud 34 app with a personal-settings UI, per-user workflow configuration (source/target stack, assigned-user and label filters, email notification), a TimedJob background runner, and Gitea CI pipelines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<NcSettingsSection
|
||||
name="Deck Workflow-Automatisierung"
|
||||
description="Verschiebt überfällige Karten aus einem Deck-Stapel automatisch in einen anderen Stapel. Ein Hintergrundjob prüft die Regeln alle paar Minuten.">
|
||||
<NcNoteCard v-if="loadError" type="error">
|
||||
{{ loadError }}
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="loading" class="wfda-loading">
|
||||
<NcLoadingIcon :size="32" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<table v-if="workflows.length" class="wfda-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Von Stapel</th>
|
||||
<th>Nach Stapel</th>
|
||||
<th>Filter</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Aktiv</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="workflow in workflows" :key="workflow.id">
|
||||
<td>{{ workflow.title }}</td>
|
||||
<td>{{ stackLabel(workflow.boardId, workflow.sourceStackId) }}</td>
|
||||
<td>{{ stackLabel(workflow.boardId, workflow.targetStackId) }}</td>
|
||||
<td>
|
||||
<span v-if="!workflow.filterUserIds.length && !workflow.filterLabelIds.length">–</span>
|
||||
<span v-else>
|
||||
<span v-if="workflow.filterUserIds.length">{{ workflow.filterUserIds.length }} Benutzer</span>
|
||||
<span v-if="workflow.filterUserIds.length && workflow.filterLabelIds.length">, </span>
|
||||
<span v-if="workflow.filterLabelIds.length">{{ workflow.filterLabelIds.length }} Label</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ workflow.notifyEmail ? 'Ja' : 'Nein' }}</td>
|
||||
<td>{{ workflow.enabled ? 'Ja' : 'Nein' }}</td>
|
||||
<td class="wfda-actions">
|
||||
<NcButton type="tertiary" @click="editWorkflow(workflow)">
|
||||
Bearbeiten
|
||||
</NcButton>
|
||||
<NcButton type="tertiary" @click="removeWorkflow(workflow)">
|
||||
Löschen
|
||||
</NcButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="wfda-empty">
|
||||
Noch keine Workflows angelegt.
|
||||
</p>
|
||||
|
||||
<NcButton v-if="!showForm" type="primary" class="wfda-add-button" @click="startCreate">
|
||||
Workflow hinzufügen
|
||||
</NcButton>
|
||||
|
||||
<form v-if="showForm" class="wfda-form" @submit.prevent="save">
|
||||
<NcTextField
|
||||
:value.sync="form.title"
|
||||
label="Titel"
|
||||
required />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.boardId"
|
||||
label="Board"
|
||||
:options="boardOptions"
|
||||
:reduce="option => option.value"
|
||||
placeholder="Board wählen"
|
||||
@update:model-value="onBoardChange" />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.sourceStackId"
|
||||
label="Quell-Stapel"
|
||||
:options="stackOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
placeholder="Quell-Stapel wählen" />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.targetStackId"
|
||||
label="Ziel-Stapel"
|
||||
:options="stackOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
placeholder="Ziel-Stapel wählen" />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.filterUserIds"
|
||||
label="Nur für zugewiesene Benutzer (optional)"
|
||||
:options="participantOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
multiple
|
||||
placeholder="Alle Benutzer" />
|
||||
|
||||
<NcSelect
|
||||
v-model="form.filterLabelIds"
|
||||
label="Nur mit Label (optional)"
|
||||
:options="labelOptions"
|
||||
:reduce="option => option.value"
|
||||
:disabled="!form.boardId"
|
||||
multiple
|
||||
placeholder="Alle Labels" />
|
||||
|
||||
<NcCheckboxRadioSwitch :checked.sync="form.notifyEmail">
|
||||
Benachrichtigungs-E-Mail an mich senden
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcCheckboxRadioSwitch :checked.sync="form.enabled">
|
||||
Workflow aktiv
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcNoteCard v-if="formError" type="error">
|
||||
{{ formError }}
|
||||
</NcNoteCard>
|
||||
|
||||
<div class="wfda-form-actions">
|
||||
<NcButton type="primary" native-type="submit" :disabled="saving">
|
||||
Speichern
|
||||
</NcButton>
|
||||
<NcButton type="tertiary" :disabled="saving" @click="cancelForm">
|
||||
Abbrechen
|
||||
</NcButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
|
||||
import {
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
fetchBoards,
|
||||
fetchLabels,
|
||||
fetchParticipants,
|
||||
fetchStacks,
|
||||
fetchWorkflows,
|
||||
updateWorkflow,
|
||||
} from './api.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const workflows = ref([])
|
||||
const boards = ref([])
|
||||
const stacksByBoard = reactive({})
|
||||
|
||||
const showForm = ref(false)
|
||||
const saving = ref(false)
|
||||
const formError = ref('')
|
||||
const editingId = ref(null)
|
||||
|
||||
const stackOptions = ref([])
|
||||
const labelOptions = ref([])
|
||||
const participantOptions = ref([])
|
||||
|
||||
const boardOptions = computed(() => boards.value.map((board) => ({ value: board.id, label: board.title })))
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
title: '',
|
||||
boardId: null,
|
||||
sourceStackId: null,
|
||||
targetStackId: null,
|
||||
filterUserIds: [],
|
||||
filterLabelIds: [],
|
||||
notifyEmail: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function stackLabel(boardId, stackId) {
|
||||
const stacks = stacksByBoard[boardId]
|
||||
if (!stacks) {
|
||||
return `#${stackId}`
|
||||
}
|
||||
const stack = stacks.find((entry) => entry.id === stackId)
|
||||
return stack ? stack.title : `#${stackId}`
|
||||
}
|
||||
|
||||
async function loadStacksFor(boardId) {
|
||||
if (!boardId || stacksByBoard[boardId]) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
stacksByBoard[boardId] = await fetchStacks(boardId)
|
||||
} catch (e) {
|
||||
stacksByBoard[boardId] = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const [loadedWorkflows, loadedBoards] = await Promise.all([fetchWorkflows(), fetchBoards()])
|
||||
workflows.value = loadedWorkflows
|
||||
boards.value = loadedBoards
|
||||
await Promise.all(loadedWorkflows.map((workflow) => loadStacksFor(workflow.boardId)))
|
||||
} catch (e) {
|
||||
loadError.value = 'Konnte Workflows oder Deck-Boards nicht laden. Ist die Deck-App aktiviert?'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onBoardChange(boardId) {
|
||||
form.sourceStackId = null
|
||||
form.targetStackId = null
|
||||
form.filterUserIds = []
|
||||
form.filterLabelIds = []
|
||||
stackOptions.value = []
|
||||
labelOptions.value = []
|
||||
participantOptions.value = []
|
||||
|
||||
if (!boardId) {
|
||||
return
|
||||
}
|
||||
|
||||
const [stacks, labels, participants] = await Promise.all([
|
||||
fetchStacks(boardId),
|
||||
fetchLabels(boardId),
|
||||
fetchParticipants(boardId),
|
||||
])
|
||||
|
||||
stacksByBoard[boardId] = stacks
|
||||
stackOptions.value = stacks.map((stack) => ({ value: stack.id, label: stack.title }))
|
||||
labelOptions.value = labels.map((label) => ({ value: label.id, label: label.title }))
|
||||
participantOptions.value = participants.map((participant) => ({ value: participant.uid, label: participant.displayName }))
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
editingId.value = null
|
||||
formError.value = ''
|
||||
stackOptions.value = []
|
||||
labelOptions.value = []
|
||||
participantOptions.value = []
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function editWorkflow(workflow) {
|
||||
Object.assign(form, {
|
||||
title: workflow.title,
|
||||
boardId: workflow.boardId,
|
||||
sourceStackId: workflow.sourceStackId,
|
||||
targetStackId: workflow.targetStackId,
|
||||
filterUserIds: [...workflow.filterUserIds],
|
||||
filterLabelIds: [...workflow.filterLabelIds],
|
||||
notifyEmail: workflow.notifyEmail,
|
||||
enabled: workflow.enabled,
|
||||
})
|
||||
editingId.value = workflow.id
|
||||
formError.value = ''
|
||||
showForm.value = true
|
||||
await onBoardChange(workflow.boardId)
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showForm.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
async function save() {
|
||||
formError.value = ''
|
||||
if (!form.title.trim()) {
|
||||
formError.value = 'Bitte einen Titel angeben.'
|
||||
return
|
||||
}
|
||||
if (!form.boardId || !form.sourceStackId || !form.targetStackId) {
|
||||
formError.value = 'Bitte Board, Quell- und Ziel-Stapel wählen.'
|
||||
return
|
||||
}
|
||||
if (form.sourceStackId === form.targetStackId) {
|
||||
formError.value = 'Quell- und Ziel-Stapel müssen sich unterscheiden.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateWorkflow(editingId.value, form)
|
||||
} else {
|
||||
await createWorkflow(form)
|
||||
}
|
||||
showForm.value = false
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
formError.value = e?.response?.data?.ocs?.meta?.message || 'Speichern fehlgeschlagen.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeWorkflow(workflow) {
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(`Workflow "${workflow.title}" wirklich löschen?`)) {
|
||||
return
|
||||
}
|
||||
await deleteWorkflow(workflow.id)
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wfda-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.wfda-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wfda-table th,
|
||||
.wfda-table td {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.wfda-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wfda-empty {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wfda-add-button {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.wfda-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 480px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
}
|
||||
|
||||
.wfda-form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user