Backend (FastAPI): - REST API: auth, plants, beds, plantings - CRUD layer with CRUDBase - Pydantic v2 schemas for all entities - Alembic migration: complete schema + all enums - Seed data: 28 global plants + 15 compatibilities Frontend (Vue 3 + PrimeVue): - Axios client with JWT interceptor + auto-refresh - Pinia stores: auth, beds, plants - Views: Login, Beds, BedDetail, PlantLibrary - Components: AppLayout, BedForm, PlantingForm, PlantForm Docker: - docker-compose.yml (production) - docker-compose.dev.yml (development with hot-reload) - Nginx config with SPA fallback + API proxy - Multi-stage frontend Dockerfile - .env.example, .gitignore Version: 1.0.0-alpha
137 lines
4.7 KiB
Vue
137 lines
4.7 KiB
Vue
<template>
|
||
<div>
|
||
<div class="page-header">
|
||
<div>
|
||
<h2>Beete</h2>
|
||
<p class="subtitle">Übersicht aller Beete in diesem Tenant</p>
|
||
</div>
|
||
<Button label="Neues Beet" icon="pi pi-plus" @click="openCreateDialog" />
|
||
</div>
|
||
|
||
<DataTable
|
||
:value="bedsStore.beds"
|
||
:loading="bedsStore.loading"
|
||
striped-rows
|
||
hover
|
||
responsive-layout="scroll"
|
||
class="mt-3"
|
||
>
|
||
<template #empty>Noch keine Beete vorhanden.</template>
|
||
|
||
<Column field="name" header="Name" sortable>
|
||
<template #body="{ data }">
|
||
<router-link :to="`/beete/${data.id}`" class="bed-link">{{ data.name }}</router-link>
|
||
</template>
|
||
</Column>
|
||
<Column header="Größe (m²)" sortable sort-field="area_m2">
|
||
<template #body="{ data }">
|
||
{{ data.area_m2 }} m²
|
||
<span class="dim">({{ data.width_m }} × {{ data.length_m }} m)</span>
|
||
</template>
|
||
</Column>
|
||
<Column field="location" header="Lage" sortable>
|
||
<template #body="{ data }">
|
||
<Tag :value="locationLabel(data.location)" :severity="locationSeverity(data.location)" />
|
||
</template>
|
||
</Column>
|
||
<Column field="soil_type" header="Bodentyp" sortable>
|
||
<template #body="{ data }">{{ soilLabel(data.soil_type) }}</template>
|
||
</Column>
|
||
<Column header="Aktionen" style="width: 8rem">
|
||
<template #body="{ data }">
|
||
<div class="actions">
|
||
<Button icon="pi pi-pencil" text severity="secondary" @click="openEditDialog(data)" />
|
||
<Button icon="pi pi-trash" text severity="danger" @click="confirmDelete(data)" />
|
||
</div>
|
||
</template>
|
||
</Column>
|
||
</DataTable>
|
||
|
||
<!-- Create/Edit Dialog -->
|
||
<Dialog
|
||
v-model:visible="dialogVisible"
|
||
:header="editingBed ? 'Beet bearbeiten' : 'Neues Beet'"
|
||
modal
|
||
style="width: 480px"
|
||
>
|
||
<BedForm :initial="editingBed" @save="handleSave" @cancel="dialogVisible = false" />
|
||
</Dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, ref } from 'vue'
|
||
import { useBedsStore } from '@/stores/beds'
|
||
import { useConfirm } from 'primevue/useconfirm'
|
||
import { useToast } from 'primevue/usetoast'
|
||
import DataTable from 'primevue/datatable'
|
||
import Column from 'primevue/column'
|
||
import Button from 'primevue/button'
|
||
import Tag from 'primevue/tag'
|
||
import Dialog from 'primevue/dialog'
|
||
import BedForm from '@/components/BedForm.vue'
|
||
|
||
const bedsStore = useBedsStore()
|
||
const confirm = useConfirm()
|
||
const toast = useToast()
|
||
|
||
const dialogVisible = ref(false)
|
||
const editingBed = ref(null)
|
||
|
||
onMounted(() => bedsStore.fetchBeds())
|
||
|
||
function openCreateDialog() {
|
||
editingBed.value = null
|
||
dialogVisible.value = true
|
||
}
|
||
function openEditDialog(bed) {
|
||
editingBed.value = bed
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleSave(payload) {
|
||
try {
|
||
if (editingBed.value) {
|
||
await bedsStore.updateBed(editingBed.value.id, payload)
|
||
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Beet aktualisiert.', life: 3000 })
|
||
} else {
|
||
await bedsStore.createBed(payload)
|
||
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Neues Beet angelegt.', life: 3000 })
|
||
}
|
||
dialogVisible.value = false
|
||
} catch (err) {
|
||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.detail || 'Speichern fehlgeschlagen.', life: 5000 })
|
||
}
|
||
}
|
||
|
||
function confirmDelete(bed) {
|
||
confirm.require({
|
||
message: `Beet „${bed.name}" wirklich löschen?`,
|
||
header: 'Bestätigung',
|
||
icon: 'pi pi-exclamation-triangle',
|
||
acceptLabel: 'Löschen',
|
||
rejectLabel: 'Abbrechen',
|
||
acceptClass: 'p-button-danger',
|
||
accept: async () => {
|
||
await bedsStore.deleteBed(bed.id)
|
||
toast.add({ severity: 'info', summary: 'Gelöscht', detail: 'Beet wurde entfernt.', life: 3000 })
|
||
},
|
||
})
|
||
}
|
||
|
||
const locationLabel = (v) => ({ sonnig: 'Sonnig', halbschatten: 'Halbschatten', schatten: 'Schatten' }[v] || v)
|
||
const locationSeverity = (v) => ({ sonnig: 'warning', halbschatten: 'info', schatten: 'secondary' }[v] || 'secondary')
|
||
const soilLabel = (v) => ({ normal: 'Normal', sandig: 'Sandig', lehmig: 'Lehmig', humusreich: 'Humusreich' }[v] || v)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
||
.page-header h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.15rem; }
|
||
.subtitle { color: var(--text-color-secondary); font-size: 0.875rem; }
|
||
.bed-link { color: var(--green-700); text-decoration: none; font-weight: 600; }
|
||
.bed-link:hover { text-decoration: underline; }
|
||
.dim { color: var(--text-color-secondary); font-size: 0.8rem; margin-left: 0.3rem; }
|
||
.actions { display: flex; gap: 0.25rem; }
|
||
.mt-3 { margin-top: 1rem; }
|
||
</style>
|