Compare commits

...

3 Commits

Author SHA1 Message Date
shahondin1624 38858c8002 Bump version to 0.3.1
Fix inventory search bar: use flexbox wrapper with inline Magnify icon
and native input instead of NcTextField with broken :show-icon slot.
2026-04-22 09:46:52 +02:00
shahondin1624 7450160e9e (Closes #?) Fix inventory search bar icon overlap with text input
The NcTextField :show-icon prop with #icon slot doesn't work correctly
in @nextcloud/vue v9 — it renders a default icon that overlaps the text.
Replaced with a wrapper div that uses an absolutely positioned Magnify
icon alongside a clean NcTextField input.
2026-04-22 09:38:37 +02:00
shahondin1624 05c62d1a21 Bump version to 0.3.0 for release 2026-04-22 08:53:46 +02:00
4 changed files with 580 additions and 3 deletions
+1 -1
View File
@@ -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
View File
@@ -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')
+577
View File
@@ -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
View File
@@ -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,