Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38858c8002 | |||
| 7450160e9e | |||
| 05c62d1a21 |
+1
-1
@@ -5,7 +5,7 @@
|
||||
<name>Mitgliederverwaltung</name>
|
||||
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
||||
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
||||
<version>0.2.11</version>
|
||||
<version>0.3.1</version>
|
||||
<licence>agpl</licence>
|
||||
<author>shahondin1624</author>
|
||||
<namespace>Mitgliederverwaltung</namespace>
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ app.use(router)
|
||||
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
|
||||
// not via webpack DefinePlugin globals.
|
||||
app.provide('appName', 'mitgliederverwaltung')
|
||||
app.provide('appVersion', '0.2.11')
|
||||
app.provide('appVersion', '0.3.1')
|
||||
|
||||
app.mount('#mitgliederverwaltung')
|
||||
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<div class="inventory">
|
||||
<h2>Inventar</h2>
|
||||
|
||||
<!-- Tab buttons -->
|
||||
<div class="inventory__tabs">
|
||||
<NcButton
|
||||
:primary="activeTab === 'general'"
|
||||
@click="activeTab = 'general'"
|
||||
:style="{ backgroundColor: activeTab === 'general' ? 'var(--color-primary-element)' : undefined }"
|
||||
>
|
||||
Allgemeinmaterial
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:primary="activeTab === 'stock'"
|
||||
@click="activeTab = 'stock'"
|
||||
:style="{ backgroundColor: activeTab === 'stock' ? 'var(--color-primary-element)' : undefined }"
|
||||
>
|
||||
Verkaufsmaterial
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:primary="activeTab === 'sales'"
|
||||
@click="activeTab = 'sales'"
|
||||
:style="{ backgroundColor: activeTab === 'sales' ? 'var(--color-primary-element)' : undefined }"
|
||||
>
|
||||
Verkäufe
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Allgemeinmaterial -->
|
||||
<div v-if="activeTab === 'general'" class="inventory__section">
|
||||
<div class="inventory__toolbar">
|
||||
<NcButton @click="showGeneralDialog = true">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Neues Allgemeinmaterial
|
||||
</NcButton>
|
||||
|
||||
<div class="inventory__search">
|
||||
<div class="inventory__search-field">
|
||||
<Magnify :size="20" class="inventory__search-icon" />
|
||||
<input
|
||||
v-model="generalStore.searchText"
|
||||
type="text"
|
||||
class="inventory__search-input"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="generalStore.loading" class="inventory__loading">
|
||||
<NcLoadingIcon />
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<NcNoteCard v-if="generalStore.error" type="error" class="inventory__error">
|
||||
{{ generalStore.error }}
|
||||
<NcButton @click="generalStore.clearError()" class="inventory__error-close">
|
||||
Schließen
|
||||
</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!generalStore.loading && generalStore.filteredItems.length === 0 && !generalStore.error" class="inventory__empty">
|
||||
<Inbox :size="48" />
|
||||
<p>Keine Allgemeinmaterialien vorhanden</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-if="!generalStore.loading && generalStore.filteredItems.length > 0" class="inventory__table-wrapper">
|
||||
<table class="inventory__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Zustand</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Notizen</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in generalStore.filteredItems" :key="item.id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<span class="inventory__condition"
|
||||
:class="{ 'inventory__condition--warning': needsRepair(item.condition) }">
|
||||
{{ getConditionDisplay(item.condition) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.categories?.join(', ') || '–' }}</td>
|
||||
<td class="inventory__notes-cell">{{ item.notes || '–' }}</td>
|
||||
<td class="inventory__actions">
|
||||
<NcButton icon="edit" @click="editGeneral(item)" />
|
||||
<NcButton icon="delete" @click="deleteGeneral(item.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Verkaufsmaterial -->
|
||||
<div v-if="activeTab === 'stock'" class="inventory__section">
|
||||
<div class="inventory__toolbar">
|
||||
<NcButton @click="showStockDialog = true">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Neues Verkaufsmaterial
|
||||
</NcButton>
|
||||
|
||||
<div class="inventory__search">
|
||||
<div class="inventory__search-field">
|
||||
<Magnify :size="20" class="inventory__search-icon" />
|
||||
<input
|
||||
v-model="stockStore.searchText"
|
||||
type="text"
|
||||
class="inventory__search-input"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stockStore.loading" class="inventory__loading">
|
||||
<NcLoadingIcon />
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="stockStore.error" type="error" class="inventory__error">
|
||||
{{ stockStore.error }}
|
||||
<NcButton @click="stockStore.clearError()">Schließen</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="!stockStore.loading && stockStore.filteredItems.length === 0 && !stockStore.error" class="inventory__empty">
|
||||
<Inbox :size="48" />
|
||||
<p>Kein Verkaufsmaterial vorhanden</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!stockStore.loading && stockStore.filteredItems.length > 0" class="inventory__table-wrapper">
|
||||
<table class="inventory__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Gesamtbestand</th>
|
||||
<th>Mindestbestand</th>
|
||||
<th>Varianten</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in stockStore.filteredItems" :key="item.id">
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<span :class="{ 'inventory__low-stock': item.total_amount < item.min_threshold }">
|
||||
{{ item.total_amount ?? '–' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.min_threshold ?? '–' }}</td>
|
||||
<td>{{ (item.variants || []).length }} Varianten</td>
|
||||
<td class="inventory__actions">
|
||||
<NcButton icon="edit" @click="editStock(item)" />
|
||||
<NcButton icon="delete" @click="deleteStock(item.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Verkäufe -->
|
||||
<div v-if="activeTab === 'sales'" class="inventory__section">
|
||||
<div class="inventory__toolbar">
|
||||
<NcButton @click="showSaleDialog = true">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
Verkauf eintragen
|
||||
</NcButton>
|
||||
|
||||
<div class="inventory__filters">
|
||||
<div class="inventory__filter-item">
|
||||
<label for="sale-date-from">Von:</label>
|
||||
<NcDateTimePicker
|
||||
id="sale-date-from"
|
||||
:model-value="salesStore.filterDateFrom ? new Date(salesStore.filterDateFrom + 'T00:00:00') : null"
|
||||
@update:model-value="salesStore.setFilterDateFrom($event?.toISOString().split('T')[0] || null)"
|
||||
/>
|
||||
</div>
|
||||
<div class="inventory__filter-item">
|
||||
<label for="sale-date-to">Bis:</label>
|
||||
<NcDateTimePicker
|
||||
id="sale-date-to"
|
||||
:model-value="salesStore.filterDateTo ? new Date(salesStore.filterDateTo + 'T00:00:00') : null"
|
||||
@update:model-value="salesStore.setFilterDateTo($event?.toISOString().split('T')[0] || null)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="salesStore.loading" class="inventory__loading">
|
||||
<NcLoadingIcon />
|
||||
<span>Laden...</span>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="salesStore.error" type="error" class="inventory__error">
|
||||
{{ salesStore.error }}
|
||||
<NcButton @click="salesStore.clearError()">Schließen</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="!salesStore.loading && salesStore.filteredSales.length === 0 && !salesStore.error" class="inventory__empty">
|
||||
<Inbox :size="48" />
|
||||
<p>Keine Verkäufe vorhanden</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!salesStore.loading && salesStore.filteredSales.length > 0" class="inventory__table-wrapper">
|
||||
<table class="inventory__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Artikel</th>
|
||||
<th>Menge</th>
|
||||
<th>Stückpreis</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="sale in salesStore.filteredSales" :key="sale.id">
|
||||
<td>{{ formatDate(sale.date) }}</td>
|
||||
<td>{{ sale.stock_item_id || '–' }}</td>
|
||||
<td>{{ sale.quantity }}</td>
|
||||
<td>{{ formatPrice(sale.unit_price) }}</td>
|
||||
<td>{{ formatPrice(sale.total_price) }}</td>
|
||||
<td class="inventory__actions">
|
||||
<NcButton icon="delete" @click="deleteSale(sale.id)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Material Dialog -->
|
||||
<NcDialog
|
||||
v-if="showGeneralDialog"
|
||||
name="Allgemeinmaterial"
|
||||
:close-on-outside-click="false"
|
||||
:close-button-label="generalEditItem ? 'Schließen' : 'Abbrechen'"
|
||||
@close="closeGeneralDialog"
|
||||
>
|
||||
<GeneralMaterialForm
|
||||
:item="generalEditItem"
|
||||
:categories="categoriesStore.categories"
|
||||
@update="handleGeneralFormUpdate"
|
||||
@close="closeGeneralDialog"
|
||||
/>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Stock Item Dialog -->
|
||||
<NcDialog
|
||||
v-if="showStockDialog"
|
||||
name="Verkaufsmaterial"
|
||||
:close-on-outside-click="false"
|
||||
:close-button-label="stockEditItem ? 'Schließen' : 'Abbrechen'"
|
||||
@close="closeStockDialog"
|
||||
>
|
||||
<StockItemForm
|
||||
:item="stockEditItem"
|
||||
:categories="categoriesStore.categories"
|
||||
@update="handleStockFormUpdate"
|
||||
@close="closeStockDialog"
|
||||
/>
|
||||
</NcDialog>
|
||||
|
||||
<!-- Sale Dialog -->
|
||||
<NcDialog
|
||||
v-if="showSaleDialog"
|
||||
name="Verkauf eintragen"
|
||||
:close-on-outside-click="false"
|
||||
:close-button-label="'Abbrechen'"
|
||||
@close="closeSaleDialog"
|
||||
>
|
||||
<SaleForm
|
||||
:stock-items="stockStore.items"
|
||||
@close="closeSaleDialog"
|
||||
@create="handleSaleCreate"
|
||||
/>
|
||||
</NcDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NcButton, NcTextField, NcNoteCard, NcLoadingIcon, NcDialog, NcDateTimePicker } from '@nextcloud/vue'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
import Inbox from 'vue-material-design-icons/Inbox.vue'
|
||||
import Magnify from 'vue-material-design-icons/Magnify.vue'
|
||||
import { useCategoriesStore } from '../stores/categories.js'
|
||||
import { useGeneralStore } from '../stores/general.js'
|
||||
import { useStockStore } from '../stores/stock.js'
|
||||
import { useSalesStore } from '../stores/sales.js'
|
||||
import GeneralMaterialForm from '../components/GeneralMaterialForm.vue'
|
||||
import StockItemForm from '../components/StockItemForm.vue'
|
||||
import SaleForm from '../components/SaleForm.vue'
|
||||
|
||||
const categoriesStore = useCategoriesStore()
|
||||
const generalStore = useGeneralStore()
|
||||
const stockStore = useStockStore()
|
||||
const salesStore = useSalesStore()
|
||||
|
||||
const activeTab = ref('general')
|
||||
const showGeneralDialog = ref(false)
|
||||
const showStockDialog = ref(false)
|
||||
const showSaleDialog = ref(false)
|
||||
const generalEditItem = ref(null)
|
||||
const stockEditItem = ref(null)
|
||||
|
||||
function needsRepair(condition) {
|
||||
return condition === null || condition <= 2
|
||||
}
|
||||
|
||||
function getConditionDisplay(condition) {
|
||||
if (condition === null) return 'Nicht bewertet'
|
||||
return condition + '/5'
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return '–'
|
||||
return date
|
||||
}
|
||||
|
||||
function formatPrice(value) {
|
||||
if (!value) return '–'
|
||||
return Number(value).toLocaleString('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
})
|
||||
}
|
||||
|
||||
async function editGeneral(item) {
|
||||
generalEditItem.value = item
|
||||
showGeneralDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteGeneral(id) {
|
||||
if (confirm('Allgemeinmaterial wirklich löschen?')) {
|
||||
await generalStore.deleteItem(id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeGeneralDialog() {
|
||||
showGeneralDialog.value = false
|
||||
generalEditItem.value = null
|
||||
}
|
||||
|
||||
async function handleGeneralFormUpdate(field, value) {
|
||||
// Handled by the form component
|
||||
}
|
||||
|
||||
async function editStock(item) {
|
||||
stockEditItem.value = item
|
||||
showStockDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteStock(id) {
|
||||
if (confirm('Verkaufsmaterial wirklich löschen?')) {
|
||||
await stockStore.deleteItem(id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeStockDialog() {
|
||||
showStockDialog.value = false
|
||||
stockEditItem.value = null
|
||||
}
|
||||
|
||||
async function handleStockFormUpdate(field, value) {
|
||||
// Handled by the form component
|
||||
}
|
||||
|
||||
function closeSaleDialog() {
|
||||
showSaleDialog.value = false
|
||||
}
|
||||
|
||||
async function handleSaleCreate(data) {
|
||||
try {
|
||||
await salesStore.createSale(data)
|
||||
closeSaleDialog()
|
||||
} catch (err) {
|
||||
console.error('Failed to create sale:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSale(id) {
|
||||
if (confirm('Verkauf wirklich löschen?')) {
|
||||
await salesStore.deleteSale(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch data on mount
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
categoriesStore.fetchCategories(),
|
||||
generalStore.fetchItems(),
|
||||
stockStore.fetchItems(),
|
||||
salesStore.fetchSales(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inventory {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.inventory h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inventory__tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inventory__section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.inventory__toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__search {
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inventory__search-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-main-background);
|
||||
padding: 0 8px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.inventory__search-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.inventory__search-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.inventory__search-input {
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
font-size: 0.9em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.inventory__search-input::placeholder {
|
||||
color: var(--color-text-lighter);
|
||||
}
|
||||
|
||||
.inventory__filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__filter-item label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inventory__table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.inventory__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inventory__table th,
|
||||
.inventory__table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.inventory__table th {
|
||||
font-weight: 600;
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
|
||||
.inventory__condition {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inventory__condition--warning {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.inventory__low-stock {
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inventory__notes-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inventory__actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inventory__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.inventory__empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.inventory__empty p {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.inventory__error {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inventory__error-close {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
new VueLoaderPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
appName: JSON.stringify('mitgliederverwaltung'),
|
||||
appVersion: JSON.stringify('0.2.11'),
|
||||
appVersion: JSON.stringify('0.3.1'),
|
||||
}),
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
|
||||
Reference in New Issue
Block a user